feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent f549767cda
commit 914717cae6
179 changed files with 49746 additions and 1897 deletions

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { formatCredits } from "@/constant/credits";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import { formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import { canvasThemes } from "@/lib/canvas-theme";
import type { GenerationTask } from "@/services/api/task-center";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -66,7 +66,9 @@ export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold leading-5">生成任务</span>
<span className="block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }} aria-live="polite">当前画布 · {tasks.length} 个进行中</span>
<span className="block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }} aria-live="polite">
当前画布 · {tasks.length} 个进行中
</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-2" style={{ color: theme.accent.primary }}>
@@ -93,7 +95,7 @@ export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(
now={now}
theme={theme}
expanded={expandedTaskId === task.id}
onToggle={() => setExpandedTaskId((current) => current === task.id ? null : task.id)}
onToggle={() => setExpandedTaskId((current) => (current === task.id ? null : task.id))}
reducedMotion={Boolean(reducedMotion)}
creditsEnabled={creditsEnabled}
/>
@@ -108,8 +110,25 @@ export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(
);
}
function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion, creditsEnabled }: { task: GenerationTask; now: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; expanded: boolean; onToggle: () => void; reducedMotion: boolean; creditsEnabled: boolean }) {
const progress = typeof task.progress === "number" ? Math.max(0, Math.min(100, Math.round(task.progress))) : task.status === "queued" ? 0 : undefined;
function ActiveTaskCard({
task,
now,
theme,
expanded,
onToggle,
reducedMotion,
creditsEnabled,
}: {
task: GenerationTask;
now: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
expanded: boolean;
onToggle: () => void;
reducedMotion: boolean;
creditsEnabled: boolean;
}) {
const showsProgress = generationTaskShowsProgress(task);
const progress = showsProgress && typeof task.progress === "number" ? Math.max(0, Math.min(100, Math.round(task.progress))) : showsProgress && task.status === "queued" ? 0 : undefined;
const startedAt = task.startedAt || task.createdAt;
const elapsedMs = Math.max(0, now - parseTime(startedAt));
const durationLabel = `${task.status === "queued" ? "已等待" : "已运行"} ${formatDuration(elapsedMs)}`;
@@ -118,12 +137,7 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion, c
const transition = reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel;
return (
<motion.article
layout
layoutId={`canvas-active-task-${task.id}`}
className="overflow-hidden rounded-xl border"
style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}
>
<motion.article layout layoutId={`canvas-active-task-${task.id}`} className="overflow-hidden rounded-xl border" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<button type="button" className="block w-full p-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px]" onClick={onToggle} aria-expanded={expanded}>
<div className="flex min-w-0 items-start gap-2">
<motion.span layout="position" className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ background: `${statusTone}18`, color: statusTone }}>
@@ -131,53 +145,62 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion, c
</motion.span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-semibold" title={formatTaskKind(task)}>{formatTaskKind(task)}</span>
<span className="shrink-0 rounded-full border px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium" style={{ borderColor: `${statusTone}44`, color: statusTone }}>{statusLabel[task.status]}</span>
<span className="truncate text-xs font-semibold" title={formatTaskKind(task)}>
{formatTaskKind(task)}
</span>
<span className="shrink-0 rounded-full border px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium" style={{ borderColor: `${statusTone}44`, color: statusTone }}>
{generationTaskStatusLabel(task)}
</span>
</span>
<span className="mt-1 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }} title={generationTaskStageLabel(task)}>
{generationTaskStageLabel(task)}
</span>
<span className="mt-1 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }} title={task.stage || statusLabel[task.status]}>{task.stage || statusLabel[task.status]}</span>
</span>
{expanded ? <ChevronUp className="mt-0.5 size-3.5 shrink-0" style={{ color: theme.node.muted }} /> : <ChevronDown className="mt-0.5 size-3.5 shrink-0" style={{ color: theme.node.muted }} />}
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full" style={{ background: theme.toolbar.itemHover }}>
{progress !== undefined ? (
<motion.div
className="relative h-full rounded-full overflow-hidden"
animate={{ width: `${progress}%` }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: "easeOut" }}
style={{ background: statusTone }}
>
{/* 进度条 shimmer 扫描动画(对应 #98 决策3)*/}
{task.status === "running" && !reducedMotion ? (
<span
className="absolute inset-0 canvas-task-progress-shimmer"
style={{ background: "linear-gradient(90deg, transparent, rgba(255,255,255,.3), transparent)" }}
aria-hidden
/>
) : null}
</motion.div>
) : (
// indeterminate 模式(无具体百分比,从左到右循环扫描)
<motion.div
className="h-full rounded-full"
initial={{ width: "20%", x: "-100%" }}
animate={{ width: "20%", x: "400%" }}
transition={reducedMotion ? { duration: 0 } : { duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
style={{ background: statusTone }}
/>
)}
</div>
{showsProgress ? (
<div className="mt-3 h-1.5 overflow-hidden rounded-full" style={{ background: theme.toolbar.itemHover }}>
{progress !== undefined ? (
<motion.div className="relative h-full rounded-full overflow-hidden" animate={{ width: `${progress}%` }} transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: "easeOut" }} style={{ background: statusTone }}>
{/* 进度条 shimmer 扫描动画(对应 #98 决策3)*/}
{task.status === "running" && !reducedMotion ? (
<span className="absolute inset-0 canvas-task-progress-shimmer" style={{ background: "linear-gradient(90deg, transparent, rgba(255,255,255,.3), transparent)" }} aria-hidden />
) : null}
</motion.div>
) : (
// indeterminate 模式(无具体百分比,从左到右循环扫描)
<motion.div
className="h-full rounded-full"
initial={{ width: "20%", x: "-100%" }}
animate={{ width: "20%", x: "400%" }}
transition={reducedMotion ? { duration: 0 } : { duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
style={{ background: statusTone }}
/>
)}
</div>
) : null}
{/* 进度百分比显示(对应 #98 决策3:列表视图)*/}
{progress !== undefined && task.status === "running" ? (
<div className="mt-1 flex items-center justify-between text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>
<span>{durationLabel}</span>
<span className="font-medium tabular-nums" style={{ color: statusTone }}>{progress}%</span>
<span className="font-medium tabular-nums" style={{ color: statusTone }}>
{progress}%
</span>
</div>
) : (
<div className={`mt-3 grid gap-2 text-[var(--fs-tiny)] ${creditsEnabled ? "grid-cols-2" : "grid-cols-1"}`} style={{ color: theme.node.muted }}>
<span className="inline-flex min-w-0 items-center gap-1 truncate" title={durationLabel}><Clock3 className="size-3 shrink-0" />{durationLabel}</span>
{creditsEnabled ? <span className="inline-flex min-w-0 items-center justify-end gap-1 truncate" title={billingLabel}><Coins className="size-3 shrink-0" />{billingLabel}</span> : null}
<span className="inline-flex min-w-0 items-center gap-1 truncate" title={durationLabel}>
<Clock3 className="size-3 shrink-0" />
{durationLabel}
</span>
{creditsEnabled ? (
<span className="inline-flex min-w-0 items-center justify-end gap-1 truncate" title={billingLabel}>
<Coins className="size-3 shrink-0" />
{billingLabel}
</span>
) : null}
</div>
)}
</button>
@@ -194,7 +217,9 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion, c
>
<div className="flex items-center justify-between gap-2">
<span>当前阶段</span>
<span className="max-w-[200px] truncate text-right" style={{ color: theme.node.text }}>{task.stage || statusLabel[task.status]}</span>
<span className="max-w-[200px] truncate text-right" style={{ color: theme.node.text }}>
{generationTaskStageLabel(task)}
</span>
</div>
</motion.div>
) : null}

View File

