Compare commits

..

11 Commits

42 changed files with 1108 additions and 756 deletions
+2 -2
View File
@@ -42,9 +42,9 @@ assertNoMatch(
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
);
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/);
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/);
assertMatch(
"ecommerce video history must durable-copy media before saving",
ecommerceVideoService,
+138 -49
View File
@@ -15,6 +15,7 @@ import {
WalletOutlined,
} from "@ant-design/icons";
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";
@@ -126,6 +127,27 @@ const VIEW_KEYS = new Set<WebViewKey>([
]);
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",
"assets",
"ecommerce",
"ecommerceHub",
"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 =
@@ -233,61 +255,122 @@ function App() {
const canvasAutoOpenedRecentRef = useRef(false);
// Session store
const session = useSessionStore((s) => s.session);
const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen);
const pendingAction = useSessionStore((s) => s.pendingAction);
const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen);
const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage);
const setSession = useSessionStore((s) => s.setSession);
const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt);
const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt);
const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced);
const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced);
const clearSessionState = useSessionStore((s) => s.clearSession);
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 = useProjectStore((s) => s.projects);
const projectsLoaded = useProjectStore((s) => s.projectsLoaded);
const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow);
const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId);
const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject);
const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting);
const setProjects = useProjectStore((s) => s.setProjects);
const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded);
const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow);
const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId);
const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject);
const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject);
const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting);
const clearProjectState = useProjectStore((s) => s.clearProjectState);
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 = useTaskStore((s) => s.tasks);
const appendTask = useTaskStore((s) => s.appendTask);
const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks);
const clearTasks = useTaskStore((s) => s.clearTasks);
const {
tasks,
appendTask,
mergeServerTasks,
clearTasks,
} = useTaskStore(useShallow((s) => ({
tasks: s.tasks,
appendTask: s.appendTask,
mergeServerTasks: s.mergeServerTasks,
clearTasks: s.clearTasks,
})));
// App store
const usage = useAppStore((s) => s.usage);
const runtimeNotifications = useAppStore((s) => s.runtimeNotifications);
const serverNotifications = useAppStore((s) => s.serverNotifications);
const activeView = useAppStore((s) => s.activeView);
const workspaceExpanded = useAppStore((s) => s.workspaceExpanded);
const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool);
const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate);
const backendHealth = useAppStore((s) => s.backendHealth);
const setUsage = useAppStore((s) => s.setUsage);
const pushNotification = useAppStore((s) => s.pushNotification);
const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications);
const setServerNotifications = useAppStore((s) => s.setServerNotifications);
const setView = useAppStore((s) => s.setView);
const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded);
const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool);
const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate);
const setBackendHealth = useAppStore((s) => s.setBackendHealth);
const markNotificationRead = useAppStore((s) => s.markNotificationRead);
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
const clearAppState = useAppStore((s) => s.clearAppState);
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";
@@ -295,6 +378,12 @@ function App() {
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");
+80 -101
View File
@@ -3,6 +3,7 @@ import {
buildAuthHeaders,
isRecord,
readJsonResponse,
serverRequest,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -243,6 +244,10 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
const requestUrl = buildApiUrl("ai/image");
@@ -256,15 +261,13 @@ export const aiGenerationClient = {
projectId: input.projectId,
conversationId: input.conversationId,
});
const res = await fetch(requestUrl, {
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image generation request failed",
});
if (!res.ok) {
await throwResponseError(res, "Image generation request failed");
}
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
if (payload.providerDebug) {
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
}
@@ -272,96 +275,83 @@ export const aiGenerationClient = {
},
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video"), {
return serverRequest<{ taskId: string }>("ai/video", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video generation request failed",
});
if (!res.ok) {
await throwResponseError(res, "Video generation request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video generation response failed");
},
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video super-resolution request failed",
});
if (!res.ok) {
await throwResponseError(res, "Video super-resolution request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed");
},
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Subtitle removal request failed",
});
if (!res.ok) {
await throwResponseError(res, "Subtitle removal request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
},
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/edit"), {
return serverRequest<{ taskId: string }>("ai/video/edit", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" },
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video edit request failed",
});
if (!res.ok) {
await throwResponseError(res, "Video edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
},
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image super-resolution request failed",
});
if (!res.ok) {
await throwResponseError(res, "Image super-resolution request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed");
},
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/edit"), {
return serverRequest<{ taskId: string }>("ai/image/edit", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image edit request failed",
});
if (!res.ok) {
await throwResponseError(res, "Image edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
},
async cancelTask(taskId: string): Promise<void> {
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), {
method: "PATCH",
headers: buildAuthHeaders(),
});
if (!res.ok && res.status !== 404) {
await throwResponseError(res, "Task cancel failed");
try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed",
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
throw error;
}
},
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
method: "GET",
headers: buildAuthHeaders(),
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
fallbackMessage: "Task status request failed",
});
if (!res.ok) {
await throwResponseError(res, "Task status request failed");
}
return readJsonResponse<AiTaskStatus>(res, "Task status response failed");
},
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
@@ -387,49 +377,41 @@ export const aiGenerationClient = {
if (params?.status) search.set("status", params.status);
if (params?.type) search.set("type", params.type);
if (params?.projectId) search.set("projectId", params.projectId);
const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), {
method: "GET",
headers: buildAuthHeaders(),
});
if (!res.ok) {
try {
await throwResponseError(res, "Task history request failed");
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true;
return [];
}
throw error;
try {
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
fallbackMessage: "Task history request failed",
});
return extractTaskList(payload).map(toPreviewTask);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true;
return [];
}
throw error;
}
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
return extractTaskList(payload).map(toPreviewTask);
},
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), {
method: "PATCH",
headers: buildAuthHeaders(),
body: JSON.stringify({ conversationId }),
});
if (res.status === 404) {
return;
}
if (!res.ok) {
await throwResponseError(res, "Task conversation binding failed");
try {
await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
method: "PATCH",
body: { conversationId },
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task conversation binding failed",
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
throw error;
}
},
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const res = await fetch(buildApiUrl("oss/upload"), {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Asset upload failed",
});
if (!res.ok) {
await throwResponseError(res, "Asset upload failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
},
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
@@ -451,15 +433,12 @@ export const aiGenerationClient = {
},
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const res = await fetch(buildApiUrl("oss/upload-by-url"), {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Asset upload by URL failed",
});
if (!res.ok) {
await throwResponseError(res, "Asset upload by URL failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response failed");
},
subscribeTaskStatus(
+3 -8
View File
@@ -1,4 +1,4 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
import { serverRequest } from "./serverConnection";
export interface ProviderHealthEntry {
status: string;
@@ -32,13 +32,8 @@ export interface ProviderHealthResponse {
export const providerHealthClient = {
async getStatus(): Promise<ProviderHealthResponse> {
const res = await fetch(buildApiUrl("admin/providers/status"), {
method: "GET",
headers: buildAuthHeaders(),
return serverRequest<ProviderHealthResponse>("admin/providers/status", {
fallbackMessage: "Provider health request failed",
});
if (!res.ok) {
throw new Error(`Provider health request failed (${res.status})`);
}
return res.json() as Promise<ProviderHealthResponse>;
},
};
+23 -12
View File
@@ -1,4 +1,4 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
import { serverRequest } from "./serverConnection";
export interface ScriptEvalResult {
totalScore: number;
@@ -107,6 +107,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeEvidenceItems(source: unknown[], limit: number): string[] {
const items: string[] = [];
for (const item of source) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
if (!isRecord(value)) return {};
@@ -132,7 +143,7 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!Array.isArray(source)) continue;
const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
const items = normalizeEvidenceItems(source, 3);
if (items.length > 0) normalized[dimensionKey] = items;
}
@@ -140,10 +151,13 @@ function normalizeEvidence(value: unknown): Record<string, string[]> {
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const res = await fetch(buildApiUrl("ai/chat"), {
const payload = await serverRequest<{
content?: string;
choices?: Array<{ message?: { content?: string } }>;
text?: string;
}>("ai/chat", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({
body: {
model: MODEL,
messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT },
@@ -153,16 +167,13 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
stream: false,
temperature: 0.3,
max_tokens: 4096,
}),
},
signal,
timeoutMs: 180_000,
maxRetries: 0,
fallbackMessage: "评测请求失败",
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
}
const payload = await res.json();
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
+8 -3
View File
@@ -22,6 +22,9 @@ export interface ServerRequestOptions {
signal?: AbortSignal;
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
timeoutMs?: number;
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
maxRetries?: number;
fallbackMessage?: string;
}
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -343,8 +346,10 @@ const MAX_RETRIES = 2;
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
let lastError: unknown;
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
const fallbackMessage = options?.fallbackMessage || "Request failed";
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const controller = timeoutMs > 0 ? new AbortController() : null;
const timeoutId =
controller && typeof window !== "undefined"
@@ -366,11 +371,11 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
credentials: "include",
});
const payload = await readJsonResponse<unknown>(response, "Request failed");
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) {
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
continue;
}
+28 -28
View File
@@ -41,6 +41,32 @@ interface AppShellProps {
}
const BRAND_LOGO_URL = ossAssets.brand.logo;
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[]);
const PRIMARY_NAV_ORDER: WebViewKey[] = [
"workbench",
"ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"community",
"assets",
"more",
];
function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100;
@@ -76,37 +102,11 @@ function AppShell({
const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
const toolSurfaceViews = [
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[];
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
const visibleNavItems = useMemo(
() => {
const orderedKeys: WebViewKey[] = [
"workbench",
"ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"community",
"assets",
"more",
];
return orderedKeys
return PRIMARY_NAV_ORDER
.map((key) => navItems.find((item) => item.key === key))
.filter((item): item is WebNavItem => Boolean(item));
},
+1
View File
@@ -1,5 +1,6 @@
import { HomeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
import "../styles/pages/not-found.css";
interface NotFoundPageProps {
onGoHome: () => void;
+1
View File
@@ -14,6 +14,7 @@ import {
ThunderboltOutlined,
} from "@ant-design/icons";
import { useRef, useState } from "react";
import "../../styles/pages/agent.css";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebGenerationPreviewTask } from "../../types";
+1
View File
@@ -11,6 +11,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { useDebounce } from "../../hooks/useDebounce";
@@ -0,0 +1,40 @@
interface CanvasMarkingPopoverProps {
value?: string;
placeholder: string;
onChange: (value: string) => void;
onClear: () => void;
onDone: () => void;
}
export function CanvasMarkingPopover({
value,
placeholder,
onChange,
onClear,
onDone,
}: CanvasMarkingPopoverProps) {
return (
<div
className="studio-canvas-marking-popover"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder={placeholder}
value={value || ""}
onChange={(event) => onChange(event.target.value)}
/>
<div className="studio-canvas-marking-actions">
{value ? (
<button type="button" className="studio-canvas-marking-clear" onClick={onClear}>
</button>
) : null}
<button type="button" className="studio-canvas-marking-done" onClick={onDone}>
</button>
</div>
</div>
);
}
+166 -330
View File
@@ -28,10 +28,13 @@
import {
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "../../styles/pages/canvas.css";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type {
@@ -52,6 +55,7 @@ import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory
import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
import { useCanvasAssetSummary, useCanvasVisibleNodes } from "./useCanvasDerivedState";
import {
toHappyHorseDisplayModel,
} from "../../utils/happyHorseRouting";
@@ -118,7 +122,7 @@ import {
defaultVideoModel,
image4kCapableModels,
imageFocusRatioOptions,
imageModelOptions,
imageModelOptions as fallbackCanvasImageModelOptions,
imageRatioOptions,
textModelOptions,
videoDurationOptions,
@@ -182,6 +186,8 @@ import {
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
@@ -193,7 +199,6 @@ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL
// --- Canvas generation keep-alive (survives page refresh / view switch) ---
const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
function buildNodeMentionOptions(
kind: CanvasNodeKind,
@@ -354,6 +359,8 @@ function CanvasPage({
const [projectNameEditing, setProjectNameEditing] = useState(false);
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
@@ -394,10 +401,12 @@ function CanvasPage({
const suppressNextPaneClickRef = useRef(false);
const canvasAutoSaveTimerRef = useRef<number | null>(null);
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
const canvasAutoSaveRetryTimerRef = useRef<number | null>(null);
const canvasAutoSaveInFlightRef = useRef(false);
const canvasAutoSavePendingRef = useRef(false);
const lastAutoSavedWorkflowFingerprintRef = useRef("");
const canvasAutoSaveHydrationRef = useRef(true);
const textNodeMentionFocusTimerRef = useRef<number | null>(null);
const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1);
@@ -458,9 +467,39 @@ function CanvasPage({
callbacksRef: dragCallbacksRef,
suppressNextPaneClickRef,
});
useEffect(() => {
let cancelled = false;
if (!isAuthenticated) {
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
return () => {
cancelled = true;
};
}
modelCapabilitiesClient
.get()
.then((capabilities) => {
if (cancelled) return;
setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
})
.catch(() => {
if (cancelled) return;
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
});
return () => {
cancelled = true;
};
}, [isAuthenticated]);
const visibleImageModelOptions = useMemo(
() => filterImageModelOptionsForSession(imageModelOptions, session),
[session],
() => filterImageModelOptionsForSession(canvasImageModelOptions, session),
[canvasImageModelOptions, session],
);
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
const resolveVisibleImageModel = useCallback(
@@ -486,7 +525,11 @@ function CanvasPage({
else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
else updateTextNodePrompt(nodeId, nextValue);
closeTextNodeMention(nodeId);
setTimeout(() => {
if (textNodeMentionFocusTimerRef.current !== null) {
window.clearTimeout(textNodeMentionFocusTimerRef.current);
}
textNodeMentionFocusTimerRef.current = window.setTimeout(() => {
textNodeMentionFocusTimerRef.current = null;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextCaret, nextCaret);
@@ -522,10 +565,22 @@ function CanvasPage({
const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current);
if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current);
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
}
};
}, []);
// Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
// — see useEffect below near runCanvasAutoSave
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets);
const shouldShowEmptyProjectState =
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
@@ -2587,13 +2642,17 @@ function CanvasPage({
setConnectorDrag(null);
};
const collapsedPackageNodeKeys = new Set(
nodePackages.flatMap((nodePackage) =>
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
)
);
const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) =>
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id }));
const {
isNodeCollapsedInPackage,
visibleTextNodes,
visibleImageNodes,
visibleVideoNodes,
} = useCanvasVisibleNodes({
textNodes,
imageNodes,
videoNodes,
nodePackages,
});
const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);
@@ -3126,7 +3185,13 @@ function CanvasPage({
canvasAutoSaveInFlightRef.current = false;
if (canvasAutoSavePendingRef.current) {
canvasAutoSavePendingRef.current = false;
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
}
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
canvasAutoSaveRetryTimerRef.current = null;
void runCanvasAutoSave();
}, canvasAutoSaveIdleTimeoutMs);
}
}
}, [
@@ -3195,7 +3260,13 @@ function CanvasPage({
);
return;
}
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
}
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
canvasAutoSaveRetryTimerRef.current = null;
void runCanvasAutoSave();
}, canvasAutoSaveIdleTimeoutMs);
}, canvasAutoSaveDebounceMs);
return () => {
@@ -3207,6 +3278,10 @@ function CanvasPage({
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
canvasAutoSaveIdleHandleRef.current = null;
}
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
canvasAutoSaveRetryTimerRef.current = null;
}
};
}, [
isAuthenticated,
@@ -3933,7 +4008,7 @@ function CanvasPage({
) : null}
</svg>
) : null}
{textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => {
{visibleTextNodes.map((textNode) => {
const textNodeSelected = isSelectedNode("text", textNode.id);
const textNodeActive = isActiveSelectedNode("text", textNode.id);
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
@@ -4082,126 +4157,26 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
/>
</div>
{textNodeActive && !isCanvasNodeMoving ? (() => {
const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const filteredMentions = mentionState.open
? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateTextNodePrompt(textNode.id, value);
// Detect @-mention trigger
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(textNode.id);
};
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const opt = filteredMentions[mentionState.activeIndex];
if (opt) {
const ta = e.currentTarget;
insertTextNodeMention(textNode.id, opt, ta);
}
} else if (e.key === "Escape") {
e.preventDefault();
closeTextNodeMention(textNode.id);
}
};
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const caret = ta.selectionStart || 0;
setTextNodeMentionStates((prev) => {
const cur = prev[textNode.id];
if (!cur?.open) return prev;
return { ...prev, [textNode.id]: { ...cur, caret } };
});
};
return (
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={textNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
>
<span className="studio-canvas-mention-thumb">
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
title={textNodeGenerating ? "生成中" : "生成"}
disabled={textNodeGenerating || !textNodeCanGenerate}
aria-busy={textNodeGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!textNodeGenerating && textNodeCanGenerate) {
void handleGenerateTextNode(textNode.id);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
})() : null}
{textNodeActive && !isCanvasNodeMoving ? (
<CanvasTextPromptComposer
nodeId={textNode.id}
prompt={textNode.prompt}
canGenerate={textNodeCanGenerate}
isGenerating={textNodeGenerating}
mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[textNode.id]}
onPromptChange={updateTextNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
onGenerate={handleGenerateTextNode}
/>
) : null}
</div>
</div>
);
})}
{imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => {
{visibleImageNodes.map((imageNode) => {
const imageNodeSelected = isSelectedNode("image", imageNode.id);
const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
@@ -4459,38 +4434,7 @@ function CanvasPage({
</button>
</div>
) : null}
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const imgFilteredMentions = imgMentionState.open
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
: [];
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateImageNodePrompt(imageNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(imageNode.id);
};
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
};
return (
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (
<div className="studio-canvas-image-composer">
<div className="studio-canvas-image-composer__tools">
<button
@@ -4529,47 +4473,23 @@ function CanvasPage({
>
<FileImageOutlined /><span></span>
</button>
{markingPopoverNodeId === imageNode.id && (
<div
className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
value={imageNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
);
}}
/>
<div className="studio-canvas-marking-actions">
{imageNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
{markingPopoverNodeId === imageNode.id ? (
<CanvasMarkingPopover
value={imageNode.marking}
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
onChange={(value) => {
setImageNodes((nodes) =>
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: value } : node)),
);
}}
onClear={() => {
setImageNodes((nodes) =>
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: "" } : node)),
);
}}
onDone={() => setMarkingPopoverNodeId(null)}
/>
) : null}
<button
type="button"
title="多宫格生成"
@@ -4611,28 +4531,18 @@ function CanvasPage({
</button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div>
<div className="studio-canvas-text-composer__input-wrap">
<textarea
<CanvasPromptMentionTextarea
nodeId={imageNode.id}
value={imageNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleImagePromptChange}
onKeyDown={handleImagePromptKeyDown}
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
mentionOptions={buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[imageNode.id]}
mentionKind="image"
onPromptChange={updateImageNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
/>
{imgMentionState.open && (
<div className="studio-canvas-mention-panel">
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label"></span></div>
)}
</div>
)}
</div>
<div className="studio-canvas-image-composer__footer">
<CanvasSelectChip
ariaLabel="选择生图模型"
@@ -4700,12 +4610,12 @@ function CanvasPage({
</button>
</div>
</div>
); })() : null}
) : null}
</div>
</div>
);
})}
{videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
{visibleVideoNodes.map((videoNode) => {
const videoNodeSelected = isSelectedNode("video", videoNode.id);
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
@@ -4855,38 +4765,7 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
/>
</div>
{videoNodeActive && !isCanvasNodeMoving ? (() => {
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const vidFilteredMentions = vidMentionState.open
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
: [];
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateVideoNodePrompt(videoNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(videoNode.id);
};
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
};
return (
{videoNodeActive && !isCanvasNodeMoving ? (
<div className="studio-canvas-video-composer">
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
<button
@@ -4941,47 +4820,23 @@ function CanvasPage({
>
{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
</button>
{markingPopoverNodeId === videoNode.id && (
<div
className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角在城市街头行走"
value={videoNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
);
}}
/>
<div className="studio-canvas-marking-actions">
{videoNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
{markingPopoverNodeId === videoNode.id ? (
<CanvasMarkingPopover
value={videoNode.marking}
placeholder="描述标记内容,如:主角在城市街头行走"
onChange={(value) => {
setVideoNodes((nodes) =>
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: value } : node)),
);
}}
onClear={() => {
setVideoNodes((nodes) =>
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: "" } : node)),
);
}}
onDone={() => setMarkingPopoverNodeId(null)}
/>
) : null}
{cameraMotionDropdownNodeId === videoNode.id && (
<div
className="studio-canvas-camera-dropdown"
@@ -5008,43 +4863,24 @@ function CanvasPage({
<button type="button"></button>
<button type="button" className="is-active"></button>
</div>
<div className="studio-canvas-text-composer__input-wrap">
<textarea
<CanvasPromptMentionTextarea
nodeId={videoNode.id}
value={videoNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleVideoPromptChange}
onKeyDown={handleVideoPromptKeyDown}
placeholder="根据文字描述生成视频。"
mentionOptions={buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[videoNode.id]}
mentionKind="video"
onPromptChange={updateVideoNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
/>
{vidMentionState.open ? (
<div className="studio-canvas-mention-panel">
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
>
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
<CanvasSelectChip
ariaLabel="选择视频模型"
className="canvas-select-chip--model studio-canvas-composer-chip"
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
options={canvasEnterpriseVideoModelOptions}
options={canvasVideoModelOptions}
open={canvasSelectMenu === `${videoNode.id}:video-model`}
onToggle={() =>
setCanvasSelectMenu((current) =>
@@ -5122,7 +4958,7 @@ function CanvasPage({
</button>
</div>
</div>
); })() : null}
) : null}
</div>
</div>
);
@@ -5416,7 +5252,7 @@ function CanvasPage({
onClick={() => setSelectedExistingCategory(category.key)}
>
{category.label}
<span>{serverAssets.filter((asset) => asset.type === category.key).length} </span>
<span>{assetCountsByCategory.get(category.key) ?? 0} </span>
</button>
))}
</div>
@@ -0,0 +1,219 @@
import { SendOutlined } from "@ant-design/icons";
import { useRef, type CSSProperties, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeKind, CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
open: false,
query: "",
start: 0,
caret: 0,
activeIndex: 0,
};
interface CanvasPromptMentionTextareaProps {
nodeId: string;
value: string;
placeholder: string;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
kind?: CanvasNodeKind,
) => void;
mentionKind?: CanvasNodeKind;
}
export function CanvasPromptMentionTextarea({
nodeId,
value,
placeholder,
mentionOptions,
mentionState = DEFAULT_MENTION_STATE,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
mentionKind,
}: CanvasPromptMentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const filteredMentions = mentionState.open
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
const caret = event.target.selectionStart || 0;
onPromptChange(nodeId, value);
const beforeCaret = value.slice(0, caret);
const atIndex = beforeCaret.lastIndexOf("@");
if (atIndex >= 0) {
const query = beforeCaret.slice(atIndex + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
}));
return;
}
}
onCloseMention(nodeId);
};
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
}));
} else if (event.key === "ArrowUp") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: {
...mentionState,
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
},
}));
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const option = filteredMentions[mentionState.activeIndex];
if (option) {
onInsertMention(nodeId, option, event.currentTarget, mentionKind);
}
} else if (event.key === "Escape") {
event.preventDefault();
onCloseMention(nodeId);
}
};
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
const caret = event.currentTarget.selectionStart || 0;
onMentionStateChange((prev) => {
const current = prev[nodeId];
if (!current?.open) return prev;
return { ...prev, [nodeId]: { ...current, caret } };
});
};
return (
<div className="studio-canvas-text-composer__input-wrap">
<textarea
ref={textareaRef}
value={value}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder={placeholder}
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
<button
key={option.token}
type="button"
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
onInsertMention(nodeId, option, textareaRef.current, mentionKind);
}}
>
<span className="studio-canvas-mention-thumb">
{option.kind === "image" && option.previewUrl ? (
<img src={option.previewUrl} alt="" />
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
<span className="studio-canvas-mention-token">{option.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
);
}
interface CanvasTextPromptComposerProps {
nodeId: string;
prompt: string;
canGenerate: boolean;
isGenerating: boolean;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
kind?: CanvasNodeKind,
) => void;
onGenerate: (nodeId: string) => void | Promise<void>;
}
export function CanvasTextPromptComposer({
nodeId,
prompt,
canGenerate,
isGenerating,
mentionOptions,
mentionState,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
onGenerate,
}: CanvasTextPromptComposerProps) {
return (
<div className="studio-canvas-text-composer">
<CanvasPromptMentionTextarea
nodeId={nodeId}
value={prompt}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
mentionOptions={mentionOptions}
mentionState={mentionState}
onPromptChange={onPromptChange}
onMentionStateChange={onMentionStateChange}
onCloseMention={onCloseMention}
onInsertMention={onInsertMention}
/>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
title={isGenerating ? "生成中" : "生成"}
disabled={isGenerating || !canGenerate}
aria-busy={isGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isGenerating && canGenerate) {
void onGenerate(nodeId);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useCallback, useMemo } from "react";
import type { ServerAssetItem } from "../../api/assetClient";
import type {
CanvasImageNode,
CanvasNodeKind,
CanvasNodePackage,
CanvasTextNode,
CanvasVideoNode,
} from "./canvasTypes";
import { getCanvasSelectionKey } from "./canvasUtils";
export function useCanvasAssetSummary(serverAssets: ServerAssetItem[]) {
return useMemo(() => {
const canvasAssets: ServerAssetItem[] = [];
const assetCountsByCategory = new Map<string, number>();
for (const asset of serverAssets) {
if (asset.imageUrl) {
canvasAssets.push(asset);
}
assetCountsByCategory.set(asset.type, (assetCountsByCategory.get(asset.type) ?? 0) + 1);
}
return { canvasAssets, assetCountsByCategory };
}, [serverAssets]);
}
export function useCanvasVisibleNodes({
textNodes,
imageNodes,
videoNodes,
nodePackages,
}: {
textNodes: CanvasTextNode[];
imageNodes: CanvasImageNode[];
videoNodes: CanvasVideoNode[];
nodePackages: CanvasNodePackage[];
}) {
const collapsedPackageNodeKeys = useMemo(
() => new Set(
nodePackages.flatMap((nodePackage) =>
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
)
),
[nodePackages],
);
const isNodeCollapsedInPackage = useCallback(
(kind: CanvasNodeKind, id: string) =>
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })),
[collapsedPackageNodeKeys],
);
const visibleTextNodes = useMemo(
() => textNodes.filter((textNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "text", id: textNode.id }))),
[collapsedPackageNodeKeys, textNodes],
);
const visibleImageNodes = useMemo(
() => imageNodes.filter((imageNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "image", id: imageNode.id }))),
[collapsedPackageNodeKeys, imageNodes],
);
const visibleVideoNodes = useMemo(
() => videoNodes.filter((videoNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "video", id: videoNode.id }))),
[collapsedPackageNodeKeys, videoNodes],
);
return {
collapsedPackageNodeKeys,
isNodeCollapsedInPackage,
visibleTextNodes,
visibleImageNodes,
visibleVideoNodes,
};
}
+10 -5
View File
@@ -82,11 +82,16 @@ export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
const cy = pos.y + size.height / 2;
const right = pos.x + size.width;
const bottom = pos.y + size.height;
const others = [
...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
];
const others: Array<{ pos: CanvasPoint; size: CanvasNodeSize }> = [];
for (const node of textNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const node of imageNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const node of videoNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const other of others) {
const ocx = other.pos.x + other.size.width / 2;
const ocy = other.pos.y + other.size.height / 2;
@@ -17,6 +17,7 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import StudioToolLayout from "../../components/StudioToolLayout";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
+1
View File
@@ -10,6 +10,7 @@ import {
SearchOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/community.css";
import { useDebounce } from "../../hooks/useDebounce";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
@@ -1,4 +1,5 @@
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
import "../../styles/pages/compliance.css";
type ComplianceKind = "agreement" | "privacy";
@@ -1,4 +1,5 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
import "../../styles/pages/dialog-generator.css";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
@@ -24,6 +24,7 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
import "../../styles/pages/avatar-console.css";
import type { WebViewKey } from "../../types";
import {
bringAvatarEditorLayerForward,
@@ -18,6 +18,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription";
+85 -18
View File
@@ -12,7 +12,8 @@ import {
SettingOutlined,
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import "../../styles/pages/ecommerce.css";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
@@ -900,25 +901,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
const cloneRatioOptions = hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions;
const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
const productSetRatioOptions = useMemo(
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
[productSetOutput, productSetPlatform],
);
const hotUploadedRatioOption = useMemo(
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
[cloneOutput, cloneReferenceImages],
);
const baseCloneRatioOptions = useMemo(
() => getPlatformRatioOptions(platform, cloneOutput),
[cloneOutput, platform],
);
const cloneRatioOptions = useMemo(
() => hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions,
[baseCloneRatioOptions, hotUploadedRatioOption],
);
const productSetLanguageOptions = useMemo(
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
[productSetMarket, productSetPlatform],
);
const cloneLanguageOptions = useMemo(
() => getPlatformLanguageOptions(platform, market),
[market, platform],
);
const detailLanguageOptions = useMemo(
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
[detailMarket, detailPlatform],
);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
];
const ecommerceVideoImageDataUrls = useMemo(
() => productImages.map((img) => img.src),
[productImages],
);
const ecommerceVideoImageFiles = useMemo(
() => productImages.map((img) => img.file),
[productImages],
);
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
[cloneSetCounts],
);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
@@ -927,9 +961,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const cloneVideoDurationStyle: CSSProperties = useMemo(
() => ({
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
}) as CSSProperties,
[cloneVideoDurationProgress],
);
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
@@ -1098,6 +1135,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
const clearCloneSetCountHold = () => {
window.removeEventListener("pointerup", clearCloneSetCountHold);
window.removeEventListener("pointercancel", clearCloneSetCountHold);
if (countHoldTimeoutRef.current !== null) {
window.clearTimeout(countHoldTimeoutRef.current);
countHoldTimeoutRef.current = null;
@@ -1212,6 +1251,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
requirement,
});
const latestCloneSettingSnapshot = useMemo(
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
[
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
],
);
const persistLatestCloneSetting = () => {
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
latestCloneSettingRef.current = snapshot;
@@ -1259,8 +1326,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
useEffect(() => {
latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
});
latestCloneSettingRef.current = latestCloneSettingSnapshot;
}, [latestCloneSettingSnapshot]);
useEffect(() => {
const latestSetting = readCloneLatestSetting();
@@ -2616,8 +2683,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}>
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={productImages.map((img) => img.file)}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -8,6 +8,8 @@ import {
TagsOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/ecommerce.css";
import type { WebProjectSummary } from "../../types";
import { useDebounce } from "../../hooks/useDebounce";
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
@@ -1,4 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/ecommerce-video.css";
import {
CloseOutlined,
CopyOutlined,
@@ -121,6 +122,7 @@ export default function EcommerceVideoWorkspace({
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const actionNoticeTimerRef = useRef<number | null>(null);
const setView = useAppStore((s) => s.setView);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
const keepalivePollingStartedRef = useRef(false);
@@ -276,9 +278,23 @@ export default function EcommerceVideoWorkspace({
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan.
useEffect(() => {
return () => {
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
};
}, []);
const showNotice = (msg: string) => {
setActionNotice(msg);
setTimeout(() => setActionNotice(null), 3000);
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
actionNoticeTimerRef.current = window.setTimeout(() => {
actionNoticeTimerRef.current = null;
setActionNotice(null);
}, 3000);
};
const handleDownload = async (url: string) => {
+12 -23
View File
@@ -9,6 +9,7 @@ import {
type AdVideoUserConfig,
} from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { serverRequest } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
@@ -430,15 +431,6 @@ export interface VideoHistoryListResponse {
offset: number;
}
import { getStoredToken } from "../../api/serverConnection";
const API_BASE = "/api/ai/ecommerce/video-history";
function getAuthHeaders(): Record<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all(
@@ -486,13 +478,12 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, {
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload),
body: historyPayload,
maxRetries: 0,
fallbackMessage: "Failed to save video history",
});
if (!res.ok) throw new Error("Failed to save video history");
return res.json();
}
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
@@ -511,12 +502,10 @@ export async function fetchVideoHistory(
limit = 20,
offset = 0,
): Promise<VideoHistoryListResponse> {
const res = await fetch(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("Failed to fetch video history");
const history = (await res.json()) as VideoHistoryListResponse;
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
fallbackMessage: "Failed to fetch video history",
});
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
@@ -524,9 +513,9 @@ export async function fetchVideoHistory(
}
export async function deleteVideoHistory(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/${id}`, {
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
maxRetries: 0,
fallbackMessage: "Failed to delete video history",
});
if (!res.ok) throw new Error("Failed to delete video history");
}
+15 -4
View File
@@ -50,6 +50,12 @@ function ScriptReviewShowcase() {
const scoreRef = useRef<HTMLSpanElement>(null);
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]);
const animationTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearAnimationTimers = () => {
animationTimersRef.current.forEach((timer) => clearTimeout(timer));
animationTimersRef.current = [];
};
useEffect(() => {
const el = document.getElementById("script-review-showcase");
@@ -69,18 +75,23 @@ function ScriptReviewShowcase() {
useEffect(() => {
if (!animated) return;
const timer = setTimeout(() => {
clearAnimationTimers();
const scheduleAnimation = (callback: () => void, delay: number) => {
const timer = setTimeout(callback, delay);
animationTimersRef.current.push(timer);
};
scheduleAnimation(() => {
animateNumber(scoreRef.current, 77, 1400);
barRefs.current.forEach((bar, i) => {
if (!bar) return;
const pct = parseFloat(bar.dataset.pct ?? "0");
setTimeout(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
scheduleAnimation(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
});
scoreValRefs.current.forEach((el, i) => {
setTimeout(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
scheduleAnimation(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
});
}, 500);
return () => clearTimeout(timer);
return clearAnimationTimers;
}, [animated]);
return (
@@ -24,6 +24,7 @@ import {
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
@@ -80,6 +81,20 @@ const CAMERA_EFFECT_PRESETS = [
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
] as const;
const CAMERA_EFFECT_PROMPT_BY_KEY = new Map<string, string>(
CAMERA_EFFECT_PRESETS.map((effect) => [effect.key, effect.prompt]),
);
function getCameraEffectsPrompt(effectKeys: Set<string>): string {
if (effectKeys.size === 0) return "";
const prompts: string[] = [];
for (const key of effectKeys) {
const prompt = CAMERA_EFFECT_PROMPT_BY_KEY.get(key);
if (prompt) prompts.push(prompt);
}
return prompts.join("");
}
function shotScaleToZoom(shotScale: number): number {
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
@@ -152,6 +167,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
abortRef.current = false;
taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, {
kind: "image",
onProgress: (e) => {
setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) {
@@ -398,9 +414,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const refUrls = await uploadReferenceImages([cameraImage]);
const model = "wan2.7-image-pro";
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
const effectsDesc = cameraEffects.size > 0
? Array.from(cameraEffects).map((key) => CAMERA_EFFECT_PRESETS.find((e) => e.key === key)?.prompt).filter(Boolean).join("")
: "";
const effectsDesc = getCameraEffectsPrompt(cameraEffects);
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}${cameraPrompt}`
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
@@ -446,6 +460,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
return waitForTask(taskId, {
kind: "image",
abortRef,
onProgress: (e) => setGenerationProgress(e.progress || 0),
});
@@ -559,7 +574,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
+1
View File
@@ -14,6 +14,7 @@ import {
} from "@ant-design/icons";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/more.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
interface MorePageProps {
+1
View File
@@ -18,6 +18,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import "../../styles/pages/profile.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/provider-health.css";
import {
CheckCircleOutlined,
CloseCircleOutlined,
@@ -16,6 +16,7 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
@@ -9,6 +9,8 @@ import {
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
import { useSessionStore } from "../../stores";
@@ -243,9 +245,21 @@ function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[
.slice(0, 5);
}
function normalizeEvidenceItems(evidence: unknown[] | undefined, limit: number): string[] {
if (!Array.isArray(evidence)) return [];
const items: string[] = [];
for (const item of evidence) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
return normalizeEvidenceItems(evidence, 3);
}
function formatReportMarkdown(result: EvalResult, script: string): string {
@@ -11,6 +11,8 @@ import {
WarningOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import type {
WebEnterpriseUsageMember,
WebEnterpriseUsageRecord,
@@ -13,6 +13,8 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/subtitle-removal.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
@@ -13,6 +13,7 @@ import {
SwapOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
+2 -1
View File
@@ -34,6 +34,7 @@ import {
type ReactNode,
type SyntheticEvent,
} from "react";
import "../../styles/pages/workbench.css";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
@@ -369,7 +370,7 @@ function WorkbenchPage({
.get()
.then((capabilities) => {
if (cancelled) return;
const nextVideoModels = VIDEO_MODEL_OPTIONS;
const nextVideoModels = capabilities.videoModels.length ? capabilities.videoModels : VIDEO_MODEL_OPTIONS;
applyImageModels(capabilities.imageModels);
setVideoModelOptions(nextVideoModels);
+11 -28
View File
@@ -3,6 +3,8 @@
* Persists task state to localStorage so in-progress tasks survive page switches.
*/
import { waitForTask } from "../../api/taskSubscription";
const KEEPALIVE_PREFIX = "omniai:tool-task:";
interface ToolTaskKeepalive {
@@ -59,38 +61,19 @@ export function clearToolTaskState(key: string): void {
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
}
const TASK_POLL_INTERVAL = 3000;
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
export async function pollTaskUntilDone(
taskId: string,
onProgress?: (progress: number) => void,
abortRef?: { current: boolean },
kind: "image" | "video" = "video",
): Promise<string | null> {
const startTime = Date.now();
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
while (true) {
if (abortRef?.current) return null;
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
if (!task) return null;
const progress = Math.min(99, task.progress || 0);
onProgress?.(progress);
if (task.status === "completed") {
return task.resultUrl || null;
}
if (task.status === "failed" || task.status === "cancelled") {
return null;
}
} catch {
// retry on next poll
}
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
try {
return await waitForTask(taskId, {
kind,
abortRef,
onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))),
});
} catch {
return null;
}
}
+29 -18
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import type { GenerationQueueItem } from "../stores/useGenerationStore";
import { useGenerationStore } from "../stores/useGenerationStore";
import {
@@ -13,7 +14,17 @@ interface UseGenerationTasksOptions {
export function useGenerationTasks(options: UseGenerationTasksOptions) {
const { sourceView, autoResume = true } = options;
const store = useGenerationStore();
const {
queue,
addTask,
updateTask: updateStoredTask,
getRunningTasks,
} = useGenerationStore(useShallow((s) => ({
queue: s.queue,
addTask: s.addTask,
updateTask: s.updateTask,
getRunningTasks: s.getRunningTasks,
})));
const pollingStartedRef = useRef(false);
// ── Auto-resume: re-subscribe to running tasks on mount ────
@@ -21,7 +32,7 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
if (!autoResume || pollingStartedRef.current) return;
pollingStartedRef.current = true;
const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView);
const active = getRunningTasks().filter((t) => t.sourceView === sourceView);
if (active.length > 0) {
startBackgroundPolling();
}
@@ -29,19 +40,19 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
return () => {
pollingStartedRef.current = false;
};
}, [autoResume, sourceView, store]);
}, [autoResume, sourceView, getRunningTasks]);
// ── Subscribe to live updates ───────────────────────────
useEffect(() => {
return subscribeToTaskUpdates((updated) => {
store.updateTask(updated.id, updated);
updateStoredTask(updated.id, updated);
});
}, [store]);
}, [updateStoredTask]);
// ── View-scoped computed lists ──────────────────────────
const myTasks = useMemo(
() => store.queue.filter((t) => t.sourceView === sourceView),
[store.queue, sourceView],
() => queue.filter((t) => t.sourceView === sourceView),
[queue, sourceView],
);
const activeTasks = useMemo(
@@ -63,41 +74,41 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
const submitTask = useCallback(
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
store.addTask({ ...task, id, createdAt: Date.now() });
addTask({ ...task, id, createdAt: Date.now() });
return id;
},
[store],
[addTask],
);
const updateTask = useCallback(
(id: string, patch: Partial<GenerationQueueItem>) => {
store.updateTask(id, patch);
updateStoredTask(id, patch);
},
[store],
[updateStoredTask],
);
const markCompleted = useCallback(
(id: string, resultUrl: string) => {
store.updateTask(id, { status: "completed", progress: 100, resultUrl });
updateStoredTask(id, { status: "completed", progress: 100, resultUrl });
},
[store],
[updateStoredTask],
);
const markFailed = useCallback(
(id: string, error: string) => {
store.updateTask(id, { status: "failed", error });
updateStoredTask(id, { status: "failed", error });
},
[store],
[updateStoredTask],
);
const retryTask = useCallback(
(id: string) => {
const task = store.queue.find((t) => t.id === id);
const task = queue.find((t) => t.id === id);
if (task) {
store.updateTask(id, { status: "pending", progress: 0, error: null });
updateStoredTask(id, { status: "pending", progress: 0, error: null });
}
},
[store],
[queue, updateStoredTask],
);
return {
-1
View File
@@ -1,6 +1,5 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@xyflow/react/dist/style.css";
import "./styles/index.css";
import App from "./App";
import { reportError } from "./utils/errorReporting";
+87 -95
View File
@@ -1,20 +1,12 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient";
import {
buildLocalTimeoutMessage,
buildTaskFailureInfo,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
import { waitForTask, type TaskProgressEvent } from "../api/taskSubscription";
import { buildTaskFailureInfo } from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void;
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const activePollers = new Map<string, { current: boolean }>();
const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback);
return () => { pollCallbacks.delete(callback); };
@@ -34,109 +26,109 @@ function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined;
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`;
if (activePollers.has(key)) return;
const kind = getQueueItemKind(item);
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
let lastProgress = Math.max(0, Number(item.progress || 0));
let lastProgressAt = Date.now();
const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
cleanupPoll(key);
return;
}
attemptsRef.current++;
const timeoutReason = isTaskLocallyTimedOut({
startedAt: current.createdAt || item.createdAt || Date.now(),
lastProgressAt,
progress: lastProgress,
policy: timeoutPolicy,
});
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
const error = buildLocalTimeoutMessage(kind);
useGenerationStore.getState().updateTask(item.id, {
status: "failed",
error,
});
notifyCallbacks({ ...item, status: "failed", error });
cleanupPoll(key);
return;
}
try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const nextProgress = Number(status.progress || 0);
if (nextProgress > lastProgress || status.status === "completed") {
lastProgress = Math.max(lastProgress, nextProgress);
lastProgressAt = Date.now();
}
const patch: Partial<GenerationQueueItem> = {
progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl,
error: status.error || current.error,
};
if (status.status === "completed") {
patch.status = "completed";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "completed" });
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
} else {
patch.status = "running";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "running" });
}
} catch {
// Network errors during polling are retried until the lifecycle guard trips.
}
}, POLL_INTERVAL);
activePollers.set(key, interval);
function updateTaskAndNotify(id: string, patch: Partial<GenerationQueueItem>): GenerationQueueItem | null {
const current = useGenerationStore.getState().queue.find((i) => i.id === id);
if (!current) return null;
const next = { ...current, ...patch };
useGenerationStore.getState().updateTask(id, patch);
notifyCallbacks(next);
return next;
}
function cleanupPoll(key: string): void {
const interval = activePollers.get(key);
if (interval) {
clearInterval(interval);
activePollers.delete(key);
}
function isTerminalStatus(status: GenerationQueueItem["status"]): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
function pollTask(item: GenerationQueueItem): void {
const key = `poll-${item.id}`;
if (activePollers.has(key) || !item.taskId) return;
const kind = getQueueItemKind(item);
const abortRef = { current: false };
activePollers.set(key, abortRef);
const applyProgress = (event: TaskProgressEvent) => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) {
abortRef.current = true;
return;
}
const patch: Partial<GenerationQueueItem> = {
progress: Number(event.progress || 0),
resultUrl: event.resultUrl || current.resultUrl,
error: event.error || current.error,
};
if (event.status === "completed") {
patch.status = "completed";
patch.progress = 100;
} else if (event.status === "failed" || event.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(event.error).message;
} else {
patch.status = "running";
}
updateTaskAndNotify(item.id, patch);
};
void waitForTask(item.taskId, {
kind,
model: getQueueItemModel(item),
startedAt: item.createdAt || Date.now(),
abortRef,
onProgress: applyProgress,
})
.then((resultUrl) => {
if (abortRef.current) return;
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) return;
updateTaskAndNotify(item.id, {
status: "completed",
progress: 100,
resultUrl: resultUrl || current.resultUrl,
});
})
.catch((error) => {
if (abortRef.current) return;
const failure = buildTaskFailureInfo(error instanceof Error ? error.message : String(error));
updateTaskAndNotify(item.id, {
status: "failed",
error: failure.message,
});
})
.finally(() => {
cleanupPoll(key, abortRef);
});
}
function cleanupPoll(key: string, abortRef: { current: boolean }): void {
if (activePollers.get(key) !== abortRef) return;
activePollers.delete(key);
}
export function startBackgroundPolling(): void {
const tasks = useGenerationStore.getState().getRunningTasks();
const attemptsMap = new Map<string, { current: number }>();
tasks.forEach((task) => {
if (task.taskId) {
if (!attemptsMap.has(task.id)) {
attemptsMap.set(task.id, { current: 0 });
}
pollTask(task, attemptsMap.get(task.id)!);
pollTask(task);
}
});
}
export function resumeTaskPolling(taskId: string, storeId: string): void {
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
if (task && task.status !== "completed" && task.status !== "failed") {
pollTask(task, { current: 0 });
if (task && !isTerminalStatus(task.status)) {
pollTask({ ...task, taskId });
}
}
export function stopAllPolling(): void {
activePollers.forEach((interval) => clearInterval(interval));
activePollers.forEach((abortRef) => {
abortRef.current = true;
});
activePollers.clear();
}
-19
View File
@@ -9,28 +9,9 @@
@import "./pages/script-review-visual.css";
@import "./pages/script-review-showcase.css";
@import "./pages/model-generation-showcase.css";
@import "./pages/workbench.css";
@import "./pages/ecommerce.css";
@import "./pages/ecommerce-video.css";
@import "./pages/community.css";
@import "./pages/assets.css";
@import "./pages/more.css";
@import "./pages/avatar-console.css";
@import "./pages/more-tools.css";
@import "./pages/studio-layout.css";
@import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css";
@import "./pages/dialog-generator.css";
@import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css";
@import "./pages/profile.css";
@import "./pages/canvas.css";
@import "./pages/agent.css";
@import "./pages/compliance.css";
@import "./pages/provider-health.css";
@import "./pages/legacy-pages.css";
@import "./pages/not-found.css";
@import "./components/recharge-modal.css";
@import "./components/dropzone.css";
@import "./components/skeleton.css";
+3 -3
View File
@@ -3,6 +3,8 @@
* Falls back gracefully when Notification API is unavailable.
*/
import { toast } from "../components/toast/toastStore";
let permissionGranted = false;
async function requestPermission(): Promise<boolean> {
@@ -35,9 +37,7 @@ export function notifyTaskCompleted(label: string, mode: "image" | "video" = "im
// Use the existing toast system for in-app notifications
function dispatchGenToast(msg: string) {
try {
import("../components/toast/toastStore").then((m) => m.toast(msg, "success"));
} catch { /* toast system not loaded */ }
toast(msg, "success");
}
/** Call once on app init to pre-warm permission. */