chore: commit workspace updates
This commit is contained in:
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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
+1130
-1204
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]);
|
||||
|
||||
|
||||
+40
-40
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user