feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user