"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( projectId: string, loaded: { id: string; revision: string } | null, setNodes: React.Dispatch>, setEdges: React.Dispatch>, canvasEdges: Edge[] = [], ) { const [tasks, setTasks] = useState([]); const hiddenNodes = useRef(new Set()); const recoveredEdges = useRef(new Set()); const edgeEdits = useRef({ projectId, current: new Set(), removed: new Set() }); 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) { const rejected = (response.status >= 400 && response.status < 500) || body.code === "CANVAS_SERVICE_UNAVAILABLE"; if (rejected) { 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 (rejected) 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, 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; 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( 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() }; }