275 lines
9.7 KiB
TypeScript
275 lines
9.7 KiB
TypeScript
"use client";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
reconcileCanvasTasks,
|
|
type CanvasGenerationTask,
|
|
type CanvasTaskRequest,
|
|
} from "@/lib/video-canvas/task-contract";
|
|
|
|
type Node = CanvasTaskRequest["node"];
|
|
type Edge = CanvasTaskRequest["edges"][number];
|
|
const storageKey = (projectId: string) => `video-canvas-outbox:${projectId}`;
|
|
function outbox(projectId: string): CanvasTaskRequest[] {
|
|
try {
|
|
const entries = JSON.parse(localStorage.getItem(storageKey(projectId)) || "[]");
|
|
return Array.isArray(entries)
|
|
? entries.filter(
|
|
(item) =>
|
|
item?.projectId === projectId && typeof item?.requestId === "string" && item?.node?.id,
|
|
)
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
function remember(request: CanvasTaskRequest) {
|
|
const entries = outbox(request.projectId).filter((item) => item.requestId !== request.requestId);
|
|
localStorage.setItem(storageKey(request.projectId), JSON.stringify([...entries, request]));
|
|
}
|
|
function forget(projectId: string, requestId: string) {
|
|
localStorage.setItem(
|
|
storageKey(projectId),
|
|
JSON.stringify(outbox(projectId).filter((item) => item.requestId !== requestId)),
|
|
);
|
|
}
|
|
export function useVideoCanvasTasks<T extends Node>(
|
|
projectId: string,
|
|
loaded: { id: string; revision: string } | null,
|
|
setNodes: React.Dispatch<React.SetStateAction<T[]>>,
|
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>,
|
|
) {
|
|
const [tasks, setTasks] = useState<CanvasGenerationTask[]>([]);
|
|
const hiddenNodes = useRef(new Set<string>());
|
|
const recoveredEdges = useRef(new Set<string>());
|
|
const refreshRef = useRef<() => void>(() => {});
|
|
const activeProject = useRef(projectId);
|
|
activeProject.current = projectId;
|
|
const apply = useCallback(
|
|
(tasks: CanvasGenerationTask[]) => {
|
|
if (activeProject.current !== projectId || !loaded) return;
|
|
setTasks((current) => {
|
|
const next = new Map(current.map((task) => [task.id, task]));
|
|
for (const task of tasks) next.set(task.id, task);
|
|
return [...next.values()];
|
|
});
|
|
const visibleTasks = tasks.map((task) => ({
|
|
...task,
|
|
request: {
|
|
...task.request,
|
|
hiddenNodeIds: [...(task.request.hiddenNodeIds || []), ...hiddenNodes.current],
|
|
},
|
|
}));
|
|
setNodes((nodes) => reconcileCanvasTasks(nodes as any, visibleTasks, loaded.revision) as T[]);
|
|
const recoverable = visibleTasks.filter((task) => {
|
|
if (recoveredEdges.current.has(task.id)) return false;
|
|
recoveredEdges.current.add(task.id);
|
|
return Date.parse(task.createdAt) > Date.parse(loaded.revision);
|
|
});
|
|
setEdges((edges) => {
|
|
const existing = new Set(edges.map((edge) => `${edge.source}>${edge.target}`));
|
|
const extras = recoverable
|
|
.flatMap((task) =>
|
|
task.request.edges.filter(
|
|
(edge) =>
|
|
!task.request.hiddenNodeIds.includes(edge.source) &&
|
|
!task.request.hiddenNodeIds.includes(edge.target),
|
|
),
|
|
)
|
|
.filter((edge) => {
|
|
const key = `${edge.source}>${edge.target}`;
|
|
if (existing.has(key)) return false;
|
|
existing.add(key);
|
|
return true;
|
|
});
|
|
return extras.length ? [...edges, ...extras] : edges;
|
|
});
|
|
},
|
|
[projectId, loaded, setNodes, setEdges],
|
|
);
|
|
const send = useCallback(
|
|
async (request: CanvasTaskRequest) => {
|
|
const response = await fetch("/api/canvas/generation-tasks", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(request),
|
|
signal: AbortSignal.timeout(20000),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok || !body.success) {
|
|
if (response.status >= 400 && response.status < 500) {
|
|
forget(projectId, request.requestId);
|
|
if (activeProject.current === projectId)
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === request.node.id && node.draftValues?.taskRequestId === request.requestId
|
|
? {
|
|
...node,
|
|
draftValues: {
|
|
...node.draftValues,
|
|
generationStatus: "failed",
|
|
generationError: body.error,
|
|
},
|
|
}
|
|
: node,
|
|
),
|
|
);
|
|
}
|
|
throw Error(body.error || "提交暂时失败,恢复连接后自动核对");
|
|
}
|
|
forget(projectId, request.requestId);
|
|
if (activeProject.current !== projectId) return body.task as CanvasGenerationTask;
|
|
// The server may return an existing task for the same active node.
|
|
setNodes((nodes) =>
|
|
nodes.map((node) =>
|
|
node.id === request.node.id
|
|
? {
|
|
...node,
|
|
draftValues: {
|
|
...node.draftValues,
|
|
taskRequestId: body.task.requestId,
|
|
generationTaskId: body.task.id,
|
|
},
|
|
}
|
|
: node,
|
|
),
|
|
);
|
|
apply([body.task]);
|
|
return body.task as CanvasGenerationTask;
|
|
},
|
|
[projectId, apply, setNodes],
|
|
);
|
|
const submit = useCallback(
|
|
async (
|
|
node: T,
|
|
kind: CanvasTaskRequest["kind"],
|
|
params: Record<string, unknown>,
|
|
edges: Edge[] = [],
|
|
attachment?: CanvasTaskRequest["attachment"],
|
|
) => {
|
|
if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交");
|
|
const requestId = crypto.randomUUID();
|
|
const snapshot = {
|
|
...node,
|
|
draftValues: {
|
|
...node.draftValues,
|
|
taskRequestId: requestId,
|
|
generationTaskId: undefined,
|
|
appliedTaskStatus: undefined,
|
|
generationStatus: "pending",
|
|
generationProgress: 0,
|
|
pendingLabel: "提交中",
|
|
generationError: undefined,
|
|
},
|
|
};
|
|
const request: CanvasTaskRequest = {
|
|
projectId,
|
|
requestId,
|
|
kind,
|
|
node: snapshot,
|
|
params,
|
|
edges,
|
|
attachment,
|
|
};
|
|
// Persist before POST: a refresh during an uncertain network response retries the same ID.
|
|
remember(request);
|
|
setNodes((nodes) => nodes.map((item) => (item.id === node.id ? snapshot : item)));
|
|
try {
|
|
await send(request);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "连接中断,恢复后将核对任务,请勿重复提交",
|
|
);
|
|
}
|
|
refreshRef.current();
|
|
},
|
|
[loaded, projectId, send, setNodes],
|
|
);
|
|
useEffect(() => {
|
|
if (!loaded || loaded.id !== projectId) return;
|
|
hiddenNodes.current.clear();
|
|
recoveredEdges.current.clear();
|
|
setTasks([]);
|
|
let stopped = false,
|
|
running = false;
|
|
const refresh = async () => {
|
|
if (stopped || running) return;
|
|
running = true;
|
|
try {
|
|
const response = await fetch(
|
|
`/api/canvas/generation-tasks?projectId=${encodeURIComponent(projectId)}`,
|
|
{ cache: "no-store", signal: AbortSignal.timeout(15000) },
|
|
);
|
|
if (!response.ok) return;
|
|
const body = await response.json();
|
|
if (stopped || !body.success) return;
|
|
apply(body.tasks);
|
|
const accepted = new Map<string, CanvasGenerationTask>(
|
|
body.tasks.map((task: CanvasGenerationTask) => [task.requestId, task]),
|
|
);
|
|
for (const request of outbox(projectId)) {
|
|
if (stopped) break;
|
|
if (accepted.has(request.requestId)) {
|
|
forget(projectId, request.requestId);
|
|
continue;
|
|
}
|
|
await send(request);
|
|
}
|
|
} catch {
|
|
/* Network loss is not a generation failure. The next online/focus event queries again. */
|
|
} finally {
|
|
running = false;
|
|
}
|
|
};
|
|
refreshRef.current = () => void refresh();
|
|
const wake = () => {
|
|
if (document.visibilityState === "visible") void refresh();
|
|
};
|
|
const timer = setInterval(() => void refresh(), 5000);
|
|
window.addEventListener("online", wake);
|
|
window.addEventListener("focus", wake);
|
|
document.addEventListener("visibilitychange", wake);
|
|
void refresh();
|
|
return () => {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
window.removeEventListener("online", wake);
|
|
window.removeEventListener("focus", wake);
|
|
document.removeEventListener("visibilitychange", wake);
|
|
};
|
|
}, [projectId, loaded, apply, send]);
|
|
const cancel = useCallback(
|
|
async (taskId: string) => {
|
|
const response = await fetch("/api/canvas/generation-tasks", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ projectId, taskId }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
const body = await response.json();
|
|
if (body.task) apply([body.task]);
|
|
if (!response.ok || !body.success) throw Error(body.error || "取消失败,请刷新任务状态");
|
|
refreshRef.current();
|
|
},
|
|
[projectId, apply],
|
|
);
|
|
const dismiss = useCallback(
|
|
async (nodeId: string, taskId?: string) => {
|
|
hiddenNodes.current.add(nodeId);
|
|
if (!taskId) return;
|
|
const response = await fetch("/api/canvas/generation-tasks", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ projectId, taskId, nodeId }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
if (!response.ok) {
|
|
hiddenNodes.current.delete(nodeId);
|
|
throw Error("移除结果失败,请重试");
|
|
}
|
|
},
|
|
[projectId],
|
|
);
|
|
return { submit, cancel, dismiss, tasks, refresh: () => refreshRef.current() };
|
|
}
|