Compare commits

..

15 Commits

Author SHA1 Message Date
stringadmin 2574dfe3d7 fix: harden generation task recovery 2026-06-08 20:57:40 +08:00
stringadmin 136fb15397 docs: update legal compliance pages 2026-06-08 20:57:40 +08:00
stringadmin 4e97e706fd Add beta application email fields 2026-06-08 18:30:05 +08:00
stringadmin 30536ad15f Fix wan2.7 image quality selection 2026-06-08 18:26:44 +08:00
stringadmin e78cc05299 Merge remote-tracking branch 'origin/master' into codex/generation-task-reliability 2026-06-08 17:39:11 +08:00
stringadmin b88be66e7f Merge pull request 'feat: Workbench SaaS视觉升级与视图重置机制' (#26) from feat/workbench-saas-polish-and-reset into master
Reviewed-on: #26
2026-06-08 09:31:55 +00:00
stringadmin 1a9196a63a Merge branch 'master' into feat/workbench-saas-polish-and-reset 2026-06-08 09:31:49 +00:00
ludan 4dfcb6fc8a feat: Workbench SaaS视觉升级与视图重置机制
本次提交包含以下改进:

## 1. Workbench视图重置机制 (App.tsx + WorkbenchPage.tsx)
- 在App.tsx中新增workbenchResetToken状态,每次导航到workbench页面且存在session时递增token
- WorkbenchPage新增resetToken属性,检测token变化后自动调用handleNewConversation()重置工作台状态
- 重置时清空消息列表和活跃会话ID,确保每次进入工作台都是全新状态

## 2. 滚动操作提示系统 (WorkbenchPage.tsx)
- 新增scrollActionHint状态和hideScrollActionHint/showScrollActionHint方法
- 用户滚动离开消息区域时自动显示滚动方向提示(顶部/底部按钮)
- 1.4秒后自动隐藏提示,优化交互体验
- 手动点击滚动按钮后立即隐藏提示
- 为滚动按钮添加--top/--bottom标识类名,支持独立定位

## 3. Prompt案例弹窗自适应布局 (WorkbenchPage.tsx)
- renderPromptCaseOverlay重构为动态计算moda l类名
- 根据图片实测宽高比(is-tall-media/is-portrait-media)和文案长度(is-long-copy)动态调整布局
- 添加handlePromptCaseImageLoad回调在图片加载后测量尺寸

## 4. Workbench SaaS视觉美化 (workbench.css)
- 全新SaaS风格设计变量(--wb-panel, --wb-line, --wb-shadow等)
- 首页区域:标题样式、Composer输入框圆角/阴影/聚焦态、发送按钮渐变样式
- 模式选择/芯片组件:下拉菜单、悬停态优化、选中态高亮
- 聊天消息区:气泡圆角、头像样式、消息间距、空状态引导
- 图片/视频结果卡片:边框、阴影、标签徽章、视频PLAY标识
- 生成中卡片:停止按钮样式
- 会话侧边栏:折叠态浮动按钮定位、展开态面板样式、选中项左侧指示条
- 滚动快捷键:固定定位圆形按钮、显示/隐藏过渡动画
- Prompt案例弹窗:桌面端毛玻璃双栏布局、移动端底部面板布局
- @media适配:560px/720px/900px/980px四个断点全覆盖

## 5. 全局移动端布局变量 (dark-green.css)
- 新增--dg-mobile-nav-height/gap/space CSS变量,统一移动端底部导航高度计算
- 优化Topbar z-index层级
- 非特殊页面自动添加顶部padding避让移动导航
- Profile弹窗fixed定位及安全区域适配
2026-06-08 17:30:21 +08:00
stringadmin e351e93200 Center beta application review layout 2026-06-08 16:35:32 +08:00
stringadmin 117b9354eb Restore moderation page styles 2026-06-08 16:32:16 +08:00
stringadmin 446514dd06 Fix beta application review page scrolling 2026-06-08 16:26:38 +08:00
stringadmin 85a174bcb5 Avoid clearing sessions on permission errors 2026-06-08 16:20:52 +08:00
stringadmin 560a7baddc Restore image generation estimate to 20 credits 2026-06-08 16:07:04 +08:00
stringadmin 4f7f67a278 Scale generation billing estimates to 1-to-100 credits 2026-06-08 16:03:52 +08:00
stringadmin 3963d9ae2f Show billing estimate and clarify session replacement 2026-06-08 15:55:50 +08:00
25 changed files with 2449 additions and 123 deletions
+7 -1
View File
@@ -374,6 +374,7 @@ function App() {
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
@@ -459,6 +460,9 @@ function App() {
);
const handleSetView = useCallback((view: WebViewKey) => {
if (view === "workbench" && Boolean(session)) {
setWorkbenchResetToken((token) => token + 1);
}
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
@@ -467,7 +471,7 @@ function App() {
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [setView, setWorkspaceExpanded]);
}, [session, setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage();
@@ -1313,11 +1317,13 @@ function App() {
case "workbench":
return (
<WorkbenchPage
key={`workbench-${workbenchResetToken}`}
isAuthenticated={Boolean(session)}
session={session}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
resetToken={workbenchResetToken}
/>
);
case "home":
+72 -1
View File
@@ -4,6 +4,7 @@ import {
isRecord,
readJsonResponse,
serverRequest,
isServerRequestError,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -247,6 +248,46 @@ let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
const PENDING_CANCEL_TASKS_KEY = "omniai:pending-task-cancellations";
function readPendingCancelTaskIds(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(PENDING_CANCEL_TASKS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : [];
} catch {
return [];
}
}
function writePendingCancelTaskIds(taskIds: string[]): void {
if (typeof window === "undefined") return;
try {
const uniqueIds = Array.from(new Set(taskIds.filter(Boolean)));
if (uniqueIds.length) {
window.localStorage.setItem(PENDING_CANCEL_TASKS_KEY, JSON.stringify(uniqueIds));
} else {
window.localStorage.removeItem(PENDING_CANCEL_TASKS_KEY);
}
} catch {
// Pending cancellation recovery is best-effort.
}
}
function markTaskCancelPending(taskId: string): void {
writePendingCancelTaskIds([...readPendingCancelTaskIds(), taskId]);
}
function clearPendingTaskCancel(taskId: string): void {
writePendingCancelTaskIds(readPendingCancelTaskIds().filter((id) => id !== taskId));
}
function shouldRetryTaskCancel(error: unknown): boolean {
if (!isServerRequestError(error)) return true;
const status = error.status;
return status === 429 || status === undefined || status >= 500;
}
export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
@@ -335,18 +376,48 @@ export const aiGenerationClient = {
},
async cancelTask(taskId: string): Promise<void> {
markTaskCancelPending(taskId);
try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed",
});
clearPendingTaskCancel(taskId);
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
if (isOptionalApiRouteMissing(error) || !shouldRetryTaskCancel(error)) {
clearPendingTaskCancel(taskId);
return;
}
throw error;
}
},
cancelTaskOnUnload(taskId: string): void {
markTaskCancelPending(taskId);
const url = buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/cancel`);
const headers = buildAuthHeaders();
const body = JSON.stringify({ reason: "page_unload" });
try {
void fetch(url, {
method: "PATCH",
headers,
body,
credentials: "include",
keepalive: true,
});
} catch {
// Page unload cancellation is best-effort.
}
},
flushPendingTaskCancellations(): void {
readPendingCancelTaskIds().forEach((taskId) => {
this.cancelTask(taskId).catch(() => {});
});
},
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
+4
View File
@@ -2,6 +2,7 @@ import { serverRequest } from "./serverConnection";
export interface BetaApplicationInput {
name: string;
email: string;
phone: string;
wechat: string;
industry: string;
@@ -16,6 +17,7 @@ export interface BetaApplicationInput {
wantFeature: string[];
selfStatement: string;
signature: string;
applicationDate: string;
agreeRules: boolean;
}
@@ -72,6 +74,7 @@ function normalizeApplication(raw: unknown): BetaApplicationItem {
userId: readNumberOrNull(item.userId),
username: readNullableString(item.username),
name: readString(item.name),
email: readString(item.email),
phone: readString(item.phone),
wechat: readString(item.wechat),
industry: readString(item.industry),
@@ -86,6 +89,7 @@ function normalizeApplication(raw: unknown): BetaApplicationItem {
wantFeature: readStringArray(item.wantFeature),
selfStatement: readString(item.selfStatement),
signature: readString(item.signature),
applicationDate: readString(item.applicationDate),
agreeRules: item.agreeRules === true,
status: normalizeStatus(item.status),
inviteCode: readNullableString(item.inviteCode),
+4
View File
@@ -21,6 +21,10 @@ function getEffectiveLimit(): number {
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
}
export function getEffectiveGenerationLimit(): number {
return getEffectiveLimit();
}
export function getGenerationUserKey(userId?: string | number | null): string {
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
}
+6 -1
View File
@@ -38,9 +38,14 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled);
if (!enabled) return null;
const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value);
return {
value,
label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value),
label:
value === "wan2.7-image-pro"
? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro"
: label,
description: toStringValue(raw.description) || undefined,
badge: toStringValue(raw.badge) || undefined,
enabled,
+12
View File
@@ -248,6 +248,17 @@ function isNonAuthErrorCode(code: string | undefined): boolean {
].includes(code);
}
function isAuthFailureResponse(status: number, payload: unknown): boolean {
if (status === 401) return true;
if (status !== 403) return false;
const code = getPayloadCode(payload);
if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true;
const message = getPayloadMessage(payload) || "";
return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message);
}
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
if (status !== 401 && status !== 403) return;
if (typeof window === "undefined") return;
@@ -263,6 +274,7 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
// Non-auth 403 errors (enterprise model access, insufficient balance) must
// not trigger session expiry.
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
if (!isAuthFailureResponse(status, payload)) return;
const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return;
+12 -6
View File
@@ -10,6 +10,7 @@ interface BetaApplicationModalProps {
/* ── Form state ── */
interface BetaFormData {
name: string;
email: string;
phone: string;
wechat: string;
industry: string;
@@ -24,11 +25,13 @@ interface BetaFormData {
wantFeature: string[];
selfStatement: string;
signature: string;
applicationDate: string;
agreeRules: boolean;
}
const INITIAL_FORM: BetaFormData = {
name: "",
email: "",
phone: "",
wechat: "",
industry: "",
@@ -43,6 +46,7 @@ const INITIAL_FORM: BetaFormData = {
wantFeature: [],
selfStatement: "",
signature: "",
applicationDate: "",
agreeRules: false,
};
@@ -156,10 +160,12 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
const validate = () => {
if (!form.name.trim()) return "请填写姓名 / 常用昵称";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱";
if (!form.phone.trim()) return "请填写联系手机号码";
if (!form.wechat.trim()) return "请填写微信账号";
if (!form.selfStatement.trim()) return "请填写申请自述";
if (!form.signature.trim()) return "请填写申请人确认签字";
if (!form.applicationDate.trim()) return "请填写申请日期";
if (!form.agreeRules) return "请先阅读并同意内测规则";
return null;
};
@@ -178,6 +184,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
await betaApplicationClient.submit({
...form,
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
wechat: form.wechat.trim(),
industry: form.industry.trim(),
@@ -190,9 +197,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
feedbackWilling: form.feedbackWilling.trim(),
selfStatement: form.selfStatement.trim(),
signature: form.signature.trim(),
applicationDate: form.applicationDate.trim(),
});
setForm(INITIAL_FORM);
setMessage({ tone: "success", text: "申请已提交,请留意站内通知。" });
setMessage({ tone: "success", text: "申请已提交,请留意预留邮箱中的审核结果。" });
} catch (error) {
setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" });
} finally {
@@ -229,6 +237,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<h3 className="beta-doc-section__title"></h3>
<div className="beta-doc-grid">
<TextField label="姓名 / 常用昵称" value={form.name} onChange={(v) => update("name", v)} />
<TextField label="接收内测码邮箱" value={form.email} onChange={(v) => update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" />
<TextField label="联系手机号码" value={form.phone} onChange={(v) => update("phone", v)} />
<TextField label="微信账号" value={form.wechat} onChange={(v) => update("wechat", v)} />
<TextField label="所在行业 / 职业" value={form.industry} onChange={(v) => update("industry", v)} />
@@ -297,7 +306,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<li> <strong>500 50,000 </strong>使</li>
<li>线</li>
<li></li>
<li> <strong>48 </strong> </li>
<li> <strong>48 </strong> </li>
<li>线</li>
</ol>
@@ -312,10 +321,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<div className="beta-doc-grid beta-doc-grid--two">
<TextField label="申请人确认签字" value={form.signature} onChange={(v) => update("signature", v)} placeholder="请签署姓名" />
<div className="beta-text-field">
<span className="beta-text-field__label"></span>
<input type="text" className="beta-text-field__input" value="2026年 月 日" readOnly />
</div>
<TextField label="申请填写日期" value={form.applicationDate} onChange={(v) => update("applicationDate", v)} placeholder="例如:2026年6月8日" />
</div>
</section>
@@ -214,6 +214,7 @@ export default function BetaApplicationsPage({ session, onOpenLogin }: BetaAppli
<h3></h3>
<div className="beta-admin-field-grid">
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
<DetailField label="接收内测码邮箱" value={valueOrEmpty(selectedApplication.email)} />
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
@@ -248,6 +249,7 @@ export default function BetaApplicationsPage({ session, onOpenLogin }: BetaAppli
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
<div className="beta-admin-field-grid">
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
<DetailField label="申请填写日期" value={valueOrEmpty(selectedApplication.applicationDate)} />
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
+12 -8
View File
@@ -396,7 +396,6 @@ function CanvasPage({
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
const imageNodeInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLElement>(null);
const videoGenerationInFlightRef = useRef(new Set<string>());
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
@@ -417,7 +416,7 @@ function CanvasPage({
const {
textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
@@ -1887,13 +1886,14 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点");
}
let requestModel = resolveVideoRequestModel({ model, referenceUrls });
const task = await onCreateTask({
task = await onCreateTask({
title: videoNode.title || "视频节点生成",
type: "video",
prompt: prompt || "根据参考图片生成视频",
@@ -1916,10 +1916,12 @@ function CanvasPage({
if (task.status === "completed" && !task.outputUrl) {
throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
}
const taskId = task.id;
addCanvasGenKeepalive(taskId, nodeId, "video", projectId || "");
setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) });
const outputUrl =
task.outputUrl ||
(await waitForImageTaskResult(task.id, (status) => {
(await waitForVideoTaskResult(taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
const statusLabel =
status.status === "pending"
@@ -1932,11 +1934,12 @@ function CanvasPage({
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
}));
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
setVideoNodes((currentNodes) =>
@@ -1947,7 +1950,7 @@ function CanvasPage({
videoUrl: outputUrl,
assetRef: immediateAssetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: outputUrl,
updatedAt: new Date().toISOString(),
@@ -1961,7 +1964,7 @@ function CanvasPage({
url: outputUrl,
mediaType: "video/mp4",
resultType: "video",
taskId: task.id,
taskId,
originalUrl: outputUrl,
});
await delay(420);
@@ -1974,7 +1977,7 @@ function CanvasPage({
videoUrl: assetRef.url,
assetRef,
taskRef: {
taskId: task.id,
taskId,
status: "completed",
resultUrl: assetRef.url,
updatedAt: new Date().toISOString(),
@@ -1991,6 +1994,7 @@ function CanvasPage({
});
} finally {
videoGenerationInFlightRef.current.delete(nodeId);
if (task?.id) removeCanvasGenKeepalive(task.id);
}
};
+37 -2
View File
@@ -6,6 +6,7 @@ import type {
CanvasVideoGenerationState,
CanvasVideoNode,
} from "./canvasTypes";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
@@ -41,6 +42,13 @@ export function removeCanvasGenKeepalive(taskId: string): void {
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
}
export function cancelCanvasGenKeepaliveOnUnload(): void {
const entries = loadCanvasGenKeepalive();
if (!entries.length) return;
entries.forEach((entry) => aiGenerationClient.cancelTaskOnUnload(entry.taskId));
saveCanvasGenKeepalive([]);
}
export interface UseCanvasGenerationParams {
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
@@ -55,6 +63,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const [generationToast, setGenerationToast] = useState<string | null>(null);
const imageGenerationInFlightRef = useRef(new Set<string>());
const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const canvasGenKeepaliveRestoredRef = useRef(false);
@@ -125,7 +134,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
imageGenerationInFlightRef.current.delete(entry.nodeId);
});
} else if (entry.nodeKind === "video") {
imageGenerationInFlightRef.current.add(entry.nodeId);
videoGenerationInFlightRef.current.add(entry.nodeId);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
@@ -154,7 +163,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationInFlightRef.current.delete(entry.nodeId);
});
}
}
@@ -165,11 +174,36 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
useEffect(() => {
const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload();
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
const handleOnline = () => {
aiGenerationClient.flushPendingTaskCancellations();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, []);
return {
textGenerationState,
imageGenerationState,
@@ -177,6 +211,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
generationToast,
setGenerationToast,
imageGenerationInFlightRef,
videoGenerationInFlightRef,
textGenerationInFlightRef,
textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
@@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { communityClient } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import "../../styles/pages/compliance.css";
import type { WebCanvasWorkflow, WebUserSession } from "../../types";
import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils";
import { canManageCommunityCases } from "./communityPermissions";
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import { reportClient, type AdminReportItem } from "../../api/reportClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import "../../styles/pages/compliance.css";
import type { WebUserSession } from "../../types";
import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions";
+211 -60
View File
@@ -7,61 +7,213 @@ interface CompliancePageProps {
kind: ComplianceKind;
}
const companyName = "OmniAI";
const companyName = "南京万物可爱文化传媒有限公司";
const platformName = "Omniai平台";
const contactEmail = "system@omniai.net.cn";
const contactPhone = "15155073618";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼9A-501";
const icpRecord = "苏ICP备2026021747号-1";
const effectiveDate = "2026年06月8日";
const agreementSections = [
{
title: "服务范围",
body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
},
{
title: "账号与使用",
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
},
{
title: "内容合规",
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
},
{
title: "积分与付费",
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
},
{
title: "责任限制",
body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
},
];
const privacyPolicyText = `
2026068
2026068
使
Omniai平台的运营者使/使使/使
使/使/
使
使Cookie和同类技术
1. SDKAPI等方式访使/
2.
1//使
2/
3
使
使
1. /使
2. /便
3. 使QQ等
4.
1. IP地址访
2. 使
3.
SaaS软件服务
1. 使SaaS工具使SaaS软件工具便
2. SaaS工具生成的内容便
3. 使SaaS软件过程中产生的脱敏数据用于模型优化和改进服务
1. /
2. 使
3.
1/便
2/便
1. IP地址MAC地址WLAN接入点//
2. 使
3.
4. IP归属地IP地址归属地信息
//使
使Cookie和同类技术
1. Cookie使访使Cookie技术收集和存储您的登录状态使CookieCookie可能会导致您无法正常使用平台的某些功能
2. 使使广
1.
2.
3. SSL加密访访
访使
1. 18
2. 14使使
3.
使
SDKSDK可能会收集您的设备信息
system@omniai.net.cn
15155073618
9A楼9A-501
15
`.trim();
const privacySections = [
{
title: "收集的信息",
body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
},
{
title: "Cookie 与本地存储",
body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
},
{
title: "信息使用",
body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
},
{
title: "第三方处理",
body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
},
{
title: "用户权利",
body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
},
];
const agreementText = `
Omniai平台用户协议
2026068
2026068
使
SaaS软件服务使Omniai平台服务
18使
使使
使
1. SaaS软件工具
2. SaaS服务线使
3.
4.
5. 使
6.
使
1.
1使使Omniai平台服务时可能需要提供一些必要的信息使
2/使使
3
4怀
5Omniai平台无须对任何用户的任何登记资料承担任何责任
2.
1/使
2使
3.
1
2
使
1.
1SaaS软件服务线SaaS软件工具使
2
3
2.
1使SaaS软件创作
2使
3使
4
3.
使
AI内容生成特别提示SaaS软件中可能包含基于人工智能技术的内容生成功能AI技术的局限性使
1.
2.
1使SaaS软件独立创作生成的内容
2使SaaS软件和社区服务使使
3使
3.
1.
1
2
3
2.
1. 退
2.
3.
4. 使
1.
2. 使
3.
1.
2. AI生成内容免责SaaS软件生成内容的真实性使
3.
4.
5. 访使
6. 使使
1. 使
2.
3. 便使Omniai相关服务使Omniai的非公开内容
4. cookie等技术收集您的产品或服务使用使使
1. 使使
2.
1.
2.
1. 便
2.
3. system@omniai.net.cn与我们联系
4. 使
`.trim();
function getDocumentLines(text: string) {
return text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
}
function getLineClassName(line: string, index: number) {
if (index === 0) return "compliance-document__title";
if (/^(第[一二三四五六七八九十]+章|[一二三四五六七八九十]+、)/.test(line)) return "compliance-document__heading";
if (/^[一二三四五六七八九十]+/.test(line)) return "compliance-document__subheading";
if (/^[0-9]+\./.test(line) || /^[0-9]+/.test(line) || /^·/.test(line)) return "compliance-document__clause";
return "compliance-document__paragraph";
}
export default function CompliancePage({ kind }: CompliancePageProps) {
const isPrivacy = kind === "privacy";
const sections = isPrivacy ? privacySections : agreementSections;
const title = isPrivacy ? "隐私政策" : "用户协议";
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
const lines = getDocumentLines(isPrivacy ? privacyPolicyText : agreementText);
return (
<section className="compliance-page">
@@ -71,27 +223,26 @@ export default function CompliancePage({ kind }: CompliancePageProps) {
<div>
<span className="compliance-hero__eyebrow"></span>
<h1>{title}</h1>
<p>{companyName} 2026 6 3 </p>
<p>{companyName}{platformName}{effectiveDate}</p>
</div>
</header>
<div className="compliance-card">
{sections.map((section, index) => (
<article key={section.title} className="compliance-section">
<span>{String(index + 1).padStart(2, "0")}</span>
<div>
<h2>{section.title}</h2>
<p>{section.body}</p>
</div>
</article>
))}
</div>
<article className="compliance-card compliance-document">
{lines.map((line, index) => {
const className = getLineClassName(line, index);
if (className === "compliance-document__title") return <h2 key={`${index}-${line}`} className={className}>{line}</h2>;
if (className === "compliance-document__heading") return <h3 key={`${index}-${line}`} className={className}>{line}</h3>;
if (className === "compliance-document__subheading") return <h4 key={`${index}-${line}`} className={className}>{line}</h4>;
return <p key={`${index}-${line}`} className={className}>{line}</p>;
})}
</article>
<footer className="compliance-contact">
<strong></strong>
<span>{contactEmail}</span>
<span>{address}</span>
<span>{contactPhone}</span>
<span>ICP备2026021747号-1</span>
<span>{icpRecord}</span>
</footer>
</div>
</section>
+1
View File
@@ -2,6 +2,7 @@ import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "
import { useEffect, useState, type FormEvent } from "react";
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
import { reportClient, type ReportInput } from "../../api/reportClient";
import "../../styles/pages/compliance.css";
type SubmitState = "idle" | "loading" | "success" | "error";
+259 -34
View File
@@ -37,7 +37,7 @@ import {
import "../../styles/pages/workbench.css";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { claimGenerationSlot, getActiveGenerationTaskCount, getEffectiveGenerationLimit, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
@@ -67,7 +67,6 @@ import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
@@ -79,10 +78,12 @@ import {
import { isViduModel } from "../../utils/viduRouting";
import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import {
getImageQualityOptions,
getImageQualityOptionsForContext,
getDefaultImageQuality,
getDefaultImageQualityForContext,
getVideoQualityOptions,
getDefaultVideoQuality,
getVideoQualityLabel,
@@ -201,6 +202,7 @@ interface WorkbenchPageProps {
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void;
resetToken?: number;
}
// ─── Component ───────────────────────────────────────────────────────────
@@ -220,12 +222,19 @@ const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
video: <VideoCameraOutlined />,
};
function formatCreditValue(value: number): string {
if (!Number.isFinite(value)) return "-";
if (value >= 100) return Math.round(value).toLocaleString("zh-CN");
return Number(value.toFixed(2)).toString();
}
function WorkbenchPage({
isAuthenticated,
session,
onRequireLogin,
onOpenResultInCanvas,
onRefreshUsage,
resetToken,
}: WorkbenchPageProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const referenceInputRef = useRef<HTMLInputElement | null>(null);
@@ -244,10 +253,11 @@ function WorkbenchPage({
const activeConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const conversationMessagesCacheRef = useRef<Map<number, ChatMessage[]>>(new Map());
const skipConversationAutoSelectRef = useRef(false);
const skipConversationAutoSelectRef = useRef(Boolean(resetToken));
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionHintTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
@@ -256,7 +266,7 @@ function WorkbenchPage({
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => readStoredMessages());
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
@@ -279,7 +289,7 @@ function WorkbenchPage({
const [projectError, setProjectError] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<number | null>(() =>
readStoredActiveConversationId(readStoredMessages()),
resetToken ? null : readStoredActiveConversationId(readStoredMessages()),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
@@ -289,7 +299,9 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionHint, setScrollActionHint] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
const lastResetTokenRef = useRef(resetToken);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
@@ -415,7 +427,6 @@ function WorkbenchPage({
const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
@@ -443,6 +454,7 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
@@ -459,11 +471,72 @@ function WorkbenchPage({
setSidebarCollapsed(!hasSidebarRecords);
}, [hasSidebarRecords]);
const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]);
const hasImageReferences = activeMode === "image" && referenceItems.some((item) => item.kind === "image");
const isImageGridMode = activeMode === "image" && imageGridMode !== "single";
const imageQualityContext = useMemo(
() => ({
hasReferenceImages: hasImageReferences,
isGridMode: isImageGridMode,
}),
[hasImageReferences, isImageGridMode],
);
const imageQualityOptions = useMemo(
() => getImageQualityOptionsForContext(imageModel, imageQualityContext),
[imageModel, imageQualityContext],
);
const imageGridModeOptions = useMemo(
() =>
String(imageModel || "").toLowerCase().startsWith("wan2.7-")
? GRID_MODE_OPTIONS.filter((option) => option.value !== "grid-25")
: GRID_MODE_OPTIONS,
[imageModel],
);
const videoQualityOptions = getVideoQualityOptions(videoModel);
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
const billingEstimate = useMemo(() => {
if (activeMode === "image") {
return {
label: "预计 20 积分",
title: `图片生成按任务计费:${activeModel}${imageSettingsSummary},预计 20 积分`,
};
}
if (activeMode === "video") {
try {
const durationSeconds = Math.max(1, Math.ceil(Number(videoDuration) || 1));
const credits = calculateEnterpriseVideoCredits({
model: activeModelValue,
resolution: videoQuality,
durationSeconds,
muted: false,
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
});
return {
label: `预计 ${formatCreditValue(credits)} 积分`,
title: `${activeModel}${videoQualityLabel}${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
};
} catch {
return {
label: "计费以提交后为准",
title: "当前模型的预估计费暂不可用,实际扣费以服务端结算为准",
};
}
}
return {
label: "按 Token 结算",
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
};
}, [
activeMode,
activeModel,
activeModelValue,
imageSettingsSummary,
referenceItems,
videoDuration,
videoQuality,
videoQualityLabel,
]);
const composerPlaceholder =
referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder;
const dropdownDirection = hasActivatedWorkspace ? "up" : "down";
@@ -496,6 +569,31 @@ function WorkbenchPage({
});
}, []);
const hideScrollActionHint = useCallback(() => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
scrollActionHintTimerRef.current = null;
}
setScrollActionHint(null);
}, []);
const showScrollActionHint = useCallback((direction: "top" | "bottom") => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
setScrollActionHint(direction);
scrollActionHintTimerRef.current = window.setTimeout(() => {
setScrollActionHint(null);
scrollActionHintTimerRef.current = null;
}, 1400);
}, []);
useEffect(() => () => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
{
@@ -890,6 +988,54 @@ function WorkbenchPage({
persistKeepaliveTasks(rest);
};
const releaseKeepaliveTaskLocally = useCallback((taskId: string, options?: { cancelServer?: boolean }) => {
const task = keepaliveTasksRef.current[taskId];
taskAbortControllersRef.current.get(taskId)?.abort();
taskAbortControllersRef.current.delete(taskId);
removeKeepaliveTask(taskId);
if (task && options?.cancelServer) {
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
}
syncActiveGenerationUi();
}, [syncActiveGenerationUi]);
const releaseKeepaliveTaskAfterNetworkLoss = useCallback((task: WorkbenchKeepaliveTask, progress: number) => {
const latestTask = {
...task,
progress,
statusLabel: "网络中断,已释放提交按钮",
};
void patchConversationMessage(task.conversationId, task.assistantMessageId, {
status: "failed",
taskProgress: Math.max(progress, 100),
taskStatusLabel: "网络中断",
body: "网络中断,当前任务已停止等待并释放提交按钮。请确认网络恢复后重新提交任务。",
});
upsertKeepaliveTask(latestTask);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
if (activeConversationIdRef.current === task.conversationId) {
setIsGenerating(false);
setGenerationStatus("网络中断,已释放提交按钮");
setGenerationProgress(0);
}
}, [patchConversationMessage, releaseKeepaliveTaskLocally]);
const cancelActiveKeepaliveTasksOnPageExit = useCallback(() => {
const tasks = Object.values(keepaliveTasksRef.current);
if (!tasks.length) return;
tasks.forEach((task) => {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
releaseGenerationSlot(task.concurrencySlotId);
aiGenerationClient.cancelTaskOnUnload(task.taskId);
});
keepaliveTasksRef.current = {};
persistKeepaliveTasks({});
setIsGenerating(false);
setGenerationStatus("已释放未完成任务");
setGenerationProgress(0);
}, []);
const runKeepalivePoll = useCallback(
(task: WorkbenchKeepaliveTask) => {
if (taskAbortControllersRef.current.has(task.taskId)) return;
@@ -916,6 +1062,10 @@ function WorkbenchPage({
if (abortController.signal.aborted) return;
if (attempt > 0) await sleep(3000);
if (abortController.signal.aborted) return;
if (typeof navigator !== "undefined" && navigator.onLine === false) {
releaseKeepaliveTaskAfterNetworkLoss(task, lastKnownProgress);
return;
}
let status;
try {
@@ -929,7 +1079,8 @@ function WorkbenchPage({
taskProgress: 100,
taskStatusLabel: "任务异常",
});
removeKeepaliveTask(task.taskId);
releaseKeepaliveTaskLocally(task.taskId, { cancelServer: true });
onRefreshUsage?.();
return;
}
continue;
@@ -1128,9 +1279,15 @@ function WorkbenchPage({
useEffect(() => {
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
setImageQuality(getDefaultImageQuality(imageModel));
setImageQuality(getDefaultImageQualityForContext(imageModel, imageQualityContext));
}
}, [imageModel, imageQuality, imageQualityOptions]);
}, [imageModel, imageQuality, imageQualityContext, imageQualityOptions]);
useEffect(() => {
if (!imageGridModeOptions.some((option) => option.value === imageGridMode)) {
setImageGridMode("single");
}
}, [imageGridMode, imageGridModeOptions]);
useEffect(() => {
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
@@ -1151,6 +1308,24 @@ function WorkbenchPage({
};
}, [runKeepalivePoll]);
useEffect(() => {
const handlePageHide = () => {
cancelActiveKeepaliveTasksOnPageExit();
};
const handleOnline = () => {
Object.values(keepaliveTasksRef.current).forEach((task) => runKeepalivePoll(task));
syncActiveGenerationUi();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
}, [cancelActiveKeepaliveTasksOnPageExit, runKeepalivePoll, syncActiveGenerationUi]);
useEffect(() => {
persistPromptHistory(promptHistory);
}, [promptHistory]);
@@ -1266,6 +1441,12 @@ function WorkbenchPage({
activeConversationIdRef.current = null;
}, [syncActiveGenerationUi]);
useEffect(() => {
if (resetToken === undefined || lastResetTokenRef.current === resetToken) return;
lastResetTokenRef.current = resetToken;
handleNewConversation();
}, [handleNewConversation, resetToken]);
const handleSelectProject = useCallback((id: string) => {
if (!id) {
handleNewConversation();
@@ -1420,6 +1601,7 @@ function WorkbenchPage({
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
shouldFollowNewMessagesRef.current = atBottom;
setComposerHidden(!(atTop || atBottom));
hideScrollActionHint();
lastScrollTopRef.current = top;
};
@@ -1434,24 +1616,27 @@ function WorkbenchPage({
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
hideScrollActionHint();
} else if (Math.abs(delta) > scrollDeltaThreshold) {
setComposerHidden(true);
showScrollActionHint(delta < 0 ? "top" : "bottom");
}
lastScrollTopRef.current = top;
};
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]);
}, [hasActivatedWorkspace, hideScrollActionHint, showScrollActionHint]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
if (!surface) return;
const top = direction === "top" ? 0 : surface.scrollHeight;
hideScrollActionHint();
setComposerHidden(false);
surface.scrollTo({ top, behavior: "smooth" });
}, []);
}, [hideScrollActionHint]);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -1771,7 +1956,7 @@ function WorkbenchPage({
const trimmedPrompt = (promptOverride ?? inputValue).trim();
if (!trimmedPrompt) return;
const userKey = getGenerationUserKey(session?.user.id);
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return;
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= getEffectiveGenerationLimit()) return;
setReferencePreviewOpen(false);
let conversationId = activeConversationIdRef.current ?? activeConversationId;
@@ -2250,8 +2435,11 @@ function WorkbenchPage({
setProjectError("仅支持对视频结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const userKey = getGenerationUserKey(session?.user.id);
const activeCount = getActiveGenerationTaskCount(userKey);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2372,8 +2560,11 @@ function WorkbenchPage({
setProjectError("仅支持对图片结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
const userKey = getGenerationUserKey(session?.user.id);
const activeCount = getActiveGenerationTaskCount(userKey);
const limit = getEffectiveGenerationLimit();
if (activeCount >= limit) {
setProjectError(`当前已有 ${activeCount} 个任务进行中(上限${limit}个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2545,7 +2736,16 @@ function WorkbenchPage({
}
};
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
const activeGenerationLimit = getEffectiveGenerationLimit();
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= activeGenerationLimit;
const promptIsEmpty = !inputValue.trim();
const sendDisabled = promptIsEmpty || generationLimitReached;
const sendButtonTitle = promptIsEmpty
? "输入内容后可发送"
: generationLimitReached
? `当前已有 ${activeGenerationCount} 个任务进行中(上限 ${activeGenerationLimit} 个),请等待任一任务完成`
: billingEstimate.title;
const suggestedPrompts = [
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
@@ -2810,7 +3010,7 @@ function WorkbenchPage({
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
options={GRID_MODE_OPTIONS}
options={imageGridModeOptions}
disabled={disabled}
isOpen={toolbarMenuId === "image-grid-mode"}
onToggle={() => toggleToolbarMenu("image-grid-mode")}
@@ -2882,10 +3082,15 @@ function WorkbenchPage({
)}
</div>
<div className="wb-composer__toolbar-right">
<span className="wb-composer__billing-estimate" title={billingEstimate.title}>
{billingEstimate.label}
</span>
<button
type="button"
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
disabled={sendDisabled || isGenerating}
title={isGenerating ? "任务处理中" : sendButtonTitle}
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
onClick={() => {
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
mode: activeMode,
@@ -2933,9 +3138,29 @@ function WorkbenchPage({
</div>
) : null;
const renderPromptCaseOverlay = () =>
selectedPromptCase ? (
<div className="wb-prompt-case-modal" role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
const renderPromptCaseOverlay = () => {
if (!selectedPromptCase) return null;
const measuredRatio = promptCaseMeasuredRatios[selectedPromptCase.id];
const ratioParts = selectedPromptCase.ratio.replace(/\s+/g, "").split(":").map(Number);
const declaredRatio =
ratioParts.length === 2 && ratioParts[0] > 0 && ratioParts[1] > 0
? ratioParts[0] / ratioParts[1]
: null;
const caseRatio =
typeof measuredRatio === "number" && Number.isFinite(measuredRatio) && measuredRatio > 0
? measuredRatio
: declaredRatio;
const copyLength = `${selectedPromptCase.summary} ${selectedPromptCase.prompt}`.length;
const modalClassName = [
"wb-prompt-case-modal",
caseRatio && caseRatio < 0.72 ? "is-tall-media" : "",
caseRatio && caseRatio >= 0.72 && caseRatio < 1 ? "is-portrait-media" : "",
copyLength > 260 ? "is-long-copy" : "",
].filter(Boolean).join(" ");
return (
<div className={modalClassName} role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
<button
type="button"
className="wb-prompt-case-modal__backdrop"
@@ -2944,7 +3169,11 @@ function WorkbenchPage({
/>
<section className="wb-prompt-case-modal__panel">
<div className="wb-prompt-case-modal__media">
<img src={selectedPromptCase.imageUrl} alt={selectedPromptCase.title} />
<img
src={selectedPromptCase.imageUrl}
alt={selectedPromptCase.title}
onLoad={(event) => handlePromptCaseImageLoad(selectedPromptCase.id, event)}
/>
</div>
<aside className="wb-prompt-case-modal__sidebar">
<button
@@ -2984,7 +3213,8 @@ function WorkbenchPage({
</aside>
</section>
</div>
) : null;
);
};
if (!hasActivatedWorkspace) {
return (
@@ -3079,8 +3309,8 @@ function WorkbenchPage({
</div>
</div>
{renderConversationSidebar()}
</div>
{renderConversationSidebar()}
{renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()}
{renderDeleteDialog()}
@@ -3166,11 +3396,6 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span>
</div>
)}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard
message={message}
@@ -3227,10 +3452,10 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<div className={`wb-chat-scroll-actions${scrollActionHint ? ` is-showing-${scrollActionHint}` : ""}`} aria-label="聊天滚动">
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
@@ -3239,7 +3464,7 @@ function WorkbenchPage({
</button>
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
title="到达聊天底部"
aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")}
+1 -1
View File
@@ -231,7 +231,7 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
}));
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
{ value: "wan2.7-image", label: "wan 2.7" },
{ value: "gpt-image-2", label: "omni-GPT" },
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
+2 -2
View File
@@ -33,7 +33,7 @@ const initialState: SessionState = {
loginPromptOpen: false,
pendingAction: null,
sessionReplacedOpen: false,
sessionReplacedMessage: '您的账号已在其他设备登录,此设备的登录状态已失效。',
sessionReplacedMessage: '当前账号已在其他设备登录,此设备的登录状态已失效。',
};
export const useSessionStore = create<SessionState & SessionActions>((set) => ({
@@ -55,7 +55,7 @@ export const useSessionStore = create<SessionState & SessionActions>((set) => ({
showSessionReplaced: (message) => set({
sessionReplacedOpen: true,
sessionReplacedMessage: message || '您的账号已在其他设备登录(最多同时 2 台设备),此设备的登录状态已失效。',
sessionReplacedMessage: message || '当前账号已在其他设备登录,此设备的登录状态已失效。',
}),
hideSessionReplaced: () => set({ sessionReplacedOpen: false }),
+19 -1
View File
@@ -3,7 +3,11 @@
flex-direction: column;
gap: 18px;
width: min(1180px, calc(100vw - 48px));
margin: 0 auto;
height: 100%;
min-height: 0;
padding: 24px;
overflow: hidden;
}
.beta-admin-toolbar {
@@ -90,6 +94,8 @@
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
flex: 1;
min-height: 0;
align-items: start;
}
@@ -97,7 +103,7 @@
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 220px);
max-height: 100%;
overflow: auto;
padding-right: 4px;
}
@@ -174,6 +180,10 @@
flex-direction: column;
gap: 14px;
min-width: 0;
max-height: 100%;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.beta-admin-detail__header,
@@ -376,6 +386,7 @@
.beta-admin-page__inner {
width: min(100%, calc(100vw - 24px));
padding: 16px 12px;
overflow: auto;
}
.beta-admin-toolbar,
@@ -385,11 +396,18 @@
.beta-admin-layout {
grid-template-columns: 1fr;
overflow: visible;
}
.beta-admin-list {
max-height: none;
}
.beta-admin-detail {
max-height: none;
overflow: visible;
padding-right: 0;
}
}
@media (max-width: 640px) {
+67
View File
@@ -788,6 +788,65 @@
overflow: hidden;
}
.compliance-document {
gap: 0;
padding: 30px 34px 34px;
overflow: visible;
}
.compliance-document__title,
.compliance-document__heading,
.compliance-document__subheading,
.compliance-document__paragraph,
.compliance-document__clause {
margin: 0;
max-width: 100%;
color: var(--fg-body);
letter-spacing: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.compliance-document__title {
margin-bottom: 18px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: clamp(24px, 3vw, 32px);
line-height: 1.22;
}
.compliance-document__heading {
margin: 26px 0 12px;
padding-top: 4px;
color: var(--accent);
font-size: 20px;
line-height: 1.35;
}
.compliance-document__subheading {
margin: 18px 0 8px;
font-size: 16px;
line-height: 1.5;
}
.compliance-document__paragraph,
.compliance-document__clause {
color: var(--fg-muted);
font-size: 14px;
line-height: 1.85;
}
.compliance-document__paragraph + .compliance-document__paragraph,
.compliance-document__clause + .compliance-document__clause,
.compliance-document__paragraph + .compliance-document__clause,
.compliance-document__clause + .compliance-document__paragraph {
margin-top: 8px;
}
.compliance-document__clause {
padding-left: 12px;
}
.compliance-section {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
@@ -892,4 +951,12 @@
.compliance-section {
grid-template-columns: 1fr;
}
.compliance-document {
padding: 22px 18px 26px;
}
.compliance-document__clause {
padding-left: 0;
}
}
+15
View File
@@ -11915,6 +11915,21 @@
gap: 6px;
}
.wb-composer__billing-estimate {
max-width: 138px;
padding: 6px 9px;
border: 2px solid #111;
background: #fffbe8;
color: #111;
font-size: 12px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 2px 2px 0 #111;
}
.wb-composer__send-primary {
display: inline-flex;
align-items: center;
File diff suppressed because it is too large Load Diff
+44
View File
@@ -1794,6 +1794,14 @@
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .wb-composer__billing-estimate {
border: 1px solid var(--border-default);
border-radius: 999px;
background: var(--bg-elevated);
color: var(--fg-body);
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .wb-composer__send-primary:hover:not(:disabled) {
background: var(--accent-hover);
color: var(--dg-button-text);
@@ -10837,8 +10845,44 @@
}
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] {
--dg-mobile-nav-height: 58px;
--dg-mobile-nav-gap: 12px;
--dg-mobile-nav-space: calc(var(--dg-mobile-nav-height) + var(--dg-mobile-nav-gap));
}
.web-shell[data-ui-theme="dark-green"] .web-topbar {
z-index: 72;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
min-height: 0;
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
overflow: auto;
}
.web-shell[data-ui-theme="dark-green"]:not([data-view="home"]):not([data-view="login"]):not([data-view="workbench"]):not([data-view="agent"]):not([data-view="avatarConsole"]) .web-shell__page {
padding-top: var(--dg-mobile-nav-space);
}
.web-shell[data-ui-theme="dark-green"] .profile-popover {
position: fixed;
top: calc(56px + var(--dg-mobile-nav-space) + env(safe-area-inset-top, 0px));
right: 12px;
z-index: 120;
width: min(288px, calc(100vw - 24px));
max-height: calc(100svh - 56px - var(--dg-mobile-nav-space) - 24px);
overflow-y: auto;
transform-origin: top right;
}
.web-shell[data-ui-theme="dark-green"] .floating-nav {
top: calc(56px + env(safe-area-inset-top, 0px));
z-index: 50;
right: 12px;
bottom: auto;
left: 12px;
+4 -3
View File
@@ -40,6 +40,7 @@ export const ENTERPRISE_VIDEO_RESOLUTION_OPTIONS = [
export const ENTERPRISE_DEFAULT_VIDEO_MODEL = HAPPY_HORSE_UI_MODEL;
export const ENTERPRISE_DEFAULT_VIDEO_RESOLUTION = "1080P";
const CREDITS_PER_CNY = 100;
export interface EnterpriseVideoPricingInput {
model: string;
@@ -74,11 +75,11 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput)
}
if (model.includes("vidu")) {
return resolution === "720P" ? 0.4 : 0.8;
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("pixverse")) {
return resolution === "720P" ? 0.4 : 0.8;
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("kling")) {
@@ -94,5 +95,5 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput)
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2));
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
}
+19
View File
@@ -25,11 +25,30 @@ export function getImageQualityOptions(model: string): CanvasOption[] {
: imageQualityOptions.filter((option) => option.value !== "4K");
}
export function getImageQualityOptionsForContext(
model: string,
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
): CanvasOption[] {
const options = getImageQualityOptions(model);
const shouldLimitTo2K =
String(model || "").toLowerCase() === "wan2.7-image-pro" &&
(context?.hasReferenceImages || context?.isGridMode);
return shouldLimitTo2K ? options.filter((option) => option.value !== "4K") : options;
}
export function getDefaultImageQuality(model: string): string {
const options = getImageQualityOptions(model);
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
}
export function getDefaultImageQualityForContext(
model: string,
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
): string {
const options = getImageQualityOptionsForContext(model, context);
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
}
// ─── Video quality ────────────────────────────────────────────────────────────
function normalizeVideoModel(model: string): string {
+5 -3
View File
@@ -32,8 +32,10 @@ export interface TextTokenUsage {
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const CREDITS_PER_CNY = 100;
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
@@ -151,7 +153,7 @@ export function estimateTextTokenCredits(usage: TextTokenUsage): number {
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));