ai-chatbot/hooks/use-video-canvas-tasks.ts

303 lines
12 KiB
TypeScript

"use client";
import { withConnectedText } from "@/lib/video-canvas/connections";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
reconcileCanvasTasks,
type CanvasGenerationTask,
type CanvasTaskRequest,
} from "@/lib/video-canvas/task-contract";
class CanvasTaskRejectedError extends Error {}
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[]>>,
canvasEdges: Edge[] = [],
) {
const [tasks, setTasks] = useState<CanvasGenerationTask[]>([]);
const hiddenNodes = useRef(new Set<string>());
const recoveredEdges = useRef(new Set<string>());
const edgeEdits = useRef({ projectId, current: new Set<string>(), removed: new Set<string>() });
if (edgeEdits.current.projectId !== projectId)
edgeEdits.current = { projectId, current: new Set(), removed: new Set() };
const currentEdgeKeys = new Set(canvasEdges.map(edge => `${edge.source}>${edge.target}`));
for (const key of edgeEdits.current.current) if (!currentEdgeKeys.has(key)) edgeEdits.current.removed.add(key);
for (const key of currentEdgeKeys) edgeEdits.current.removed.delete(key);
edgeEdits.current.current = currentEdgeKeys;
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}`;
// A request is a historical snapshot, not permission to undo a
// later disconnect while its response/poll is in flight.
if (existing.has(key) || edgeEdits.current.removed.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,
),
);
}
if (response.status >= 400 && response.status < 500)
throw new CanvasTaskRejectedError(body.error || "任务参数未通过校验");
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();
// The task params retain resolved text; the editable form retains only
// the user's prompt, so linked content is neither duplicated nor exposed
// as resolver markup when the node is reopened.
const editableDraft = { ...node.draftValues };
if (typeof editableDraft.prompt === "string") editableDraft.prompt = withConnectedText(editableDraft.prompt, []);
if (editableDraft.params && typeof editableDraft.params === "object" && !Array.isArray(editableDraft.params)) {
const params = editableDraft.params as Record<string, unknown>;
if (typeof params.prompt === "string") editableDraft.params = { ...params, prompt: withConnectedText(params.prompt, []) };
}
const snapshot = {
...node,
draftValues: {
...editableDraft,
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 {
const accepted = await send(request);
refreshRef.current();
return accepted.requestId;
} catch (error) {
if (kind === "smart-edit" && error instanceof CanvasTaskRejectedError) throw error;
toast.error(
error instanceof Error ? error.message : "连接中断,恢复后将核对任务,请勿重复提交",
);
}
refreshRef.current();
return requestId;
},
[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() };
}