From e9601a651c6c5ee8a80ea838a8ec293763b04a4f Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 10 Jun 2026 16:00:26 +0800 Subject: [PATCH 1/2] Use time-driven generation progress --- src/api/aiGenerationClient.ts | 19 +- src/api/taskSubscription.ts | 22 ++- src/features/canvas/CanvasPage.tsx | 6 + .../canvas/CanvasSmoothedProgressRing.tsx | 29 ++- src/features/canvas/canvasUtils.ts | 24 ++- .../ecommerce/EcommerceProgressBar.tsx | 42 ++++- .../workbench/SmoothedProgressBar.tsx | 24 ++- .../components/WorkbenchChatCards.tsx | 30 ++- src/hooks/useSmoothedProgress.test.ts | 49 +++++ src/hooks/useSmoothedProgress.ts | 173 +++++++++++++++++- 10 files changed, 390 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useSmoothedProgress.test.ts diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index 6fdfe29..090977d 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -150,6 +150,10 @@ export interface AiTaskStatus { type: "image" | "video"; status: "pending" | "running" | "completed" | "failed" | "cancelled"; progress: number; + progressSource?: "real" | "estimated" | string | null; + stage?: string | null; + startedAt?: string | null; + expectedDurationMs?: number | null; resultUrl: string | null; error: string | null; params?: Record; @@ -514,7 +518,20 @@ export const aiGenerationClient = { subscribeTaskStatus( taskId: string, - onUpdate: (task: Pick) => void, + onUpdate: ( + task: Pick< + AiTaskStatus, + | "taskId" + | "status" + | "progress" + | "progressSource" + | "stage" + | "startedAt" + | "expectedDurationMs" + | "resultUrl" + | "error" + >, + ) => void, ): () => void { const url = buildApiUrl(`ai/tasks/${taskId}/stream`); const controller = new AbortController(); diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts index 3084581..c04d17f 100644 --- a/src/api/taskSubscription.ts +++ b/src/api/taskSubscription.ts @@ -9,6 +9,10 @@ export interface TaskProgressEvent { taskId: string; status: string; progress: number; + progressSource?: "real" | "estimated" | string | null; + stage?: string | null; + startedAt?: string | null; + expectedDurationMs?: number | null; resultUrl?: string | null; error?: string | null; } @@ -37,7 +41,8 @@ export function waitForTask( operation: options.operation, }); const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs; - const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs; + const noProgressTimeoutMs = + options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs; const startedAt = options.startedAt ?? Date.now(); return new Promise((resolve, reject) => { @@ -58,7 +63,10 @@ export function waitForTask( }; timeoutId = setTimeout( - () => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))), + () => + settle(() => + reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))), + ), timeoutMs, ); @@ -105,7 +113,11 @@ export function waitForTask( policy: { ...timeoutPolicy, noProgressTimeoutMs }, }); if (timeoutReason) { - settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))); + settle(() => + reject( + new Error(buildLocalTimeoutMessage(options.kind || "video")), + ), + ); return; } try { @@ -114,6 +126,10 @@ export function waitForTask( taskId, status: task.status, progress: task.progress || 0, + progressSource: task.progressSource, + stage: task.stage, + startedAt: task.startedAt, + expectedDurationMs: task.expectedDurationMs, resultUrl: task.resultUrl, error: task.error, }); diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 8ba9351..18ee3d0 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -33,6 +33,10 @@ import { communityClient } from "../../api/communityClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import WorkspacePageShell from "../../components/WorkspacePageShell"; +import { + DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS, + DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS, +} from "../../hooks/useSmoothedProgress"; import type { WebCanvasWorkflow } from "../../types"; import type { AssetLibraryCategory } from "../assets/localAssetStore"; import { @@ -4492,6 +4496,7 @@ function CanvasPage({ progress={imageNodeProgress} status={imageTaskState?.status || "running"} message={imageTaskState?.message || "图片生成中"} + expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS} /> ) : null} {imageNodeFocusActive && imageFocusSelectionReady ? ( @@ -4866,6 +4871,7 @@ function CanvasPage({ progress={videoNodeProgress} status={videoTaskState?.status || "running"} message={videoTaskState?.message || "视频生成中"} + expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS} /> ) : null} {renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")} diff --git a/src/features/canvas/CanvasSmoothedProgressRing.tsx b/src/features/canvas/CanvasSmoothedProgressRing.tsx index a80eefc..8ee0d7c 100644 --- a/src/features/canvas/CanvasSmoothedProgressRing.tsx +++ b/src/features/canvas/CanvasSmoothedProgressRing.tsx @@ -1,4 +1,8 @@ -import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; +import { + DEFAULT_GENERATION_EXPECTED_DURATION_MS, + useSmoothedProgress, + type ProgressSource, +} from "../../hooks/useSmoothedProgress"; import { canvasGenerationProgressStyle } from "./canvasUtils"; type NodeGenStatus = "submitting" | "running" | "success" | "error"; @@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps { progress: number; status: NodeGenStatus; message?: string; + progressSource?: ProgressSource; + startedAt?: number | string | Date | null; + expectedDurationMs?: number | null; } -export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) { - const smoothed = useSmoothedProgress(progress, status); +export function CanvasSmoothedProgressRing({ + progress, + status, + message, + progressSource = "estimated", + startedAt, + expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS, +}: CanvasSmoothedProgressRingProps) { + const smoothed = useSmoothedProgress(progress, status, { + progressSource, + startedAt, + expectedDurationMs, + }); return (
-
diff --git a/src/features/canvas/canvasUtils.ts b/src/features/canvas/canvasUtils.ts index 72fc37c..d46d2b5 100644 --- a/src/features/canvas/canvasUtils.ts +++ b/src/features/canvas/canvasUtils.ts @@ -262,7 +262,17 @@ export async function waitForImageTaskResult( abortRef, onProgress: (e) => { if (onStatus) { - onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); + onStatus({ + taskId, + status: e.status, + progress: e.progress, + progressSource: e.progressSource, + stage: e.stage, + startedAt: e.startedAt, + expectedDurationMs: e.expectedDurationMs, + resultUrl: e.resultUrl ?? undefined, + error: e.error ?? undefined, + } as AiTaskStatus); } }, }); @@ -281,7 +291,17 @@ export async function waitForVideoTaskResult( abortRef, onProgress: (e) => { if (onStatus) { - onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); + onStatus({ + taskId, + status: e.status, + progress: e.progress, + progressSource: e.progressSource, + stage: e.stage, + startedAt: e.startedAt, + expectedDurationMs: e.expectedDurationMs, + resultUrl: e.resultUrl ?? undefined, + error: e.error ?? undefined, + } as AiTaskStatus); } }, }); diff --git a/src/features/ecommerce/EcommerceProgressBar.tsx b/src/features/ecommerce/EcommerceProgressBar.tsx index f8cca03..53da5eb 100644 --- a/src/features/ecommerce/EcommerceProgressBar.tsx +++ b/src/features/ecommerce/EcommerceProgressBar.tsx @@ -1,4 +1,9 @@ -import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; +import { useRef } from "react"; +import { + DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS, + formatEstimatedRemainingLabel, + useSmoothedProgress, +} from "../../hooks/useSmoothedProgress"; interface EcommerceProgressBarProps { status: "idle" | "generating" | "done" | "failed" | string; @@ -12,19 +17,42 @@ function mapStatus(status: string): "running" | "completed" | "failed" { return "running"; } -export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) { - const progress = mapStatus(status) === "running" ? 50 : 100; - const smoothed = useSmoothedProgress(progress, mapStatus(status)); +export function EcommerceProgressBar({ + status, + label, +}: EcommerceProgressBarProps) { + const startedAtRef = useRef(Date.now()); + const mappedStatus = mapStatus(status); + const progress = mappedStatus === "running" ? 50 : 100; + const smoothed = useSmoothedProgress(progress, mappedStatus, { + progressSource: mappedStatus === "running" ? "estimated" : "real", + startedAt: startedAtRef.current, + expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS, + }); + const remainingLabel = + mappedStatus === "running" + ? formatEstimatedRemainingLabel({ + nowMs: Date.now(), + startedAt: startedAtRef.current, + expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS, + }) + : null; if (status === "idle") return null; return (
- {label || "AI 正在生成"} + + {label || "AI 正在生成"} + {remainingLabel ? ` / ${remainingLabel}` : ""} +
-
+
{smoothed}%
); -} \ No newline at end of file +} diff --git a/src/features/workbench/SmoothedProgressBar.tsx b/src/features/workbench/SmoothedProgressBar.tsx index 2d2d809..1ed8202 100644 --- a/src/features/workbench/SmoothedProgressBar.tsx +++ b/src/features/workbench/SmoothedProgressBar.tsx @@ -1,4 +1,8 @@ -import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; +import { + DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS, + useSmoothedProgress, + type ProgressSource, +} from "../../hooks/useSmoothedProgress"; type MessageStatus = "thinking" | "completed" | "failed" | string; @@ -6,6 +10,9 @@ interface SmoothedProgressBarProps { progress: number; status: MessageStatus; label?: string; + progressSource?: ProgressSource; + startedAt?: number | string | Date | null; + expectedDurationMs?: number | null; } function mapMessageStatus(status: MessageStatus) { @@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) { return "running" as const; } -export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) { - const smoothed = useSmoothedProgress(progress, mapMessageStatus(status)); +export function SmoothedProgressBar({ + progress, + status, + label, + progressSource = "estimated", + startedAt, + expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS, +}: SmoothedProgressBarProps) { + const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), { + progressSource, + startedAt, + expectedDurationMs, + }); return ( <> {label || "超分处理中..."} diff --git a/src/features/workbench/components/WorkbenchChatCards.tsx b/src/features/workbench/components/WorkbenchChatCards.tsx index ace9f58..4d73210 100644 --- a/src/features/workbench/components/WorkbenchChatCards.tsx +++ b/src/features/workbench/components/WorkbenchChatCards.tsx @@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient"; import { communityClient } from "../../../api/communityClient"; import { saveAssetToLocalLibrary } from "../../assets/localAssetStore"; import { SmoothedProgressBar } from "../SmoothedProgressBar"; -import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress"; +import { + DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS, + DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS, + DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS, + formatEstimatedRemainingLabel, + useSmoothedProgress, +} from "../../../hooks/useSmoothedProgress"; import { renderMarkdownBlocks } from "../markdownRenderer"; import { downloadResultAsset } from "../workbenchDownload"; import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes"; @@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({ progress={message.taskProgress ?? 18} status={message.status || "thinking"} label={message.taskStatusLabel || "超分处理中..."} + progressSource="estimated" + expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS} />
) : null} @@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({ const specs = message.result?.specs || []; const prompt = message.prompt || message.body; const isVideo = message.mode === "video"; - const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed"); + const expectedDurationMs = isVideo + ? DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS + : DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS; + const progressStatus = message.status === "thinking" ? "running" : "completed"; + const smoothed = useSmoothedProgress(message.taskProgress ?? 5, progressStatus, { + progressSource: progressStatus === "running" ? "estimated" : "real", + startedAt: message.createdAt, + expectedDurationMs, + }); + const remainingLabel = progressStatus === "running" + ? formatEstimatedRemainingLabel({ + nowMs: Date.now(), + startedAt: message.createdAt, + expectedDurationMs, + }) + : null; + const statusLabel = message.taskStatusLabel || (isVideo ? "视频生成中" : "图片生成中"); return (
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
- {message.taskStatusLabel || "Generating..."} + {remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel} {prompt}
{specs.length > 0 && ( diff --git a/src/hooks/useSmoothedProgress.test.ts b/src/hooks/useSmoothedProgress.test.ts new file mode 100644 index 0000000..c023c28 --- /dev/null +++ b/src/hooks/useSmoothedProgress.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "../test/testHarness"; + +import { + calculateEstimatedProgress, + formatEstimatedRemainingLabel, + resolveProgressStartedAt, +} from "./useSmoothedProgress"; + +describe("useSmoothedProgress helpers", () => { + it("calculates estimated progress from elapsed time with an easing curve", () => { + expect( + Math.round( + calculateEstimatedProgress({ + nowMs: 220_000, + startedAtMs: 100_000, + expectedDurationMs: 120_000, + }), + ), + ).toBe(85); + }); + + it("caps estimated progress below completion", () => { + expect( + Math.round( + calculateEstimatedProgress({ + nowMs: 900_000, + startedAtMs: 0, + expectedDurationMs: 120_000, + }), + ), + ).toBe(92); + }); + + it("parses local workbench timestamps", () => { + expect(resolveProgressStartedAt("2026-06-10 09:30")).toBe( + new Date(2026, 5, 10, 9, 30).getTime(), + ); + }); + + it("formats remaining time for estimated tasks", () => { + expect( + formatEstimatedRemainingLabel({ + nowMs: new Date(2026, 5, 10, 9, 30).getTime(), + startedAt: "2026-06-10 09:29", + expectedDurationMs: 120_000, + }), + ).toBe("预计还需 1 分钟"); + }); +}); diff --git a/src/hooks/useSmoothedProgress.ts b/src/hooks/useSmoothedProgress.ts index 15309dd..234dddd 100644 --- a/src/hooks/useSmoothedProgress.ts +++ b/src/hooks/useSmoothedProgress.ts @@ -1,6 +1,14 @@ import { useEffect, useRef, useState } from "react"; -type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error"; +type ProgressStatus = + | "queued" + | "running" + | "submitting" + | "completed" + | "failed" + | "success" + | "error"; +export type ProgressSource = "real" | "estimated"; interface SmoothedProgressOptions { creepSpeed?: number; @@ -8,6 +16,11 @@ interface SmoothedProgressOptions { creepCeiling?: number; creepAhead?: number; completionDuration?: number; + progressSource?: ProgressSource; + startedAt?: number | string | Date | null; + expectedDurationMs?: number | null; + estimatedFloor?: number; + estimatedCeiling?: number; } const DEFAULT_CREEP_SPEED = 0.5; @@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09; const DEFAULT_CREEP_CEILING = 97; const DEFAULT_CREEP_AHEAD = 25; const DEFAULT_COMPLETION_DURATION = 450; +export const DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS = 120_000; +export const DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS = 240_000; +export const DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS = 180_000; +export const DEFAULT_GENERATION_EXPECTED_DURATION_MS = + DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS; +const DEFAULT_ESTIMATED_FLOOR = 12; +const DEFAULT_ESTIMATED_CEILING = 92; +const ESTIMATED_PROGRESS_CURVE = 2.4; +const MIN_EXPECTED_DURATION_MS = 1_000; function isTerminal(status: ProgressStatus): boolean { - return status === "completed" || status === "success" || status === "failed" || status === "error"; + return ( + status === "completed" || + status === "success" || + status === "failed" || + status === "error" + ); } function isSuccess(status: ProgressStatus): boolean { return status === "completed" || status === "success"; } +export function resolveProgressStartedAt( + value: number | string | Date | null | undefined, +): number | null { + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : null; + } + + if (typeof value === "number") { + return Number.isFinite(value) && value > 0 ? value : null; + } + + if (typeof value !== "string" || !value.trim()) return null; + + const normalized = value.trim(); + const localMatch = normalized.match( + /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/, + ); + if (localMatch) { + const [, year, month, day, hours, minutes, seconds] = localMatch; + const time = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hours), + Number(minutes), + Number(seconds || 0), + ).getTime(); + return Number.isFinite(time) ? time : null; + } + + const parsed = Date.parse(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +export function calculateEstimatedProgress(input: { + nowMs: number; + startedAtMs: number; + expectedDurationMs?: number | null; + floor?: number; + ceiling?: number; +}): number { + const floor = Math.max( + 0, + Math.min(99, input.floor ?? DEFAULT_ESTIMATED_FLOOR), + ); + const ceiling = Math.max( + floor, + Math.min(99, input.ceiling ?? DEFAULT_ESTIMATED_CEILING), + ); + const duration = Math.max( + MIN_EXPECTED_DURATION_MS, + Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS, + ); + const elapsed = Math.max(0, input.nowMs - input.startedAtMs); + const ratio = elapsed / duration; + const eased = 1 - Math.exp(-ratio * ESTIMATED_PROGRESS_CURVE); + return Math.min(ceiling, floor + eased * (ceiling - floor)); +} + +export function formatEstimatedRemainingLabel(input: { + nowMs: number; + startedAt: number | string | Date | null | undefined; + expectedDurationMs?: number | null; +}): string | null { + const startedAtMs = resolveProgressStartedAt(input.startedAt); + if (!startedAtMs) return null; + const duration = Math.max( + MIN_EXPECTED_DURATION_MS, + Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS, + ); + const remainingSeconds = Math.max( + 0, + Math.ceil((startedAtMs + duration - input.nowMs) / 1000), + ); + if (remainingSeconds <= 0) return "即将完成"; + if (remainingSeconds < 60) return `预计还需 ${remainingSeconds} 秒`; + return `预计还需 ${Math.ceil(remainingSeconds / 60)} 分钟`; +} + /** * Smoothly interpolates between coarse server-reported progress values. * On completion, animates quickly to 100% instead of jumping. @@ -37,24 +144,59 @@ export function useSmoothedProgress( const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE; const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING; const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD; - const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION; + const completionDuration = + options?.completionDuration ?? DEFAULT_COMPLETION_DURATION; + const progressSource = options?.progressSource ?? "real"; + const startedAt = options?.startedAt; + const expectedDurationMs = + options?.expectedDurationMs ?? DEFAULT_GENERATION_EXPECTED_DURATION_MS; + const estimatedFloor = options?.estimatedFloor ?? DEFAULT_ESTIMATED_FLOOR; + const estimatedCeiling = + options?.estimatedCeiling ?? DEFAULT_ESTIMATED_CEILING; const [displayed, setDisplayed] = useState(0); const rafRef = useRef(0); const targetRef = useRef(realProgress); const statusRef = useRef(status); + const progressSourceRef = useRef(progressSource); + const expectedDurationMsRef = useRef(expectedDurationMs); + const estimatedFloorRef = useRef(estimatedFloor); + const estimatedCeilingRef = useRef(estimatedCeiling); + const estimatedStartedAtRef = useRef(null); const completionStartRef = useRef(null); const completionBaseRef = useRef(0); useEffect(() => { targetRef.current = realProgress; statusRef.current = status; + progressSourceRef.current = progressSource; + expectedDurationMsRef.current = expectedDurationMs; + estimatedFloorRef.current = estimatedFloor; + estimatedCeilingRef.current = estimatedCeiling; + + if (progressSource === "estimated" && !isTerminal(status)) { + estimatedStartedAtRef.current = + resolveProgressStartedAt(startedAt) ?? + estimatedStartedAtRef.current ?? + Date.now(); + } if (isSuccess(status) && completionStartRef.current === null) { completionStartRef.current = performance.now(); completionBaseRef.current = displayed; + } else if (!isSuccess(status)) { + completionStartRef.current = null; } - }, [realProgress, status]); + }, [ + displayed, + estimatedCeiling, + estimatedFloor, + expectedDurationMs, + progressSource, + realProgress, + startedAt, + status, + ]); useEffect(() => { if (status === "failed" || status === "error") { @@ -82,6 +224,20 @@ export function useSmoothedProgress( if (isTerminal(currentStatus)) return current; + if (progressSourceRef.current === "estimated") { + const target = calculateEstimatedProgress({ + nowMs: Date.now(), + startedAtMs: estimatedStartedAtRef.current ?? Date.now(), + expectedDurationMs: expectedDurationMsRef.current, + floor: estimatedFloorRef.current, + ceiling: estimatedCeilingRef.current, + }); + if (current >= target) return current; + const gap = target - current; + const step = (gap * chaseRate + 0.2) * dt * 60; + return Math.min(current + step, target); + } + const target = targetRef.current; if (current >= target) { @@ -100,7 +256,14 @@ export function useSmoothedProgress( rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); - }, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]); + }, [ + status, + chaseRate, + creepSpeed, + creepCeiling, + creepAhead, + completionDuration, + ]); if (status === "failed" || status === "error") return Math.round(displayed); if (isSuccess(status) && displayed >= 99.5) return 100; From ba2e7cfda2652ad6e21c303783e49d064dc44184 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 10 Jun 2026 17:37:18 +0800 Subject: [PATCH 2/2] Consolidate generation task stores --- docs/optimization-backlog.md | 9 ++ src/stores/useGenerationStore.test.ts | 84 +++++++++++++++++++ src/stores/useGenerationStore.ts | 114 +++++++++++++++++++++++++- src/stores/useTaskStore.ts | 37 +-------- 4 files changed, 206 insertions(+), 38 deletions(-) create mode 100644 docs/optimization-backlog.md create mode 100644 src/stores/useGenerationStore.test.ts diff --git a/docs/optimization-backlog.md b/docs/optimization-backlog.md new file mode 100644 index 0000000..ffafefe --- /dev/null +++ b/docs/optimization-backlog.md @@ -0,0 +1,9 @@ +# Optimization Backlog + +## Progress Contract Frontend Consumption + +- Status: pending +- Priority: medium +- Context: The backend now returns `progressSource`, `stage`, `startedAt`, and `expectedDurationMs` on generation task status payloads. The frontend progress UI currently still derives these values locally from message state and static defaults. +- Follow-up: Wire the backend task progress contract through `aiGenerationClient`, task/message view models, and the progress card components so model-aware `expectedDurationMs` and real provider progress can be consumed end to end. +- Boundary: Keep this separate from the task store consolidation. The store consolidation is complete without requiring these fields because `WebGenerationPreviewTask` is not the source for Workbench progress cards. diff --git a/src/stores/useGenerationStore.test.ts b/src/stores/useGenerationStore.test.ts new file mode 100644 index 0000000..fd724c7 --- /dev/null +++ b/src/stores/useGenerationStore.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from "../test/testHarness"; +import { useGenerationStore } from "./useGenerationStore"; +import type { WebGenerationPreviewTask } from "../types"; + +function previewTask(id: string, status: WebGenerationPreviewTask["status"] = "running"): WebGenerationPreviewTask { + return { + id, + title: "Task", + type: "image", + status, + progress: status === "completed" ? 100 : 10, + prompt: "prompt", + createdAt: "2026-06-10T08:00:00.000Z", + source: "server", + }; +} + +describe("useGenerationStore task state", () => { + afterEach(() => { + useGenerationStore.setState({ queue: [], tasks: [] }); + }); + + it("merges server preview tasks without duplicating local rows", () => { + const store = useGenerationStore.getState(); + + store.appendTask(previewTask("server-1")); + store.mergeServerTasks([previewTask("server-1", "completed"), previewTask("server-2")]); + + const tasks = useGenerationStore.getState().tasks; + expect(tasks.map((task) => task.id)).toEqual(["server-1", "server-2"]); + expect(tasks[0].status).toBe("completed"); + }); + + it("syncs running queue updates into matching preview tasks", () => { + const store = useGenerationStore.getState(); + + store.addTask({ + id: "local-task-1", + taskId: "server-task-1", + title: "Image", + type: "image", + status: "running", + progress: 5, + prompt: "prompt", + createdAt: Date.now(), + sourceView: "workbench", + }); + + expect(useGenerationStore.getState().tasks[0].id).toBe("server-task-1"); + expect(useGenerationStore.getState().tasks[0].status).toBe("running"); + + store.updateTask("local-task-1", { + status: "completed", + progress: 100, + resultUrl: "https://oss.example/result.png", + }); + + const task = useGenerationStore.getState().tasks[0]; + expect(task.status).toBe("completed"); + expect(task.progress).toBe(100); + expect(task.outputUrl).toBe("https://oss.example/result.png"); + }); + + it("clears preview tasks and running queue together", () => { + const store = useGenerationStore.getState(); + + store.appendTask(previewTask("server-task-1")); + store.addTask({ + id: "local-task-1", + title: "Image", + type: "image", + status: "running", + progress: 5, + prompt: "prompt", + createdAt: Date.now(), + sourceView: "workbench", + }); + + store.clearTasks(); + + expect(useGenerationStore.getState().tasks).toEqual([]); + expect(useGenerationStore.getState().queue).toEqual([]); + }); +}); diff --git a/src/stores/useGenerationStore.ts b/src/stores/useGenerationStore.ts index ad20197..71dd89b 100644 --- a/src/stores/useGenerationStore.ts +++ b/src/stores/useGenerationStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { WebGenerationPreviewTask } from "../types"; export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; @@ -17,6 +18,8 @@ export interface GenerationQueueItem { params?: Record; } +type PreviewTaskPatch = Partial; + interface PersistedQueueSnapshot { version: 1; items: GenerationQueueItem[]; @@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void { interface GenerationStoreState { queue: GenerationQueueItem[]; + tasks: WebGenerationPreviewTask[]; addTask: (item: GenerationQueueItem) => void; updateTask: (id: string, patch: Partial) => void; removeTask: (id: string) => void; + setTasks: (tasks: WebGenerationPreviewTask[]) => void; + appendTask: (task: WebGenerationPreviewTask) => void; + mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void; + clearTasks: () => void; getRunningTasks: () => GenerationQueueItem[]; getPendingTasks: () => GenerationQueueItem[]; getTasksByView: (sourceView: string) => GenerationQueueItem[]; @@ -64,14 +72,87 @@ interface GenerationStoreState { const initialQueue = loadPersistedQueue(); +function trimTasks(tasks: WebGenerationPreviewTask[]): WebGenerationPreviewTask[] { + return tasks.slice(0, MAX_ITEMS); +} + +function mergePreviewTaskById( + tasks: WebGenerationPreviewTask[], + taskId: string | undefined, + patch: PreviewTaskPatch, +): WebGenerationPreviewTask[] { + if (!taskId) return tasks; + let changed = false; + const next = tasks.map((task) => { + if (task.id !== taskId) return task; + changed = true; + return { ...task, ...patch }; + }); + return changed ? next : tasks; +} + +function toPreviewTaskStatus(status: GenerationQueueItem["status"]): WebGenerationPreviewTask["status"] { + if (status === "pending") return "queued"; + if (status === "cancelled") return "failed"; + return status; +} + +function toPreviewTaskPatch(item: GenerationQueueItem): PreviewTaskPatch { + const status = toPreviewTaskStatus(item.status); + + return { + status, + progress: item.status === "completed" ? 100 : item.progress, + outputUrl: item.resultUrl || undefined, + errorMessage: item.error || undefined, + }; +} + +function toPreviewTask(item: GenerationQueueItem): WebGenerationPreviewTask | null { + if (item.type === "ecommerce-video") return null; + const type = item.type; + const createdAt = Number.isFinite(item.createdAt) + ? new Date(item.createdAt).toISOString() + : new Date().toISOString(); + + return { + id: item.taskId || item.id, + title: item.title, + type, + status: toPreviewTaskStatus(item.status), + progress: item.status === "completed" ? 100 : item.progress, + prompt: item.prompt, + createdAt, + projectId: + typeof item.params?.projectId === "string" ? item.params.projectId : undefined, + outputUrl: item.resultUrl || undefined, + source: "preview", + errorMessage: item.error || undefined, + }; +} + +function upsertPreviewTask( + tasks: WebGenerationPreviewTask[], + task: WebGenerationPreviewTask | null, +): WebGenerationPreviewTask[] { + if (!task) return tasks; + return trimTasks([task, ...tasks.filter((item) => item.id !== task.id)]); +} + +function previewTaskIdsForItem(item: GenerationQueueItem): string[] { + return Array.from(new Set([item.taskId, item.id].filter(Boolean) as string[])); +} + export const useGenerationStore = create((set, get) => ({ queue: initialQueue, + tasks: [], addTask: (item) => { set((state) => { const next = [item, ...state.queue].slice(0, MAX_ITEMS); + const previewTasks = upsertPreviewTask(state.tasks, toPreviewTask(item)); persistQueue(next.filter((i) => i.status === "pending" || i.status === "running")); - return { queue: next }; + return { queue: next, tasks: previewTasks }; }); }, @@ -80,8 +161,16 @@ export const useGenerationStore = create((set, get) => ({ const next = state.queue.map((item) => item.id === id ? { ...item, ...patch } : item, ); + const updated = next.find((item) => item.id === id); + let previewTasks = state.tasks; + if (updated) { + const previewPatch = toPreviewTaskPatch(updated); + for (const previewTaskId of previewTaskIdsForItem(updated)) { + previewTasks = mergePreviewTaskById(previewTasks, previewTaskId, previewPatch); + } + } persistQueue(next.filter((i) => i.status === "pending" || i.status === "running")); - return { queue: next }; + return { queue: next, tasks: previewTasks }; }); }, @@ -93,6 +182,27 @@ export const useGenerationStore = create((set, get) => ({ }); }, + setTasks: (tasks) => set({ tasks: trimTasks(tasks) }), + + appendTask: (task) => set((state) => ({ + tasks: trimTasks([task, ...state.tasks.filter((item) => item.id !== task.id)]), + })), + + mergeServerTasks: (serverTasks) => set((state) => { + const serverIds = new Set(serverTasks.map((task) => task.id)); + return { + tasks: trimTasks([ + ...serverTasks, + ...state.tasks.filter((task) => !serverIds.has(task.id)), + ]), + }; + }), + + clearTasks: () => { + persistQueue([]); + set({ tasks: [], queue: [] }); + }, + getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"), getPendingTasks: () => get().queue.filter((i) => i.status === "pending"), getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView), diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index d8d9368..510a458 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -1,36 +1 @@ -import { create } from 'zustand'; -import type { WebGenerationPreviewTask } from '../types'; - -interface TaskState { - tasks: WebGenerationPreviewTask[]; -} - -interface TaskActions { - setTasks: (tasks: WebGenerationPreviewTask[]) => void; - appendTask: (task: WebGenerationPreviewTask) => void; - mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void; - clearTasks: () => void; -} - -const initialState: TaskState = { - tasks: [], -}; - -export const useTaskStore = create((set) => ({ - ...initialState, - - setTasks: (tasks) => set({ tasks }), - - appendTask: (task) => set((state) => ({ - tasks: [task, ...state.tasks], - })), - - mergeServerTasks: (serverTasks) => set((state) => { - const serverIds = new Set(serverTasks.map((task) => task.id)); - return { - tasks: [...serverTasks, ...state.tasks.filter((task) => !serverIds.has(task.id))].slice(0, 80), - }; - }), - - clearTasks: () => set({ tasks: [] }), -})); +export { useGenerationStore as useTaskStore } from "./useGenerationStore";