Consolidate generation task stores
This commit is contained in:
@@ -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.
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 +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: [] }),
|
|
||||||
}));
|
|
||||||
|
|||||||
Reference in New Issue
Block a user