Files
omniai-web/src/App.tsx
T

1492 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import ErrorBoundary from "./components/ErrorBoundary";
import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier";
import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient";
import { setUserMaxConcurrency } from "./api/generationConcurrency";
import { notificationClient } from "./api/notificationClient";
import {
SERVER_SESSION_REPLACED_EVENT,
SERVER_SESSION_EXPIRED_EVENT,
checkServerHealth,
clearAllUserStorage,
getErrorMessage,
type ServerSessionReplacedDetail,
} from "./api/serverConnection";
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError";
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
import AppShell from "./components/AppShell";
import { ShellIcon } from "./components/ShellIcon";
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
const AgentPage = lazy(() => import("./features/agent/AgentPage"));
const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
const CanvasPage = lazy(() => import("./features/canvas/CanvasPage"));
const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMixPage"));
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const BetaApplicationsPage = lazy(() => import("./features/beta-applications/BetaApplicationsPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage"));
const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
const MorePage = lazy(() => import("./features/more/MorePage"));
const ReportPage = lazy(() => import("./features/report/ReportPage"));
const ProfilePage = lazy(() => import("./features/profile/ProfilePage"));
const ProviderHealthPage = lazy(() => import("./features/provider-health/ProviderHealthPage"));
const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/ResolutionUpscalePage"));
const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage"));
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
import {
useSessionStore,
useProjectStore,
useTaskStore,
useAppStore,
type PendingAction,
} from "./stores";
import type {
WebCanvasWorkflow,
WebGenerationPreviewTask,
WebImageWorkbenchTool,
WebNavItem,
WebNotification,
WebProjectSummary,
WebUsageSummary,
WebUserSession,
WebViewKey,
} from "./types";
type SaveCanvasWorkflowOptions = {
silent?: boolean;
reason?: "manual" | "autosave" | "publish";
};
const emptyUsageSummary: WebUsageSummary = {
balanceCents: 0,
imageUsed: 0,
videoUsed: 0,
textUsed: 0,
source: "preview",
};
const VIEW_KEYS = new Set<WebViewKey>([
"home",
"workbench",
"community",
"login",
"agent",
"canvas",
"assets",
"ecommerceHub",
"ecommerce",
"ecommerceTemplates",
"sizeTemplate",
"scriptTokens",
"tokenUsage",
"imageWorkbench",
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
"more",
"communityReview",
"communityCaseAdd",
"betaApplications",
"report",
"providerHealth",
"userAgreement",
"privacyPolicy",
"not-found",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"login",
"workbench",
"canvas",
"community",
"communityReview",
"communityCaseAdd",
"betaApplications",
"assets",
"ecommerce",
"ecommerceHub",
"ecommerceTemplates",
"sizeTemplate",
"digitalHuman",
"characterMix",
"more",
]);
let legacyPageStylesPromise: Promise<unknown> | null = null;
function loadLegacyPageStyles(): Promise<unknown> {
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
return legacyPageStylesPromise;
}
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
rawView === "profile" || rawView === "auth"
? "login"
: rawView === "ecommerceHub"
? "ecommerce"
: rawView === "bug-feedback" || rawView === "feedback"
? "report"
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
? "userAgreement"
: rawView === "privacy" || rawView === "privacy-policy"
? "privacyPolicy"
: rawView === "community-review"
? "communityReview"
: rawView === "community-case-add"
? "communityCaseAdd"
: rawView === "beta-applications" || rawView === "beta-application-review"
? "betaApplications"
: rawView;
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
}
function readViewFromHash(): WebViewKey {
const raw = window.location.hash.replace(/^#\/?/, "");
if (!raw) return "home";
return normalizeViewKey(raw);
}
function isWorkspaceView(view: WebViewKey): boolean {
return (
view !== "workbench" &&
view !== "home" &&
view !== "community" &&
view !== "assets" &&
view !== "ecommerceHub" &&
view !== "ecommerce" &&
view !== "scriptTokens" &&
view !== "login" &&
view !== "not-found"
);
}
function isAdminAccount(session: WebUserSession | null): boolean {
if (!session) return false;
const role = String(session.user.role || "").trim().toLowerCase();
const enterpriseRole = String(session.user.enterpriseRole || "").trim().toLowerCase();
return role === "admin" || enterpriseRole === "admin";
}
function createWorkflowFromResult(payload: WorkbenchResultActionPayload): WebCanvasWorkflow {
const now = Date.now();
const resultNodeKind = payload.resultType === "video" ? "video" : "image";
return {
id: `workflow-result-${now}`,
version: 1,
title: `继续编辑:${payload.title}`,
description: payload.prompt || "从生成结果进入画布继续创作。",
source: "blank",
settings: {
model: payload.resultType === "video" ? "Seedance 2.0" : "omni-水果 Pro",
ratio: payload.resultType === "video" ? "16:9" : "1:1",
duration: payload.resultType === "video" ? "6s" : "0s",
resolution: payload.resultType === "video" ? "720p" : "2K",
},
nodes: [
{
id: "prompt",
kind: "prompt",
label: "原始提示词",
detail: payload.prompt || "继续补充提示词",
position: { x: 80, y: 120 },
},
{
id: "generated-result",
kind: resultNodeKind,
label: payload.resultType === "video" ? "生成视频" : "生成图片",
detail: payload.title,
previewUrl: payload.resultUrl,
position: { x: 410, y: 80 },
},
{
id: "next-edit",
kind: "model",
label: "二次编辑",
detail: "可继续扩图、图生图、视频化或拆分镜头",
position: { x: 760, y: 160 },
},
{
id: "output",
kind: "output",
label: "新版输出",
detail: "保存资产或提交社区",
position: { x: 1080, y: 110 },
},
],
edges: [
{ id: "prompt-generated-result", source: "prompt", target: "generated-result", label: "生成", animated: true },
{ id: "generated-result-next-edit", source: "generated-result", target: "next-edit", label: "继续", animated: true },
{ id: "next-edit-output", source: "next-edit", target: "output", label: "输出", animated: true },
],
};
}
function App() {
const initialView = readViewFromHash();
const lastNonAuthViewRef = useRef<WebViewKey>(initialView === "login" ? "workbench" : initialView);
const canvasAutoOpenedRecentRef = useRef(false);
// Session store
const {
session,
loginPromptOpen,
pendingAction,
sessionReplacedOpen,
sessionReplacedMessage,
setSession,
openLoginPrompt,
closeLoginPrompt,
showSessionReplaced,
hideSessionReplaced,
clearSession: clearSessionState,
} = useSessionStore(useShallow((s) => ({
session: s.session,
loginPromptOpen: s.loginPromptOpen,
pendingAction: s.pendingAction,
sessionReplacedOpen: s.sessionReplacedOpen,
sessionReplacedMessage: s.sessionReplacedMessage,
setSession: s.setSession,
openLoginPrompt: s.openLoginPrompt,
closeLoginPrompt: s.closeLoginPrompt,
showSessionReplaced: s.showSessionReplaced,
hideSessionReplaced: s.hideSessionReplaced,
clearSession: s.clearSession,
})));
// Project store
const {
projects,
projectsLoaded,
canvasWorkflow,
currentCanvasProjectId,
pendingDeleteProject,
deleteProjectSubmitting,
setProjects,
setProjectsLoaded,
setCanvasWorkflow,
setCurrentCanvasProjectId,
openDeleteProject: openDeleteProjectModal,
closeDeleteProject: closeDeleteProjectModal,
setDeleteProjectSubmitting,
clearProjectState,
} = useProjectStore(useShallow((s) => ({
projects: s.projects,
projectsLoaded: s.projectsLoaded,
canvasWorkflow: s.canvasWorkflow,
currentCanvasProjectId: s.currentCanvasProjectId,
pendingDeleteProject: s.pendingDeleteProject,
deleteProjectSubmitting: s.deleteProjectSubmitting,
setProjects: s.setProjects,
setProjectsLoaded: s.setProjectsLoaded,
setCanvasWorkflow: s.setCanvasWorkflow,
setCurrentCanvasProjectId: s.setCurrentCanvasProjectId,
openDeleteProject: s.openDeleteProject,
closeDeleteProject: s.closeDeleteProject,
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
clearProjectState: s.clearProjectState,
})));
// Task store
const {
tasks,
setTasks,
appendTask,
mergeServerTasks,
clearTasks,
} = useTaskStore(useShallow((s) => ({
tasks: s.tasks,
setTasks: s.setTasks,
appendTask: s.appendTask,
mergeServerTasks: s.mergeServerTasks,
clearTasks: s.clearTasks,
})));
// App store
const {
usage,
runtimeNotifications,
serverNotifications,
activeView,
workspaceExpanded,
imageWorkbenchTool,
pendingEcommerceTemplate,
backendHealth,
setUsage,
pushNotification,
setRuntimeNotifications,
setServerNotifications,
setView,
setWorkspaceExpanded,
setImageWorkbenchTool,
setPendingEcommerceTemplate,
setBackendHealth,
markNotificationRead,
markAllNotificationsRead,
clearAppState,
} = useAppStore(useShallow((s) => ({
usage: s.usage,
runtimeNotifications: s.runtimeNotifications,
serverNotifications: s.serverNotifications,
activeView: s.activeView,
workspaceExpanded: s.workspaceExpanded,
imageWorkbenchTool: s.imageWorkbenchTool,
pendingEcommerceTemplate: s.pendingEcommerceTemplate,
backendHealth: s.backendHealth,
setUsage: s.setUsage,
pushNotification: s.pushNotification,
setRuntimeNotifications: s.setRuntimeNotifications,
setServerNotifications: s.setServerNotifications,
setView: s.setView,
setWorkspaceExpanded: s.setWorkspaceExpanded,
setImageWorkbenchTool: s.setImageWorkbenchTool,
setPendingEcommerceTemplate: s.setPendingEcommerceTemplate,
setBackendHealth: s.setBackendHealth,
markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead,
clearAppState: s.clearAppState,
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) {
void loadLegacyPageStyles();
}
}, [activeView, ecommerceEverMounted]);
// Dismiss boot splash after first render
useEffect(() => {
const splash = document.getElementById("app-boot-splash");
if (splash) {
splash.style.opacity = "0";
const timer = setTimeout(() => splash.remove(), 350);
return () => clearTimeout(timer);
}
}, []);
// Pre-warm notification permission (lazy, on first click)
useEffect(() => { initNotificationPermission(); }, []);
// Global unhandled error / rejection listeners — report to server
useEffect(() => {
const handleUnhandled = (event: ErrorEvent) => {
reportError(event.error || new Error(event.message), "unhandled");
};
const handleRejection = (event: PromiseRejectionEvent) => {
reportError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), "rejection");
};
window.addEventListener("error", handleUnhandled);
window.addEventListener("unhandledrejection", handleRejection);
return () => {
window.removeEventListener("error", handleUnhandled);
window.removeEventListener("unhandledrejection", handleRejection);
};
}, []);
// Initialize canvasWorkflow if null
useEffect(() => {
if (!canvasWorkflow) {
setCanvasWorkflow(createBlankWorkflow());
}
}, [canvasWorkflow, setCanvasWorkflow]);
// Initialize activeView from hash
useEffect(() => {
setView(initialView);
if (isWorkspaceView(initialView)) {
setWorkspaceExpanded(true);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Recover background tasks on app start ──────────
useEffect(() => {
recoverAndResumeTasks();
}, []);
const navItems = useMemo<WebNavItem[]>(
() => [
{ key: "home", label: "首页", hint: "项目入口", icon: <ShellIcon name="home" /> },
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <ShellIcon name="robot" /> },
{
key: "ecommerce",
label: "电商生成",
hint: "AI创作与海报生成",
icon: <ShellIcon name="shopping" />,
},
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <ShellIcon name="branches" /> },
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <ShellIcon name="global" /> },
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <ShellIcon name="bar-chart" /> },
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <ShellIcon name="wallet" /> },
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <ShellIcon name="heart" /> },
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <ShellIcon name="folder" /> },
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <ShellIcon name="robot" /> },
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <ShellIcon name="customer-service" /> },
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <ShellIcon name="swap" /> },
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ShellIcon name="tool" /> },
],
[],
);
const handleSetView = useCallback((view: WebViewKey) => {
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
lastNonAuthViewRef.current = view;
}
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage();
clearSessionState();
setUserMaxConcurrency(null);
setProjects([]);
setProjectsLoaded(true);
setUsage(emptyUsageSummary);
clearTasks();
setRuntimeNotifications([]);
setServerNotifications([]);
setCanvasWorkflow(createBlankWorkflow());
setCurrentCanvasProjectId(null);
canvasAutoOpenedRecentRef.current = false;
setWorkspaceExpanded(false);
if (options?.resetView) {
handleSetView("login");
}
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
const showSessionReplacedModal = useCallback((message?: string) => {
clearAuthenticatedState({ resetView: true });
showSessionReplaced(message);
}, [clearAuthenticatedState, showSessionReplaced]);
useEffect(() => {
const handleSessionReplaced = (event: Event) => {
const detail = (event as CustomEvent<ServerSessionReplacedDetail>).detail;
showSessionReplacedModal(detail?.message);
};
window.addEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionReplaced);
window.addEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionReplaced);
return () => {
window.removeEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionReplaced);
window.removeEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionReplaced);
};
}, [showSessionReplacedModal]);
const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => {
setProjectsLoaded(false);
if (!nextSession) {
setProjects([]);
setUsage(emptyUsageSummary);
clearTasks();
setProjectsLoaded(true);
return;
}
const [projectResult, usageResult, taskResult] = await Promise.allSettled([
keyServerClient.listProjects(),
keyServerClient.getUsageSummary(),
aiGenerationClient.listTasks({ limit: 100 }),
]);
const loadedProjects = projectResult.status === "fulfilled" ? projectResult.value : [];
setProjects(loadedProjects);
setProjectsLoaded(projectResult.status === "fulfilled");
setUsage(
usageResult.status === "fulfilled"
? usageResult.value
: {
...emptyUsageSummary,
source: "server",
errorMessage: usageResult.reason instanceof Error ? usageResult.reason.message : "用量服务暂时不可用",
},
);
if (taskResult.status === "fulfilled") {
mergeServerTasks(taskResult.value);
setRuntimeNotifications(useAppStore.getState().runtimeNotifications.filter((item: WebNotification) => item.id !== "server-task-history-sync-error"));
} else {
setRuntimeNotifications([
{
id: "server-task-history-sync-error",
type: "info" as const,
title: "任务历史同步失败",
description: getErrorMessage(taskResult.reason),
createdAt: new Date().toISOString(),
isRead: false,
targetView: "login" as const,
},
...useAppStore.getState().runtimeNotifications.filter((item: WebNotification) => item.id !== "server-task-history-sync-error"),
].slice(0, 30));
}
}, [setProjects, setProjectsLoaded, setUsage, clearTasks, mergeServerTasks, setRuntimeNotifications]);
useEffect(() => {
if (!window.location.hash) {
window.history.replaceState(null, "", "#/home");
}
const handleHashChange = () => {
const nextView = readViewFromHash();
setView(nextView);
if (nextView !== "login") {
lastNonAuthViewRef.current = nextView;
}
if (isWorkspaceView(nextView)) {
setWorkspaceExpanded(true);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [setView, setWorkspaceExpanded]);
useEffect(() => {
let cancelled = false;
const loadSession = async () => {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
await hydrateAccountData(nextSession);
};
void loadSession();
return () => {
cancelled = true;
};
}, [hydrateAccountData, setSession]);
const refreshUsage = useCallback(async () => {
try {
const fresh = await keyServerClient.getUsageSummary();
setUsage(fresh);
} catch {
// silent — balance display will update on next refreshUsage call
}
}, [setUsage]);
useEffect(() => {
if (!session || sessionReplacedOpen) return undefined;
let cancelled = false;
let checking = false;
const verifySession = async () => {
if (checking) return;
checking = true;
try {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
if (nextSession) {
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
} else {
clearAuthenticatedState({ resetView: true });
}
} finally {
checking = false;
}
};
const handleFocusCheck = () => void verifySession();
const handleVisibilityCheck = () => {
if (document.visibilityState === "visible") {
void verifySession();
}
};
const intervalId = window.setInterval(() => void verifySession(), 60_000);
window.addEventListener("focus", handleFocusCheck);
document.addEventListener("visibilitychange", handleVisibilityCheck);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", handleFocusCheck);
document.removeEventListener("visibilitychange", handleVisibilityCheck);
};
}, [clearAuthenticatedState, session, sessionReplacedOpen, setSession]);
useEffect(() => {
let cancelled = false;
const refreshBackendHealth = async () => {
const health = await checkServerHealth();
if (!cancelled) {
setBackendHealth(health);
}
};
void refreshBackendHealth();
const intervalId = window.setInterval(() => void refreshBackendHealth(), 60_000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [setBackendHealth]);
const refreshServerNotifications = useCallback(async () => {
if (!keyServerClient.getStoredSession()) {
setServerNotifications([]);
return;
}
try {
const items = await notificationClient.list();
setServerNotifications(items);
} catch {
setServerNotifications([]);
}
}, [setServerNotifications]);
useEffect(() => {
if (!session) {
setServerNotifications([]);
return;
}
void refreshServerNotifications();
const intervalId = window.setInterval(() => void refreshServerNotifications(), 45_000);
return () => window.clearInterval(intervalId);
}, [refreshServerNotifications, session, setServerNotifications]);
const queueLoginGate = useCallback((action: PendingAction) => {
openLoginPrompt(action);
}, [openLoginPrompt]);
const openWorkflowProject = useCallback(
async (workflow: WebCanvasWorkflow) => {
const nextWorkflow = cloneWorkflow(workflow);
setCanvasWorkflow(nextWorkflow);
const project = await keyServerClient.createProjectSpace(nextWorkflow);
let savedProject = project;
try {
savedProject = await keyServerClient.saveProjectContent(project.id, nextWorkflow);
} catch (error) {
pushNotification({
type: "info",
title: "项目已创建,内容暂未保存",
description: error instanceof Error ? error.message : String(error),
targetView: "canvas",
targetId: project.id,
});
}
setCurrentCanvasProjectId(project.id);
setProjects([savedProject, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== savedProject.id)]);
setWorkspaceExpanded(true);
handleSetView("canvas");
},
[pushNotification, handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded],
);
const appendPreviewTask = useCallback(async (input: CreatePreviewTaskInput): Promise<WebGenerationPreviewTask> => {
const task = await webGenerationGateway.createPreviewTask(input);
appendTask(task);
return task;
}, [appendTask]);
const handleStartCreate = useCallback(() => {
const workflow = createBlankWorkflow("新建项目");
if (!session) {
queueLoginGate({
kind: "project",
label: "新建项目",
description: "登录或注册后,我们会为你创建专属项目空间,并以画布方式打开。",
workflow,
});
return;
}
void openWorkflowProject(workflow);
}, [openWorkflowProject, queueLoginGate, session]);
const handleStartTemplateCanvasCreate = useCallback(() => {
const workflow = createBlankWorkflow("新建项目");
canvasAutoOpenedRecentRef.current = true;
setCanvasWorkflow(cloneWorkflow(workflow));
setCurrentCanvasProjectId(null);
setWorkspaceExpanded(true);
handleSetView("canvas");
}, [handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded]);
const handleImportWorkflow = useCallback(
(workflow: WebCanvasWorkflow) => {
const nextWorkflow = cloneWorkflow(workflow);
if (!session) {
queueLoginGate({
kind: "project",
label: "导入社区案例",
description: "登录或注册后,案例会写入你的项目空间,并以画布方式打开。",
workflow: nextWorkflow,
});
return;
}
void openWorkflowProject(nextWorkflow);
},
[openWorkflowProject, queueLoginGate, session],
);
const handleOpenProject = useCallback(
async (project: WebProjectSummary) => {
if (!session) {
openLoginPrompt();
return;
}
try {
const workflow = await keyServerClient.getProjectContent(project.id);
setCanvasWorkflow(workflow);
setCurrentCanvasProjectId(project.id);
setProjects([project, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== project.id)]);
setWorkspaceExpanded(true);
handleSetView("canvas");
} catch (error) {
pushNotification({
type: "info",
title: "项目打开失败",
description: error instanceof Error ? error.message : String(error),
targetView: "ecommerce",
targetId: project.id,
});
}
},
[pushNotification, session, handleSetView, openLoginPrompt, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded],
);
useEffect(() => {
const shouldAutoOpenRecentProject =
activeView === "canvas" &&
Boolean(session) &&
projectsLoaded &&
projects.length > 0 &&
!currentCanvasProjectId &&
canvasWorkflow &&
canvasWorkflow.source === "blank" &&
canvasWorkflow.nodes.length === 0 &&
!canvasAutoOpenedRecentRef.current;
if (!shouldAutoOpenRecentProject) {
return;
}
canvasAutoOpenedRecentRef.current = true;
handleOpenProject(projects[0]).catch(() => {
// Reset flag on failure so auto-open can retry on next dependency change
canvasAutoOpenedRecentRef.current = false;
});
}, [
activeView,
canvasWorkflow.nodes.length,
canvasWorkflow.source,
currentCanvasProjectId,
handleOpenProject,
projects,
projectsLoaded,
session,
]);
const handleSaveCanvasWorkflow = useCallback(
async (workflow: WebCanvasWorkflow, options?: SaveCanvasWorkflowOptions) => {
if (!session) {
openLoginPrompt();
throw new Error("请先登录后再保存画布。");
}
const nextWorkflow = cloneWorkflow(workflow);
let projectId = currentCanvasProjectId;
if (!projectId) {
const project = await keyServerClient.createProjectSpace(nextWorkflow);
projectId = project.id;
setCurrentCanvasProjectId(projectId);
}
const savedProject = await keyServerClient.saveProjectContent(projectId, nextWorkflow);
setProjects([savedProject, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== projectId)]);
if (options?.reason !== "autosave") {
setCanvasWorkflow(nextWorkflow);
}
if (!options?.silent) {
pushNotification({
type: "info",
title: "画布已保存",
description: savedProject.name || nextWorkflow.title,
targetView: "canvas",
targetId: projectId,
});
}
return savedProject;
},
[currentCanvasProjectId, pushNotification, session, openLoginPrompt, setCurrentCanvasProjectId, setProjects, setCanvasWorkflow],
);
const handleDeleteProject = useCallback(
(project: WebProjectSummary) => {
if (!session) {
openLoginPrompt();
return;
}
openDeleteProjectModal(project);
},
[session, openLoginPrompt, openDeleteProjectModal],
);
const closeDeleteProject = useCallback(() => {
if (deleteProjectSubmitting) return;
closeDeleteProjectModal();
}, [deleteProjectSubmitting, closeDeleteProjectModal]);
const confirmDeleteProject = useCallback(async () => {
if (!pendingDeleteProject || deleteProjectSubmitting) return;
const project = pendingDeleteProject;
setDeleteProjectSubmitting(true);
try {
await keyServerClient.deleteProject(project.id, { cleanupUserData: true });
setProjects(useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== project.id));
if (currentCanvasProjectId === project.id) {
setCurrentCanvasProjectId(null);
}
pushNotification({
type: "info",
title: "项目已删除",
description: project.name,
targetView: "community",
targetId: project.id,
});
closeDeleteProjectModal();
} catch (error) {
pushNotification({
type: "info",
title: "项目删除失败",
description: error instanceof Error ? error.message : String(error),
targetView: "community",
targetId: project.id,
});
} finally {
setDeleteProjectSubmitting(false);
}
}, [currentCanvasProjectId, deleteProjectSubmitting, pendingDeleteProject, pushNotification, setCurrentCanvasProjectId, setProjects, setDeleteProjectSubmitting, closeDeleteProjectModal]);
const handleCreateTask = useCallback(
async (input: CreatePreviewTaskInput) => {
if (!session) {
queueLoginGate({
kind: "task",
label: input.title || "创建生成任务",
description: "登录或注册后,将继续发送当前输入并同步生成任务记录。",
input,
});
throw new Error("需要先登录后继续");
}
const task = await appendPreviewTask(input);
if (task.status === "failed") {
throw new Error(translateTaskError(task.errorMessage));
}
return task;
},
[appendPreviewTask, queueLoginGate, session],
);
const handleRequireTaskLogin = useCallback(
(input: CreatePreviewTaskInput) => {
queueLoginGate({
kind: "task",
label: input.title || "创建生成任务",
description: "登录或注册后,将继续发送当前输入并同步生成任务记录。",
input,
});
},
[queueLoginGate],
);
const executePendingAction = useCallback(
async (action: PendingAction) => {
if (action.kind === "project") {
await openWorkflowProject(action.workflow!);
return;
}
await appendPreviewTask(action.input!);
handleSetView(lastNonAuthViewRef.current || "workbench");
},
[appendPreviewTask, openWorkflowProject, handleSetView],
);
const completeAuth = useCallback(
async (nextSession: WebUserSession) => {
hideSessionReplaced();
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
await hydrateAccountData(nextSession);
if (nextSession.user.email && !nextSession.user.emailVerified) {
toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证");
}
const action = pendingAction;
closeLoginPrompt();
if (action) {
await executePendingAction(action);
return;
}
const redirectTarget = sessionStorage.getItem("omniai:redirect-after-login") as WebViewKey | null;
sessionStorage.removeItem("omniai:redirect-after-login");
handleSetView(redirectTarget || lastNonAuthViewRef.current || "workbench");
},
[executePendingAction, hydrateAccountData, pendingAction, handleSetView, hideSessionReplaced, setSession, closeLoginPrompt],
);
const handleLogin = useCallback(
async (username: string, password: string) => {
const nextSession = await keyServerClient.login({ username, password });
await completeAuth(nextSession);
},
[completeAuth],
);
const handleRegister = useCallback(
async (username: string, password: string, betaCode: string) => {
const nextSession = await keyServerClient.register({ username, password, betaCode });
await completeAuth(nextSession);
},
[completeAuth],
);
const handleLogout = useCallback(() => {
hideSessionReplaced();
clearAuthenticatedState({ resetView: true });
}, [clearAuthenticatedState, hideSessionReplaced]);
const handleOpenResultInCanvas = useCallback(
async (payload: WorkbenchResultActionPayload) => {
const recentProject = projects?.[0];
// If user has recent projects, append result to the latest one
if (recentProject && session) {
try {
const workflow = await keyServerClient.getProjectContent(recentProject.id);
const nodeId = `img-${Date.now()}`;
const maxY = (workflow.nodes || []).reduce((m, n) => Math.max(m, n.position?.y || 0), 0);
workflow.nodes = [
...(workflow.nodes || []),
{
id: nodeId,
kind: payload.resultType === "video" ? "video" : "image",
label: payload.title || "生成结果",
detail: payload.prompt || "",
position: { x: 280, y: maxY + 60 },
previewUrl: payload.resultUrl,
params: payload.resultType === "video"
? { model: "Kling V3 Omni", aspectRatio: "16:9", resolution: "720p", duration: "6s", videoMode: "text-to-video" }
: { model: "omni-水果 Pro", aspectRatio: "1:1", imageSize: "2K" },
assetRef: payload.resultOssKey ? { url: payload.resultUrl, ossKey: payload.resultOssKey, mediaType: payload.resultType === "video" ? "video/mp4" : "image/png", sourceTaskId: payload.taskId } : undefined,
},
];
await keyServerClient.saveProjectContent(recentProject.id, workflow);
setCanvasWorkflow(workflow);
setCurrentCanvasProjectId(recentProject.id);
setProjects([recentProject, ...useProjectStore.getState().projects.filter((p: WebProjectSummary) => p.id !== recentProject.id)]);
setWorkspaceExpanded(true);
handleSetView("canvas");
return;
} catch (err) {
console.warn("[App] failed to append result to recent project, creating new:", err instanceof Error ? err.message : err);
}
}
// Fallback: create new canvas workflow
setCanvasWorkflow(createWorkflowFromResult(payload));
setCurrentCanvasProjectId(null);
setWorkspaceExpanded(true);
handleSetView("canvas");
},
[projects, session, handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded],
);
const notifications = useMemo<WebNotification[]>(() => {
const creditsNotification: WebNotification[] =
session && usage.balanceCents > 0 && usage.balanceCents < 10_000
? [
{
id: "credits-low",
type: "credits_low",
title: "积分余额偏低",
description: `当前剩余 ${(usage.balanceCents / 100).toFixed(2)} 积分,请留意生成消耗。`,
createdAt: new Date().toISOString(),
isRead: false,
targetView: "login",
},
]
: [];
return [...runtimeNotifications, ...serverNotifications, ...creditsNotification].slice(0, 30);
}, [runtimeNotifications, serverNotifications, session, usage.balanceCents]);
const handleMarkNotificationRead = useCallback((id: string, isRead = true) => {
markNotificationRead(id, isRead);
notificationClient.markRead(id, isRead).catch((err) => {
console.warn("[notification] markRead failed:", err?.message || err);
});
}, [markNotificationRead]);
const handleMarkAllNotificationsRead = useCallback(() => {
markAllNotificationsRead();
notificationClient.markAllRead().catch((err) => {
console.warn("[notification] markAllRead failed:", err?.message || err);
});
}, [markAllNotificationsRead]);
const handleOpenLogin = useCallback(() => {
closeLoginPrompt();
handleSetView("login");
}, [handleSetView, closeLoginPrompt]);
const handleOpenImageWorkbenchTool = useCallback(
(tool: WebImageWorkbenchTool) => {
setImageWorkbenchTool(tool);
handleSetView("imageWorkbench");
},
[handleSetView, setImageWorkbenchTool],
);
const renderAdminOnlyPage = useCallback(
(content: React.ReactNode) => {
if (isAdminAccount(session)) return content;
return (
<div className="feature-access-gate">
<div className="feature-access-gate__content" aria-hidden="true">
{content}
</div>
<div className="feature-access-gate__overlay" role="dialog" aria-modal="true" aria-labelledby="feature-access-title">
<section className="feature-access-gate__panel panel-surface">
<span className="feature-access-gate__eyebrow"></span>
<h2 id="feature-access-title"></h2>
<p></p>
</section>
</div>
</div>
);
},
[session],
);
const PUBLIC_VIEWS = PUBLIC_VIEW_SET;
useEffect(() => {
if (!session && !PUBLIC_VIEWS.has(activeView)) {
sessionStorage.setItem("omniai:redirect-after-login", activeView);
}
}, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps
const activePage = (() => {
switch (activeView) {
case "login":
return (
<ProfilePage
session={session}
usage={usage}
projects={projects}
tasks={tasks}
pendingActionLabel={pendingAction?.label ?? null}
onLogin={handleLogin}
onRegister={handleRegister}
onAuthComplete={completeAuth}
onSessionChange={setSession}
onLogout={handleLogout}
onOpenWorkbench={() => handleSetView("workbench")}
onOpenCommunity={() => handleSetView("community")}
onDeleteProject={handleDeleteProject}
onOpenProject={handleOpenProject}
onRemoveWork={(task) => setTasks(tasks.filter((item) => item.id !== task.id))}
/>
);
case "community":
return (
<CommunityPage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
/>
);
case "agent":
return (
<AgentPage
tasks={tasks}
isAuthenticated={Boolean(session)}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
onOpenLogin={handleOpenLogin}
/>
);
case "canvas":
return (
<CanvasPage
workflow={canvasWorkflow}
projectId={currentCanvasProjectId}
projects={projects}
projectsLoaded={projectsLoaded}
onOpenCommunity={() => handleSetView("community")}
onOpenProject={handleOpenProject}
onStartCreate={handleStartCreate}
isAuthenticated={Boolean(session)}
session={session}
onOpenLogin={handleOpenLogin}
onSaveWorkflow={handleSaveCanvasWorkflow}
onCreateTask={handleCreateTask}
/>
);
case "assets":
return <AssetsPage isAuthenticated={Boolean(session)} onOpenLogin={handleOpenLogin} />;
case "ecommerce":
case "ecommerceHub":
return null;
case "ecommerceTemplates":
return (
<EcommerceTemplatesPage
projects={projects}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectTemplate={(template) => {
setPendingEcommerceTemplate(template);
handleSetView("ecommerce");
}}
onStartCreate={handleStartTemplateCanvasCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
/>
);
case "sizeTemplate":
return (
<SizeTemplatePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectView={handleSetView}
/>
);
case "digitalHuman":
return (
<DigitalHumanPage
isAuthenticated={Boolean(session)}
isAdmin={isAdminAccount(session)}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "avatarConsole":
return <AvatarConsolePage onOpenMore={() => handleSetView("more")} onSelectView={handleSetView} />;
case "characterMix":
return (
<CharacterMixPage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "more":
return <MorePage onSelectView={handleSetView} onOpenImageTool={handleOpenImageWorkbenchTool} />;
case "scriptTokens":
return <ScriptTokensPage />;
case "tokenUsage":
return (
<TokenUsagePage
session={session}
usage={usage}
loadEnterpriseUsage={() => keyServerClient.getEnterpriseUsageSummary()}
loadPersonalUsage={() => keyServerClient.getPersonalUsageSummary()}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "imageWorkbench":
return (
<ImageWorkbenchPage
initialTool={imageWorkbenchTool}
onOpenMore={() => handleSetView("more")}
onSelectView={handleSetView}
/>
);
case "resolutionUpscale":
return (
<ResolutionUpscalePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "watermarkRemoval":
return (
<WatermarkRemovalPage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "subtitleRemoval":
return (
<SubtitleRemovalPage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report":
return <ReportPage />;
case "providerHealth":
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
case "userAgreement":
return <CompliancePage kind="agreement" />;
case "privacyPolicy":
return <CompliancePage kind="privacy" />;
case "communityReview":
return (
<CommunityReviewPage
session={session}
onOpenLogin={handleOpenLogin}
onOpenReport={() => handleSetView("report")}
onOpenCaseAdd={() => handleSetView("communityCaseAdd")}
/>
);
case "communityCaseAdd":
return (
<CommunityCaseAddPage
session={session}
onOpenLogin={handleOpenLogin}
onOpenReview={() => handleSetView("communityReview")}
/>
);
case "betaApplications":
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
case "workbench":
return (
<WorkbenchPage
isAuthenticated={Boolean(session)}
session={session}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
/>
);
case "home":
return (
<HomePage
onOpenGenerate={() => handleSetView("workbench")}
onOpenCanvas={() => handleSetView("canvas")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onOpenScriptReview={() => handleSetView("scriptTokens")}
onOpenTokenMonitor={() => handleSetView("tokenUsage")}
onSelectView={handleSetView}
onOpenImageTool={handleOpenImageWorkbenchTool}
/>
);
case "not-found":
default:
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
}
})();
return (
<AppShell
activeView={activeView}
navItems={navItems}
session={session}
usage={usage}
notifications={notifications}
backendHealth={backendHealth}
workspaceExpanded={workspaceExpanded}
onSelectView={handleSetView}
onLogout={handleLogout}
onOpenLogin={handleOpenLogin}
onMarkNotificationRead={handleMarkNotificationRead}
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
>
<ErrorBoundary key={activeView}>
<Suspense fallback={
<div className="page-loading-center">
<div className="page-loading-spinner" />
<span className="page-loading-center__text">...</span>
</div>
}>
<PageTransition viewKey={activeView}>
{activePage}
</PageTransition>
</Suspense>
</ErrorBoundary>
{/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
{ecommerceEverMounted && (
<div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
<Suspense fallback={null}>
<EcommercePage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
initialTemplate={pendingEcommerceTemplate}
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/>
</Suspense>
</div>
)}
{loginPromptOpen && pendingAction ? (
<div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title">
<button
type="button"
className="login-gate-modal__scrim"
aria-label="关闭登录提示"
onClick={closeLoginPrompt}
/>
<section className="login-gate-modal__panel panel-surface">
<span className="login-gate-modal__eyebrow"></span>
<h2 id="login-gate-title">{pendingAction.label}</h2>
<p>{pendingAction.description}</p>
<div className="login-gate-modal__actions">
<button type="button" className="login-gate-modal__primary" onClick={handleOpenLogin}>
/
</button>
<button
type="button"
className="login-gate-modal__secondary"
onClick={() => {
closeLoginPrompt();
handleSetView("community");
}}
>
</button>
</div>
</section>
</div>
) : null}
{sessionReplacedOpen ? (
<div className="session-replaced-modal" role="dialog" aria-modal="true" aria-labelledby="session-replaced-title">
<div className="session-replaced-modal__scrim" />
<section className="session-replaced-modal__panel" role="status">
<span className="session-replaced-modal__eyebrow"></span>
<h2 id="session-replaced-title"></h2>
<p>{sessionReplacedMessage}</p>
<div className="session-replaced-modal__actions">
<button
type="button"
className="session-replaced-modal__primary"
onClick={() => {
hideSessionReplaced();
handleSetView("login");
}}
>
</button>
<button
type="button"
className="session-replaced-modal__secondary"
onClick={hideSessionReplaced}
>
</button>
</div>
</section>
</div>
) : null}
{pendingDeleteProject ? (
<div className="project-delete-modal" role="dialog" aria-modal="true" aria-labelledby="project-delete-title">
<button
type="button"
className="project-delete-modal__scrim"
aria-label="关闭删除确认"
onClick={closeDeleteProject}
disabled={deleteProjectSubmitting}
/>
<section className="project-delete-modal__panel">
<span className="project-delete-modal__icon">
<ShellIcon name="delete" />
</span>
<h2 id="project-delete-title"></h2>
<p>{pendingDeleteProject.name}</p>
<div className="project-delete-modal__actions">
<button
type="button"
className="project-delete-modal__secondary"
onClick={closeDeleteProject}
disabled={deleteProjectSubmitting}
>
</button>
<button
type="button"
className="project-delete-modal__danger"
onClick={() => void confirmDeleteProject()}
disabled={deleteProjectSubmitting}
>
{deleteProjectSubmitting ? "删除中..." : "确认删除"}
</button>
</div>
</section>
</div>
) : null}
<ToastContainer />
</AppShell>
);
}
export default App;