chore: commit workspace updates

This commit is contained in:
ddCat committed 2026-09-14 01:11:56 +08:00
1 parent 01a2d171f5
commit 9727656b2b
491 files changed
+12826 -72249

No files matched your search

@@ -1,76 +0,0 @@
import { useCallback, useRef, useState, type ReactNode } from "react";
// 根据视口宽度动态计算面板宽度约束,避免小屏幕上面板挤压画布
export function getPanelWidthBounds(): { min: number; max: number } {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
if (vw < 768) return { min: 260, max: 360 };
if (vw < 1024) return { min: 280, max: 440 };
if (vw < 1440) return { min: 320, max: 560 };
return { min: 360, max: 760 };
}
// 智能体面板列包裹器:承载面板宽度管理、resize 拖拽和顶部停靠偏移。
// 面板停靠在画布工具栏下方,避免悬浮工具栏遮挡 Agent 标题和消息;沉浸模式传 0。
// closing 期间保持宽度和渲染,让面板播放滑出动画;动画结束后由父组件卸载列。
export function AssistantPanelColumn({
width,
closing,
topInset,
onWidthChange,
children,
}: {
width: number;
closing: boolean;
topInset: string;
onWidthChange: (width: number) => void;
children: (resizing: boolean) => ReactNode;
}) {
const columnRef = useRef<HTMLDivElement>(null);
const [resizing, setResizing] = useState(false);
// 拖拽时列右边缘固定(flex 末位),左边缘随鼠标移动。
const startResize = useCallback((event: React.MouseEvent) => {
event.preventDefault();
const rightEdge = columnRef.current?.getBoundingClientRect().right ?? 0;
const { min, max } = getPanelWidthBounds();
const move = (e: MouseEvent) => {
onWidthChange(Math.min(max, Math.max(min, rightEdge - e.clientX)));
};
const stop = () => {
setResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", stop);
};
setResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", stop);
}, [onWidthChange]);
return (
<div
ref={columnRef}
className="relative z-[var(--z-toolbar)] flex shrink-0 overflow-hidden"
style={{
width,
paddingTop: topInset,
transition: resizing ? "none" : "width var(--motion-dur-base-calc) var(--motion-ease-out), padding-top var(--motion-dur-base-calc) var(--motion-ease-out)",
}}
>
<div className="h-full w-full">
{!closing ? (
<button
type="button"
className="absolute inset-y-0 left-0 z-[var(--node-z-overlay)] w-4 -translate-x-1/2 cursor-col-resize"
onMouseDown={startResize}
aria-label="调整右侧面板宽度"
/>
) : null}
{children(resizing)}
</div>
</div>
);
}
@@ -4,7 +4,7 @@ import { CheckCircle2, CloudUpload, Eye, LoaderCircle, RotateCcw, TriangleAlert,
import type { GenerationTask } from "@/services/api/task-center";
import type { MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentChange } from "./use-canvas-agent-operations";
import type { CanvasOperationChange } from "./use-canvas-operation-history";
import { aceternityMotion } from "@/lib/aceternity-motion";
export type CanvasUploadStatus = {
@@ -113,7 +113,7 @@ export function CanvasMergeStatusToast({ progress, theme }: { progress: MergeVid
);
}
export function CanvasAgentChangeToast({ change, theme, onView, onUndo, onClose }: { change: CanvasAgentChange; theme: CanvasTheme; onView: () => void; onUndo: () => void; onClose: () => void }) {
export function CanvasOperationChangeToast({ change, theme, onView, onUndo, onClose }: { change: CanvasOperationChange; theme: CanvasTheme; onView: () => void; onUndo: () => void; onClose: () => void }) {
return (
<div
data-canvas-no-zoom
+22 -43
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -31,10 +31,6 @@ type CanvasTopBarProps = {
onUndo: () => void;
onRedo: () => void;
onShare: () => void;
agentOpen: boolean;
agentPanelWidth?: number;
compactAgentStatus?: { connected: boolean; enabled: boolean; activity: string };
onToggleAgent: () => void;
shortcutRequestNonce: number;
mediaPerformanceMode: CanvasMediaPerformanceMode;
onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
@@ -62,10 +58,6 @@ export function CanvasTopBar({
onUndo,
onRedo,
onShare,
agentOpen,
agentPanelWidth,
compactAgentStatus,
onToggleAgent,
shortcutRequestNonce,
mediaPerformanceMode,
onMediaPerformanceModeChange,
@@ -101,7 +93,7 @@ export function CanvasTopBar({
return (
<>
<div className="canvas-topbar pointer-events-none absolute left-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5" style={{ right: agentOpen && agentPanelWidth ? `calc(${agentPanelWidth}px + var(--space-3))` : 0, transition: "right var(--motion-dur-base-calc) var(--motion-ease-out)" }}>
<div className="canvas-topbar pointer-events-none absolute inset-x-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
<CanvasTopBarTooltip label="打开画布菜单">
<Dropdown
@@ -160,7 +152,13 @@ export function CanvasTopBar({
{title}
</button>
<CanvasTopBarTooltip label="重命名画布">
<button type="button" className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<button
type="button"
className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2"
style={{ color: theme.node.text }}
onClick={onStartTitleEditing}
aria-label="重命名画布"
>
<Pencil className="size-3.5" />
</button>
</CanvasTopBarTooltip>
@@ -185,9 +183,16 @@ export function CanvasTopBar({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
<CanvasTopBarTooltip label="搜索画布节点">
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" />
<Button
type="text"
className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex"
style={{ color: theme.node.text }}
icon={<Search className="size-4" />}
onClick={onOpenSearch}
aria-label="搜索画布节点"
/>
</CanvasTopBarTooltip>
{!agentOpen ? <CanvasTopBarTooltip label="导入第三方画布">
<CanvasTopBarTooltip label="导入第三方画布">
<Dropdown
trigger={["click"]}
placement="bottomRight"
@@ -202,8 +207,8 @@ export function CanvasTopBar({
<span className="hidden lg:inline">导入第三方画布</span>
</Button>
</Dropdown>
</CanvasTopBarTooltip> : null}
{!agentOpen ? <CanvasTopBarTooltip label="媒体性能模式">
</CanvasTopBarTooltip>
<CanvasTopBarTooltip label="媒体性能模式">
<Dropdown
trigger={["click"]}
menu={{
@@ -219,8 +224,7 @@ export function CanvasTopBar({
>
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" />
</Dropdown>
</CanvasTopBarTooltip> : null}
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
</CanvasTopBarTooltip>
{user && creditsEnabled ? (
<CanvasTopBarTooltip label="查看积分明细">
<Link
@@ -235,14 +239,7 @@ export function CanvasTopBar({
</CanvasTopBarTooltip>
) : null}
<CanvasTopBarTooltip label="进入专注模式(Shift + Ctrl/Cmd + F)">
<Button
type="text"
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
style={{ color: theme.node.text }}
icon={<Focus className="size-4" />}
onClick={onEnterFocusMode}
aria-label="进入专注模式"
/>
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Focus className="size-4" />} onClick={onEnterFocusMode} aria-label="进入专注模式" />
</CanvasTopBarTooltip>
{shortDramaGuide ? (
<CanvasTopBarTooltip label={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
@@ -262,17 +259,6 @@ export function CanvasTopBar({
<CanvasTopBarTooltip label="分享画布">
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" />
</CanvasTopBarTooltip>
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="canvas-topbar-action !h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
aria-pressed={agentOpen}
>
Agent
</Button>
</div>
</div>
<CanvasShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
@@ -304,10 +290,3 @@ function canvasTitleInputSize(value: string) {
const visualLength = Array.from(value || "画布名称").reduce((length, character) => length + (character.codePointAt(0)! > 0xff ? 2 : 1), 0);
return Math.min(30, Math.max(5, visualLength));
}
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return <CanvasTopBarTooltip label="打开本地 Codex 面板"><button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} aria-label="打开本地 Codex 面板"><span className="size-2 rounded-full" style={{ background: dotColor }} /><span className="max-w-[180px] truncate">{label}</span></button></CanvasTopBarTooltip>;
}
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,9 @@ import { listGenerationTasks, type GenerationTask } from "@/services/api/task-ce
export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
const query = useQuery<GenerationTask[]>({
queryKey: ["canvas-active-tasks", projectId],
queryFn: () => listGenerationTasks(5, { projectId, activeOnly: true }),
// Agent 的持久化执行仍复用任务队列/计费生命周期,但不应占据画布右上角的“生成任务”浮层。
// 多取一页再过滤,避免 Agent 排在前面时把真正的画布生成任务挤掉。
queryFn: () => listGenerationTasks(30, { projectId, activeOnly: true }).then((tasks) => tasks.filter((task) => !isInternalAgentTask(task)).slice(0, 5)),
enabled: enabled && Boolean(projectId),
refetchInterval: (current) => (current.state.data?.length ? 2_000 : 10_000),
refetchOnWindowFocus: true,
@@ -33,3 +35,6 @@ export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
};
}
function isInternalAgentTask(task: GenerationTask) {
return task.operation?.startsWith("cloud_agent") === true;
}
@@ -1,48 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CANVAS_AGENT_PANEL_MOTION_MS } from "@/components/canvas/canvas-assistant-panel";
import type { CanvasAgentMode } from "@/components/canvas/canvas-agent-chat-ui";
import { useCallback, useState } from "react";
export function useCanvasAssistantVisibility() {
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
const [assistantMounted, setAssistantMounted] = useState(false);
const [assistantClosing, setAssistantClosing] = useState(false);
const [agentMode, setAgentMode] = useState<CanvasAgentMode>("online");
const [assistantOpen, setAssistantOpen] = useState(false);
const openAgent = useCallback((mode?: CanvasAgentMode) => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (mode) setAgentMode(mode);
setAssistantMounted(true);
setAssistantClosing(false);
setAssistantCollapsed(false);
const openAgent = useCallback(() => {
setAssistantOpen(true);
}, []);
const closeAgent = useCallback(() => {
if (!assistantMounted || assistantClosing) return;
setAssistantCollapsed(true);
setAssistantClosing(true);
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
setAssistantMounted(false);
setAssistantClosing(false);
}, CANVAS_AGENT_PANEL_MOTION_MS);
}, [assistantClosing, assistantMounted]);
useEffect(() => () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
setAssistantOpen(false);
}, []);
return {
agentMode,
assistantClosing,
assistantMounted,
assistantOpen: assistantMounted && !assistantCollapsed,
assistantClosing: false,
assistantMounted: true,
assistantOpen,
closeAgent,
openAgent,
setAgentMode,
};
}
@@ -67,12 +67,6 @@ function selectRunningHubWorkflow(config: AiConfig) {
|| config.runningHub.workflows[0];
}
function selectComfyBridgeWorkflow(config: AiConfig) {
return config.comfyBridge.workflows.find((item) => item.workflowId.trim() === config.comfyBridge.workflowId.trim())
|| config.comfyBridge.workflows.find((item) => item.capability === config.comfyBridge.capability)
|| config.comfyBridge.workflows[0];
}
export function useCanvasConnectionController({
projectId,
config,
@@ -233,7 +227,7 @@ export function useCanvasConnectionController({
setContextMenu(null);
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => {
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
@@ -257,19 +251,16 @@ export function useCanvasConnectionController({
? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : ""
: "";
const selectedWorkflowProvider = nodeType === CanvasNodeType.Config
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "comfyui" : undefined)
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : undefined)
: undefined;
if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) {
message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`);
message.error("RunningHub 工作流插件未启用");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const runningHubWorkflow = selectedWorkflowProvider === "runninghub" ? selectRunningHubWorkflow(config) : undefined;
const comfyBridgeWorkflow = selectedWorkflowProvider === "comfyui" ? selectComfyBridgeWorkflow(config) : undefined;
const workflowCapability = selectedWorkflowProvider === "runninghub"
? normalizeRunningHubCapability(runningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability))
: comfyBridgeWorkflow?.capability || "image";
const workflowCapability = normalizeRunningHubCapability(runningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
const metadata: CanvasNodeMetadata | undefined = nodeType === CanvasNodeType.Config
? {
generationMode: selectedWorkflowProvider ? workflowCapability === "video" ? "video" as const : workflowCapability === "audio" ? "audio" as const : "image" as const : "image" as const,
@@ -278,9 +269,6 @@ export function useCanvasConnectionController({
...(selectedWorkflowProvider === "runninghub" ? {
workflowTitle: "RunningHub 工作流",
...(runningHubWorkflow ? { runningHubWorkflowId: runningHubWorkflow.workflowId, runningHubWorkflowKind: runningHubWorkflow.kind === "app" ? "app" as const : "workflow" as const } : {}),
} : selectedWorkflowProvider === "comfyui" ? {
workflowTitle: "ComfyUI Bridge",
...(comfyBridgeWorkflow ? { comfyBridgeWorkflowId: comfyBridgeWorkflow.workflowId } : {}),
} : {})
}
: nodeType === CanvasNodeType.Drawing
@@ -304,7 +292,7 @@ export function useCanvasConnectionController({
? pending.position
: connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType);
const newNode = createCanvasNode(nodeType, position, metadata);
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : "ComfyUI Bridge";
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = "RunningHub 工作流";
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.Drawing) {
message.error("批量连接暂不支持创建绘图,请先连接到普通节点");
@@ -312,12 +300,6 @@ export function useCanvasConnectionController({
setConnecting(null);
return;
}
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.MediaConversion) {
message.error("转换节点一次只能连接一个输入,请先连接到普通节点");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const batchPlan = batchSourceNodeIds.length
? planBatchConnections({ sourceNodeIds: batchSourceNodeIds, targetNodeId: newNode.id, nodes: [...nodesRef.current, newNode], connections: connectionsRef.current, config, allowCapacityOverflow: true })
: null;
@@ -337,7 +319,8 @@ export function useCanvasConnectionController({
setConnections(nextConnections);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
const skippedCount = batchPlan.skipped.length;
const duplicateCount = batchPlan.duplicates.length;
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
@@ -403,18 +386,18 @@ export function useCanvasConnectionController({
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (nodeType === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio && nodeType !== CanvasNodeType.MediaConversion) setDialogNodeId(newNode.id);
else if (nodeType === CanvasNodeType.Script) setDialogNodeId(null);
else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, runtimeStatuses, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => {
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub") => {
const nodeType = type;
if (nodeType === CanvasNodeType.Config) {
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return `${workflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`;
if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return "RunningHub 工作流插件未启用";
}
if (pending.batchSourceNodeIds?.length) {
if (nodeType === CanvasNodeType.MediaConversion) return "转换节点一次只能连接一个输入";
if (nodeType === CanvasNodeType.Drawing || nodeType === CanvasNodeType.Config) return "批量连接暂不支持此节点类型";
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: getNodeSpec(nodeType).width, height: getNodeSpec(nodeType).height };
const plan = planBatchConnections({ sourceNodeIds: pending.batchSourceNodeIds, targetNodeId: pendingNode.id, nodes: [...nodesRef.current, pendingNode], connections: connectionsRef.current, config, allowCapacityOverflow: true });
@@ -1,3 +1,4 @@
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { useCallback, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
@@ -101,6 +102,10 @@ export function useCanvasGenerationExecutor({
nodeId,
async () => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (isCanvasNodeGenerating(sourceNode)) {
message.info("该节点的生成任务仍在进行中,请等待完成后再生成");
return;
}
if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") {
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
@@ -224,8 +229,8 @@ export function useCanvasGenerationExecutor({
generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"
? {
provider: generationConfig.taskWorkflowProvider,
workflowId: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.workflowId : generationConfig.comfyBridge.workflowId,
workflowKind: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.selectedKind : undefined,
workflowId: generationConfig.runningHub.workflowId,
workflowKind: generationConfig.runningHub.selectedKind,
parameters: sourceNode?.metadata?.workflowParameters,
}
: undefined,
@@ -387,6 +392,7 @@ export function useCanvasGenerationExecutor({
);
}
function generationModelRequirements(
mode: CanvasNodeGenerationMode,
input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">,
@@ -12,7 +12,7 @@ import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { generationFailureMetadata } from "@/lib/generation-error";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { consumeCanvasAgentGenerationContinuation } from "./use-canvas-agent-operations";
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
type CanvasGenerationRequest = {
targetNodeId: string;
@@ -94,11 +94,11 @@ export async function recoverCanvasGenerationTaskNode(input: {
applyGenerationTaskResult: (nodeId: string, task: GenerationTask) => Promise<void>;
signal: AbortSignal;
isCurrentProject?: () => boolean;
consumeContinuation?: typeof consumeCanvasAgentGenerationContinuation;
consumeContinuation?: typeof consumeCanvasGenerationContinuation;
}) {
const isCurrentProject = () => !input.signal.aborted && (input.isCurrentProject?.() ?? true);
if (!isCurrentProject()) return;
const consumeContinuation = input.consumeContinuation ?? consumeCanvasAgentGenerationContinuation;
const consumeContinuation = input.consumeContinuation ?? consumeCanvasGenerationContinuation;
const recoveryBaseNodes = useCanvasStore.getState().projects.find((project) => project.id === input.projectId)?.nodes ?? input.nodesRef.current;
try {
if (input.completed.projectId && input.completed.projectId !== input.projectId) throw new Error("生成任务不属于当前画布");
@@ -107,7 +107,7 @@ export async function recoverCanvasGenerationTaskNode(input: {
throw new Error(input.completed.error || (input.completed.status === "cancelled" ? "任务已取消" : "任务失败"));
}
if (!input.continuationOnly) {
if (input.node.type === CanvasNodeType.Script && input.completed.type === "agent_storyboard_rows") {
if (input.node.type === CanvasNodeType.Script && input.completed.type === "canvas_text" && input.completed.operation === "storyboard") {
const result = storyboardRowsFromTask(input.completed);
const recoveredNodes = input.nodesRef.current.map((item) =>
item.id === input.node.id
@@ -381,7 +381,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
if (!isCurrentProject()) throw error;
return [];
})
).filter((task) => task.projectId === startedProjectId && (task.type.startsWith("canvas_") || task.type === "agent_storyboard_rows"))
).filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_"))
: [];
if (!isCurrentProject()) return;
await Promise.all(
@@ -14,7 +14,6 @@ import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { createDefaultPortraitClearanceState, PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows";
import { usePluginStore } from "@/stores/use-plugin-store";
@@ -129,32 +128,31 @@ export function useCanvasNodeOperations({
setSelectedConnectionId(null);
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position, workflowProvider?: "runninghub" | "comfyui") => {
const createNode = useCallback((type: CanvasNodeTypeId, position?: Position, workflowProvider?: "runninghub") => {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
const selectedWorkflowProvider = type === CanvasNodeType.Config
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "comfyui" : undefined)
? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : undefined)
: undefined;
if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) {
message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`);
message.error("RunningHub 工作流插件未启用");
return;
}
const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : type === CanvasNodeType.Config && selectedWorkflowProvider === "comfyui" ? "ComfyUI Bridge" : undefined;
const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : undefined;
const metadata: CanvasNodeMetadata | undefined = type === CanvasNodeType.Drawing
? { drawingEngine: defaultDrawingEngine }
: type === CanvasNodeType.Config
? { generationMode: "image", workflowProvider: selectedWorkflowProvider || "model" }
: type === PORTRAIT_CLEARANCE_NODE_TYPE
? { portraitClearance: createDefaultPortraitClearanceState() }
: undefined;
: undefined;
const node = createCanvasNode(type, position || getCanvasCenter(), metadata);
if (workflowTitle) node.title = workflowTitle;
commitNodes([...nodesRef.current, node]);
selectNodes(new Set([node.id]));
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing && type !== CanvasNodeType.MediaConversion && type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.comfyBridge.enabled, effectiveConfig.comfyBridge.workflows.length, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]);
if (type === CanvasNodeType.Script) setDialogNodeId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), {
@@ -413,7 +411,8 @@ export function useCanvasNodeOperations({
const sourceNode = sourceByTargetId.get(targetNode.id);
if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新复制");
});
if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing && source.type !== CanvasNodeType.MediaConversion && source.type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(id);
if (source.type === CanvasNodeType.Script) setDialogNodeId(null);
else if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing) setDialogNodeId(id);
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, nodesRef, selectNodes, setDialogNodeId]);
const setPrimaryVersion = useCallback((nodeId: string) => {
@@ -527,7 +526,7 @@ export function useCanvasNodeOperations({
selectNodes(topLevelIds);
setContextMenu(null);
const primaryNode = nextNodes.find((node) => !node.parentId);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing && primaryNode.type !== PORTRAIT_CLEARANCE_NODE_TYPE ? primaryNode.id : null);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing && primaryNode.type !== CanvasNodeType.Script ? primaryNode.id : null);
return true;
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]);
@@ -1,21 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { applyCanvasOperations, summarizeCanvasOperations, type CanvasOperation, type CanvasSnapshot } from "@/lib/canvas/canvas-operation-contract";
import { createGenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { persistCanvasAgentGenerationContinuationEffect } from "@/services/canvas-generation-consumer";
import { persistCanvasOperationContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
export type CanvasAgentGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasAgentGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasAgentGenerationOptions = Pick<CanvasNodeGenerationOptions, "context" | "retryContext" | "onTaskUpdate" | "skipDuplicateConfirmation">;
type CanvasAgentGenerationContinuationDependencies = {
export type CanvasGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasGenerationOptions = Pick<CanvasNodeGenerationOptions, "context" | "retryContext" | "onTaskUpdate" | "skipDuplicateConfirmation">;
type CanvasGenerationContinuationDependencies = {
consumeAgent?: typeof consumeGenerationTaskAgent;
persistContinuation?: typeof persistCanvasAgentGenerationContinuationEffect;
persistContinuation?: typeof persistCanvasOperationContinuationEffect;
projectId?: string;
nodeId?: string;
previousNodes?: CanvasNodeData[];
@@ -23,7 +23,7 @@ type CanvasAgentGenerationContinuationDependencies = {
setNodes?: Dispatch<SetStateAction<CanvasNodeData[]>>;
};
type UseCanvasAgentOperationsOptions = {
type UseCanvasOperationHistoryOptions = {
projectId: string;
domainProjectId?: string;
projectTitle: string;
@@ -35,7 +35,7 @@ type UseCanvasAgentOperationsOptions = {
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set<string> };
viewportRef: { current: ViewportTransform };
generateNodeRef: { current: ((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasAgentGenerationOptions) => Promise<void>) | null };
generateNodeRef: { current: ((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasGenerationOptions) => Promise<void>) | null };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
@@ -45,27 +45,27 @@ type UseCanvasAgentOperationsOptions = {
focusSelection: () => boolean;
};
export type CanvasAgentChange = {
export type CanvasOperationChange = {
id: string;
summary: string;
nodeIds: string[];
undoCount: number;
};
type CanvasAgentUndoBatch = { snapshot: CanvasAgentSnapshot; afterNodes: CanvasNodeData[]; afterConnections: CanvasConnection[]; change: Omit<CanvasAgentChange, "undoCount"> };
type CanvasOperationUndoBatch = { snapshot: CanvasSnapshot; afterNodes: CanvasNodeData[]; afterConnections: CanvasConnection[]; change: Omit<CanvasOperationChange, "undoCount"> };
type RunCanvasAgentGenerationOpsInput = {
generationOps: Array<Extract<CanvasAgentOp, { type: "run_generation" }>>;
type RunCanvasGenerationOpsInput = {
generationOps: Array<Extract<CanvasOperation, { type: "run_generation" }>>;
nodes: CanvasNodeData[];
context?: CanvasAgentGenerationContext;
generate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options: CanvasAgentGenerationOptions) => Promise<void>;
context?: CanvasGenerationContext;
generate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options: CanvasGenerationOptions) => Promise<void>;
subscribeTasks?: (ids: readonly string[], listener: (task: GenerationTask) => void) => () => void;
consumeTask?: typeof consumeGenerationTaskAgent;
resumeAgent?: (task: GenerationTask) => Promise<void>;
onContinuation?: (nodeId: string, continuation: CanvasAgentGenerationContinuation) => Promise<void> | void;
onContinuation?: (nodeId: string, continuation: CanvasGenerationContinuation) => Promise<void> | void;
};
export async function runCanvasAgentGenerationOps({
export async function runCanvasGenerationOps({
generationOps,
nodes,
context,
@@ -74,7 +74,7 @@ export async function runCanvasAgentGenerationOps({
consumeTask = consumeGenerationTaskAgent,
resumeAgent = async () => undefined,
onContinuation,
}: RunCanvasAgentGenerationOpsInput) {
}: RunCanvasGenerationOpsInput) {
const observations = new Map<string, Promise<void>>();
const observe = (taskId: string, nodeId: string, continuationIdPromise?: Promise<string>) => {
const existing = observations.get(taskId);
@@ -91,8 +91,8 @@ export async function runCanvasAgentGenerationOps({
conversationId: task.clientContext?.conversationId || context?.conversationId,
messageId: task.clientContext?.messageId || context?.messageId,
};
const continuationId = await (continuationIdPromise ?? canvasAgentGenerationContinuationId(task.clientContext?.nodeId || nodeId, taskContext));
const continuation: CanvasAgentGenerationContinuation = {
const continuationId = await (continuationIdPromise ?? canvasGenerationContinuationId(task.clientContext?.nodeId || nodeId, taskContext));
const continuation: CanvasGenerationContinuation = {
id: continuationId,
taskId: task.id,
...(taskContext.conversationId ? { conversationId: taskContext.conversationId } : {}),
@@ -141,7 +141,7 @@ export async function runCanvasAgentGenerationOps({
const retryOf = op.retry ? target?.metadata?.taskId : undefined;
if (op.retry && !retryOf) throw new Error("当前节点没有可重试的生成任务");
const retryContext = retryOf ? await createGenerationRetryContext(retryOf, target?.metadata?.attemptGroupId) : undefined;
const continuationIdPromise = canvasAgentGenerationContinuationId(op.nodeId, context);
const continuationIdPromise = canvasGenerationContinuationId(op.nodeId, context);
let observation: Promise<void> | undefined;
let continuationTaskId = "";
const generationPromise = generate(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt, {
@@ -170,11 +170,11 @@ export async function runCanvasAgentGenerationOps({
);
}
export async function consumeCanvasAgentGenerationContinuation(
export async function consumeCanvasGenerationContinuation(
task: GenerationTask,
continuation: CanvasAgentGenerationContinuation,
onCompleted: (continuation: CanvasAgentGenerationContinuation, signal?: AbortSignal) => Promise<void> | void,
dependencies: CanvasAgentGenerationContinuationDependencies = {},
continuation: CanvasGenerationContinuation,
onCompleted: (continuation: CanvasGenerationContinuation, signal?: AbortSignal) => Promise<void> | void,
dependencies: CanvasGenerationContinuationDependencies = {},
signal?: AbortSignal,
) {
if (continuation.status !== "pending" || continuation.taskId !== task.id || task.status !== "succeeded") return task;
@@ -185,7 +185,7 @@ export async function consumeCanvasAgentGenerationContinuation(
async ({ effectKey, signal: leaseSignal }) => {
const completed = { ...continuation, status: "completed" as const, effectKey };
if (dependencies.projectId && dependencies.nodeId && dependencies.nodesRef && dependencies.setNodes) {
await (dependencies.persistContinuation ?? persistCanvasAgentGenerationContinuationEffect)({
await (dependencies.persistContinuation ?? persistCanvasOperationContinuationEffect)({
projectId: dependencies.projectId,
nodeId: dependencies.nodeId,
continuation: completed,
@@ -202,13 +202,13 @@ export async function consumeCanvasAgentGenerationContinuation(
);
}
async function canvasAgentGenerationContinuationId(nodeId: string, context?: CanvasAgentGenerationContext) {
const seed = new TextEncoder().encode(`canvas-agent-generation\0${context?.source || ""}\0${context?.conversationId || ""}\0${context?.messageId || ""}\0${nodeId}`);
async function canvasGenerationContinuationId(nodeId: string, context?: CanvasGenerationContext) {
const seed = new TextEncoder().encode(`canvas-operation-generation\0${context?.source || ""}\0${context?.conversationId || ""}\0${context?.messageId || ""}\0${nodeId}`);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", seed));
return `agent:${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
export function useCanvasAgentOperations({
export function useCanvasOperationHistory({
projectId,
domainProjectId,
projectTitle,
@@ -228,11 +228,11 @@ export function useCanvasAgentOperations({
setViewport,
setContextMenu,
focusSelection,
}: UseCanvasAgentOperationsOptions) {
const undoStackRef = useRef<CanvasAgentUndoBatch[]>([]);
}: UseCanvasOperationHistoryOptions) {
const undoStackRef = useRef<CanvasOperationUndoBatch[]>([]);
const [undoOpsCount, setUndoOpsCount] = useState(0);
const [lastAgentChange, setLastAgentChange] = useState<CanvasAgentChange | null>(null);
const snapshot = useMemo<CanvasAgentSnapshot>(
const [lastAgentChange, setLastAgentChange] = useState<CanvasOperationChange | null>(null);
const snapshot = useMemo<CanvasSnapshot>(
() => ({ projectId, domainProjectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }),
[connections, domainProjectId, nodes, projectId, projectTitle, selectedNodeIds, viewport],
);
@@ -253,11 +253,11 @@ export function useCanvasAgentOperations({
}, [connections, nodes]);
const applyOps = useCallback(
async (ops?: CanvasAgentOp[], generationContext?: CanvasAgentGenerationContext) => {
async (ops?: CanvasOperation[], generationContext?: CanvasGenerationContext) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, domainProjectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(
const generationOps = safeOps.filter((op): op is Extract<CanvasOperation, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasOperations(
before,
safeOps.filter((op) => op.type !== "run_generation"),
);
@@ -278,7 +278,7 @@ export function useCanvasAgentOperations({
setViewport(next.viewport);
setContextMenu(null);
if (safeOps.length) {
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasAgentOps(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasOperations(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
undoStackRef.current = [...undoStackRef.current, { snapshot: before, afterNodes: next.nodes, afterConnections: next.connections, change }].slice(-10);
const nextUndoCount = undoStackRef.current.length;
setUndoOpsCount(nextUndoCount);
@@ -288,14 +288,14 @@ export function useCanvasAgentOperations({
if (generationOps.length) {
const generate = generateNodeRef.current;
if (generate)
await runCanvasAgentGenerationOps({
await runCanvasGenerationOps({
generationOps,
nodes: nodesRef.current,
generate,
context: generationContext,
onContinuation: async (nodeId, continuation) => {
if (continuation.status === "completed" && continuation.effectKey) {
await persistCanvasAgentGenerationContinuationEffect({
await persistCanvasOperationContinuationEffect({
projectId,
nodeId,
continuation,
@@ -368,7 +368,7 @@ export function useCanvasAgentOperations({
return { agentSnapshot: snapshot, agentUndoCount: undoOpsCount, applyAgentOps: applyOps, canUndoAgentOps: undoOpsCount > 0, dismissLastAgentChange: () => setLastAgentChange(null), lastAgentChange, undoAgentOps: undoOps, viewLastAgentChange };
}
function agentAffectedNodeIds(ops: CanvasAgentOp[], nodes: CanvasNodeData[]) {
function agentAffectedNodeIds(ops: CanvasOperation[], nodes: CanvasNodeData[]) {
const existingIds = new Set(nodes.map((node) => node.id));
const ids = new Set<string>();
ops.forEach((op) => {
@@ -1,3 +1,4 @@
import { mergeAgentCanvasEditor } from "@/lib/canvas/agent-canvas-patch";
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
@@ -8,7 +9,7 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -83,6 +84,7 @@ export function useCanvasProjectLifecycle({
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
const [loadError, setLoadError] = useState("");
const [loadAttempt, setLoadAttempt] = useState(0);
const [agentCreatedNodes, setAgentCreatedNodes] = useState<{ projectId: string; nodes: CanvasNodeData[] } | null>(null);
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -172,9 +174,27 @@ export function useCanvasProjectLifecycle({
};
}, [projectLoaded]);
useEffect(() => subscribeAgentCanvasRefresh((project, previous) => {
if (!projectLoaded || project.id !== projectId) return;
// Merge only server-changed fields so dragging/editing other nodes can
// continue while Agent media tasks complete. Same-field conflicts fail.
const merged = previous ? mergeAgentCanvasEditor(previous, project, nodesRef.current, connectionsRef.current) : project;
nodesRef.current = merged.nodes;
connectionsRef.current = merged.connections;
setNodes(merged.nodes);
setConnections(merged.connections);
const previousIds = new Set(previous?.nodes.map((node) => node.id));
const created = project.nodes.filter((node) => !previousIds.has(node.id));
if (created.length) setAgentCreatedNodes({ projectId: project.id, nodes: created });
}), [projectId, projectLoaded, nodesRef, connectionsRef, setNodes, setConnections]);
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo });
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
if (stored && Object.entries(patch).every(([key, value]) => JSON.stringify(stored[key as keyof CanvasProject]) === JSON.stringify(value))) return;
updateProject(projectId, patch);
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
useEffect(() => {
@@ -258,6 +278,7 @@ export function useCanvasProjectLifecycle({
loadError,
retryLoad: () => setLoadAttempt((attempt) => attempt + 1),
addedSkills,
agentCreatedNodes: agentCreatedNodes?.projectId === projectId ? agentCreatedNodes.nodes : null,
clearCanvasFiles,
createAndOpenProject,
currentProject,
@@ -268,6 +289,7 @@ export function useCanvasProjectLifecycle({
};
}
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
return currentSessions.map((session) => {
@@ -346,21 +346,29 @@ export function useCanvasSelectionController({
if (hadPendingSelection && !wasSelection && strategy === "replace") deselectCanvas();
}, [deselectCanvas, resetSelectionBox, screenToCanvas, updateSelectionPreview]);
// 重渲染只更新回调,不能拆掉进行中的手势监听并清除 iframe 保护层 / 待执行帧。
const gestureHandlersRef = useRef({ finishNodeDrag, finishSelection, cancelSelectionBox, handleNodeDragMove, handlePointerMove });
useEffect(() => {
gestureHandlersRef.current = { finishNodeDrag, finishSelection, cancelSelectionBox, handleNodeDragMove, handlePointerMove };
});
useEffect(() => {
const handleMouseUp = (event: MouseEvent) => {
finishNodeDrag(event.clientX, event.clientY);
gestureHandlersRef.current.finishNodeDrag(event.clientX, event.clientY);
};
const handlePointerUp = (event: PointerEvent) => {
finishNodeDrag(event.clientX, event.clientY);
finishSelection(event.clientX, event.clientY);
gestureHandlersRef.current.finishNodeDrag(event.clientX, event.clientY);
gestureHandlersRef.current.finishSelection(event.clientX, event.clientY);
};
const cancel = () => {
finishNodeDrag();
cancelSelectionBox();
gestureHandlersRef.current.finishNodeDrag();
gestureHandlersRef.current.cancelSelectionBox();
};
window.addEventListener("mousemove", handleNodeDragMove);
const onMouseMove = (event: MouseEvent) => gestureHandlersRef.current.handleNodeDragMove(event);
const onPointerMove = (event: PointerEvent) => gestureHandlersRef.current.handlePointerMove(event);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", cancel);
window.addEventListener("blur", cancel);
@@ -368,14 +376,14 @@ export function useCanvasSelectionController({
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
applyCanvasNodeDragPreview(containerRef.current, null);
window.removeEventListener("mousemove", handleNodeDragMove);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("blur", cancel);
};
}, [cancelSelectionBox, finishNodeDrag, finishSelection, handleNodeDragMove, handlePointerMove]);
}, [containerRef]);
return {
alignmentGuides,
@@ -170,8 +170,8 @@ export function useCanvasStoryboard({
assertCurrent();
const request = {
projectId,
type: "agent_storyboard_rows",
operation: "storyboard_rows",
type: "canvas_text",
operation: "storyboard",
prompt: skillExecution.prompt,
model: generationConfig.model,
...(logicalModelIDForConfig(generationConfig) ? { logicalModelId: logicalModelIDForConfig(generationConfig) } : {}),
@@ -6,8 +6,10 @@ import { applyCanvasLiveViewport } from "@/lib/canvas/canvas-live-viewport";
import { getCanvasNodesBounds, viewportAtScale, viewportForBounds, type CanvasViewportSize } from "@/lib/canvas/canvas-viewport";
import { CanvasNodeType, type CanvasNodeData, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
import { useCanvasViewportTransition } from "./use-canvas-viewport-transition";
import { unobscuredCanvasArea, viewportForAgentNodes } from "@/lib/canvas/canvas-viewport";
type UseCanvasViewportControllerOptions = {
agentCreatedNodes: CanvasNodeData[] | null;
containerRef: { current: HTMLDivElement | null };
size: CanvasViewportSize;
viewportRef: { current: ViewportTransform };
@@ -22,6 +24,7 @@ type UseCanvasViewportControllerOptions = {
};
export function useCanvasViewportController({
agentCreatedNodes,
containerRef,
size,
viewportRef,
@@ -58,6 +61,23 @@ export function useCanvasViewportController({
}, [containerRef]);
const { cancelViewportTransition, transitionViewportTo } = useCanvasViewportTransition(viewportRef, previewViewport, commitViewport);
const focusedAgentBatch = useRef<CanvasNodeData[] | null>(null);
useEffect(() => {
if (!agentCreatedNodes || focusedAgentBatch.current === agentCreatedNodes || size.width <= 0 || size.height <= 0) return;
focusedAgentBatch.current = agentCreatedNodes;
const targets = agentCreatedNodes.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current));
const rect = containerRef.current?.getBoundingClientRect();
const panel = containerRef.current?.ownerDocument.querySelector(".canvas-agent-panel")?.getBoundingClientRect();
const area = unobscuredCanvasArea(size, rect && panel ? { left: panel.left - rect.left, top: panel.top - rect.top, right: panel.right - rect.left, bottom: panel.bottom - rect.top } : undefined);
const target = viewportForAgentNodes(targets, viewportRef.current, area);
if (!target) return;
setSelectedNodeIds(new Set(targets.map((node) => node.id)));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(null);
setToolbarNodeId(null);
transitionViewportTo(target);
}, [agentCreatedNodes, containerRef, nodesRef, size, viewportRef, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, setToolbarNodeId, transitionViewportTo]);
const screenToCanvas = useCallback((clientX: number, clientY: number): Position => {
const rect = containerRef.current?.getBoundingClientRect();
@@ -124,7 +144,7 @@ export function useCanvasViewportController({
const scale = Math.min(1.18, Math.max(viewportRef.current.k, 0.72));
transitionViewportTo({ x: size.width / 2 - (node.position.x + node.width / 2) * scale, y: size.height / 2 - (node.position.y + node.height / 2) * scale, k: scale });
selectFocusedNode(node.id);
setDialogNodeId(node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion ? null : node.id);
setDialogNodeId(node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Script ? null : node.id);
}, [nodesRef, selectFocusedNode, setDialogNodeId, size.height, size.width, transitionViewportTo, viewportRef]);
const setZoomScale = useCallback((scale: number) => {
@@ -1,162 +0,0 @@
import { useEffect, useRef } from "react";
import { readPortraitTask, readPortraitTaskImage, readPortraitTaskResult, claimPortraitModelJob, completePortraitModelJob, failPortraitModelJob, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { PORTRAIT_CLEARANCE_NODE_TYPE, type PortraitClearanceNodeState, type PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
import { portraitVisionMessages, portraitVisionModelError, resolvePortraitVisionModel, parsePortraitVisionToolResponse, portraitVisionTool } from "@/lib/portrait-clearance/vision";
import { requestToolResponse } from "@/services/api/image";
import { resolveModelChannel, useEffectiveConfig } from "@/stores/use-config-store";
type PortraitClearanceCoordinatorOptions = {
nodes: CanvasNodeData[];
onUpdateState: (nodeId: string, state: PortraitClearanceNodeState) => void;
};
const PORTRAIT_MODEL_JOB_TIMEOUT_MS = 75_000;
export function usePortraitClearanceCoordinator({ nodes, onUpdateState }: PortraitClearanceCoordinatorOptions) {
const runningRef = useRef(false);
const abortRef = useRef<Set<AbortController>>(new Set());
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
const effectiveConfig = useEffectiveConfig();
useEffect(() => {
let disposed = false;
const poll = async () => {
if (disposed || runningRef.current) return;
const active = nodesRef.current.filter((node) => {
if (node.type !== PORTRAIT_CLEARANCE_NODE_TYPE) return false;
const task = node.metadata?.portraitClearance;
return Boolean(task?.activeTaskId && task.task?.status !== "completed" && task.task?.status !== "partial" && task.task?.status !== "failed" && task.task?.status !== "cancelled");
});
if (!active.length) return;
runningRef.current = true;
try {
for (const node of active) {
if (disposed) break;
const state = node.metadata?.portraitClearance;
const taskId = state?.activeTaskId;
if (!state || !taskId) continue;
try {
const task = await readPortraitTask(taskId);
let next = mergeTaskState(state, task);
if (task.status === "waiting_model" && state.analysisMode === "local-plus-vision") {
const modelError = portraitVisionModelError(effectiveConfig, state.modelPolicy);
if (!modelError) {
await runPortraitModelJobs(taskId, state, effectiveConfig, abortRef, task.modelRef);
const refreshed = await readPortraitTask(taskId);
next = mergeTaskState(state, refreshed);
} else {
next = { ...next, task: { ...next.task!, errorCode: state.modelPolicy.mode === "pinned" ? "portrait_vision_model_removed" : "portrait_vision_model_unavailable", errorMessage: modelError } };
}
}
const effectiveTask = task.status === "waiting_model" && state.analysisMode === "local-plus-vision" ? await readPortraitTask(taskId).catch(() => task) : task;
if ((effectiveTask.status === "completed" || effectiveTask.status === "partial") && effectiveTask.detailsAvailable) {
try {
const result = await readPortraitTaskResult(taskId);
if (isResult(result)) next = { ...next, lastResult: { taskId, highestRisk: result.highestRisk, riskCounts: result.riskCounts, candidateCount: result.candidateCount, comparedCount: result.comparedCount, ...(effectiveTask.modelRef ? { modelRef: effectiveTask.modelRef } : {}), completedAt: effectiveTask.completedAt || effectiveTask.updatedAt, detailsAvailable: true } };
} catch {
// The task summary remains useful if the large result is temporarily unavailable.
}
}
if (effectiveTask.status === "completed" || effectiveTask.status === "partial") next = { ...next, activeTaskId: undefined };
onUpdateState(node.id, next);
} catch {
// Runtime reconnects are expected; the next poll resumes from the durable task id.
}
}
} finally {
runningRef.current = false;
}
};
void poll();
const timer = window.setInterval(() => void poll(), 1_500);
return () => {
disposed = true;
window.clearInterval(timer);
for (const controller of abortRef.current) controller.abort();
abortRef.current.clear();
};
}, [effectiveConfig, onUpdateState]);
}
async function runPortraitModelJobs(taskId: string, state: PortraitClearanceNodeState, config: ReturnType<typeof useEffectiveConfig>, abortRef: { current: Set<AbortController> }, frozenModelRef?: string) {
const model = frozenModelRef || resolvePortraitVisionModel(config, state.modelPolicy);
const channel = resolveModelChannel(config, model);
const channelLimit = Number.isInteger(channel.concurrencyLimit) && (channel.concurrencyLimit || 0) > 0 ? channel.concurrencyLimit! : 10;
const limit = Math.min(10, Math.max(1, state.settings.modelConcurrency), channelLimit);
const jobs = [];
for (let index = 0; index < limit; index += 1) {
const job = await claimPortraitModelJob(taskId);
if (!job?.leaseToken) break;
jobs.push(job);
}
await Promise.all(jobs.map((job) => runPortraitModelJob(taskId, state, config, abortRef, job, model)));
}
async function runPortraitModelJob(taskId: string, state: PortraitClearanceNodeState, config: ReturnType<typeof useEffectiveConfig>, abortRef: { current: Set<AbortController> }, job: Awaited<ReturnType<typeof claimPortraitModelJob>>, model: string) {
if (!job?.leaseToken) return;
const controller = new AbortController();
let timedOut = false;
let timeoutId: number | undefined;
abortRef.current.add(controller);
try {
const timeout = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
reject(new Error("portrait_model_timeout"));
}, PORTRAIT_MODEL_JOB_TIMEOUT_MS);
});
const work = (async () => {
const result = await readPortraitTaskResult(taskId, controller.signal);
const pair = isResult(result) ? result.pairs.find((candidate) => candidate.id === job.pairId) : undefined;
if (!pair) {
await failPortraitModelJob(taskId, job, "portrait_model_job_conflict", "模型作业对应的比对结果不存在", false, controller.signal);
return;
}
const [query, comparison] = await Promise.all([readPortraitTaskImage(taskId, job.queryImageId, controller.signal), readPortraitTaskImage(taskId, job.comparisonImageId, controller.signal)]);
const response = await requestToolResponse({ ...config, model }, portraitVisionMessages({ queryDataUrl: query.dataUrl, comparisonDataUrl: comparison.dataUrl, queryName: job.queryImageId, comparisonName: job.comparisonImageId, localPrecheck: pair.localPrecheck }), [portraitVisionTool], { type: "function", name: "submit_portrait_comparison" }, undefined, { signal: controller.signal });
if (timedOut) throw new Error("portrait_model_timeout");
const vision = parsePortraitVisionToolResponse(response);
await completePortraitModelJob(taskId, job, vision as unknown as Record<string, unknown>, controller.signal);
})();
await Promise.race([work, timeout]);
} catch (error) {
if (controller.signal.aborted && !timedOut) return;
const rawMessage = error instanceof Error ? error.message : "视觉模型分析失败";
const message = timedOut ? "视觉模型请求超过 75 秒,已跳过该候选" : rawMessage;
const errorCode = timedOut ? "portrait_model_timeout" : rawMessage === "portrait_vision_result_invalid" || rawMessage === "portrait_vision_tool_missing" ? "portrait_vision_result_invalid" : "portrait_model_rate_limited";
const retryable = !timedOut && rawMessage !== "portrait_vision_result_invalid" && rawMessage !== "portrait_vision_tool_missing";
try {
await failPortraitModelJob(taskId, job, errorCode, message, retryable);
} catch {
// The lease may have expired or the task may have been stopped; the next poll can reclaim it safely.
}
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
abortRef.current.delete(controller);
}
}
function mergeTaskState(state: PortraitClearanceNodeState, task: PortraitRuntimeTask): PortraitClearanceNodeState {
return {
...state,
activeTaskId: task.taskId,
task: {
status: task.status,
stage: task.stage,
progress: task.progress,
processedCandidates: task.processedCandidates,
...(task.totalCandidates === undefined ? {} : { totalCandidates: task.totalCandidates }),
...(task.errorCode ? { errorCode: task.errorCode } : {}),
...(task.errorMessage ? { errorMessage: task.errorMessage } : {}),
updatedAt: task.updatedAt,
},
};
}
function isResult(value: unknown): value is { highestRisk: PortraitRiskLevel; riskCounts: Partial<Record<PortraitRiskLevel, number>>; candidateCount: number; comparedCount: number; pairs: Array<{ id: string; queryImageId: string; comparisonImageId: string; localPrecheck: unknown }> } {
return Boolean(value && typeof value === "object" && "highestRisk" in value && typeof (value as { highestRisk?: unknown }).highestRisk === "string" && "riskCounts" in value && typeof (value as { riskCounts?: unknown }).riskCounts === "object" && "candidateCount" in value && typeof (value as { candidateCount?: unknown }).candidateCount === "number" && "comparedCount" in value && typeof (value as { comparedCount?: unknown }).comparedCount === "number" && Array.isArray((value as { pairs?: unknown }).pairs));
}