@@ -9,6 +9,10 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { imageToDataUrl } from "@/services/image-storage";
import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
import { activeGenerationConsumerController } from "@/services/generation-consumer-lifecycle";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -74,6 +78,7 @@ const CANVAS_OP_SCHEMA = {
nodeId: { type: "string" },
mode: GENERATION_MODE_SCHEMA,
prompt: { type: "string" },
retry: { type: "boolean" },
},
required: ["type"],
additionalProperties: false,
@@ -88,7 +93,16 @@ function generationToolDefinition(name: string, description: string, mode?: "tex
return toolDefinition(
name,
description,
{ prompt: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, referenceNodeIds: { type: "array", items: { type: "string" } }, ...(mode ? {} : { mode: GENERATION_MODE_SCHEMA }), autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES },
{
prompt: { type: "string" },
title: { type: "string" },
x: { type: "number" },
y: { type: "number" },
referenceNodeIds: { type: "array", items: { type: "string" } },
...(mode ? {} : { mode: GENERATION_MODE_SCHEMA }),
autoRun: { type: "boolean" },
...GENERATION_OPTION_PROPERTIES,
},
["prompt"],
);
}
@@ -97,12 +111,48 @@ const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}),
toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}),
toolDefinition("canvas_apply_ops", "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"], false),
toolDefinition("canvas_create_node", "创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。", { nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA }, ["nodeType"]),
toolDefinition(
"canvas_apply_ops",
"批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
{ ops: { type: "array", items: CANVAS_OP_SCHEMA } },
["ops"],
false,
),
toolDefinition(
"canvas_create_node",
"创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。",
{ nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA },
["nodeType"],
),
toolDefinition("canvas_create_text_node", "在当前画布创建单个文本节点。", { text: { type: "string" }, x: { type: "number" }, y: { type: "number" }, title: { type: "string" }, width: { type: "number" }, height: { type: "number" } }),
toolDefinition("canvas_create_text_nodes", "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。", { items: { type: "array", minItems: 1, items: { type: "object", properties: { text: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" } }, required: ["text"], additionalProperties: false } }, x: { type: "number" }, y: { type: "number" }, gap: { type: "number" }, direction: { type: "string", enum: ["row", "column"] } }, ["items"]),
toolDefinition(
"canvas_create_text_nodes",
"批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
{
items: {
type: "array",
minItems: 1,
items: {
type: "object",
properties: { text: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" } },
required: ["text"],
additionalProperties: false,
},
},
x: { type: "number" },
y: { type: "number" },
gap: { type: "number" },
direction: { type: "string", enum: ["row", "column"] },
},
["items"],
),
toolDefinition("canvas_create_cinematic_session", "把自然语言创作指令提交给后端影视 Agent 会话,后端拆解为剧本、场景、分镜、镜头和成片节点,并返回可写回画布的操作。", { prompt: { type: "string" } }, ["prompt"]),
toolDefinition("canvas_create_image_prompt_flow", "创建提示词文本节点和图片目标节点并自动连线,可选择立即触发生图。", { prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" }, autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES }, ["prompt"]),
toolDefinition(
"canvas_create_image_prompt_flow",
"创建提示词文本节点和图片目标节点并自动连线,可选择立即触发生图。",
{ prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" }, autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES },
["prompt"],
),
generationToolDefinition("canvas_create_generation_flow", "创建通用生成流程:提示词文本节点、对应类型的生成目标节点和参考节点连线,可用于文案、生图、视频或音频。"),
generationToolDefinition("canvas_generate_text", "创建通用文本生成流程并立即触发生成。", "text"),
generationToolDefinition("canvas_generate_image", "创建通用图片生成流程并立即触发生成。", "image"),
@@ -110,13 +160,29 @@ const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
generationToolDefinition("canvas_generate_audio", "创建通用音频生成流程并立即触发生成。", "audio"),
toolDefinition("canvas_update_node", "更新节点基础字段或 metadata。", { id: { type: "string" }, patch: JSON_RECORD_SCHEMA, metadata: JSON_RECORD_SCHEMA }, ["id"]),
toolDefinition("canvas_update_node_text", "更新文本节点内容和标题。", { id: { type: "string" }, text: { type: "string" }, title: { type: "string" } }, ["id", "text"]),
toolDefinition("canvas_move_nodes", "移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。", { items: { type: "array", minItems: 1, items: { type: "object", properties: { id: { type: "string" }, x: { type: "number" }, y: { type: "number" }, dx: { type: "number" }, dy: { type: "number" } }, required: ["id"], additionalProperties: false } } }, ["items"]),
toolDefinition(
"canvas_move_nodes",
"移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。",
{
items: {
type: "array",
minItems: 1,
items: { type: "object", properties: { id: { type: "string" }, x: { type: "number" }, y: { type: "number" }, dx: { type: "number" }, dy: { type: "number" } }, required: ["id"], additionalProperties: false },
},
},
["items"],
),
toolDefinition("canvas_resize_node", "调整节点尺寸。", { id: { type: "string" }, width: { type: "number" }, height: { type: "number" }, freeResize: { type: "boolean" } }, ["id", "width", "height"]),
toolDefinition("canvas_delete_nodes", "删除指定节点及相关连线。", { ids: { type: "array", items: { type: "string" }, minItems: 1 } }, ["ids"]),
toolDefinition("canvas_connect_nodes", "批量连接节点。", { connections: { type: "array", minItems: 1, items: { type: "object", properties: { fromNodeId: { type: "string" }, toNodeId: { type: "string" } }, required: ["fromNodeId", "toNodeId"], additionalProperties: false } } }, ["connections"]),
toolDefinition(
"canvas_connect_nodes",
"批量连接节点。",
{ connections: { type: "array", minItems: 1, items: { type: "object", properties: { fromNodeId: { type: "string" }, toNodeId: { type: "string" } }, required: ["fromNodeId", "toNodeId"], additionalProperties: false } } },
["connections"],
),
toolDefinition("canvas_select_nodes", "设置当前选中节点。", { ids: { type: "array", items: { type: "string" } } }, ["ids"]),
toolDefinition("canvas_set_viewport", "调整画布视口。", { viewport: VIEWPORT_SCHEMA }, ["viewport"]),
toolDefinition("canvas_run_generation", "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" } }, ["nodeId"]),
toolDefinition("canvas_run_generation", "触发指定节点生成;对已有生成任务明确重试时传 retry=true。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]),
];
type OnlineAgentTab = "setup" | "chat" | "history" | "log";
type OnlineAgentLog = { id: string; time: string; title: string; data?: unknown };
@@ -135,7 +201,7 @@ type CanvasAssistantPanelProps = {
activeSessionId: string | null;
onSelectNodeIds: (ids: Set<string>) => void;
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
onApplyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot;
onApplyOps: (ops?: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise<CanvasAgentSnapshot>;
canUndoOps: boolean;
undoOpsCount: number;
onUndoOps: () => CanvasAgentSnapshot | null;
@@ -150,7 +216,96 @@ type CanvasAssistantPanelProps = {
resizing?: boolean;
};
export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, projectId, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, undoOpsCount, onUndoOps, onPasteImage, agentMode, onAgentModeChange, autoConnectLocal, closing, onCollapse, cinematicEntry = false, onCinematicEntryConsumed, resizing = false }: CanvasAssistantPanelProps) {
export type CinematicContinuationFailureDisposition = "abort" | "durable-ack" | "provider-failed";
type CinematicContinuationLiveSessionState = { sessions: CanvasAssistantSession[]; activeChatId: string | null };
type CanvasCinematicContinuationBoundaryInput<T> = {
projectId: string;
effectKey?: string;
signal?: AbortSignal;
readSnapshot: () => CanvasAgentSnapshot;
executeOps: () => Promise<T>;
completeSession: (effectKey?: string) => CanvasAssistantSession[];
readLiveSessionState: () => CinematicContinuationLiveSessionState;
restoreLiveSessions: (sessions: CanvasAssistantSession[], activeChatId: string | null) => void;
restoreLiveSnapshot: (state: Pick<CanvasAgentSnapshot, "nodes" | "connections">) => void;
failProvider: (error: unknown) => void;
onFailureDisposition?: (disposition: CinematicContinuationFailureDisposition, error: unknown) => void;
persistContinuation?: typeof persistCanvasCinematicSessionContinuationEffect;
};
export function handleCinematicContinuationFailure(error: unknown, failProvider: (error: unknown) => void): CinematicContinuationFailureDisposition {
if (isAgentSessionPollingAbort(error)) return "abort";
if (isCanvasGenerationDurableAckError(error)) return "durable-ack";
failProvider(error);
return "provider-failed";
}
export async function runCanvasCinematicContinuationBoundary<T>(input: CanvasCinematicContinuationBoundaryInput<T>) {
const previousSnapshot = input.readSnapshot();
const previousSessionState = input.readLiveSessionState();
try {
if (input.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
const result = await input.executeOps();
const attemptedSnapshot = input.readSnapshot();
input.completeSession(input.effectKey);
const attemptedSessionState = input.readLiveSessionState();
if (input.effectKey) {
await (input.persistContinuation ?? persistCanvasCinematicSessionContinuationEffect)({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes: previousSnapshot.nodes,
nodes: attemptedSnapshot.nodes,
previousConnections: previousSnapshot.connections,
connections: attemptedSnapshot.connections,
previousChatSessions: previousSessionState.sessions,
chatSessions: attemptedSessionState.sessions,
previousActiveChatId: previousSessionState.activeChatId,
activeChatId: attemptedSessionState.activeChatId,
signal: input.signal,
readLiveSessionState: input.readLiveSessionState,
restoreLiveSessions: input.restoreLiveSessions,
restoreLiveSnapshot: input.restoreLiveSnapshot,
});
}
return result;
} catch (error) {
const disposition = handleCinematicContinuationFailure(error, input.failProvider);
input.onFailureDisposition?.(disposition, error);
throw error;
}
}
export const canvasCinematicContinuationEntryAdapters = {
"online-tool": runCanvasCinematicContinuationBoundary,
"submit-cinematic": runCanvasCinematicContinuationBoundary,
"resume-cinematic": runCanvasCinematicContinuationBoundary,
} as const;
export function CanvasAssistantPanel({
nodes,
selectedNodeIds,
snapshot,
projectId,
sessions,
activeSessionId,
onSelectNodeIds,
onSessionsChange,
onApplyOps,
canUndoOps,
undoOpsCount,
onUndoOps,
onPasteImage,
agentMode,
onAgentModeChange,
autoConnectLocal,
closing,
onCollapse,
cinematicEntry = false,
onCinematicEntryConsumed,
resizing = false,
}: CanvasAssistantPanelProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const effectiveConfig = useEffectiveConfig();
@@ -167,17 +322,25 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
const localSessionsRef = useRef(localSessions);
const [localActiveSessionId, setLocalActiveSessionIdState] = useState<string | null>(activeSessionId);
const localActiveSessionIdRef = useRef(localActiveSessionId);
const setLocalActiveSessionId = (activeId: string | null) => {
localActiveSessionIdRef.current = activeId;
setLocalActiveSessionIdState(activeId);
};
const applyingExternalSessionsRef = useRef(false);
const chatListRef = useRef<HTMLDivElement>(null);
const snapshotRef = useRef(snapshot);
const pendingToolContextRef = useRef(new Map<string, PendingOnlineToolContext>());
const cinematicSessionControllersRef = useRef(new Map<string, AbortController>());
const generationConsumerControllerRef = useRef(new AbortController());
useEffect(() => {
if (!sessions.length) return;
if (sessions === localSessions && activeSessionId === localActiveSessionId) return;
applyingExternalSessionsRef.current = true;
localSessionsRef.current = sessions;
setLocalSessions(sessions);
setLocalActiveSessionId(activeSessionId);
}, [activeSessionId, sessions]);
@@ -186,10 +349,14 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
snapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => () => {
// 收起面板或刷新页面时只停止前端查询,后台任务由下次挂载根据持久化 ID 继续接管。
cinematicSessionControllersRef.current.forEach((controller) => controller.abort());
cinematicSessionControllersRef.current.clear();
useEffect(() => {
generationConsumerControllerRef.current = activeGenerationConsumerController(generationConsumerControllerRef.current);
return () => {
// 收起面板或刷新页面时只停止前端查询,后台任务由下次挂载根据持久化 ID 继续接管。
cinematicSessionControllersRef.current.forEach((controller) => controller.abort());
cinematicSessionControllersRef.current.clear();
generationConsumerControllerRef.current.abort();
};
}, []);
useEffect(() => {
@@ -225,7 +392,26 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}, [selectedNodeKey]);
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session)));
const next = localSessionsRef.current.map((session) => (session.id === sessionId ? updater(session) : session));
localSessionsRef.current = next;
setLocalSessions(next);
return next;
};
const readCinematicSessionState = (): CinematicContinuationLiveSessionState => ({ sessions: localSessionsRef.current, activeChatId: localActiveSessionIdRef.current });
const restoreCinematicSessions = (sessions: CanvasAssistantSession[], activeId: string | null) => {
localSessionsRef.current = sessions;
setLocalSessions(sessions);
setLocalActiveSessionId(activeId);
};
const restoreCinematicSnapshot = (state: Pick<CanvasAgentSnapshot, "nodes" | "connections">) => {
snapshotRef.current = { ...snapshotRef.current, nodes: state.nodes, connections: state.connections };
};
const hasAgentGenerationEffect = (sessionId: string, effectKey?: string) => {
const session = localSessionsRef.current.find((candidate) => candidate.id === sessionId);
return Boolean(session && generationEffectApplied(session, effectKey));
};
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
@@ -273,13 +459,13 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}));
};
const completeCinematicSession = (sessionId: string, backendSessionId: string, ops: CanvasAgentOp[], recovered = false) => {
updateSession(sessionId, (session) => {
const completeCinematicSession = (sessionId: string, backendSessionId: string, ops: CanvasAgentOp[], recovered = false, effectKey?: string) => {
return updateSession(sessionId, (session) => {
const pending = session.pendingBackendSession;
if (pending?.id !== backendSessionId) return session;
const completedAt = new Date().toISOString();
const summary = summarizeCanvasAgentOps(ops) || "影视项目已写回当前画布。";
return {
const completed = {
...session,
pendingBackendSession: undefined,
messages: upsertAssistantMessage(session.messages, {
@@ -291,6 +477,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}),
updatedAt: completedAt,
};
return effectKey ? applyGenerationConsumerEffect(completed, effectKey, (current) => current).value : completed;
});
};
@@ -344,7 +531,11 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
},
},
);
return { backendSessionId: detail.session.id, ops: requireOps(JSON.parse(cinematicAgentSessionOpsJson(detail))) };
return {
backendSessionId: detail.session.id,
ops: requireOps(JSON.parse(cinematicAgentSessionOpsJson(detail))),
continuationTask: [...detail.tasks].reverse().find((task) => task.status === "succeeded"),
};
} catch (error) {
if (backendSessionId && !isAgentSessionPollingAbort(error)) failCinematicSession(sessionId, backendSessionId, error);
throw error;
@@ -414,10 +605,17 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const messages = await buildToolAgentMessages(snapshotRef.current, history, userMessage);
addOnlineLog(`Agent Tool Loop ${loop.step} 开始`, { toolChoice: "required" });
let streamed = "";
const result = await requestToolResponse({ ...requestConfig, systemPrompt: "" }, messages, ONLINE_AGENT_TOOLS, "required", (text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
}, { promptCacheKey: canvasAgentPromptCacheKey(sessionId) });
const result = await requestToolResponse(
{ ...requestConfig, systemPrompt: "" },
messages,
ONLINE_AGENT_TOOLS,
"required",
(text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
},
{ promptCacheKey: canvasAgentPromptCacheKey(sessionId) },
);
addOnlineLog("模型工具回复", result);
if (result.toolCalls.length) {
const writableCalls = result.toolCalls.filter(isWritableToolCall);
@@ -425,7 +623,13 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: result.content || streamed || "准备执行工具,等待确认。" });
const toolMessageId = nanoid();
pendingToolContextRef.current.set(toolMessageId, { messages, toolCalls: result.toolCalls, assistantId, step: loop.step });
const toolMessage: CanvasAssistantMessage = { id: toolMessageId, role: "tool", title: "确认工具调用", text: summarizeToolCalls(result.toolCalls), detail: { status: "pending", step: loop.step, toolCalls: result.toolCalls, impact: previewOnlineToolCalls(result.toolCalls, snapshotRef.current, effectiveConfig) } };
const toolMessage: CanvasAssistantMessage = {
id: toolMessageId,
role: "tool",
title: "确认工具调用",
text: summarizeToolCalls(result.toolCalls),
detail: { status: "pending", step: loop.step, toolCalls: result.toolCalls, impact: previewOnlineToolCalls(result.toolCalls, snapshotRef.current, effectiveConfig) },
};
appendMessage(sessionId, toolMessage);
addOnlineLog("等待用户确认", result.toolCalls);
return;
@@ -437,6 +641,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
addOnlineLog(`Agent Tool Loop ${loop.step} 结束`, { reply: result.content });
}
} catch (error) {
if (isAgentSessionPollingAbort(error)) return;
addOnlineLog("请求失败", error instanceof Error ? error.message : error);
appendMessage(sessionId, { id: nanoid(), role: "error", title: "操作失败", text: error instanceof Error ? error.message : "操作失败" });
} finally {
@@ -458,11 +663,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
};
const continueOnlineToolLoopAfterResults = async (sessionId: string, assistantId: string, messages: ResponseInputMessage[], toolCalls: ResponseToolCall[], toolResults: OnlineExecutedToolCall[], step: number) => {
const nextMessages: ResponseInputMessage[] = [
...messages,
...toolCalls.map(toolCallToResponseInput),
...toolResults.map((item) => ({ role: "tool" as const, tool_call_id: item.toolCallId, content: JSON.stringify(item.result) })),
];
const nextMessages: ResponseInputMessage[] = [...messages, ...toolCalls.map(toolCallToResponseInput), ...toolResults.map((item) => ({ role: "tool" as const, tool_call_id: item.toolCallId, content: JSON.stringify(item.result) }))];
if (step >= ONLINE_AGENT_MAX_STEPS) {
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: toolResults.map((item) => toolResultText(item.result)).join("\n") || "工具已执行。" });
addOnlineLog("Agent Tool Loop 达到步数上限", { maxSteps: ONLINE_AGENT_MAX_STEPS });
@@ -470,10 +671,17 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}
const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model };
let streamed = "";
const next = await requestToolResponse({ ...requestConfig, systemPrompt: "" }, nextMessages, ONLINE_AGENT_TOOLS, "auto", (text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
}, { promptCacheKey: canvasAgentPromptCacheKey(sessionId) });
const next = await requestToolResponse(
{ ...requestConfig, systemPrompt: "" },
nextMessages,
ONLINE_AGENT_TOOLS,
"auto",
(text) => {
streamed = text;
if (text.trim()) upsertMessage(sessionId, { id: assistantId, role: "assistant", text });
},
{ promptCacheKey: canvasAgentPromptCacheKey(sessionId) },
);
addOnlineLog(`Agent Tool Loop ${step + 1} 回复`, next);
if (next.toolCalls.length) {
const writableCalls = next.toolCalls.filter(isWritableToolCall);
@@ -481,7 +689,13 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: next.content || streamed || "准备执行工具,等待确认。" });
const toolMessageId = nanoid();
pendingToolContextRef.current.set(toolMessageId, { messages: nextMessages, toolCalls: next.toolCalls, assistantId, step: step + 1 });
appendMessage(sessionId, { id: toolMessageId, role: "tool", title: "确认工具调用", text: summarizeToolCalls(next.toolCalls), detail: { status: "pending", step: step + 1, toolCalls: next.toolCalls, impact: previewOnlineToolCalls(next.toolCalls, snapshotRef.current, effectiveConfig) } });
appendMessage(sessionId, {
id: toolMessageId,
role: "tool",
title: "确认工具调用",
text: summarizeToolCalls(next.toolCalls),
detail: { status: "pending", step: step + 1, toolCalls: next.toolCalls, impact: previewOnlineToolCalls(next.toolCalls, snapshotRef.current, effectiveConfig) },
});
addOnlineLog("等待用户确认", next.toolCalls);
return;
}
@@ -491,10 +705,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: next.content || streamed || toolResults.map((item) => toolResultText(item.result)).join("\n") || "工具已执行。" });
};
const executeOps = (ops: CanvasAgentOp[]) => {
const executeOps = async (ops: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => {
const beforeSnapshot = snapshotRef.current;
const before = snapshotSignature(beforeSnapshot);
const next = onApplyOps(ops);
const next = await onApplyOps(ops, context);
snapshotRef.current = next;
const ranGeneration = ops.some((op) => op.type === "run_generation" && Boolean(op.nodeId));
const changed = before !== snapshotSignature(next) || ranGeneration;
@@ -502,7 +716,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
return { changed, ops, ranGeneration, noopReason, before: JSON.parse(before), after: JSON.parse(snapshotSignature(next)) };
};
const executeOnlineTool = async (sessionId: string, name: string, args: Record<string, unknown>): Promise<OnlineToolResult> => {
const executeOnlineTool = async (sessionId: string, name: string, args: Record<string, unknown>, messageId?: string): Promise<OnlineToolResult> => {
const current = snapshotRef.current;
try {
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
@@ -513,17 +727,32 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}
if (name === "canvas_create_cinematic_session") {
const cinematic = await runCinematicSession(sessionId, requireString(args.prompt, "prompt"), current, effectiveConfig);
try {
const result = executeOps(cinematic.ops);
completeCinematicSession(sessionId, cinematic.backendSessionId, cinematic.ops);
return { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(cinematic.ops) || "后端影视 Agent 已写回画布。" : result.noopReason, data: result };
} catch (error) {
failCinematicSession(sessionId, cinematic.backendSessionId, error);
throw error;
let continuationResult: OnlineToolResult | undefined;
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
if (hasAgentGenerationEffect(sessionId, effectKey)) return;
const result = await canvasCinematicContinuationEntryAdapters["online-tool"]({
projectId,
effectKey,
signal,
readSnapshot: () => snapshotRef.current,
executeOps: () => executeOps(cinematic.ops),
completeSession: (key) => completeCinematicSession(sessionId, cinematic.backendSessionId, cinematic.ops, false, key),
readLiveSessionState: readCinematicSessionState,
restoreLiveSessions: restoreCinematicSessions,
restoreLiveSnapshot: restoreCinematicSnapshot,
failProvider: (failure) => failCinematicSession(sessionId, cinematic.backendSessionId, failure),
});
continuationResult = { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(cinematic.ops) || "后端影视 Agent 已写回画布。" : result.noopReason, data: result };
};
if (cinematic.continuationTask) {
await consumeGenerationTaskAgent(cinematic.continuationTask, cinematic.backendSessionId, applyContinuation, { signal: generationConsumerControllerRef.current.signal });
} else {
await applyContinuation();
}
return continuationResult ?? { ok: true, message: "后端影视 Agent 已完成。" };
}
const ops = onlineToolToOps(name, args, current, effectiveConfig);
const result = executeOps(ops);
const result = await executeOps(ops, { source: "online", conversationId: sessionId, messageId: messageId || sessionId });
return { ok: result.changed, message: result.changed ? summarizeCanvasAgentOps(ops) || "画布操作已执行。" : result.noopReason, data: result };
} catch (error) {
if (isAgentSessionPollingAbort(error)) throw error;
@@ -533,7 +762,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const executeOnlineToolCall = async (sessionId: string, toolCall: ResponseToolCall): Promise<OnlineExecutedToolCall> => {
try {
const result = await executeOnlineTool(sessionId, toolCall.function.name, parseToolArguments(toolCall.function.arguments));
const result = await executeOnlineTool(sessionId, toolCall.function.name, parseToolArguments(toolCall.function.arguments), toolCall.id);
return { toolCallId: toolCall.id, name: toolCall.function.name, result };
} catch (error) {
if (isAgentSessionPollingAbort(error)) throw error;
@@ -578,6 +807,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
pendingToolContextRef.current.delete(messageId);
await continueOnlineToolLoopAfterResults(session.id, assistantId, previousMessages, toolCalls, results, pendingContext?.step || Number(detail.step) || 1);
} catch (error) {
if (isAgentSessionPollingAbort(error)) return;
addOnlineLog("工具续跑失败", error instanceof Error ? error.message : error);
appendMessage(session.id, { id: nanoid(), role: "error", title: "操作失败", text: error instanceof Error ? error.message : "操作失败" });
} finally {
@@ -630,18 +860,45 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
setPrompt("");
setIsRunning(true);
let backendSessionId = "";
let continuationFailureDisposition: CinematicContinuationFailureDisposition | undefined;
try {
const cinematic = await runCinematicSession(session.id, value, snapshotRef.current, effectiveConfig, (createdId) => {
backendSessionId = createdId;
});
const next = onApplyOps(cinematic.ops);
snapshotRef.current = next;
completeCinematicSession(session.id, cinematic.backendSessionId, cinematic.ops);
setCinematicEntryActive(false);
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
if (hasAgentGenerationEffect(session.id, effectKey)) return;
await canvasCinematicContinuationEntryAdapters["submit-cinematic"]({
projectId,
effectKey,
signal,
readSnapshot: () => snapshotRef.current,
executeOps: async () => {
const next = await onApplyOps(cinematic.ops);
snapshotRef.current = next;
return next;
},
completeSession: (key) => completeCinematicSession(session.id, cinematic.backendSessionId, cinematic.ops, false, key),
readLiveSessionState: readCinematicSessionState,
restoreLiveSessions: restoreCinematicSessions,
restoreLiveSnapshot: restoreCinematicSnapshot,
failProvider: (failure) => failCinematicSession(session.id, cinematic.backendSessionId, failure),
onFailureDisposition: (disposition) => {
continuationFailureDisposition = disposition;
},
});
setCinematicEntryActive(false);
};
if (cinematic.continuationTask) {
await consumeGenerationTaskAgent(cinematic.continuationTask, cinematic.backendSessionId, applyContinuation, { signal: generationConsumerControllerRef.current.signal });
} else {
await applyContinuation();
}
} catch (error) {
if (isAgentSessionPollingAbort(error)) return;
if (backendSessionId) failCinematicSession(session.id, backendSessionId, error);
else appendMessage(session.id, { id: nanoid(), role: "error", title: "影视项目生成失败", text: error instanceof Error ? error.message : "影视项目生成失败" });
if (continuationFailureDisposition) return;
handleCinematicContinuationFailure(error, (failure) => {
if (backendSessionId) failCinematicSession(session.id, backendSessionId, failure);
else appendMessage(session.id, { id: nanoid(), role: "error", title: "影视项目生成失败", text: failure instanceof Error ? failure.message : "影视项目生成失败" });
});
} finally {
setIsRunning(false);
}
@@ -653,17 +910,46 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
cinematicSessionControllersRef.current.set(pending.id, controller);
setIsRunning(true);
addOnlineLog("恢复后端影视 Agent 会话", { backendSessionId: pending.id });
let continuationFailureDisposition: CinematicContinuationFailureDisposition | undefined;
try {
const detail = await resumeCinematicAgentSession(pending.id, { signal: controller.signal });
const ops = requireOps(JSON.parse(cinematicAgentSessionOpsJson(detail)));
executeOps(ops);
completeCinematicSession(sessionId, pending.id, ops, true);
addOnlineLog("后端影视 Agent 会话恢复完成", { backendSessionId: pending.id });
} catch (error) {
if (!isAgentSessionPollingAbort(error)) {
failCinematicSession(sessionId, pending.id, error);
addOnlineLog("后端影视 Agent 会话恢复失败", error instanceof Error ? error.message : error);
const continuationTask = [...detail.tasks].reverse().find((task) => task.status === "succeeded");
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
if (hasAgentGenerationEffect(sessionId, effectKey)) return;
await canvasCinematicContinuationEntryAdapters["resume-cinematic"]({
projectId,
effectKey,
signal,
readSnapshot: () => snapshotRef.current,
executeOps: () => executeOps(ops),
completeSession: (key) => completeCinematicSession(sessionId, pending.id, ops, true, key),
readLiveSessionState: readCinematicSessionState,
restoreLiveSessions: restoreCinematicSessions,
restoreLiveSnapshot: restoreCinematicSnapshot,
failProvider: (failure) => {
failCinematicSession(sessionId, pending.id, failure);
addOnlineLog("后端影视 Agent 会话恢复失败", failure instanceof Error ? failure.message : failure);
},
onFailureDisposition: (disposition, error) => {
continuationFailureDisposition = disposition;
if (disposition === "durable-ack") addOnlineLog("后端影视 Agent 会话持久化失败,保留待恢复状态", error instanceof Error ? error.message : error);
},
});
addOnlineLog("后端影视 Agent 会话恢复完成", { backendSessionId: pending.id });
};
if (continuationTask) {
await consumeGenerationTaskAgent(continuationTask, pending.id, applyContinuation, { signal: controller.signal });
} else {
await applyContinuation();
}
} catch (error) {
if (continuationFailureDisposition) return;
const disposition = handleCinematicContinuationFailure(error, (failure) => {
failCinematicSession(sessionId, pending.id, failure);
addOnlineLog("后端影视 Agent 会话恢复失败", failure instanceof Error ? failure.message : failure);
});
if (disposition === "durable-ack") addOnlineLog("后端影视 Agent 会话持久化失败,保留待恢复状态", error instanceof Error ? error.message : error);
} finally {
if (cinematicSessionControllersRef.current.get(pending.id) === controller) cinematicSessionControllersRef.current.delete(pending.id);
if (cinematicSessionControllersRef.current.size === 0) setIsRunning(false);
@@ -702,7 +988,15 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
<>
{view === "history" ? (
<Tooltip title="删除全部">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8"
style={iconButtonStyle}
icon={<X className="size-4" />}
disabled={!historySessions.length}
onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))}
/>
</Tooltip>
) : null}
<Tooltip title="新对话">
@@ -738,7 +1032,12 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
onDelete={(id) => setDeleteChatIds([id])}
/>
) : view === "log" ? (
<OnlineAgentLogView logs={onlineLogs} theme={theme} context={{ model: activeModel, running: agentBusy, confirmTools, messages: messages.length, nodes: snapshot.nodes.length, connections: snapshot.connections.length }} onClear={() => setOnlineLogs([])} />
<OnlineAgentLogView
logs={onlineLogs}
theme={theme}
context={{ model: activeModel, running: agentBusy, confirmTools, messages: messages.length, nodes: snapshot.nodes.length, connections: snapshot.connections.length }}
onClear={() => setOnlineLogs([])}
/>
) : messages.length ? (
<>
{messages.map((message) => (
@@ -750,7 +1049,14 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={(text) => { setPrompt(text); void sendMessage(text, messages); }} />
<AgentChatEmptyState
theme={theme}
nodeCount={contextSummary.nodeCount}
onSelect={(text) => {
setPrompt(text);
void sendMessage(text, messages);
}}
/>
)}
</div>
)}
@@ -782,12 +1088,13 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
onAddFiles={addImagesToCanvas}
left={
<>
<VoiceRecordingButton
disabled={agentBusy}
onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))}
/>
<VoiceRecordingButton disabled={agentBusy} onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))} />
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => updateConfig("textModel", model)} />
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[var(--fs-tiny)] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>影视项目</span> : null}
{cinematicEntryActive ? (
<span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[var(--fs-tiny)] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>
影视项目
</span>
) : null}
</>
}
/>
@@ -834,32 +1141,20 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
boxShadow: `0 24px 72px ${theme.spatial.shadow}`,
}}
>
<AgentPanelChrome
theme={theme}
mode={agentMode}
context={contextSummary}
referenceCount={selectedReferences.length}
confirmTools={confirmTools}
canUndo={agentMode === "online" && canUndoOps}
undoCount={agentMode === "online" ? undoOpsCount : 0}
onModeChange={onAgentModeChange}
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
onUndo={undoLastOnlineBatch}
onCollapse={collapse}
/>
{agentMode === "local" ? (
<CanvasLocalAgentPanel
embedded
snapshot={snapshot}
canUndoOps={canUndoOps}
undoOpsCount={undoOpsCount}
onApplyOps={onApplyOps}
onUndoOps={onUndoOps}
autoConnect={autoConnectLocal}
/>
) : (
onlineContent
)}
<AgentPanelChrome
theme={theme}
mode={agentMode}
context={contextSummary}
referenceCount={selectedReferences.length}
confirmTools={confirmTools}
canUndo={agentMode === "online" && canUndoOps}
undoCount={agentMode === "online" ? undoOpsCount : 0}
onModeChange={onAgentModeChange}
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
onUndo={undoLastOnlineBatch}
onCollapse={collapse}
/>
{agentMode === "local" ? <CanvasLocalAgentPanel embedded snapshot={snapshot} canUndoOps={canUndoOps} undoOpsCount={undoOpsCount} onApplyOps={onApplyOps} onUndoOps={onUndoOps} autoConnect={autoConnectLocal} /> : onlineContent}
</motion.aside>
);
}
@@ -879,9 +1174,21 @@ function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; v
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
optionRender={(option) => {
const model = String(option.value);
return <span className="flex min-w-0 items-center gap-2"><AgentModelIcon model={model} /><span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span><span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span></span>;
return (
<span className="flex min-w-0 items-center gap-2">
<AgentModelIcon model={model} />
<span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span>
<span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span>
</span>
);
}}
labelRender={() => <span className="flex min-w-0 items-center gap-1.5"><AgentModelIcon model={current} /><span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}</span>}
labelRender={() => (
<span className="flex min-w-0 items-center gap-1.5">
<AgentModelIcon model={current} />
<span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>
{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}
</span>
)}
onChange={onChange}
aria-label="选择 Agent 文本模型"
title={current ? `${modelDisplayName(config, current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
@@ -899,16 +1206,7 @@ function resolveModelIcon(model: string) {
// 与 model-picker 保持同一套厂商图标规则。
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
if (
name.includes("gemini") ||
name.includes("google") ||
name.includes("nano banana") ||
name.includes("nanobanana") ||
name.includes("imagen") ||
name.includes("veo") ||
name.includes("omni flash") ||
name.includes("omni-flash")
) {
if (name.includes("gemini") || name.includes("google") || name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen") || name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) {
return "/icons/gemini.svg";
}
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
@@ -918,17 +1216,7 @@ function resolveModelIcon(model: string) {
return "";
}
function AssistantHistory({
sessions,
activeSession,
onOpen,
onDelete,
}: {
sessions: CanvasAssistantSession[];
activeSession: CanvasAssistantSession | null;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
}) {
function AssistantHistory({ sessions, activeSession, onOpen, onDelete }: { sessions: CanvasAssistantSession[]; activeSession: CanvasAssistantSession | null; onOpen: (id: string) => void; onDelete: (id: string) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
@@ -941,7 +1229,11 @@ function AssistantHistory({
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{session.id === activeSession?.id ? <span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
{session.id === activeSession?.id ? (
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>
当前
</span>
) : null}
<div className="truncate text-sm font-medium leading-5">{session.title}</div>
</div>
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{sessionPreview(session)}</div>
@@ -1008,12 +1300,28 @@ function OnlineAgentLogView({ logs, theme, context, onClear }: { logs: OnlineAge
return (
<div className="flex min-h-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
<Segmented
size="small"
value={mode}
onChange={(value) => setMode(value as "text" | "json")}
options={[
{ label: "排查日志", value: "text" },
{ label: "原始 JSON", value: "json" },
]}
/>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} 条</span>
<Button size="small" icon={<Copy className="size-3.5" />} disabled={!logs.length} onClick={() => void copy()}>复制</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatOnlineLogText([lastError], context))}>最近错误</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>清空</Button>
<span className="text-xs" style={{ color: theme.node.muted }}>
{logs.length} 条
</span>
<Button size="small" icon={<Copy className="size-3.5" />} disabled={!logs.length} onClick={() => void copy()}>
复制
</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatOnlineLogText([lastError], context))}>
最近错误
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
清空
</Button>
</div>
</div>
<textarea
@@ -1131,7 +1439,7 @@ function parseToolArguments(value: string) {
}
}
function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
export function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
if (name === "canvas_apply_ops") return requireOps(input.ops);
if (name === "canvas_create_node") {
const nodeType = requireNodeType(input.nodeType);
@@ -1146,7 +1454,13 @@ function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot:
const y = numberOr(input.y, 0);
const gap = numberOr(input.gap, 40);
const direction = input.direction === "row" ? "row" : "column";
return items.map((item, index) => textNodeOp({ ...item, text: requireString(item.text, "text") }, numberOr(item.x, direction === "row" ? x + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + gap) : x), numberOr(item.y, direction === "row" ? y : y + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].height + gap))));
return items.map((item, index) =>
textNodeOp(
{ ...item, text: requireString(item.text, "text") },
numberOr(item.x, direction === "row" ? x + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + gap) : x),
numberOr(item.y, direction === "row" ? y : y + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].height + gap)),
),
);
}
if (name === "canvas_create_image_prompt_flow") return generationFlowOps({ ...input, mode: "image" }, snapshot, config);
if (name === "canvas_create_generation_flow") return generationFlowOps(input, snapshot, config);
@@ -1155,7 +1469,8 @@ function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot:
if (name === "canvas_generate_video") return generationFlowOps({ ...input, mode: "video", autoRun: true }, snapshot, config);
if (name === "canvas_generate_audio") return generationFlowOps({ ...input, mode: "audio", autoRun: true }, snapshot, config);
if (name === "canvas_update_node") return [{ type: "update_node", id: requireString(input.id, "id"), patch: recordOptional(input.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
if (name === "canvas_update_node_text") return [{ type: "update_node", id: requireString(input.id, "id"), patch: stringOptional(input.title) ? { title: stringOptional(input.title) } : undefined, metadata: { content: requireString(input.text, "text"), status: "success" } }];
if (name === "canvas_update_node_text")
return [{ type: "update_node", id: requireString(input.id, "id"), patch: stringOptional(input.title) ? { title: stringOptional(input.title) } : undefined, metadata: { content: requireString(input.text, "text"), status: "success" } }];
if (name === "canvas_move_nodes") {
return requireRecordArray(input.items, "items").map((item) => {
const id = requireString(item.id, "id");
@@ -1163,12 +1478,21 @@ function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot:
return { type: "update_node", id, patch: { position: { x: numberOr(item.x, (current?.position.x || 0) + numberOr(item.dx, 0)), y: numberOr(item.y, (current?.position.y || 0) + numberOr(item.dy, 0)) } } };
});
}
if (name === "canvas_resize_node") return [{ type: "update_node", id: requireString(input.id, "id"), patch: { width: requireNumber(input.width, "width"), height: requireNumber(input.height, "height") }, metadata: typeof input.freeResize === "boolean" ? { freeResize: input.freeResize } : undefined }];
if (name === "canvas_resize_node")
return [
{
type: "update_node",
id: requireString(input.id, "id"),
patch: { width: requireNumber(input.width, "width"), height: requireNumber(input.height, "height") },
metadata: typeof input.freeResize === "boolean" ? { freeResize: input.freeResize } : undefined,
},
];
if (name === "canvas_delete_nodes") return [{ type: "delete_node", ids: requireStringArray(input.ids, "ids") }];
if (name === "canvas_connect_nodes") return requireRecordArray(input.connections, "connections").map((connection) => ({ type: "connect_nodes", fromNodeId: requireString(connection.fromNodeId, "fromNodeId"), toNodeId: requireString(connection.toNodeId, "toNodeId") }));
if (name === "canvas_connect_nodes")
return requireRecordArray(input.connections, "connections").map((connection) => ({ type: "connect_nodes", fromNodeId: requireString(connection.fromNodeId, "fromNodeId"), toNodeId: requireString(connection.toNodeId, "toNodeId") }));
if (name === "canvas_select_nodes") return [{ type: "select_nodes", ids: requireStringArray(input.ids, "ids") }];
if (name === "canvas_set_viewport") return [{ type: "set_viewport", viewport: requireViewport(input.viewport) }];
if (name === "canvas_run_generation") return [runGenerationOp(requireString(input.nodeId, "nodeId"), generationMode(input.mode), stringOptional(input.prompt))];
if (name === "canvas_run_generation") return [runGenerationOp(requireString(input.nodeId, "nodeId"), generationMode(input.mode), stringOptional(input.prompt), input.retry === true)];
throw new Error(`不支持的工具:${name}`);
}
@@ -1192,7 +1516,16 @@ function generationFlowOps(input: Record<string, unknown>, snapshot: CanvasAgent
}
function textNodeOp(input: Record<string, unknown>, x: number, y: number): CanvasAgentOp {
return { type: "add_node", id: stringOptional(input.id), nodeType: CanvasNodeType.Text, title: stringOptional(input.title), position: { x, y }, width: numberOptional(input.width), height: numberOptional(input.height), metadata: { content: stringOptional(input.text), status: "success", fontSize: 14 } };
return {
type: "add_node",
id: stringOptional(input.id),
nodeType: CanvasNodeType.Text,
title: stringOptional(input.title),
position: { x, y },
width: numberOptional(input.width),
height: numberOptional(input.height),
metadata: { content: stringOptional(input.text), status: "success", fontSize: 14 },
};
}
function generationTargetNodeOp(id: string, input: Record<string, unknown>, x: number, y: number, config: AiConfig): CanvasAgentOp {
@@ -1238,8 +1571,8 @@ function generationNodeType(mode: "text" | "image" | "video" | "audio") {
return CanvasNodeType.Image;
}
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string): CanvasAgentOp {
return { type: "run_generation", nodeId, mode, prompt };
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string, retry?: boolean): CanvasAgentOp {
return { type: "run_generation", nodeId, mode, prompt, ...(retry ? { retry: true } : {}) };
}
function isWritableToolCall(call: ResponseToolCall) {
@@ -1353,7 +1686,7 @@ function toCanvasAgentOp(value: unknown): CanvasAgentOp {
if (type === "connect_nodes") return { type, id: stringOptional(item.id), fromNodeId: requireString(item.fromNodeId, "fromNodeId"), toNodeId: requireString(item.toNodeId, "toNodeId") };
if (type === "set_viewport") return { type, viewport: requireViewport(item.viewport) };
if (type === "select_nodes") return { type, ids: requireStringArray(item.ids, "ids") };
if (type === "run_generation") return { type, nodeId: requireString(item.nodeId, "nodeId"), mode: generationMode(item.mode), prompt: stringOptional(item.prompt) };
if (type === "run_generation") return { type, nodeId: requireString(item.nodeId, "nodeId"), mode: generationMode(item.mode), prompt: stringOptional(item.prompt), ...(item.retry === true ? { retry: true } : {}) };
throw new Error("不支持的画布操作类型");
}
@@ -1552,6 +1885,7 @@ function backendAgentProviderConfig(config: ReturnType<typeof resolveModelReques
apiFormat: config.apiFormat,
interfaceType: config.interfaceType,
baseUrl: config.baseUrl,
allowLocalChannel: config.allowLocalChannel === true,
apiKey: config.apiKey,
secretKey: config.secretKey,
model: config.model,

View File

@@ -1,12 +1,13 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useSearchParams } from "react-router";
import { App, Button, Input, Segmented, Tooltip } from "antd";
import { memo, useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Button, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { Copy, FolderOpen, History, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
import { consumeLocalRuntimeEventStream, postCanvasRuntimeState, prepareCanvasRuntimeConnection, waitForCanvasRuntimeReconnect, type LocalRuntimeEvent } from "@/lib/canvas/local-runtime-connection";
import { createClientId } from "@/lib/client-id";
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/canvas/use-canvas-agent-store";
@@ -19,11 +20,10 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_CONNECT_STEPS = [
{ title: "从仓库安装插件", text: "插件暂未上架公共目录,请按项目 README 添加仓库 marketplace;安装后新建 Codex 对话。" },
{ title: "打开画布连接", text: "回到这里点击连接,网页会自动读取本机 Agent 配置。" },
{ title: "手动启动备用", text: "如果自动发现失败,请按插件说明启动本地 Agent 后再回到这里连接。" },
{ title: "自动连接本机 Runtime", text: "首次使用时,已信任的 Framefield 网页会自动建立当前浏览器的本机安全会话。" },
{ title: "打开画布连接", text: "回到这里点击连接,画布会复用同一个本机 Runtime 安全会话。" },
];
type AgentEventPayload = {
@@ -37,18 +37,59 @@ type AgentEventPayload = {
};
type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
type AgentLogContext = { connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snapshot, canUndoOps, undoOpsCount = 0, collapsed, embedded, headless, autoConnect, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; undoOpsCount?: number; collapsed?: boolean; embedded?: boolean; headless?: boolean; autoConnect?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) {
export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
snapshot,
canUndoOps,
undoOpsCount = 0,
collapsed,
embedded,
headless,
autoConnect,
onApplyOps,
onUndoOps,
}: {
snapshot: CanvasAgentSnapshot;
canUndoOps: boolean;
undoOpsCount?: number;
collapsed?: boolean;
embedded?: boolean;
headless?: boolean;
autoConnect?: boolean;
onApplyOps: (ops: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise<CanvasAgentSnapshot>;
onUndoOps: () => CanvasAgentSnapshot | null;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const { message, modal } = App.useApp();
const [searchParams] = useSearchParams();
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore();
const {
width,
connected,
enabled,
prompt,
attachments,
sending,
waiting,
messages,
eventLogs,
threads,
activeThreadId,
workspacePath,
loadingThreads,
activeTab,
confirmTools,
activity,
connectError,
pendingTool,
setAgentState,
addMessage: pushMessage,
addEventLog: pushEventLog,
clearEventLogs,
} = useCanvasAgentStore();
const [resizing, setResizing] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const snapshotRef = useRef(snapshot);
@@ -60,14 +101,28 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(createClientId());
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const connectionControllerRef = useRef<AbortController | null>(null);
const activeToolRequestIdsRef = useRef(new Set<string>());
const recoveredToolResultIdsRef = useRef(new Set<string>());
const syncState = useCallback(
(clientId: string, nextSnapshot: CanvasAgentSnapshot) => {
void postCanvasRuntimeState(getLocalRuntimeSessionClient(), clientId, nextSnapshot).catch(() => {
pushEventLog({
id: `${Date.now()}-${Math.random()}`,
time: new Date().toLocaleTimeString(),
title: "状态同步失败",
text: "本机 Runtime 暂未接收画布状态",
});
});
},
[pushEventLog],
);
const loadThreads = useCallback(async () => {
const projectId = snapshotRef.current.projectId;
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`);
const data = await fetchAgentJson<AgentThreadsResponse>(`/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`);
const current = useCanvasAgentStore.getState();
setAgentState({
threads: data.data || [],
@@ -76,7 +131,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
});
const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId;
if (nextThreadId && !current.messages.length) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
const thread = await fetchAgentJson<AgentThreadResponse>(`/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
}
} catch (error) {
@@ -84,11 +139,28 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
} finally {
setAgentState({ loadingThreads: false });
}
}, [endpoint, setAgentState, token]);
}, [setAgentState]);
useEffect(() => {
snapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
if (!connected) return;
const clientId = clientIdRef.current;
snapshot.nodes.forEach((node) => {
const continuation = node.metadata?.agentGenerationContinuation;
const requestId = continuation?.source === "local" && continuation.status === "completed" ? continuation.messageId : undefined;
if (!requestId || activeToolRequestIdsRef.current.has(requestId) || recoveredToolResultIdsRef.current.has(requestId)) return;
recoveredToolResultIdsRef.current.add(requestId);
void postToolResult(clientId, { requestId, result: snapshot })
.then(() => {
syncState(clientId, snapshot);
})
.catch(() => {
recoveredToolResultIdsRef.current.delete(requestId);
});
});
}, [connected, snapshot, syncState]);
useEffect(() => {
confirmToolsRef.current = confirmTools;
}, [confirmTools]);
@@ -106,61 +178,76 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []);
useEffect(() => {
if (!enabled || !token.trim()) return;
localStorage.setItem("canvas-agent-url", endpoint);
localStorage.setItem("canvas-agent-token", token);
if (!enabled) return;
const controller = new AbortController();
connectionControllerRef.current = controller;
const clientId = clientIdRef.current;
const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`);
source.addEventListener("hello", () => {
errorLoggedRef.current = false;
connectedRef.current = true;
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, snapshotRef.current);
});
source.addEventListener("tool_call", (event) => {
const data = parseEventData<AgentPendingToolCall>(event);
if (data) void handleToolCall(endpoint, token, data);
});
source.addEventListener("agent_event", (event) => {
const data = parseEventData<AgentEventPayload>(event);
if (data) handleAgentEvent(data);
});
source.addEventListener("agent_log", (event) => {
const text = parseEventData<{ text?: unknown }>(event)?.text;
addEventLog("日志", text, text);
});
source.addEventListener("agent_error", (event) => {
const message = parseEventData<{ message?: unknown }>(event)?.message;
setAgentState({ activity: "出错", waiting: false });
addMessage({ role: "error", title: "错误", text: normalizeText(message) });
addEventLog("错误", message, message);
});
source.addEventListener("agent_done", () => {
setAgentState({ activity: "完成", waiting: false, sending: false });
void loadThreads();
});
source.onerror = () => {
const wasConnected = connectedRef.current;
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token";
if (!errorLoggedRef.current || wasConnected) {
addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text });
if (!headless) message.error(text);
let lastEventId = "";
const receive = (event: LocalRuntimeEvent) => {
if (event.id) lastEventId = event.id;
if (event.type === "hello") {
errorLoggedRef.current = false;
connectedRef.current = true;
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
syncState(clientId, snapshotRef.current);
return;
}
errorLoggedRef.current = true;
connectedRef.current = false;
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: text });
if (!wasConnected) {
source.close();
setAgentState({ enabled: false });
if (event.type === "tool_call") {
const data = parseEventJson<AgentPendingToolCall>(event.data);
if (data) void handleToolCall(data);
return;
}
if (event.type === "agent_event") {
const data = parseEventJson<AgentEventPayload>(event.data);
if (data) handleAgentEvent(data);
return;
}
if (event.type === "agent_log") {
const text = parseEventJson<{ text?: unknown }>(event.data)?.text;
addEventLog("日志", text, text);
return;
}
if (event.type === "agent_error") {
const errorMessage = parseEventJson<{ message?: unknown }>(event.data)?.message;
setAgentState({ activity: "出错", waiting: false });
addMessage({ role: "error", title: "错误", text: normalizeText(errorMessage) });
addEventLog("错误", errorMessage, errorMessage);
return;
}
if (event.type === "agent_done") {
setAgentState({ activity: "完成", waiting: false, sending: false });
void loadThreads();
}
};
void (async () => {
while (!controller.signal.aborted) {
try {
await prepareCanvasRuntimeConnection(useLocalRuntimeStore, controller.signal);
await consumeLocalRuntimeEventStream(getLocalRuntimeSessionClient(), `/events?clientId=${encodeURIComponent(clientId)}`, { signal: controller.signal, lastEventId, onEvent: receive });
if (!controller.signal.aborted) throw new Error("Canvas stream closed");
} catch (error) {
if (controller.signal.aborted) return;
const wasConnected = connectedRef.current;
const text = wasConnected ? "本地 Agent 连接已断开,正在重连" : "本地 Agent 连接失败,请检查本机 Runtime";
if (!errorLoggedRef.current || wasConnected) {
addEventLog(wasConnected ? "连接断开" : "连接失败", text);
if (!headless) message.error(text);
}
errorLoggedRef.current = true;
connectedRef.current = false;
clearAgentSession({ activity: wasConnected ? "正在重连" : "连接失败", connected: false, connectError: text });
await waitForCanvasRuntimeReconnect(controller.signal);
}
}
})();
return () => {
source.close();
controller.abort();
if (connectionControllerRef.current === controller) connectionControllerRef.current = null;
connectedRef.current = false;
setAgentState({ connected: false });
};
}, [enabled, endpoint, loadThreads, message, setAgentState, token]);
}, [enabled, loadThreads, message, setAgentState, syncState]);
useEffect(() => {
if (connected) void loadThreads();
@@ -168,9 +255,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
useEffect(() => {
if (!connected) return;
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, snapshot), 300);
const timer = setTimeout(() => syncState(clientIdRef.current, snapshot), 300);
return () => clearTimeout(timer);
}, [connected, endpoint, snapshot, token]);
}, [connected, snapshot, syncState]);
const sendPrompt = async (overrideText?: string) => {
const text = (overrideText ?? prompt).trim();
@@ -185,11 +272,13 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
try {
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
const data = (await res.json()) as { threadId?: string };
const data = await fetchAgentJson<{ threadId?: string }>("/agent/codex/turn", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }),
});
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
addEventLog("本地 Agent 已接收", { status: res.status });
addEventLog("本地 Agent 已接收", { accepted: true });
files.forEach((item) => {
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);
@@ -209,12 +298,14 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
const prev = useCanvasAgentStore.getState().attachments;
try {
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
const url = URL.createObjectURL(file);
attachmentUrlsRef.current.add(url);
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
}));
const next = await Promise.all(
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
const url = URL.createObjectURL(file);
attachmentUrlsRef.current.add(url);
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
}),
);
const merged = [...prev, ...next];
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
next.forEach((item) => {
@@ -239,10 +330,10 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
setAgentState({ attachments: attachments.filter((item) => item.id !== id) });
};
const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
const handleToolCall = async (payload: AgentPendingToolCall) => {
if (confirmToolsRef.current && (payload.name === "canvas_apply_ops" || (isProjectAgentToolName(payload.name) && !isProjectAgentReadTool(payload.name)))) {
if (pendingToolRef.current) {
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" });
await postToolResult(clientIdRef.current, { requestId: payload.requestId, error: "仍有待确认的画布工具调用" });
return;
}
pendingToolRef.current = payload;
@@ -250,32 +341,45 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
addEventLog("等待确认", payload, payload);
return;
}
await runToolCall(endpoint, token, payload);
await runToolCall(payload);
};
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
try {
const input = (payload.input || {}) as Record<string, unknown>;
const projectToolName = isProjectAgentToolName(payload.name) ? payload.name : null;
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : projectToolName ? "执行项目工具" : "读取画布", waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = payload.name === "canvas_apply_ops" ? onApplyOpsRef.current((input.ops || []) as CanvasAgentOp[]) : projectToolName ? await runProjectAgentTool(projectToolName, input, snapshotRef.current.domainProjectId) : snapshotRef.current;
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
if (payload.name === "canvas_apply_ops") void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
const runToolCall = async (payload: AgentPendingToolCall) => {
activeToolRequestIdsRef.current.add(payload.requestId);
try {
const input = (payload.input || {}) as Record<string, unknown>;
const projectToolName = isProjectAgentToolName(payload.name) ? payload.name : null;
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : projectToolName ? "执行项目工具" : "读取画布", waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result =
payload.name === "canvas_apply_ops"
? await onApplyOpsRef.current((input.ops || []) as CanvasAgentOp[], { source: "local", conversationId: activeThreadId || clientIdRef.current, messageId: payload.requestId })
: projectToolName
? await runProjectAgentTool(projectToolName, input, snapshotRef.current.domainProjectId)
: snapshotRef.current;
await postToolResult(clientIdRef.current, { requestId: payload.requestId, result });
if (payload.name === "canvas_apply_ops") syncState(clientIdRef.current, result as CanvasAgentSnapshot);
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps((input.ops || []) as CanvasAgentOp[]) || "画布操作" : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
addMessage({
role: "tool",
title: `${toolName(payload.name)}完成`,
text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps((input.ops || []) as CanvasAgentOp[]) || "画布操作" : "已完成",
detail: { requestId: payload.requestId, name: payload.name, input, result },
});
} catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败";
setAgentState({ activity: "工具失败", waiting: false });
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
await postToolResult(clientIdRef.current, { requestId: payload.requestId, error: message });
} finally {
activeToolRequestIdsRef.current.delete(payload.requestId);
}
};
const rejectPendingTool = async () => {
if (!pendingTool) return;
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" });
await postToolResult(clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" });
setAgentState({ activity: "已取消", waiting: false });
addMessage({ role: "tool", title: "拒绝执行", text: toolName(pendingTool.name), detail: { requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input } });
pendingToolRef.current = null;
@@ -287,7 +391,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
const tool = pendingTool;
pendingToolRef.current = null;
setAgentState({ pendingTool: null });
await runToolCall(endpoint, token, tool);
await runToolCall(tool);
};
const undoLastTool = () => {
@@ -295,48 +399,33 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
if (!restored) return;
setAgentState({ activity: "已撤销" });
addMessage({ role: "tool", title: "已撤销 Agent 批次", text: "已恢复到本次写回前的画布状态", detail: restored });
if (connected) void postState(endpoint, token, clientIdRef.current, restored);
if (connected) syncState(clientIdRef.current, restored);
};
const toggleAgentConnection = async () => {
if (enabled) {
connectionControllerRef.current?.abort();
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
return;
}
const urlToken = searchParams.get("agentToken") || "";
const urlEndpoint = searchParams.get("agentUrl") || "";
const discovered = urlToken ? null : await discoverAgentConfig(endpoint || DEFAULT_AGENT_URL);
const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, "");
const nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
if (!nextEndpoint) {
const text = "请填写本地 Agent 地址";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
return;
}
if (!nextToken) {
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
return;
}
const controller = new AbortController();
connectionControllerRef.current = controller;
setAgentState({ connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
try {
const parsed = new URL(nextEndpoint);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
} catch {
const text = "本地 Agent 地址格式不正确";
await prepareCanvasRuntimeConnection(useLocalRuntimeStore, controller.signal);
if (controller.signal.aborted) return;
errorLoggedRef.current = false;
setAgentState({ enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
} catch (error) {
if (controller.signal.aborted) return;
const text = error instanceof Error ? error.message : "本机 Runtime 连接失败";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
return;
} finally {
if (connectionControllerRef.current === controller) connectionControllerRef.current = null;
}
errorLoggedRef.current = false;
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
};
useEffect(() => {
if (urlAgentAutoConnect && confirmTools) setAgentState({ confirmTools: false });
}, [confirmTools, setAgentState, urlAgentAutoConnect]);
useEffect(() => {
if (!autoConnect || autoConnectRef.current || enabled || connected) return;
autoConnectRef.current = true;
@@ -363,7 +452,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
if (!connected || !projectId) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const data = await fetchAgentJson<AgentThreadResponse>("/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" });
await loadThreads();
} catch (error) {
@@ -379,7 +468,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
if (!connected || !projectId || !threadId) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const data = await fetchAgentJson<AgentThreadResponse>(`/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" });
await loadThreads();
} catch (error) {
@@ -395,7 +484,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
if (!connected || !projectId || !threadId) return;
setAgentState({ loadingThreads: true });
try {
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
await fetchAgentJson(`/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const current = useCanvasAgentStore.getState();
setAgentState({
threads: current.threads.filter((thread) => thread.id !== threadId),
@@ -451,7 +540,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) {
setAgentState({ messages: currentMessages.map((message, i) => i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message) });
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
return;
}
}
@@ -508,18 +597,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
/>
{activeTab === "setup" ? (
<AgentConnectView
theme={theme}
url={url}
token={token}
enabled={enabled}
connected={connected}
activity={activity}
connectError={connectError}
onUrlChange={(url) => setAgentState({ url, connectError: "" })}
onTokenChange={(token) => setAgentState({ token, connectError: "" })}
onToggleEnabled={toggleAgentConnection}
/>
<AgentConnectView theme={theme} enabled={enabled} connected={connected} activity={activity} connectError={connectError} onToggleEnabled={toggleAgentConnection} />
) : activeTab === "history" ? (
<AgentHistoryView
theme={theme}
@@ -537,7 +615,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
<AgentLogView
logs={eventLogs}
theme={theme}
context={{ endpoint, connected, enabled, activity, waiting, sending, messages: messages.length, pendingTool: pendingTool?.name }}
context={{ connected, enabled, activity, waiting, sending, messages: messages.length, pendingTool: pendingTool?.name }}
onClear={clearEventLogs}
onCopied={(text) => message.success(text)}
onCopyBlocked={(text) => message.warning(text)}
@@ -545,11 +623,28 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
) : (
<>
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(text) => { setAgentState({ prompt: text }); void sendPrompt(text); }} /> : null}
{!messages.length && !pendingTool && !waiting ? (
<AgentChatEmptyState
theme={theme}
nodeCount={snapshot.nodes.length}
onSelect={(text) => {
setAgentState({ prompt: text });
void sendPrompt(text);
}}
/>
) : null}
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} isStreaming={(sending || waiting) && item.id === messages.at(-1)?.id && item.role === "assistant"} />
))}
{pendingTool ? <AgentPendingToolCard summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)} detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input, impact: previewCanvasAgentOps(pendingTool.input?.ops || [], snapshot) }} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
{pendingTool ? (
<AgentPendingToolCard
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input, impact: previewCanvasAgentOps(pendingTool.input?.ops || [], snapshot) }}
theme={theme}
onReject={rejectPendingTool}
onApprove={approvePendingTool}
/>
) : null}
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
<AgentChatComposer
@@ -565,11 +660,12 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
onRemoveAttachment={removeAttachment}
left={
<>
<VoiceRecordingButton
disabled={!connected || sending || waiting}
onTranscribed={(text) => setAgentState({ prompt: prompt.trim() ? `${prompt} ${text}` : text })}
/>
{attachments.length ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
<VoiceRecordingButton disabled={!connected || sending || waiting} onTranscribed={(text) => setAgentState({ prompt: prompt.trim() ? `${prompt} ${text}` : text })} />
{attachments.length ? (
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
</span>
) : null}
</>
}
/>
@@ -603,7 +699,21 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
);
});
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
function AgentLogView({
logs,
theme,
context,
onClear,
onCopied,
onCopyBlocked,
}: {
logs: AgentEventLog[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
context: AgentLogContext;
onClear: () => void;
onCopied: (text: string) => void;
onCopyBlocked: (text: string) => void;
}) {
const [mode, setMode] = useState<"text" | "json">("text");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
@@ -624,12 +734,28 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
<div className="text-base font-semibold leading-6">运行日志</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
<Segmented
size="small"
value={mode}
onChange={(value) => setMode(value as "text" | "json")}
options={[
{ label: "排查日志", value: "text" },
{ label: "原始 JSON", value: "json" },
]}
/>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} 条</span>
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>复制</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>最近错误</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>清空</Button>
<span className="text-xs" style={{ color: theme.node.muted }}>
{logs.length} 条
</span>
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
复制
</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
最近错误
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
清空
</Button>
</div>
</div>
<textarea
@@ -645,8 +771,21 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
);
}
function AgentConnectView({ theme, url, token, enabled, connected, activity, connectError, onUrlChange, onTokenChange, onToggleEnabled }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; url: string; token: string; enabled: boolean; connected: boolean; activity: string; connectError: string; onUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onToggleEnabled: () => void }) {
const { message } = App.useApp();
function AgentConnectView({
theme,
enabled,
connected,
activity,
connectError,
onToggleEnabled,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
enabled: boolean;
connected: boolean;
activity: string;
connectError: string;
onToggleEnabled: () => void;
}) {
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
return (
@@ -662,7 +801,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
{AGENT_CONNECT_STEPS.map((step) => (
<div key={step.title} className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
{step.text}
</div>
</div>
))}
</div>
@@ -677,7 +818,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
</span>
</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
默认自动读取 Local URL 和 Connect token,失败时再手动填写。
自动复用“设置 → 本机工具”建立的短期安全会话。
</div>
</div>
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
@@ -685,22 +826,6 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
</Button>
</div>
<div className="mt-3 grid gap-2.5">
<label className="grid gap-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
<Link2 className="size-3.5" />
本地地址
<span className="font-normal opacity-70">Local URL</span>
</span>
<Input size="large" prefix={<Link2 className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={url} onChange={(event) => onUrlChange(event.target.value)} placeholder="例如 http://127.0.0.1:17371" />
</label>
<label className="grid gap-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: theme.node.muted }}>
<KeyRound className="size-3.5" />
连接 Token
<span className="font-normal opacity-70">Connect token</span>
</span>
<Input.Password size="large" prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={token} onChange={(event) => onTokenChange(event.target.value)} placeholder="自动发现,或手动填入 Connect token" />
</label>
{connectError ? (
<div className="rounded-md px-2.5 py-2 text-xs leading-5" style={{ background: "rgba(220,38,38,.08)", color: "#dc2626" }}>
{connectError}
@@ -713,14 +838,38 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
);
}
function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loading, connected, onRefresh, onNewThread, onResumeThread, onDeleteThread }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; threads: AgentThreadSummary[]; activeThreadId: string; workspacePath: string; loading: boolean; connected: boolean; onRefresh: () => void; onNewThread: () => void; onResumeThread: (threadId: string) => void; onDeleteThread: (thread: AgentThreadSummary) => void }) {
function AgentHistoryView({
theme,
threads,
activeThreadId,
workspacePath,
loading,
connected,
onRefresh,
onNewThread,
onResumeThread,
onDeleteThread,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
threads: AgentThreadSummary[];
activeThreadId: string;
workspacePath: string;
loading: boolean;
connected: boolean;
onRefresh: () => void;
onNewThread: () => void;
onResumeThread: (threadId: string) => void;
onDeleteThread: (thread: AgentThreadSummary) => void;
}) {
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
<FolderOpen className="size-3.5 shrink-0" />
<span className="shrink-0">工作空间</span>
<span className="min-w-0 truncate" title={workspacePath}>{workspacePath || "默认画布目录"}</span>
<span className="min-w-0 truncate" title={workspacePath}>
{workspacePath || "默认画布目录"}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm" style={{ color: theme.node.muted }}>
@@ -743,7 +892,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{active ? <span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
{active ? (
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>
当前
</span>
) : null}
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
</div>
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{thread.preview || thread.id}</div>
@@ -772,14 +925,9 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
);
}
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot) {
try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot) });
} catch {}
}
async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
async function postToolResult(clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
const response = await getLocalRuntimeSessionClient().request(`/canvas/result?clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
if (!response.ok) throw new Error("Canvas Agent 工具结果写回失败");
}
function agentMessageToChatMessage(item: AgentChatItem) {
@@ -800,9 +948,9 @@ function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> |
return null;
}
function parseEventData<T>(event: Event) {
function parseEventJson<T>(data: string) {
try {
return JSON.parse((event as MessageEvent).data) as T;
return JSON.parse(data) as T;
} catch {
return null;
}
@@ -811,7 +959,6 @@ function parseEventData<T>(event: Event) {
function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
const head = [
"影策 Canvas Agent 诊断日志",
`Canvas Agent: ${context.endpoint}`,
`连接: ${context.connected ? "在线" : context.enabled ? "连接中" : "未启用"}`,
`状态: ${context.activity}`,
`waiting: ${context.waiting}`,
@@ -820,10 +967,12 @@ function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
`pendingTool: ${context.pendingTool ? toolName(context.pendingTool) : "none"}`,
`logs: ${logs.length}`,
].join("\n");
const body = logs.map((item, index) => {
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
}).join("\n\n---\n\n");
const body = logs
.map((item, index) => {
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
})
.join("\n\n---\n\n");
return [head, body || "暂无事件日志"].join("\n\n");
}
@@ -939,7 +1088,12 @@ function toolSummary(item?: AgentEventItem) {
function parseToolResult(result: unknown) {
const content = objectField(result, "content");
const text = Array.isArray(content) ? content.map((item) => objectField(item, "text")).filter((item): item is string => typeof item === "string").join("\n") : "";
const text = Array.isArray(content)
? content
.map((item) => objectField(item, "text"))
.filter((item): item is string => typeof item === "string")
.join("\n")
: "";
try {
return text ? JSON.parse(text) : result;
} catch {
@@ -992,25 +1146,13 @@ function formatBytes(bytes: number) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
}
async function fetchAgentJson<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
const res = await fetch(url, init);
const data = (await res.json().catch(() => ({}))) as T & { error?: string; msg?: string };
if (!res.ok) throw new Error(data.error || data.msg || "本地 Agent 请求失败");
async function fetchAgentJson<T>(path: string, init?: RequestInit) {
const res = await getLocalRuntimeSessionClient().request(path, init);
const data = (await res.json().catch(() => ({}))) as T;
if (!res.ok) throw new Error("本地 Agent 请求失败");
return data;
}
async function discoverAgentConfig(endpoint: string) {
try {
const res = await fetch(`${endpoint}/config`);
if (!res.ok) return null;
const data = (await res.json()) as AgentConfigResponse;
return data.ok ? data : null;
} catch {
return null;
}
}
function normalizeHistoryMessages(messages: AgentChatItem[]) {
return messages
.map((item, index) => ({

View File

@@ -1,14 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Square, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Square, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { createDefaultSubtitleStyle } from "@/types/timeline";
@@ -116,13 +118,23 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp
function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<CanvasNodeContentProps, "node" | "theme" | "onCancelTask" | "onOpenTaskDetails">) {
const taskId = node.metadata?.taskId;
const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null;
const statusLabel = taskStatusLabel(node.metadata?.taskStatus);
const displayTask = {
provider: node.metadata?.taskProvider,
status: (node.metadata?.taskStatus || "running") as GenerationTask["status"],
stage: node.metadata?.taskStage,
officialStatus: node.metadata?.taskOfficialStatus,
errorCode: node.metadata?.taskErrorCode,
};
const submissionUncertain = Boolean(taskId) && isGenerationTaskSubmissionUncertain(displayTask);
const showsProgress = Boolean(taskId) && generationTaskShowsProgress(displayTask);
const progress = showsProgress && typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null;
const statusLabel = taskId ? generationTaskStatusLabel(displayTask) : "等待任务状态";
const stageLabel = taskId ? generationTaskStageLabel(displayTask) : "正在创建任务";
const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt);
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2.5 px-5 text-center" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[var(--fs-tiny)] font-semibold">{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")}</span>
{submissionUncertain ? <AlertCircle className="size-10" /> : <div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />}
<span className="text-[var(--fs-tiny)] font-semibold">{stageLabel}</span>
{taskId ? (
<div className="flex w-full max-w-[210px] flex-col items-center gap-1.5">
<div className="max-w-full truncate text-[var(--fs-label)] font-medium" style={{ color: theme.node.text }}>
@@ -139,7 +151,9 @@ function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick<C
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: theme.toolbar.itemHover, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpenTaskDetails?.(node); }}><FileText className="size-3" />详情</button>
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
{!submissionUncertain ? (
<button type="button" className="inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors" style={{ background: `${theme.accent.danger}16`, color: theme.accent.danger }} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCancelTask?.(node); }}><Square className="size-2.5 fill-current" />取消</button>
) : null}
</div>
</div>
) : null}
@@ -161,15 +175,6 @@ function useTaskElapsed(createdAt?: string) {
return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`;
}
function taskStatusLabel(status?: string) {
if (status === "queued") return "排队中";
if (status === "running") return "生成中";
if (status === "succeeded") return "任务已完成";
if (status === "failed") return "任务失败";
if (status === "cancelled") return "任务已取消";
return status ? "未知任务状态" : "等待任务状态";
}
function shortTaskId(id: string) {
if (id.length <= 20) return id;
return `${id.slice(0, 14)}...${id.slice(-4)}`;
@@ -177,10 +182,22 @@ function shortTaskId(id: string) {
function ErrorContent({ node, theme, onRetry }: Pick<CanvasNodeContentProps, "node" | "theme" | "onRetry">) {
const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
const errorDisplayTask = {
provider: node.metadata?.taskProvider,
status: (node.metadata?.taskStatus || "failed") as GenerationTask["status"],
stage: node.metadata?.taskStage,
officialStatus: node.metadata?.taskOfficialStatus,
errorCode: node.metadata?.taskErrorCode,
};
const submissionUncertain = isGenerationTaskSubmissionUncertain(errorDisplayTask);
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{generationErrorMessage(node.metadata?.errorDetails)}</div>
{moderationFailure ? (
<div className="text-xs leading-5" style={{ color: submissionUncertain ? theme.node.text : theme.accent.danger }}>{submissionUncertain ? generationTaskStatusLabel(errorDisplayTask) : generationErrorMessage(node.metadata?.errorDetails)}</div>
{submissionUncertain ? (
<div className="rounded-[var(--r-sm)] px-3 py-2 text-[var(--fs-label)] leading-4" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
{generationTaskStageLabel(errorDisplayTask)}
</div>
) : moderationFailure ? (
<div className="rounded-[var(--r-sm)] px-3 py-2 text-[var(--fs-label)] leading-4" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
修改节点提示词后,可重新点击生成。
</div>

View File

@@ -10,11 +10,25 @@ import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { pipelineStatusLabel, type CanvasStoryboardPipelineProgress, type StoryboardPipelineStage } from "@/lib/canvas/canvas-storyboard-progress";
import { generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { navigateToSettings } from "@/lib/settings-navigation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchItemStatus, CanvasNodeData, CanvasNodeStatus, CanvasWorkspaceMode, StoryboardColumn, StoryboardRow, StoryboardShotCount, StoryboardShotDuration, StoryboardVideoInputMode } from "@/types/canvas";
import type {
CanvasGenerationBatch,
CanvasGenerationBatchItem,
CanvasGenerationBatchItemStatus,
CanvasNodeData,
CanvasNodeStatus,
CanvasWorkspaceMode,
StoryboardColumn,
StoryboardRow,
StoryboardShotCount,
StoryboardShotDuration,
StoryboardVideoInputMode,
} from "@/types/canvas";
import type { TaskStatus } from "@/services/api/task-center";
export const STORYBOARD_ROW_HEIGHT = 48;
export const STORYBOARD_HEADER_HEIGHT = 124;
@@ -60,7 +74,37 @@ const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
{ label: "负面要求", value: "negativePrompt" },
];
export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onVideoInputModeChange, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: {
export function CanvasScriptNodeContent({
node,
batch,
pipeline,
scale,
mentionReferences,
onOpen,
onCreateImageNodes,
onCreateVideoNodes,
onGenerateImages,
onGenerateVideos,
onVideoInputModeChange,
onMergeVideos,
onCreateActionBoards,
onRetryBatch,
onRetryBatchItem,
onStopBatch,
onCancelBatchItem,
onAddRow,
onRemoveRow,
onUpdateRow,
onPromptChange,
onGenerateScript,
onModelChange,
onShotDurationChange,
onShotCountChange,
onComposerHeightChange,
onConnectStart,
onScrollTopChange,
workspaceMode = "professional",
}: {
node: CanvasNodeData;
batch?: CanvasGenerationBatch;
pipeline: CanvasStoryboardPipelineProgress;
@@ -111,9 +155,25 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
const hasFailedBatchItems = Boolean(batch?.items.some((item) => item.status === "failed"));
const hasWaitingBatchItems = Boolean(batch?.items.some((item) => item.status === "waiting" || item.status === "submitting"));
const hasActiveBatchItems = Boolean(batch?.items.some((item) => item.status === "waiting" || item.status === "submitting" || item.status === "queued" || item.status === "running"));
const taskFeedback = node.metadata?.status === "loading"
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : "";
const taskStatus = node.metadata?.taskStatus;
const displayStatus: TaskStatus = taskStatus === "queued" || taskStatus === "succeeded" || taskStatus === "failed" || taskStatus === "cancelled" ? taskStatus : "running";
const displayTask = node.metadata?.taskId
? {
provider: node.metadata.taskProvider,
status: displayStatus,
stage: node.metadata.taskStage,
officialStatus: node.metadata.taskOfficialStatus,
errorCode: node.metadata.taskErrorCode,
}
: null;
const taskFeedback =
node.metadata?.status === "loading"
? displayTask
? `${generationTaskStageLabel(displayTask)}${generationTaskShowsProgress(displayTask) && typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: "正在创建任务"
: node.metadata?.status === "error"
? generationErrorMessage(node.metadata.errorDetails)
: "";
const [batchDetailsOpen, setBatchDetailsOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const pipelineDisabled = !rows.length || node.metadata?.status === "loading" || hasActiveBatchItems;
@@ -124,24 +184,39 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
const moreMenuItems: MenuProps["items"] = [
{ key: "generate-images", icon: <ImageIcon className="size-3.5" />, label: "生成未完成分镜图", disabled: pipelineDisabled || pipeline.images.incomplete === 0, onClick: () => onGenerateImages(allRowIds) },
{ key: "generate-videos", icon: <Video className="size-3.5" />, label: "生成未完成视频", disabled: pipelineDisabled || pipeline.videos.incomplete === 0, onClick: () => onGenerateVideos(allRowIds) },
{ key: "merge", icon: <Merge className="size-3.5" />, label: pipeline.final.success ? "成片已完成" : pipeline.successfulVideoNodeIds.length >= 2 ? `合并 ${pipeline.successfulVideoNodeIds.length} 段视频` : "合并成片(至少 2 段视频)", disabled: !canMerge, onClick: () => onMergeVideos() },
{
key: "merge",
icon: <Merge className="size-3.5" />,
label: pipeline.final.success ? "成片已完成" : pipeline.successfulVideoNodeIds.length >= 2 ? `合并 ${pipeline.successfulVideoNodeIds.length} 段视频` : "合并成片(至少 2 段视频)",
disabled: !canMerge,
onClick: () => onMergeVideos(),
},
{ type: "divider" },
{ key: "video-input", icon: <Film className="size-3.5" />, label: "视频输入模式", children: [
{ key: "video-input-direct", label: videoInputMode === "direct" ? "✓ 直接生成" : "直接生成", onClick: () => onVideoInputModeChange("direct") },
{ key: "video-input-keyframe", label: videoInputMode === "keyframe" ? "✓ 先做首帧" : "先做首帧", onClick: () => onVideoInputModeChange("keyframe") },
] },
...(!simpleMode ? [
{ type: "divider" as const },
{ key: "create-image-nodes", icon: <Grid3X3 className="size-3.5" />, label: missingImages ? `创建 ${missingImages} 个图片节点` : "图片节点已创建", disabled: pipelineDisabled || missingImages === 0, onClick: () => onCreateImageNodes() },
{ key: "create-video-nodes", icon: <Film className="size-3.5" />, label: missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建", disabled: pipelineDisabled || missingVideos === 0, onClick: () => onCreateVideoNodes() },
{ key: "action-boards", icon: <Grid3X3 className="size-3.5" />, label: "生成动作拆分 12 宫格", disabled: !rows.length || hasActiveBatchItems, onClick: () => onCreateActionBoards() },
] : []),
...(batch ? [
{ type: "divider" as const },
{ key: "retry", icon: <RefreshCw className="size-3.5" />, label: "重试失败项", disabled: !hasFailedBatchItems, onClick: () => onRetryBatch(batch.id) },
{ key: "stop", icon: <Square className="size-3.5" />, label: "停止剩余任务", disabled: !hasWaitingBatchItems, onClick: () => onStopBatch(batch.id) },
{ key: "details", icon: <ListTree className="size-3.5" />, label: "查看批次详情", onClick: () => setBatchDetailsOpen(true) },
] : []),
{
key: "video-input",
icon: <Film className="size-3.5" />,
label: "视频输入模式",
children: [
{ key: "video-input-direct", label: videoInputMode === "direct" ? "✓ 直接生成" : "直接生成", onClick: () => onVideoInputModeChange("direct") },
{ key: "video-input-keyframe", label: videoInputMode === "keyframe" ? "✓ 先做首帧" : "先做首帧", onClick: () => onVideoInputModeChange("keyframe") },
],
},
...(!simpleMode
? [
{ type: "divider" as const },
{ key: "create-image-nodes", icon: <Grid3X3 className="size-3.5" />, label: missingImages ? `创建 ${missingImages} 个图片节点` : "图片节点已创建", disabled: pipelineDisabled || missingImages === 0, onClick: () => onCreateImageNodes() },
{ key: "create-video-nodes", icon: <Film className="size-3.5" />, label: missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建", disabled: pipelineDisabled || missingVideos === 0, onClick: () => onCreateVideoNodes() },
{ key: "action-boards", icon: <Grid3X3 className="size-3.5" />, label: "生成动作拆分 12 宫格", disabled: !rows.length || hasActiveBatchItems, onClick: () => onCreateActionBoards() },
]
: []),
...(batch
? [
{ type: "divider" as const },
{ key: "retry", icon: <RefreshCw className="size-3.5" />, label: "重试失败项", disabled: !hasFailedBatchItems, onClick: () => onRetryBatch(batch.id) },
{ key: "stop", icon: <Square className="size-3.5" />, label: "停止剩余任务", disabled: !hasWaitingBatchItems, onClick: () => onStopBatch(batch.id) },
{ key: "details", icon: <ListTree className="size-3.5" />, label: "查看批次详情", onClick: () => setBatchDetailsOpen(true) },
]
: []),
];
const submitPrompt = () => {
const value = prompt.trim();
@@ -162,21 +237,67 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
<div className="relative flex h-full w-full flex-col overflow-visible" style={{ color: theme.node.text }} onDoubleClick={(event) => event.stopPropagation()}>
<div className="relative flex h-10 shrink-0 items-center gap-2 rounded-t-[17px] border-b px-4" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<Clapperboard className="size-4" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold" title={node.title || "分镜脚本"}>{node.title || "分镜脚本"}</span>
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
<span className="text-[var(--fs-caption)] font-semibold tabular-nums" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
<Tooltip title="全屏编辑"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} aria-label="全屏编辑"><Expand className="size-3.5" /></button></Tooltip>
<span className="min-w-0 flex-1 truncate text-sm font-semibold" title={node.title || "分镜脚本"}>
{node.title || "分镜脚本"}
</span>
{batchSummary ? (
<span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>
{batchSummary}
</span>
) : taskFeedback ? (
<span className="min-w-0 max-w-[38%] truncate text-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>
{taskFeedback}
</span>
) : null}
<span className="text-[var(--fs-caption)] font-semibold tabular-nums" style={{ color: theme.node.muted }}>
{rows.length} 镜 · {totalDuration}s
</span>
<Tooltip title="全屏编辑">
<button
type="button"
className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10"
style={{ "--tw-ring-color": theme.node.muted } as CSSProperties}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
aria-label="全屏编辑"
>
<Expand className="size-3.5" />
</button>
</Tooltip>
<Dropdown open={moreMenuOpen} onOpenChange={setMoreMenuOpen} menu={{ items: moreMenuItems, onClick: () => setMoreMenuOpen(false) }} trigger={["click"]} placement="bottomRight">
<button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); setMoreMenuOpen(true); }} aria-label="更多操作"><MoreHorizontal className="size-3.5" /></button>
<button
type="button"
className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10"
style={{ "--tw-ring-color": theme.node.muted } as CSSProperties}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setMoreMenuOpen(true);
}}
aria-label="更多操作"
>
<MoreHorizontal className="size-3.5" />
</button>
</Dropdown>
</div>
{batch ? <Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>
<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />
</Modal> : null}
{batch ? (
<Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>
<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />
</Modal>
) : null}
<StoryboardMiniPipeline pipeline={pipeline} theme={theme} rows={rows} />
<div className="storyboard-header-gutter grid h-9 shrink-0 items-center border-b text-xs font-semibold" style={{ borderColor: theme.node.stroke, color: theme.node.muted, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
<HeaderCell borderColor={theme.node.stroke} align="center">序号</HeaderCell>
<HeaderCell borderColor={theme.node.stroke} align="center">时长</HeaderCell>
<HeaderCell borderColor={theme.node.stroke} align="center">
序号
</HeaderCell>
<HeaderCell borderColor={theme.node.stroke} align="center">
时长
</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>画面描述</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>台词/旁白</HeaderCell>
<span className="text-center">操作</span>
@@ -188,34 +309,87 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
aria-label="分镜镜头列表"
className="storyboard-scrollbar min-h-0 flex-1 overflow-y-scroll overflow-x-hidden outline-none focus-visible:ring-1 focus-visible:ring-inset"
style={{ "--tw-ring-color": theme.node.muted } as CSSProperties}
onScroll={(event) => { const next = event.currentTarget.scrollTop; setScrollTop(next); onScrollTopChange(next); }}
onScroll={(event) => {
const next = event.currentTarget.scrollTop;
setScrollTop(next);
onScrollTopChange(next);
}}
onWheel={(event) => event.stopPropagation()}
>
{rows.length ? rows.map((row) => (
<div key={row.id} className="relative grid border-b" style={{ height: STORYBOARD_ROW_HEIGHT, borderColor: theme.node.stroke, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
<div className="flex flex-col items-center justify-center border-r tabular-nums" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}><span className="text-sm">{row.shotNumber}</span>{batchItemByRowId.get(row.id) ? <span className="max-w-16 truncate text-[var(--fs-micro)] leading-3" title={generationBatchItemLabel(batchItemByRowId.get(row.id)!)}>{generationBatchItemLabel(batchItemByRowId.get(row.id)!)}</span> : null}</div>
<div className="grid grid-cols-[32px_1fr_32px] items-center border-r px-2" style={{ borderColor: theme.node.stroke }}>
<SmallButton title="减少 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}><Minus className="size-3" /></SmallButton>
<span className="text-center text-sm font-medium tabular-nums">{row.durationSeconds}s</span>
<SmallButton title="增加 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.min(60, row.durationSeconds + 1) })}><Plus className="size-3" /></SmallButton>
{rows.length ? (
rows.map((row) => (
<div key={row.id} className="relative grid border-b" style={{ height: STORYBOARD_ROW_HEIGHT, borderColor: theme.node.stroke, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
<div className="flex flex-col items-center justify-center border-r tabular-nums" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
<span className="text-sm">{row.shotNumber}</span>
{batchItemByRowId.get(row.id) ? (
<span className="max-w-16 truncate text-[var(--fs-micro)] leading-3" title={generationBatchItemLabel(batchItemByRowId.get(row.id)!)}>
{generationBatchItemLabel(batchItemByRowId.get(row.id)!)}
</span>
) : null}
</div>
<div className="grid grid-cols-[32px_1fr_32px] items-center border-r px-2" style={{ borderColor: theme.node.stroke }}>
<SmallButton title="减少 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}>
<Minus className="size-3" />
</SmallButton>
<span className="text-center text-sm font-medium tabular-nums">{row.durationSeconds}s</span>
<SmallButton title="增加 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.min(60, row.durationSeconds + 1) })}>
<Plus className="size-3" />
</SmallButton>
</div>
<CompactInput value={row.plotDescription} placeholder="描述画面内容" onChange={(value) => onUpdateRow(row.id, { plotDescription: value })} borderColor={theme.node.stroke} />
<CompactInput value={row.dialogue} placeholder="台词或旁白" onChange={(value) => onUpdateRow(row.id, { dialogue: value })} borderColor={theme.node.stroke} />
<div className="grid h-full place-items-center">
<button
type="button"
disabled={rows.length <= 1}
className="grid size-7 place-items-center rounded outline-none opacity-55 transition enabled:hover:bg-red-500/10 enabled:hover:opacity-100 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-20"
style={{ color: theme.accent.danger, "--tw-ring-color": theme.accent.danger } as CSSProperties}
title={rows.length <= 1 ? "至少保留一个镜头" : "删除镜头"}
aria-label={`删除镜头 ${row.shotNumber}`}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRemoveRow(row.id);
}}
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
<CompactInput value={row.plotDescription} placeholder="描述画面内容" onChange={(value) => onUpdateRow(row.id, { plotDescription: value })} borderColor={theme.node.stroke} />
<CompactInput value={row.dialogue} placeholder="台词或旁白" onChange={(value) => onUpdateRow(row.id, { dialogue: value })} borderColor={theme.node.stroke} />
<div className="grid h-full place-items-center">
<button type="button" disabled={rows.length <= 1} className="grid size-7 place-items-center rounded outline-none opacity-55 transition enabled:hover:bg-red-500/10 enabled:hover:opacity-100 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-20" style={{ color: theme.accent.danger, "--tw-ring-color": theme.accent.danger } as CSSProperties} title={rows.length <= 1 ? "至少保留一个镜头" : "删除镜头"} aria-label={`删除镜头 ${row.shotNumber}`} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemoveRow(row.id); }}><Trash2 className="size-3.5" /></button>
</div>
</div>
)) : (
<button type="button" className="grid h-full min-h-36 w-full place-items-center" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onAddRow(); }}>
))
) : (
<button
type="button"
className="grid h-full min-h-36 w-full place-items-center"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onAddRow();
}}
>
<span className="flex flex-col items-center gap-2.5">
<span className="text-sm font-bold">+ 添加第一个镜头</span>
<span className="text-[var(--fs-label)] font-medium" style={{ color: theme.node.faint }}>可先连接「故事梗概 / 项目画风」节点,或在下方输入提示词一键生成分镜表</span>
<span className="text-[var(--fs-label)] font-medium" style={{ color: theme.node.faint }}>
可先连接「故事梗概 / 项目画风」节点,或在下方输入提示词一键生成分镜表
</span>
</span>
</button>
)}
</div>
<div className="flex h-9 shrink-0 items-center justify-center border-b" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<button type="button" className="inline-flex h-7 items-center gap-1 rounded px-2 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onAddRow(); }}><Plus className="size-3.5" />添加行</button>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded px-2 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10"
style={{ "--tw-ring-color": theme.node.muted } as CSSProperties}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onAddRow();
}}
>
<Plus className="size-3.5" />
添加行
</button>
</div>
<div className="relative grid shrink-0 grid-rows-[minmax(0,1fr)_28px] gap-1.5 rounded-b-[17px] p-2.5" style={{ height: composerHeight, background: theme.node.panel }}>
<CanvasResourceMentionTextarea
@@ -258,30 +432,38 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
/>
</div>
</Tooltip>
{simpleMode ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>自动拆分 · 时长自动</span> : <Select<StoryboardShotCount>
className="min-w-24"
size="small"
value={shotCount}
disabled={node.metadata?.status === "loading"}
options={[{ value: "auto", label: "自动拆分" }, ...Array.from({ length: 10 }, (_, index) => ({ value: String(index + 1) as StoryboardShotCount, label: `${index + 1} 镜` }))]}
popupMatchSelectWidth={false}
onChange={onShotCountChange}
/>}
{simpleMode ? null : <Select<StoryboardShotDuration>
className="min-w-24"
size="small"
value={shotDuration}
disabled={node.metadata?.status === "loading"}
options={[
{ value: "auto", label: "时长自动" },
{ value: "5", label: "5 秒" },
{ value: "10", label: "10 秒" },
{ value: "15", label: "15 秒" },
{ value: "30", label: "30 秒" },
]}
popupMatchSelectWidth={false}
onChange={onShotDurationChange}
/>}
{simpleMode ? (
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
自动拆分 · 时长自动
</span>
) : (
<Select<StoryboardShotCount>
className="min-w-24"
size="small"
value={shotCount}
disabled={node.metadata?.status === "loading"}
options={[{ value: "auto", label: "自动拆分" }, ...Array.from({ length: 10 }, (_, index) => ({ value: String(index + 1) as StoryboardShotCount, label: `${index + 1} 镜` }))]}
popupMatchSelectWidth={false}
onChange={onShotCountChange}
/>
)}
{simpleMode ? null : (
<Select<StoryboardShotDuration>
className="min-w-24"
size="small"
value={shotDuration}
disabled={node.metadata?.status === "loading"}
options={[
{ value: "auto", label: "时长自动" },
{ value: "5", label: "5 秒" },
{ value: "10", label: "10 秒" },
{ value: "15", label: "15 秒" },
{ value: "30", label: "30 秒" },
]}
popupMatchSelectWidth={false}
onChange={onShotDurationChange}
/>
)}
<Button
shape="circle"
icon={<Send className="size-4" />}
@@ -320,15 +502,38 @@ function StoryboardMiniPipeline({ pipeline, theme, rows }: { pipeline: CanvasSto
{ key: "script", label: "分镜", state: rows.length > 0 ? "done" : "idle", hint: rows.length > 0 ? `${rows.length} 个镜头` : "待添加镜头" },
{ key: "images", label: "分镜图(可选)", state: storyboardStepState(pipeline.images), hint: pipelineStatusLabel(pipeline.images) },
{ key: "videos", label: "视频", state: storyboardStepState(pipeline.videos), hint: pipelineStatusLabel(pipeline.videos) },
{ key: "final", label: "合并成片", state: pipeline.final.success > 0 ? "done" : pipeline.final.failed > 0 ? "error" : pipeline.final.loading > 0 || pipeline.successfulVideoNodeIds.length >= 2 ? "current" : "idle", hint: pipelineStatusLabel(pipeline.final) },
{
key: "final",
label: "合并成片",
state: pipeline.final.success > 0 ? "done" : pipeline.final.failed > 0 ? "error" : pipeline.final.loading > 0 || pipeline.successfulVideoNodeIds.length >= 2 ? "current" : "idle",
hint: pipelineStatusLabel(pipeline.final),
},
];
return (
<div className="flex h-9 shrink-0 items-center justify-center overflow-hidden border-b px-4" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div
className="flex h-9 shrink-0 items-center justify-center overflow-hidden border-b px-4"
style={{ borderColor: theme.node.stroke, background: theme.node.fill }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{steps.map((step, index) => (
<Fragment key={step.key}>
{index > 0 ? <span className="mx-2.5 h-px min-w-3.5 flex-1 max-w-20" style={{ background: theme.node.stroke }} /> : null}
<span className="flex items-center gap-1.5 whitespace-nowrap text-[var(--fs-tiny)]" title={step.hint} style={{ color: step.state === "done" ? theme.node.muted : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.faint, fontWeight: step.state === "current" || step.state === "error" ? 700 : 500 }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: step.state === "done" ? theme.node.activeStroke : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.stroke, boxShadow: step.state === "current" ? `0 0 0 3px ${theme.accent.primarySoft}` : undefined }} />
<span
className="flex items-center gap-1.5 whitespace-nowrap text-[var(--fs-tiny)]"
title={step.hint}
style={{
color: step.state === "done" ? theme.node.muted : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.faint,
fontWeight: step.state === "current" || step.state === "error" ? 700 : 500,
}}
>
<span
className="size-2 shrink-0 rounded-full"
style={{
background: step.state === "done" ? theme.node.activeStroke : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.stroke,
boxShadow: step.state === "current" ? `0 0 0 3px ${theme.accent.primarySoft}` : undefined,
}}
/>
{step.label}
</span>
</Fragment>
@@ -339,21 +544,53 @@ function StoryboardMiniPipeline({ pipeline, theme, rows }: { pipeline: CanvasSto
function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { batch: CanvasGenerationBatch; rows: StoryboardRow[]; onRetryItem: (itemId: string) => void; onCancelItem: (itemId: string) => void }) {
const shotByRowId = new Map(rows.map((row) => [row.id, row.shotNumber]));
return <div className="w-80" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3"><span className="text-sm font-semibold">{generationBatchModeLabel(batch)}详情</span><span className="text-xs text-foreground/50">{batch.items.length} 项</span></div>
<div className="thin-scrollbar max-h-72 overflow-y-auto">
{batch.items.map((item) => {
const cancellable = Boolean(item.taskId && (item.status === "queued" || item.status === "running"));
const requiresPromptChange = isContentModerationError(item.errorDetails);
return <div key={item.id} className="flex min-h-9 items-center gap-2 border-t border-foreground/10 py-1.5 first:border-t-0">
<span className="w-14 shrink-0 text-xs font-medium">镜头 {shotByRowId.get(item.rowId) || "--"}</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails ? generationErrorMessage(item.errorDetails) : undefined}>{generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}</span>
{item.status === "failed" ? <Tooltip title={requiresPromptChange ? "请先修改提示词,再重试这个镜头" : "只重试这个镜头"}><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" onClick={() => onRetryItem(item.id)} aria-label={`重试镜头 ${shotByRowId.get(item.rowId) || ""}`}><RefreshCw className="size-3.5" /></button></Tooltip> : null}
{cancellable ? <Tooltip title="取消这个后台任务"><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-red-500/10 focus-visible:ring-2" onClick={() => onCancelItem(item.id)} aria-label={`取消镜头 ${shotByRowId.get(item.rowId) || ""} 任务`}><X className="size-3.5" /></button></Tooltip> : null}
</div>;
})}
return (
<div className="w-80" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-sm font-semibold">{generationBatchModeLabel(batch)}详情</span>
<span className="text-xs text-foreground/50">{batch.items.length} 项</span>
</div>
<div className="thin-scrollbar max-h-72 overflow-y-auto">
{batch.items.map((item) => {
const cancellable = Boolean(item.taskId && (item.status === "queued" || item.status === "running"));
const requiresPromptChange = isContentModerationError(item.errorDetails);
return (
<div key={item.id} className="flex min-h-9 items-center gap-2 border-t border-foreground/10 py-1.5 first:border-t-0">
<span className="w-14 shrink-0 text-xs font-medium">镜头 {shotByRowId.get(item.rowId) || "--"}</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails ? generationErrorMessage(item.errorDetails) : undefined}>
{generationBatchItemLabel(item)}
{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}
</span>
{item.status === "failed" ? (
<Tooltip title={requiresPromptChange ? "请先修改提示词,再重试这个镜头" : "只重试这个镜头"}>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10"
onClick={() => onRetryItem(item.id)}
aria-label={`重试镜头 ${shotByRowId.get(item.rowId) || ""}`}
>
<RefreshCw className="size-3.5" />
</button>
</Tooltip>
) : null}
{cancellable ? (
<Tooltip title="取消这个后台任务">
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-red-500/10 focus-visible:ring-2"
onClick={() => onCancelItem(item.id)}
aria-label={`取消镜头 ${shotByRowId.get(item.rowId) || ""} 任务`}
>
<X className="size-3.5" />
</button>
</Tooltip>
) : null}
</div>
);
})}
</div>
</div>
</div>;
);
}
function generationBatchModeLabel(batch: CanvasGenerationBatch) {
@@ -382,7 +619,16 @@ function batchItemTone(item?: CanvasGenerationBatchItem): CanvasNodeStatus | und
return "loading";
}
export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos, onVideoInputModeChange }: {
export function CanvasScriptEditor({
node,
open,
onClose,
onUpdateRows,
onVisibleColumnsChange,
onGenerateImages,
onGenerateVideos,
onVideoInputModeChange,
}: {
node: CanvasNodeData | null;
open: boolean;
onClose: () => void;
@@ -399,7 +645,15 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
const videoInputMode = node?.metadata?.storyboardVideoInputMode || "direct";
const filteredRows = useMemo(() => {
const keyword = query.trim().toLowerCase();
return keyword ? rows.filter((row) => [row.plotDescription, row.dialogue, row.camera, row.motion, row.timeBeats, row.imageGenerationPrompt, row.videoMotionPrompt, row.negativePrompt].some((value) => String(value || "").toLowerCase().includes(keyword))) : rows;
return keyword
? rows.filter((row) =>
[row.plotDescription, row.dialogue, row.camera, row.motion, row.timeBeats, row.imageGenerationPrompt, row.videoMotionPrompt, row.negativePrompt].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
),
)
: rows;
}, [query, rows]);
useEffect(() => {
setSelectedIds((current) => {
@@ -407,7 +661,7 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
return next.length === current.length && next.every((id, index) => id === current[index]) ? current : next;
});
}, [rows]);
const updateRow = (rowId: string, patch: Partial<StoryboardRow>) => onUpdateRows(rows.map((row) => row.id === rowId ? { ...row, ...patch } : row));
const updateRow = (rowId: string, patch: Partial<StoryboardRow>) => onUpdateRows(rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)));
const moveRow = (rowId: string, direction: -1 | 1) => {
const index = rows.findIndex((row) => row.id === rowId);
const nextIndex = index + direction;
@@ -424,17 +678,58 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
};
const removeRow = (rowId: string) => onUpdateRows(rows.filter((row) => row.id !== rowId).map((row, index) => ({ ...row, shotNumber: index + 1 })));
const columns: ColumnsType<StoryboardRow> = columnOptions.filter((option) => visibleColumns.includes(option.value)).map((option) => ({
title: option.label,
dataIndex: option.value,
key: option.value,
width: option.value === "shotNumber" ? 72 : option.value === "durationSeconds" ? 100 : option.value === "plotDescription" || option.value === "dialogue" || option.value === "timeBeats" || option.value.endsWith("Prompt") ? 260 : 170,
fixed: option.value === "shotNumber" ? "left" as const : undefined,
render: (_: unknown, row: StoryboardRow) => option.value === "shotNumber" ? <span className="font-semibold">{row.shotNumber}</span> : option.value === "durationSeconds" ? <InputNumber min={1} max={60} value={row.durationSeconds} addonAfter="s" onChange={(value) => updateRow(row.id, { durationSeconds: Number(value) || 1 })} /> : option.value === "shotSize" ? <Select className="w-full" value={row.shotSize || undefined} placeholder="选择景别" options={["特写", "近景", "中景", "全景", "远景"].map((value) => ({ value, label: value }))} onChange={(shotSize) => updateRow(row.id, { shotSize })} /> : <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} value={String(row[option.value] || "")} placeholder={`填写${option.label}`} onChange={(event) => updateRow(row.id, { [option.value]: event.target.value } as Partial<StoryboardRow>)} />,
}));
const columns: ColumnsType<StoryboardRow> = columnOptions
.filter((option) => visibleColumns.includes(option.value))
.map((option) => ({
title: option.label,
dataIndex: option.value,
key: option.value,
width: option.value === "shotNumber" ? 72 : option.value === "durationSeconds" ? 100 : option.value === "plotDescription" || option.value === "dialogue" || option.value === "timeBeats" || option.value.endsWith("Prompt") ? 260 : 170,
fixed: option.value === "shotNumber" ? ("left" as const) : undefined,
render: (_: unknown, row: StoryboardRow) =>
option.value === "shotNumber" ? (
<span className="font-semibold">{row.shotNumber}</span>
) : option.value === "durationSeconds" ? (
<InputNumber min={1} max={60} value={row.durationSeconds} addonAfter="s" onChange={(value) => updateRow(row.id, { durationSeconds: Number(value) || 1 })} />
) : option.value === "shotSize" ? (
<Select
className="w-full"
value={row.shotSize || undefined}
placeholder="选择景别"
options={["特写", "近景", "中景", "全景", "远景"].map((value) => ({ value, label: value }))}
onChange={(shotSize) => updateRow(row.id, { shotSize })}
/>
) : (
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 4 }}
value={String(row[option.value] || "")}
placeholder={`填写${option.label}`}
onChange={(event) => updateRow(row.id, { [option.value]: event.target.value } as Partial<StoryboardRow>)}
/>
),
}));
columns.push({
title: "操作", key: "actions", dataIndex: "shotNumber", width: 150, fixed: "right" as const,
render: (_: unknown, row: StoryboardRow) => <div className="flex gap-1"><SmallButton title="上移" onClick={() => moveRow(row.id, -1)}><ChevronUp className="size-3.5" /></SmallButton><SmallButton title="下移" onClick={() => moveRow(row.id, 1)}><ChevronDown className="size-3.5" /></SmallButton><SmallButton title="复制" onClick={() => duplicateRow(row)}><Copy className="size-3.5" /></SmallButton><SmallButton title="删除" onClick={() => removeRow(row.id)}><Trash2 className="size-3.5" /></SmallButton></div>,
title: "操作",
key: "actions",
dataIndex: "shotNumber",
width: 150,
fixed: "right" as const,
render: (_: unknown, row: StoryboardRow) => (
<div className="flex gap-1">
<SmallButton title="上移" onClick={() => moveRow(row.id, -1)}>
<ChevronUp className="size-3.5" />
</SmallButton>
<SmallButton title="下移" onClick={() => moveRow(row.id, 1)}>
<ChevronDown className="size-3.5" />
</SmallButton>
<SmallButton title="复制" onClick={() => duplicateRow(row)}>
<Copy className="size-3.5" />
</SmallButton>
<SmallButton title="删除" onClick={() => removeRow(row.id)}>
<Trash2 className="size-3.5" />
</SmallButton>
</div>
),
});
return (
@@ -443,33 +738,125 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
<Input.Search className="w-72" allowClear placeholder="筛选画面、台词或提示词" value={query} onChange={(event) => setQuery(event.target.value)} />
<Checkbox.Group className="script-column-picker" options={columnOptions} value={visibleColumns} onChange={(values) => onVisibleColumnsChange(values as StoryboardColumn[])} />
<span className="min-w-0 flex-1" />
<Button icon={<Plus className="size-4" />} onClick={() => onUpdateRows([...rows, editorRow(rows.length + 1)])}>新增镜头</Button>
<Button icon={<ImageIcon className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateImages(selectedIds)}>生成{videoInputMode === "keyframe" ? "首帧" : "分镜图"}</Button>
<Segmented<StoryboardVideoInputMode> value={videoInputMode} options={[{ value: "direct", label: "直接生成" }, { value: "keyframe", label: "先做首帧" }]} onChange={onVideoInputModeChange} />
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>{videoInputMode === "keyframe" ? "确认首帧并生成" : "生成视频"}</Button>
<Button icon={<Plus className="size-4" />} onClick={() => onUpdateRows([...rows, editorRow(rows.length + 1)])}>
新增镜头
</Button>
<Button icon={<ImageIcon className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateImages(selectedIds)}>
生成{videoInputMode === "keyframe" ? "首帧" : "分镜图"}
</Button>
<Segmented<StoryboardVideoInputMode>
value={videoInputMode}
options={[
{ value: "direct", label: "直接生成" },
{ value: "keyframe", label: "先做首帧" },
]}
onChange={onVideoInputModeChange}
/>
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>
{videoInputMode === "keyframe" ? "确认首帧并生成" : "生成视频"}
</Button>
</div>
<Table<StoryboardRow> rowKey="id" size="small" bordered sticky pagination={false} scroll={{ x: Math.max(900, columns.length * 180), y: "calc(78vh - 170px)" }} dataSource={filteredRows} columns={columns} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) }} />
<Table<StoryboardRow>
rowKey="id"
size="small"
bordered
sticky
pagination={false}
scroll={{ x: Math.max(900, columns.length * 180), y: "calc(78vh - 170px)" }}
dataSource={filteredRows}
columns={columns}
rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) }}
/>
</Modal>
);
}
function CompactInput({ value, placeholder, borderColor, onChange }: { value: string; placeholder: string; borderColor: string; onChange: (value: string) => void }) {
return <textarea className="thin-scrollbar h-full w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-r bg-transparent px-4 py-2.5 text-xs leading-5 outline-none transition placeholder:opacity-35 focus:bg-black/[0.02] dark:focus:bg-white/[0.025]" style={{ borderColor }} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} />;
return (
<textarea
className="thin-scrollbar h-full w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-r bg-transparent px-4 py-2.5 text-xs leading-5 outline-none transition placeholder:opacity-35 focus:bg-black/[0.02] dark:focus:bg-white/[0.025]"
style={{ borderColor }}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
/>
);
}
function HeaderCell({ children, borderColor, align = "left" }: { children: ReactNode; borderColor: string; align?: "left" | "center" }) {
return <span className={`flex h-full items-center border-r px-4 ${align === "center" ? "justify-center text-center" : "justify-start"}`} style={{ borderColor }}>{children}</span>;
return (
<span className={`flex h-full items-center border-r px-4 ${align === "center" ? "justify-center text-center" : "justify-start"}`} style={{ borderColor }}>
{children}
</span>
);
}
function SmallButton({ title, children, onClick, disabled }: { title: string; children: ReactNode; onClick: () => void; disabled?: boolean }) {
return <button type="button" disabled={disabled} className="grid size-7 shrink-0 place-items-center rounded opacity-65 transition enabled:hover:bg-black/5 enabled:hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25 dark:enabled:hover:bg-white/10" title={title} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onClick(); }}>{children}</button>;
return (
<button
type="button"
disabled={disabled}
className="grid size-7 shrink-0 place-items-center rounded opacity-65 transition enabled:hover:bg-black/5 enabled:hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25 dark:enabled:hover:bg-white/10"
title={title}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</button>
);
}
function editorRow(shotNumber: number): StoryboardRow {
return { id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, shotNumber, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", referenceNodeIds: [], status: "idle" };
return {
id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
shotNumber,
durationSeconds: 6,
plotDescription: "",
dialogue: "",
characters: [],
narrativeIntent: "",
viewerPOV: "",
performanceBlocking: "",
shotSize: "",
emotion: "",
lightingAndAtmosphere: "",
audioEffects: "",
camera: "",
motion: "",
timeBeats: "",
imageGenerationPrompt: "",
videoMotionPrompt: "",
mustHave: [],
optionalDetails: [],
continuityOut: "",
negativePrompt: "",
referenceNodeIds: [],
status: "idle",
};
}
function RowHandle({ side, top, scale, tone, theme, title, onPointerDown }: { side: "left" | "right"; top: number; scale: number; tone?: StoryboardRow["status"]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; title?: string; onPointerDown: (event: ReactPointerEvent) => void }) {
function RowHandle({
side,
top,
scale,
tone,
theme,
title,
onPointerDown,
}: {
side: "left" | "right";
top: number;
scale: number;
tone?: StoryboardRow["status"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
title?: string;
onPointerDown: (event: ReactPointerEvent) => void;
}) {
const color = tone === "loading" ? theme.accent.primary : tone === "error" ? theme.accent.danger : tone === "success" ? theme.node.activeStroke : theme.node.muted;
const inverseHitScale = 1 / Math.max(scale, 0.05);
return (