Consolidate generation task stores
Web Quality / verify (push) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled

This commit is contained in:
2026-06-10 17:37:18 +08:00
parent e9601a651c
commit ba2e7cfda2
4 changed files with 206 additions and 38 deletions
+9
View File
@@ -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.
+84
View File
@@ -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([]);
});
});
+112 -2
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { WebGenerationPreviewTask } from "../types";
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
@@ -17,6 +18,8 @@ export interface GenerationQueueItem {
params?: Record<string, unknown>; params?: Record<string, unknown>;
} }
type PreviewTaskPatch = Partial<WebGenerationPreviewTask>;
interface PersistedQueueSnapshot { interface PersistedQueueSnapshot {
version: 1; version: 1;
items: GenerationQueueItem[]; items: GenerationQueueItem[];
@@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void {
interface GenerationStoreState { interface GenerationStoreState {
queue: GenerationQueueItem[]; queue: GenerationQueueItem[];
tasks: WebGenerationPreviewTask[];
addTask: (item: GenerationQueueItem) => void; addTask: (item: GenerationQueueItem) => void;
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void; updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
removeTask: (id: string) => void; removeTask: (id: string) => void;
setTasks: (tasks: WebGenerationPreviewTask[]) => void;
appendTask: (task: WebGenerationPreviewTask) => void;
mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void;
clearTasks: () => void;
getRunningTasks: () => GenerationQueueItem[]; getRunningTasks: () => GenerationQueueItem[];
getPendingTasks: () => GenerationQueueItem[]; getPendingTasks: () => GenerationQueueItem[];
getTasksByView: (sourceView: string) => GenerationQueueItem[]; getTasksByView: (sourceView: string) => GenerationQueueItem[];
@@ -64,14 +72,87 @@ interface GenerationStoreState {
const initialQueue = loadPersistedQueue(); 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<GenerationStoreState>((set, get) => ({ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
queue: initialQueue, queue: initialQueue,
tasks: [],
addTask: (item) => { addTask: (item) => {
set((state) => { set((state) => {
const next = [item, ...state.queue].slice(0, MAX_ITEMS); 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")); 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<GenerationStoreState>((set, get) => ({
const next = state.queue.map((item) => const next = state.queue.map((item) =>
item.id === id ? { ...item, ...patch } : 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")); 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<GenerationStoreState>((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"), getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"), getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView), getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
+1 -36
View File
@@ -1,36 +1 @@
import { create } from 'zustand'; export { useGenerationStore as useTaskStore } from "./useGenerationStore";
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<TaskState & TaskActions>((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: [] }),
}));