From 9999e516ae510eafb026976857bd5de2e67d26f4 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 16:43:02 +0800 Subject: [PATCH 01/18] fix: improve generation task reliability --- scripts/smoke-generation-mocked.mjs | 4 +- src/api/aiGenerationClient.ts | 181 ++++++++--------- src/api/providerHealthClient.ts | 13 +- src/api/scriptEvalClient.ts | 22 +-- src/api/serverConnection.ts | 11 +- src/features/canvas/CanvasPage.tsx | 41 +++- .../ecommerce/ecommerceVideoService.ts | 35 ++-- .../image-workbench/ImageWorkbenchPage.tsx | 4 +- src/features/workbench/WorkbenchPage.tsx | 2 +- src/features/workbench/toolKeepalive.ts | 39 ++-- src/services/backgroundTaskRunner.ts | 182 +++++++++--------- 11 files changed, 256 insertions(+), 278 deletions(-) diff --git a/scripts/smoke-generation-mocked.mjs b/scripts/smoke-generation-mocked.mjs index f078568..ddf1a75 100644 --- a/scripts/smoke-generation-mocked.mjs +++ b/scripts/smoke-generation-mocked.mjs @@ -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, diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index accb697..5e78c26 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -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): 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 { 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("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(res, "Image generation response failed"); if (payload.providerDebug) { emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record); } @@ -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 { - 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(`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 { - const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), { - method: "GET", - headers: buildAuthHeaders(), + return serverRequest(`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(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(`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(res, "Task history response failed"); - return extractTaskList(payload).map(toPreviewTask); }, async bindTaskToConversation(taskId: string, conversationId: number): Promise { - 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(`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( diff --git a/src/api/providerHealthClient.ts b/src/api/providerHealthClient.ts index 11c48b4..6552661 100644 --- a/src/api/providerHealthClient.ts +++ b/src/api/providerHealthClient.ts @@ -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 { - const res = await fetch(buildApiUrl("admin/providers/status"), { - method: "GET", - headers: buildAuthHeaders(), + return serverRequest("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; }, -}; \ No newline at end of file +}; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index d593541..3afa296 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -1,4 +1,4 @@ -import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; +import { serverRequest } from "./serverConnection"; export interface ScriptEvalResult { totalScore: number; @@ -140,10 +140,13 @@ function normalizeEvidence(value: unknown): Record { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - 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 +156,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("模型未返回有效内容"); diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index d1302fa..3c2a499 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -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(path: string, options?: ServerRequestOptions): Promise { 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(path: string, options?: ServerRequestOpti credentials: "include", }); - const payload = await readJsonResponse(response, "Request failed"); + const payload = await readJsonResponse(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; } diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 1d99981..2ecf3b2 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -32,6 +32,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty 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 { @@ -118,7 +119,7 @@ import { defaultVideoModel, image4kCapableModels, imageFocusRatioOptions, - imageModelOptions, + imageModelOptions as fallbackCanvasImageModelOptions, imageRatioOptions, textModelOptions, videoDurationOptions, @@ -354,6 +355,8 @@ function CanvasPage({ const [projectNameEditing, setProjectNameEditing] = useState(false); const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [videoNodes, setVideoNodes] = useState([]); + const [canvasImageModelOptions, setCanvasImageModelOptions] = useState(fallbackCanvasImageModelOptions); + const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState(canvasEnterpriseVideoModelOptions); const [selectedNode, setSelectedNode] = useState(null); const [selectedNodes, setSelectedNodes] = useState([]); const [selectionContextMenu, setSelectionContextMenu] = useState(null); @@ -458,9 +461,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( @@ -5044,7 +5077,7 @@ function CanvasPage({ 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) => diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index e06f82e..9f104ad 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -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 { - const token = getStoredToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise { 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 { - 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(`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 { - const res = await fetch(`${API_BASE}/${id}`, { + await serverRequest(`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"); } diff --git a/src/features/image-workbench/ImageWorkbenchPage.tsx b/src/features/image-workbench/ImageWorkbenchPage.tsx index eed76d3..a7f084f 100644 --- a/src/features/image-workbench/ImageWorkbenchPage.tsx +++ b/src/features/image-workbench/ImageWorkbenchPage.tsx @@ -152,6 +152,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) { @@ -446,6 +447,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie const pollTaskUntilDone = useCallback(async (taskId: string): Promise => { return waitForTask(taskId, { + kind: "image", abortRef, onProgress: (e) => setGenerationProgress(e.progress || 0), }); @@ -559,7 +561,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) { diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index f40ff7e..8800fa4 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -369,7 +369,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); diff --git a/src/features/workbench/toolKeepalive.ts b/src/features/workbench/toolKeepalive.ts index 40140a6..904a97f 100644 --- a/src/features/workbench/toolKeepalive.ts +++ b/src/features/workbench/toolKeepalive.ts @@ -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 { - 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; } } diff --git a/src/services/backgroundTaskRunner.ts b/src/services/backgroundTaskRunner.ts index 15b235f..bdae615 100644 --- a/src/services/backgroundTaskRunner.ts +++ b/src/services/backgroundTaskRunner.ts @@ -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>(); +const activePollers = new Map(); const pollCallbacks = new Set(); -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 = { - 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 | 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 = { + 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(); 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(); } From 53f6a023772d15cd9e006ef5212d11f7b71793a0 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 17:04:01 +0800 Subject: [PATCH 02/18] fix: reduce store rerenders and cleanup timers --- src/App.tsx | 160 ++++++++++++------ src/features/canvas/CanvasPage.tsx | 40 ++++- src/features/ecommerce/EcommercePage.tsx | 2 + .../ecommerce/EcommerceVideoWorkspace.tsx | 17 +- src/features/home/ScriptReviewShowcase.tsx | 19 ++- src/hooks/useGenerationTasks.ts | 47 +++-- 6 files changed, 210 insertions(+), 75 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 91e878f..5ee6fc3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -233,61 +234,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"; diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 2ecf3b2..f3988d8 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -397,10 +397,12 @@ function CanvasPage({ const suppressNextPaneClickRef = useRef(false); const canvasAutoSaveTimerRef = useRef(null); const canvasAutoSaveIdleHandleRef = useRef(null); + const canvasAutoSaveRetryTimerRef = useRef(null); const canvasAutoSaveInFlightRef = useRef(false); const canvasAutoSavePendingRef = useRef(false); const lastAutoSavedWorkflowFingerprintRef = useRef(""); const canvasAutoSaveHydrationRef = useRef(true); + const textNodeMentionFocusTimerRef = useRef(null); const textNodeIdRef = useRef(9); const imageNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1); @@ -519,7 +521,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); @@ -555,6 +561,18 @@ function CanvasPage({ const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle"); const autoSaveStatusTimerRef = useRef(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 @@ -3159,7 +3177,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); } } }, [ @@ -3228,7 +3252,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 () => { @@ -3240,6 +3270,10 @@ function CanvasPage({ window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); canvasAutoSaveIdleHandleRef.current = null; } + if (canvasAutoSaveRetryTimerRef.current !== null) { + window.clearTimeout(canvasAutoSaveRetryTimerRef.current); + canvasAutoSaveRetryTimerRef.current = null; + } }; }, [ isAuthenticated, diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index cf61780..6fb7493 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1098,6 +1098,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; diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index 0fd211c..0623183 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -121,6 +121,7 @@ export default function EcommerceVideoWorkspace({ const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const abortControllerRef = useRef(null); const renderAbortRef = useRef({ current: false }); + const actionNoticeTimerRef = useRef(null); const setView = useAppStore((s) => s.setView); const keepaliveRestoredFingerprintRef = useRef(null); const keepalivePollingStartedRef = useRef(false); @@ -276,9 +277,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) => { diff --git a/src/features/home/ScriptReviewShowcase.tsx b/src/features/home/ScriptReviewShowcase.tsx index a804800..5435bef 100644 --- a/src/features/home/ScriptReviewShowcase.tsx +++ b/src/features/home/ScriptReviewShowcase.tsx @@ -50,6 +50,12 @@ function ScriptReviewShowcase() { const scoreRef = useRef(null); const barRefs = useRef<(HTMLDivElement | null)[]>([]); const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]); + const animationTimersRef = useRef[]>([]); + + 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 ( diff --git a/src/hooks/useGenerationTasks.ts b/src/hooks/useGenerationTasks.ts index 7b4fd27..bab3c20 100644 --- a/src/hooks/useGenerationTasks.ts +++ b/src/hooks/useGenerationTasks.ts @@ -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) => { 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) => { - 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 { From 60607053457c7ad0f67a9983aa9ef53ad6f6903e Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 17:19:38 +0800 Subject: [PATCH 03/18] perf: split page css from main bundle --- src/components/NotFoundPage.tsx | 1 + src/features/agent/AgentPage.tsx | 1 + src/features/assets/AssetsPage.tsx | 1 + src/features/canvas/CanvasPage.tsx | 2 ++ .../character-mix/CharacterMixPage.tsx | 1 + src/features/community/CommunityPage.tsx | 1 + src/features/compliance/CompliancePage.tsx | 1 + .../dialog-generator/DialogGeneratorPage.tsx | 1 + .../digital-human/AvatarConsolePage.tsx | 1 + .../digital-human/DigitalHumanPage.tsx | 1 + src/features/ecommerce/EcommercePage.tsx | 35 +++++++++++++++++-- .../ecommerce/EcommerceTemplatesPage.tsx | 2 ++ .../ecommerce/EcommerceVideoWorkspace.tsx | 1 + .../image-workbench/ImageWorkbenchPage.tsx | 1 + src/features/more/MorePage.tsx | 1 + src/features/profile/ProfilePage.tsx | 1 + .../provider-health/ProviderHealthPage.tsx | 3 +- .../ResolutionUpscalePage.tsx | 1 + .../script-tokens/ScriptTokensPage.tsx | 2 ++ src/features/script-tokens/TokenUsagePage.tsx | 2 ++ .../subtitle-removal/SubtitleRemovalPage.tsx | 2 ++ .../WatermarkRemovalPage.tsx | 1 + src/features/workbench/WorkbenchPage.tsx | 1 + src/main.tsx | 1 - src/styles/index.css | 18 ---------- 25 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx index 2a09a9c..0d57245 100644 --- a/src/components/NotFoundPage.tsx +++ b/src/components/NotFoundPage.tsx @@ -1,5 +1,6 @@ import { HomeOutlined } from "@ant-design/icons"; import { useCallback } from "react"; +import "../styles/pages/not-found.css"; interface NotFoundPageProps { onGoHome: () => void; diff --git a/src/features/agent/AgentPage.tsx b/src/features/agent/AgentPage.tsx index a96d470..ad0d46d 100644 --- a/src/features/agent/AgentPage.tsx +++ b/src/features/agent/AgentPage.tsx @@ -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"; diff --git a/src/features/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index e3cf85c..51b98a3 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -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"; diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index f3988d8..3c30783 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -28,6 +28,8 @@ 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"; diff --git a/src/features/character-mix/CharacterMixPage.tsx b/src/features/character-mix/CharacterMixPage.tsx index b93dc7e..d71ef0a 100644 --- a/src/features/character-mix/CharacterMixPage.tsx +++ b/src/features/character-mix/CharacterMixPage.tsx @@ -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"; diff --git a/src/features/community/CommunityPage.tsx b/src/features/community/CommunityPage.tsx index bcc738c..7c1e97e 100644 --- a/src/features/community/CommunityPage.tsx +++ b/src/features/community/CommunityPage.tsx @@ -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"; diff --git a/src/features/compliance/CompliancePage.tsx b/src/features/compliance/CompliancePage.tsx index 624754b..ec5e543 100644 --- a/src/features/compliance/CompliancePage.tsx +++ b/src/features/compliance/CompliancePage.tsx @@ -1,4 +1,5 @@ import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons"; +import "../../styles/pages/compliance.css"; type ComplianceKind = "agreement" | "privacy"; diff --git a/src/features/dialog-generator/DialogGeneratorPage.tsx b/src/features/dialog-generator/DialogGeneratorPage.tsx index 4d99386..e67278c 100644 --- a/src/features/dialog-generator/DialogGeneratorPage.tsx +++ b/src/features/dialog-generator/DialogGeneratorPage.tsx @@ -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"; diff --git a/src/features/digital-human/AvatarConsolePage.tsx b/src/features/digital-human/AvatarConsolePage.tsx index 2939295..205cb38 100644 --- a/src/features/digital-human/AvatarConsolePage.tsx +++ b/src/features/digital-human/AvatarConsolePage.tsx @@ -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, diff --git a/src/features/digital-human/DigitalHumanPage.tsx b/src/features/digital-human/DigitalHumanPage.tsx index 20092dd..0d331b5 100644 --- a/src/features/digital-human/DigitalHumanPage.tsx +++ b/src/features/digital-human/DigitalHumanPage.tsx @@ -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"; diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 6fb7493..dd5704e 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -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"; @@ -1214,6 +1215,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; @@ -1261,8 +1290,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; useEffect(() => { - latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"); - }); + latestCloneSettingRef.current = latestCloneSettingSnapshot; + }, [latestCloneSettingSnapshot]); useEffect(() => { const latestSetting = readCloneLatestSetting(); diff --git a/src/features/ecommerce/EcommerceTemplatesPage.tsx b/src/features/ecommerce/EcommerceTemplatesPage.tsx index 93a40f6..65362d9 100644 --- a/src/features/ecommerce/EcommerceTemplatesPage.tsx +++ b/src/features/ecommerce/EcommerceTemplatesPage.tsx @@ -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"; diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index 0623183..8d9db81 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -1,4 +1,5 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import "../../styles/pages/ecommerce-video.css"; import { CloseOutlined, CopyOutlined, diff --git a/src/features/image-workbench/ImageWorkbenchPage.tsx b/src/features/image-workbench/ImageWorkbenchPage.tsx index a7f084f..17a5827 100644 --- a/src/features/image-workbench/ImageWorkbenchPage.tsx +++ b/src/features/image-workbench/ImageWorkbenchPage.tsx @@ -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"; diff --git a/src/features/more/MorePage.tsx b/src/features/more/MorePage.tsx index e5e42ef..7f24631 100644 --- a/src/features/more/MorePage.tsx +++ b/src/features/more/MorePage.tsx @@ -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 { diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx index f0a14a6..474a1b1 100644 --- a/src/features/profile/ProfilePage.tsx +++ b/src/features/profile/ProfilePage.tsx @@ -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"; diff --git a/src/features/provider-health/ProviderHealthPage.tsx b/src/features/provider-health/ProviderHealthPage.tsx index c382e3c..aba3b18 100644 --- a/src/features/provider-health/ProviderHealthPage.tsx +++ b/src/features/provider-health/ProviderHealthPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import "../../styles/pages/provider-health.css"; import { CheckCircleOutlined, CloseCircleOutlined, @@ -164,4 +165,4 @@ export default function ProviderHealthPage({ session, onOpenLogin }: ProviderHea ); -} \ No newline at end of file +} diff --git a/src/features/resolution-upscale/ResolutionUpscalePage.tsx b/src/features/resolution-upscale/ResolutionUpscalePage.tsx index c33690a..93e0484 100644 --- a/src/features/resolution-upscale/ResolutionUpscalePage.tsx +++ b/src/features/resolution-upscale/ResolutionUpscalePage.tsx @@ -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"; diff --git a/src/features/script-tokens/ScriptTokensPage.tsx b/src/features/script-tokens/ScriptTokensPage.tsx index 3c8a53e..b553a84 100644 --- a/src/features/script-tokens/ScriptTokensPage.tsx +++ b/src/features/script-tokens/ScriptTokensPage.tsx @@ -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"; diff --git a/src/features/script-tokens/TokenUsagePage.tsx b/src/features/script-tokens/TokenUsagePage.tsx index 848efe4..30c16da 100644 --- a/src/features/script-tokens/TokenUsagePage.tsx +++ b/src/features/script-tokens/TokenUsagePage.tsx @@ -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, diff --git a/src/features/subtitle-removal/SubtitleRemovalPage.tsx b/src/features/subtitle-removal/SubtitleRemovalPage.tsx index cc11450..7ec16d8 100644 --- a/src/features/subtitle-removal/SubtitleRemovalPage.tsx +++ b/src/features/subtitle-removal/SubtitleRemovalPage.tsx @@ -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"; diff --git a/src/features/watermark-removal/WatermarkRemovalPage.tsx b/src/features/watermark-removal/WatermarkRemovalPage.tsx index 09ad991..ebb4259 100644 --- a/src/features/watermark-removal/WatermarkRemovalPage.tsx +++ b/src/features/watermark-removal/WatermarkRemovalPage.tsx @@ -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"; diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index 8800fa4..765603d 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -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"; diff --git a/src/main.tsx b/src/main.tsx index 3efd374..805d641 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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"; diff --git a/src/styles/index.css b/src/styles/index.css index 45a76ba..e9c2fba 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -9,28 +9,10 @@ @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"; From b8b3b8f1378be269bfbbff329cccf497a63af660 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 17:35:54 +0800 Subject: [PATCH 04/18] perf: memoize derived render data --- src/App.tsx | 27 ++++++++++ src/components/AppShell.tsx | 56 ++++++++++---------- src/features/canvas/CanvasPage.tsx | 43 +++++++++++---- src/features/ecommerce/EcommercePage.tsx | 66 ++++++++++++++++++------ src/styles/index.css | 1 - src/utils/generationNotifier.ts | 6 +-- 6 files changed, 143 insertions(+), 56 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5ee6fc3..992f62f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -127,6 +127,27 @@ const VIEW_KEYS = new Set([ ]); const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); +const LEGACY_PAGE_STYLE_VIEWS = new Set([ + "login", + "workbench", + "canvas", + "community", + "communityReview", + "communityCaseAdd", + "assets", + "ecommerce", + "ecommerceHub", + "digitalHuman", + "characterMix", + "more", +]); + +let legacyPageStylesPromise: Promise | null = null; + +function loadLegacyPageStyles(): Promise { + legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css"); + return legacyPageStylesPromise; +} function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -357,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"); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 662c17f..7d9fa3d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -41,6 +41,32 @@ interface AppShellProps { } const BRAND_LOGO_URL = ossAssets.brand.logo; +const TOOL_SURFACE_VIEW_SET = new Set([ + "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 || !!session) && !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)); }, diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 3c30783..1dd22c8 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -578,7 +578,17 @@ function CanvasPage({ // 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 = useMemo( + () => serverAssets.filter((asset) => asset.imageUrl), + [serverAssets], + ); + const assetCountsByCategory = useMemo(() => { + const counts = new Map(); + for (const asset of serverAssets) { + counts.set(asset.type, (counts.get(asset.type) ?? 0) + 1); + } + return counts; + }, [serverAssets]); const shouldShowEmptyProjectState = projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; const isWaitingForProjects = isAuthenticated && !projectsLoaded; @@ -2640,10 +2650,13 @@ function CanvasPage({ setConnectorDrag(null); }; - const collapsedPackageNodeKeys = new Set( - nodePackages.flatMap((nodePackage) => - nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : [] - ) + const collapsedPackageNodeKeys = useMemo( + () => new Set( + nodePackages.flatMap((nodePackage) => + nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : [] + ) + ), + [nodePackages], ); const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) => collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })); @@ -2684,6 +2697,18 @@ function CanvasPage({ return positionedLink ? [positionedLink] : []; }), ].filter((link) => !isLinkCollapsedInPackage(link)); + const visibleTextNodes = useMemo( + () => textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)), + [collapsedPackageNodeKeys, textNodes], + ); + const visibleImageNodes = useMemo( + () => imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)), + [collapsedPackageNodeKeys, imageNodes], + ); + const visibleVideoNodes = useMemo( + () => videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)), + [collapsedPackageNodeKeys, videoNodes], + ); const pendingLinkPreview = pendingLinkPort && pendingLinkPreviewPoint ? (() => { @@ -4002,7 +4027,7 @@ function CanvasPage({ ) : null} ) : 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; @@ -4270,7 +4295,7 @@ function CanvasPage({ ); })} - {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; @@ -4774,7 +4799,7 @@ function CanvasPage({ ); })} - {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; @@ -5485,7 +5510,7 @@ function CanvasPage({ onClick={() => setSelectedExistingCategory(category.key)} > {category.label} - {serverAssets.filter((asset) => asset.type === category.key).length} 个素材 + {assetCountsByCategory.get(category.key) ?? 0} 个素材 ))} diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index dd5704e..d47b853 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -901,25 +901,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedDetailModules, setSelectedDetailModules] = useState(defaultDetailModuleIds); const [detailStatus, setDetailStatus] = useState("idle"); const [detailResultUrl, setDetailResultUrl] = useState(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) @@ -928,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); @@ -2647,8 +2683,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
).isAuthenticated)} - productImageDataUrls={productImages.map((img) => img.src)} - productImageFiles={productImages.map((img) => img.file)} + productImageDataUrls={ecommerceVideoImageDataUrls} + productImageFiles={ecommerceVideoImageFiles} requirement={requirement} platform={platform} aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"} diff --git a/src/styles/index.css b/src/styles/index.css index e9c2fba..d58bd11 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -12,7 +12,6 @@ @import "./pages/more-tools.css"; @import "./pages/studio-layout.css"; @import "./pages/size-template.css"; -@import "./pages/legacy-pages.css"; @import "./components/recharge-modal.css"; @import "./components/dropzone.css"; @import "./components/skeleton.css"; diff --git a/src/utils/generationNotifier.ts b/src/utils/generationNotifier.ts index d8b459b..cfa5ab1 100644 --- a/src/utils/generationNotifier.ts +++ b/src/utils/generationNotifier.ts @@ -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 { @@ -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. */ From ef05667caa9ea303173596eb90ea2a3d06981c92 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 18:01:48 +0800 Subject: [PATCH 05/18] refactor: extract canvas derived state --- src/features/canvas/CanvasPage.tsx | 46 ++++-------- src/features/canvas/useCanvasDerivedState.ts | 74 ++++++++++++++++++++ 2 files changed, 87 insertions(+), 33 deletions(-) create mode 100644 src/features/canvas/useCanvasDerivedState.ts diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 1dd22c8..b965207 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -55,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"; @@ -578,17 +579,7 @@ function CanvasPage({ // Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition) // — see useEffect below near runCanvasAutoSave - const canvasAssets = useMemo( - () => serverAssets.filter((asset) => asset.imageUrl), - [serverAssets], - ); - const assetCountsByCategory = useMemo(() => { - const counts = new Map(); - for (const asset of serverAssets) { - counts.set(asset.type, (counts.get(asset.type) ?? 0) + 1); - } - return counts; - }, [serverAssets]); + const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets); const shouldShowEmptyProjectState = projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; const isWaitingForProjects = isAuthenticated && !projectsLoaded; @@ -2650,16 +2641,17 @@ function CanvasPage({ setConnectorDrag(null); }; - const collapsedPackageNodeKeys = useMemo( - () => new Set( - nodePackages.flatMap((nodePackage) => - nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : [] - ) - ), - [nodePackages], - ); - 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); @@ -2697,18 +2689,6 @@ function CanvasPage({ return positionedLink ? [positionedLink] : []; }), ].filter((link) => !isLinkCollapsedInPackage(link)); - const visibleTextNodes = useMemo( - () => textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)), - [collapsedPackageNodeKeys, textNodes], - ); - const visibleImageNodes = useMemo( - () => imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)), - [collapsedPackageNodeKeys, imageNodes], - ); - const visibleVideoNodes = useMemo( - () => videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)), - [collapsedPackageNodeKeys, videoNodes], - ); const pendingLinkPreview = pendingLinkPort && pendingLinkPreviewPoint ? (() => { diff --git a/src/features/canvas/useCanvasDerivedState.ts b/src/features/canvas/useCanvasDerivedState.ts new file mode 100644 index 0000000..7e32226 --- /dev/null +++ b/src/features/canvas/useCanvasDerivedState.ts @@ -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(); + + 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, + }; +} From 31046eae5852f859e36613afa0fae0a562ef284d Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 18:08:16 +0800 Subject: [PATCH 06/18] refactor: extract canvas text prompt composer --- src/features/canvas/CanvasPage.tsx | 131 ++----------- .../canvas/CanvasTextPromptComposer.tsx | 174 ++++++++++++++++++ 2 files changed, 190 insertions(+), 115 deletions(-) create mode 100644 src/features/canvas/CanvasTextPromptComposer.tsx diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index b965207..5597763 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -186,6 +186,7 @@ import { } from "./canvasWorkflowDeserialize"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents"; +import { CanvasTextPromptComposer } from "./CanvasTextPromptComposer"; import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; @@ -4156,121 +4157,21 @@ function CanvasPage({ onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)} /> - {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) => { - 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) => { - 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) => { - 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 ( -
-
-