@@ -211,7 +326,9 @@ export function MultiAgentPanel({ steps, isThinking = false, className }: MultiA
{tc.status === 'executing'
?
- :
+ : _hasToolError(tc.result)
+ ?
+ :
}
{tc.tool_name}
@@ -234,9 +351,9 @@ export function MultiAgentPanel({ steps, isThinking = false, className }: MultiA
)}
{step.error ? (
{step.error}
- ) : (
+ ) : step.result ? (
{step.result}
- )}
+ ) : null}
{step.tokens && (
Tokens: {step.tokens.input} in / {step.tokens.output} out
diff --git a/frontend/src/components/ai-assistant/ThinkPanel.tsx b/frontend/src/components/ai-assistant/ThinkPanel.tsx
index 98a9fc51..d2b1f951 100644
--- a/frontend/src/components/ai-assistant/ThinkPanel.tsx
+++ b/frontend/src/components/ai-assistant/ThinkPanel.tsx
@@ -3,7 +3,7 @@
import React from 'react';
import { SingleAgentThinkPanel } from './SingleAgentThinkPanel';
import { MultiAgentPanel } from './MultiAgentPanel';
-import type { AgentStep } from '@/store/useAIAssistantStore';
+import type { AgentStep, OrchestrationStyle } from '@/store/useAIAssistantStore';
interface ThinkPanelProps {
steps?: AgentStep[]; // 多智能体步骤(可选)
@@ -12,6 +12,11 @@ interface ThinkPanelProps {
thinkingContent?: string; // 思考过程内容(流式输出)
className?: string;
children?: React.ReactNode; // 思考完成后的最终内容
+ // team_tools 编排模式的元信息(仅多智能体模式使用)
+ orchestrationStyle?: OrchestrationStyle;
+ teamName?: string;
+ leaderName?: string;
+ finalResult?: string;
}
/**
@@ -25,7 +30,10 @@ interface ThinkPanelProps {
* - 自动模式切换:根据参数自动选择合适的子组件
* - 完全向后兼容:保持原有 API 不变
*/
-export function ThinkPanel({ steps = [], isThinking = false, agentName, thinkingContent, className, children }: ThinkPanelProps) {
+export function ThinkPanel({
+ steps = [], isThinking = false, agentName, thinkingContent, className, children,
+ orchestrationStyle, teamName, leaderName, finalResult,
+}: ThinkPanelProps) {
// 判断是单智能体还是多智能体模式
const isMultiAgent = steps.length > 0;
@@ -35,6 +43,10 @@ export function ThinkPanel({ steps = [], isThinking = false, agentName, thinking
steps={steps}
isThinking={isThinking}
className={className}
+ orchestrationStyle={orchestrationStyle}
+ teamName={teamName}
+ leaderName={leaderName}
+ finalResult={finalResult}
/>
);
diff --git a/frontend/src/components/ai-assistant/hooks/useSSEHandler.ts b/frontend/src/components/ai-assistant/hooks/useSSEHandler.ts
index 2184f1da..e97a058a 100644
--- a/frontend/src/components/ai-assistant/hooks/useSSEHandler.ts
+++ b/frontend/src/components/ai-assistant/hooks/useSSEHandler.ts
@@ -1,10 +1,9 @@
'use client';
import { useCallback, useRef } from 'react';
-import { useAIAssistantStore, type Message, type AgentStep, type VideoTaskData, type MusicTaskData, type HarnessEvent } from '@/store/useAIAssistantStore';
+import { useAIAssistantStore, type Message, type AgentStep, type VideoTaskData, type MusicTaskData, type HarnessEvent, type OrchestrationStyle, type MultiAgentData } from '@/store/useAIAssistantStore';
import { useCanvasStore } from '@/store/useCanvasStore';
import { useAuth } from '@/context/AuthContext';
-import type { MultiAgentData } from '@/components/canvas/MultiAgentSteps';
interface SSEEvent {
event: string;
@@ -60,8 +59,29 @@ interface StreamingState {
roundHasTools: boolean;
doneScheduled: boolean;
harnessEvents: HarnessEvent[];
+ // team_tools 编排:leader 自己的工具调用无 subtask_id,需要一个虚拟步骤承载
+ orchestrationStyle?: OrchestrationStyle;
+ leaderStepId?: string;
}
+// team_tools 虚拟 leader 步骤的稳定 subtask_id
+const LEADER_STEP_ID = '__leader__';
+
+// 媒体生成类工具 → 本地占位节点类型映射(供 applyMediaOptimisticNode 使用)
+const MEDIA_TOOL_TO_PLACEHOLDER: Record = {
+ generate_image: 'image',
+ edit_image: 'image',
+ generate_video: 'video',
+ edit_video: 'video',
+ generate_music: 'audio',
+};
+
+// tool_result 后需要触发 syncTheater 的工具(包括媒体生成与画布操作)
+const CANVAS_SYNC_TOOL_NAMES = new Set([
+ 'create_canvas_node', 'update_canvas_node', 'delete_canvas_node',
+ 'batch_create_nodes', 'edit_image', 'generate_image', 'generate_video', 'generate_music',
+]);
+
/** Calculate auto position for compaction summary node (right side of canvas) */
function calcAutoPositionForCompaction(nodes: { position: { x: number; y: number }; width?: number; height?: number; measured?: { width?: number; height?: number } }[]): { x: number; y: number } {
const MARGIN = 60;
@@ -84,6 +104,17 @@ export function useSSEHandler() {
// Debounced timer for clearing canvas node effects after tool chain completes
const effectClearTimerRef = useRef | null>(null);
+ // Debounced timer for syncTheater:多子智能体并发工具时,避免短时间内并发拉取 theater(SQLite 写锁与前端拉取争抢)
+ const syncTheaterTimerRef = useRef | null>(null);
+ const scheduleSyncTheater = useCallback((theaterId: string, delayMs = 400) => {
+ syncTheaterTimerRef.current && clearTimeout(syncTheaterTimerRef.current);
+ syncTheaterTimerRef.current = setTimeout(() => {
+ syncTheaterTimerRef.current = null;
+ const s = useCanvasStore.getState();
+ (s.theaterId === theaterId) && s.syncTheater(theaterId);
+ }, delayMs);
+ }, []);
+
// Accumulated tool argument JSON for streaming storyboard creation
const streamingArgsRef = useRef<{ toolName: string; accumulated: string; lastParseLen: number; replaced: boolean }>({
toolName: '', accumulated: '', lastParseLen: 0, replaced: false,
@@ -104,6 +135,8 @@ export function useSSEHandler() {
roundHasTools: false,
doneScheduled: false,
harnessEvents: [],
+ orchestrationStyle: undefined,
+ leaderStepId: undefined,
});
const resetStreamingState = useCallback(() => {
@@ -119,10 +152,14 @@ export function useSSEHandler() {
roundHasTools: false,
doneScheduled: false,
harnessEvents: [],
+ orchestrationStyle: undefined,
+ leaderStepId: undefined,
};
// Reset streaming args accumulator
streamingArgsRef.current = { toolName: '', accumulated: '', lastParseLen: 0, replaced: false };
parseThrottleRef.current && (clearTimeout(parseThrottleRef.current), parseThrottleRef.current = null);
+ // 清理 syncTheater debounce,避免上一轮的尾巴拉取混入下一轮
+ syncTheaterTimerRef.current && (clearTimeout(syncTheaterTimerRef.current), syncTheaterTimerRef.current = null);
}, []);
const parseSSELine = useCallback((line: string): SSEEvent | null => {
@@ -139,10 +176,49 @@ export function useSSEHandler() {
const handleSSEEvent = useCallback((eventType: string, data: unknown) => {
const state = streamingStateRef.current;
+ // ── 共用辅助:对已有节点应用画布视觉反馈(reading/updating/deleting/scanning/connecting) ──
+ // 不含 create_canvas_node 的 ghost/streaming replace(那属于 tool_call 流式参数专有逻辑)
+ const applyCanvasToolEffect = (toolName: string, args?: Record) => {
+ const canvasStore = useCanvasStore.getState();
+ const EFFECT_MAP: Record void> = {
+ get_canvas_node: () => args?.node_id && canvasStore.setNodeEffect(args.node_id as string, 'reading'),
+ update_canvas_node: () => args?.node_id && canvasStore.setNodeEffect(args.node_id as string, 'updating'),
+ delete_canvas_node: () => args?.node_id && canvasStore.setNodeEffect(args.node_id as string, 'deleting'),
+ list_canvas_nodes: () => {
+ const effects: Record = {};
+ canvasStore.nodes.forEach((n) => { n.type !== 'ghost' && (effects[n.id] = 'scanning'); });
+ Object.keys(effects).length > 0 && canvasStore.setNodeEffects(effects);
+ },
+ create_canvas_edge: () => {
+ const effects: Record = {};
+ args?.source_node_id && (effects[args.source_node_id as string] = 'connecting');
+ args?.target_node_id && (effects[args.target_node_id as string] = 'connecting');
+ Object.keys(effects).length > 0 && canvasStore.setNodeEffects(effects);
+ },
+ };
+ EFFECT_MAP[toolName]?.();
+ };
+
+ // ── 共用辅助:媒体生成工具的乐观预建(ghost 节点作为视觉预告) ──
+ // video_task_created / music_task_created 事件到达后会进一步创建 local-- 占位节点
+ const applyMediaOptimisticNode = (toolName: string) => {
+ const canvasStore = useCanvasStore.getState();
+ const placeholderType = MEDIA_TOOL_TO_PLACEHOLDER[toolName];
+ placeholderType && canvasStore.addGhostNode(placeholderType);
+ };
+
const handlers: Record void> = {
- // Leader 任务分析完成(简单任务无需多智能体UI,复杂任务后续由 subtask_created 初始化)
+ // Leader 任务分析完成:记录编排风格;team_tools 模式预置 multiAgent(因为不会有 subtask_created 事件触发初始化)
task_analyzed: () => {
- // No-op: simple tasks flow into text events; complex tasks flow into subtask_created
+ const d = data as { is_simple?: boolean; orchestration_style?: OrchestrationStyle };
+ state.orchestrationStyle = d.orchestration_style || 'legacy_json';
+ (d.orchestration_style === 'team_tools') && (state.multiAgent = state.multiAgent || {
+ steps: state.steps,
+ finalResult: '',
+ totalTokens: { input: 0, output: 0 },
+ creditCost: 0,
+ orchestrationStyle: 'team_tools',
+ });
},
// 流式文本(单智能体 + 多智能体简单任务共用)
@@ -237,38 +313,25 @@ export function useSSEHandler() {
// Canvas visual effects: show real-time feedback on affected nodes
// Cancel any pending clear — a new tool is starting, keep effects alive
effectClearTimerRef.current && (clearTimeout(effectClearTimerRef.current), effectClearTimerRef.current = null);
+
+ // create_canvas_node 专用:使用完整 args 直接用本地节点替换 ghost/streaming
const canvasStore = useCanvasStore.getState();
- const CANVAS_EFFECT_MAP: Record void> = {
- create_canvas_node: () => {
- // tool_call has complete args — replace ghost/streaming with real local node immediately
- const nodeType = (args?.node_type as string) || 'text';
- const nodeData = (args?.data as Record) || {};
- // If agent provided explicit position, use it for the local node
- const posX = args?.position_x as number | undefined;
- const posY = args?.position_y as number | undefined;
- const explicitPos = (posX != null && posY != null) ? { x: posX, y: posY } : undefined;
- // Clear streaming args state
- streamingArgsRef.current = { toolName: '', accumulated: '', lastParseLen: 0, replaced: true };
- parseThrottleRef.current && (clearTimeout(parseThrottleRef.current), parseThrottleRef.current = null);
- // Replace ghost/streaming node with a fully-formed local node
- canvasStore.replaceGhostWithLocalNode(nodeType, nodeData, explicitPos);
- },
- get_canvas_node: () => args?.node_id && canvasStore.setNodeEffect(args.node_id as string, 'reading'),
- update_canvas_node: () => args?.node_id && canvasStore.setNodeEffect(args.node_id as string, 'updating'),
- delete_canvas_node: () => args?.node_id && canvasStore.setNodeEffect(args.node_id as string, 'deleting'),
- list_canvas_nodes: () => {
- const effects: Record = {};
- canvasStore.nodes.forEach((n) => { n.type !== 'ghost' && (effects[n.id] = 'scanning'); });
- Object.keys(effects).length > 0 && canvasStore.setNodeEffects(effects);
- },
- create_canvas_edge: () => {
- const effects: Record = {};
- args?.source_node_id && (effects[args.source_node_id as string] = 'connecting');
- args?.target_node_id && (effects[args.target_node_id as string] = 'connecting');
- Object.keys(effects).length > 0 && canvasStore.setNodeEffects(effects);
- },
- };
- CANVAS_EFFECT_MAP[toolName]?.();
+ (toolName === 'create_canvas_node') && (() => {
+ const nodeType = (args?.node_type as string) || 'text';
+ const nodeData = (args?.data as Record) || {};
+ const posX = args?.position_x as number | undefined;
+ const posY = args?.position_y as number | undefined;
+ const explicitPos = (posX != null && posY != null) ? { x: posX, y: posY } : undefined;
+ // Clear streaming args state
+ streamingArgsRef.current = { toolName: '', accumulated: '', lastParseLen: 0, replaced: true };
+ parseThrottleRef.current && (clearTimeout(parseThrottleRef.current), parseThrottleRef.current = null);
+ canvasStore.replaceGhostWithLocalNode(nodeType, nodeData, explicitPos);
+ })();
+
+ // 其余画布工具:对现有节点应用视觉反馈
+ applyCanvasToolEffect(toolName, args);
+ // 媒体生成工具:ghost 预告
+ applyMediaOptimisticNode(toolName);
setMessages((prev) => {
const last = prev[prev.length - 1];
@@ -370,14 +433,18 @@ export function useSSEHandler() {
},
// 视频任务创建(generate_video 工具执行后由后端发送)
+ // 后端 media_canvas_bridge 已并行创建占位节点;前端乐观预建 local-video-
+ // 读焦一致后,syncTheater 会用后端真实节点替换 local-* 占位
video_task_created: () => {
- const d = data as { task_id?: string; video_mode?: string; model?: string };
+ const d = data as { task_id?: string; video_mode?: string; model?: string; prompt?: string };
const task: VideoTaskData = {
task_id: d.task_id || '',
video_mode: d.video_mode || '',
model: d.model || '',
};
state.videoTasks.push(task);
+ const _cStore = useCanvasStore.getState();
+ (d.task_id && _cStore.theaterId) && _cStore.addLocalMediaPlaceholder('video', d.task_id, d.prompt || '');
setMessages((prev) => {
const last = prev[prev.length - 1];
return (last?.role === 'ai' && last?.status === 'streaming')
@@ -388,12 +455,14 @@ export function useSSEHandler() {
// 音乐任务创建(generate_music 工具执行后由后端发送)
music_task_created: () => {
- const d = data as { task_id?: string; model?: string };
+ const d = data as { task_id?: string; model?: string; prompt?: string };
const task: MusicTaskData = {
task_id: d.task_id || '',
model: d.model || '',
};
state.musicTasks.push(task);
+ const _cStore = useCanvasStore.getState();
+ (d.task_id && _cStore.theaterId) && _cStore.addLocalMediaPlaceholder('audio', d.task_id, d.prompt || '');
setMessages((prev) => {
const last = prev[prev.length - 1];
return (last?.role === 'ai' && last?.status === 'streaming')
@@ -402,6 +471,120 @@ export function useSSEHandler() {
});
},
+ // team_tools:团队初始化——作为虚拟 leader 步骤承载后续 leader 自己的工具调用
+ team_created: () => {
+ const d = data as { team_name?: string; agent_name?: string; message?: string };
+ state.multiAgent = state.multiAgent || {
+ steps: state.steps,
+ finalResult: '',
+ totalTokens: { input: 0, output: 0 },
+ creditCost: 0,
+ };
+ state.multiAgent.orchestrationStyle = 'team_tools';
+ state.multiAgent.teamName = d.team_name || '';
+ state.multiAgent.teamDescription = d.message || '';
+ state.multiAgent.leaderName = d.agent_name || '';
+
+ const leaderStep: AgentStep = {
+ subtask_id: LEADER_STEP_ID,
+ agent_name: d.agent_name || 'Leader',
+ description: d.message || '协调团队执行任务',
+ status: 'running',
+ isLeader: true,
+ };
+ state.stepMap.set(LEADER_STEP_ID, leaderStep);
+ state.steps.push(leaderStep);
+ state.leaderStepId = LEADER_STEP_ID;
+
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ const baseContent = last?.role === 'ai' ? last.content : `团队协作中: ${d.team_name || 'Team'}`;
+ const newMsg: Message = {
+ role: 'ai',
+ content: baseContent,
+ status: 'streaming',
+ multi_agent: { ...state.multiAgent!, steps: [...state.steps] },
+ };
+ state.assistantMsg = newMsg;
+ return last?.role === 'ai' ? [...prev.slice(0, -1), newMsg] : [...prev, newMsg];
+ });
+ },
+
+ // team_tools:worker 派生(blueprint 实例化一个 subtask)
+ worker_spawned: () => {
+ const d = data as { worker_key?: string; worker_template_type?: string; agent_name?: string; message?: string };
+ const workerKey = d.worker_key || '';
+ const step: AgentStep = {
+ subtask_id: workerKey,
+ agent_name: d.agent_name || workerKey,
+ description: d.message || '',
+ status: 'running',
+ templateType: d.worker_template_type,
+ };
+ state.stepMap.set(workerKey, step);
+ state.steps.push(step);
+
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ return (last?.role === 'ai')
+ ? [...prev.slice(0, -1), { ...last, multi_agent: { ...state.multiAgent!, steps: [...state.steps] } }]
+ : prev;
+ });
+ },
+
+ // team_tools:leader → worker follow-up,后端同步含子智能体回复内容,前端组装为时间线
+ worker_message: () => {
+ const d = data as { worker_key?: string; agent_name?: string; message?: string; result?: string };
+ const step = state.stepMap.get(d.worker_key || '');
+ step && (step.messages = [...(step.messages || []), { request: d.message || '', reply: d.result }]);
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ return (last?.role === 'ai')
+ ? [...prev.slice(0, -1), { ...last, multi_agent: { ...state.multiAgent!, steps: [...state.steps] } }]
+ : prev;
+ });
+ },
+
+ // team_tools:worker 完成(worker_spawn 内联完成后发送)
+ worker_completed: () => {
+ const d = data as { worker_key?: string; agent_name?: string; result?: string };
+ const step = state.stepMap.get(d.worker_key || '');
+ step && (step.status = 'completed', step.result = d.result, step.agent_name = d.agent_name || step.agent_name);
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ return (last?.role === 'ai')
+ ? [...prev.slice(0, -1), { ...last, multi_agent: { ...state.multiAgent!, steps: [...state.steps] } }]
+ : prev;
+ });
+ },
+
+ // team_tools:worker 解散(leader 主动调用 worker_dismiss)
+ worker_dismissed: () => {
+ const d = data as { worker_key?: string };
+ const step = state.stepMap.get(d.worker_key || '');
+ step && (step.status = 'completed');
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ return (last?.role === 'ai')
+ ? [...prev.slice(0, -1), { ...last, multi_agent: { ...state.multiAgent!, steps: [...state.steps] } }]
+ : prev;
+ });
+ },
+
+ // team_tools:团队解散(team_end),Leader 步骤完成 + finalResult 回填
+ team_dissolved: () => {
+ const d = data as { team_name?: string; result?: string };
+ const leaderStep = state.stepMap.get(LEADER_STEP_ID);
+ leaderStep && (leaderStep.status = 'completed');
+ state.multiAgent && (state.multiAgent.finalResult = d.result || state.multiAgent.finalResult);
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ return (last?.role === 'ai')
+ ? [...prev.slice(0, -1), { ...last, multi_agent: { ...state.multiAgent!, steps: [...state.steps] } }]
+ : prev;
+ });
+ },
+
// 多智能体:子任务创建
subtask_created: () => {
const d = data as { subtask_id?: string; agent?: string; description?: string };
@@ -487,12 +670,37 @@ export function useSSEHandler() {
},
// 多智能体:子任务工具调用开始
+ // team_tools 模式下,leader 自己的工具调用无 subtask_id,需回退到虚拟 leader 步骤上累积
subtask_tool_call: () => {
const d = data as { subtask_id?: string; tool_name?: string; arguments?: Record };
- const step = state.stepMap.get(d.subtask_id || '');
+ const effectiveSubtaskId = d.subtask_id || state.leaderStepId || '';
+ const step = state.stepMap.get(effectiveSubtaskId);
step && (step.tool_calls = [...(step.tool_calls || []), {
tool_name: d.tool_name || '', arguments: d.arguments, status: 'executing' as const,
}]);
+
+ // 与主智能体一致的画布视觉反馈 + 媒体乐观预建
+ effectClearTimerRef.current && (clearTimeout(effectClearTimerRef.current), effectClearTimerRef.current = null);
+
+ // create_canvas_node 专用:与主智能体路径对齐——多智能体无 tool_pending / tool_call_delta
+ // 事件(agent_executor 显式过滤了内联信号前缀),仅在 subtask_tool_call 到达时
+ // 一次性拿到完整 args。此处立即用完整数据创建本地节点,避免用户等待 syncTheater 拉取
+ // 期间看到「已创建但内容为空」的空节点。local- 前缀会在真实节点回来后被 syncTheater
+ // 自动替换并继承位置。
+ const canvasStore = useCanvasStore.getState();
+ (d.tool_name === 'create_canvas_node') && (() => {
+ const args = d.arguments || {};
+ const nodeType = (args.node_type as string) || 'text';
+ const nodeData = (args.data as Record) || {};
+ const posX = args.position_x as number | undefined;
+ const posY = args.position_y as number | undefined;
+ const explicitPos = (posX != null && posY != null) ? { x: posX, y: posY } : undefined;
+ canvasStore.replaceGhostWithLocalNode(nodeType, nodeData, explicitPos);
+ })();
+
+ applyCanvasToolEffect(d.tool_name || '', d.arguments);
+ applyMediaOptimisticNode(d.tool_name || '');
+
setMessages((prev) => {
const last = prev[prev.length - 1];
return (last?.role === 'ai')
@@ -504,14 +712,22 @@ export function useSSEHandler() {
// 多智能体:子任务工具调用完成
subtask_tool_result: () => {
const d = data as { subtask_id?: string; tool_name?: string; success?: boolean; result?: string };
- const step = state.stepMap.get(d.subtask_id || '');
+ const effectiveSubtaskId = d.subtask_id || state.leaderStepId || '';
+ const step = state.stepMap.get(effectiveSubtaskId);
const tool = step?.tool_calls?.find((t) => t.tool_name === d.tool_name && t.status === 'executing');
tool && (tool.status = 'completed', tool.result = d.result);
- // 画布工具完成时触发前端刷新
- const canvasToolNames = ['create_canvas_node', 'update_canvas_node', 'delete_canvas_node', 'batch_create_nodes', 'edit_image'];
+ // 画布/媒体工具完成后触发后端同步,本地 ghost/local-* 会被真实节点替换
+ // 多子智能体并发时会短时间内发出大量 tool_result,统一走 debounce 降低后端拉取压力
const _cStore = useCanvasStore.getState();
- (canvasToolNames.includes(d.tool_name || '') && d.success && _cStore.theaterId) && _cStore.syncTheater(_cStore.theaterId);
+ (CANVAS_SYNC_TOOL_NAMES.has(d.tool_name || '') && d.success && _cStore.theaterId) && scheduleSyncTheater(_cStore.theaterId, 400);
+
+ // Debounced clear:与单智能体 tool_result 一致的效果清理策略
+ effectClearTimerRef.current && clearTimeout(effectClearTimerRef.current);
+ effectClearTimerRef.current = setTimeout(() => {
+ useCanvasStore.getState().clearAllNodeEffects();
+ effectClearTimerRef.current = null;
+ }, 1500);
setMessages((prev) => {
const last = prev[prev.length - 1];
@@ -718,10 +934,8 @@ export function useSSEHandler() {
// For local nodes (from tool_call immediate creation), sync immediately.
const hasLocalNodes = store.nodes.some((n) => n.id.startsWith('local-'));
const syncDelay = hasLocalNodes ? 0 : (hasGhostNodes || hasStreamingNodes) ? 1200 : 0;
- setTimeout(() => {
- const s = useCanvasStore.getState();
- (s.theaterId === theater_id) && s.syncTheater(theater_id);
- }, syncDelay);
+ // 同样走 debounce,合并同一时间多个 canvas_updated / subtask_tool_result 的拉取请求
+ scheduleSyncTheater(theater_id, Math.max(syncDelay, 400));
},
// 上下文压缩开始(电池图标显示 loading 动画)
@@ -827,7 +1041,7 @@ export function useSSEHandler() {
};
handlers[eventType]?.();
- }, [setMessages, resetStreamingState, updateCredits, setContextUsage, updateChatTitleInList]);
+ }, [setMessages, resetStreamingState, updateCredits, setContextUsage, setIsCompacting, updateChatTitleInList, scheduleSyncTheater]);
return {
parseSSELine,
diff --git a/frontend/src/components/ai-assistant/hooks/useSessionManager.ts b/frontend/src/components/ai-assistant/hooks/useSessionManager.ts
index 983459ec..80401d20 100644
--- a/frontend/src/components/ai-assistant/hooks/useSessionManager.ts
+++ b/frontend/src/components/ai-assistant/hooks/useSessionManager.ts
@@ -147,11 +147,14 @@ export function useSessionManager() {
: null;
setContextUsage(resolvedContextUsage);
- // 反序列化消息历史
- const historyMessages = messagesRes.data.map((m: { role: string; content: string }) => ({
+ // 反序列化消息历史(包含扩展字段:skill_calls, tool_calls, multi_agent)
+ const historyMessages = messagesRes.data.map((m: { role: string; content: string; skill_calls?: unknown[]; tool_calls?: unknown[]; multi_agent?: unknown }) => ({
role: m.role === 'assistant' ? 'ai' : m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
status: 'complete' as const,
+ ...(m.skill_calls?.length ? { skill_calls: m.skill_calls } : {}),
+ ...(m.tool_calls?.length ? { tool_calls: m.tool_calls } : {}),
+ ...(m.multi_agent ? { multi_agent: m.multi_agent } : {}),
}));
const finalMessages = historyMessages.length > 0 ? historyMessages : [...DEFAULT_MESSAGES];
setMessages(finalMessages);
diff --git a/frontend/src/components/canvas/AIAssistantPanel.tsx b/frontend/src/components/canvas/AIAssistantPanel.tsx
index f9b0161f..bac7491a 100644
--- a/frontend/src/components/canvas/AIAssistantPanel.tsx
+++ b/frontend/src/components/canvas/AIAssistantPanel.tsx
@@ -246,6 +246,8 @@ export function AIAssistantPanel() {
abortControllerRef.current = null;
resetStreamingState();
setIsLoading(false);
+ // 释放 auto-save(与 handleSend 的 finally 对称)
+ useCanvasStore.getState().setAiBusy(false);
// 将最后一条流式消息标记为完成
setMessages((prev) => {
const last = prev[prev.length - 1];
@@ -281,6 +283,9 @@ export function AIAssistantPanel() {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
+ // 标记 AI 推理开始,避免该期间 auto-save 与后端 media_canvas_bridge 争抢 SQLite 写锁
+ useCanvasStore.getState().setAiBusy(true);
+
// 添加用户消息 + 空的AI流式消息(触发思考面板显示)
setMessages((prev) => [
...prev,
@@ -402,6 +407,8 @@ export function AIAssistantPanel() {
}
} finally {
setIsLoading(false);
+ // 标记 AI 推理结束,释放 auto-save
+ useCanvasStore.getState().setAiBusy(false);
clearImageEditContext();
clearNodeAttachments();
clearUploadedFiles();
diff --git a/frontend/src/components/canvas/MultiAgentSteps.tsx b/frontend/src/components/canvas/MultiAgentSteps.tsx
index 1e37a9f5..a3267b3a 100644
--- a/frontend/src/components/canvas/MultiAgentSteps.tsx
+++ b/frontend/src/components/canvas/MultiAgentSteps.tsx
@@ -1,35 +1,73 @@
'use client';
import React, { useState } from 'react';
-import { ChevronDown, ChevronUp, Bot, CheckCircle2, Circle, XCircle, Loader2, RefreshCw, Zap } from 'lucide-react';
+import { ChevronDown, ChevronUp, Bot, CheckCircle2, Circle, XCircle, Loader2, RefreshCw, Zap, Crown, Image as ImageIcon, Video, Music, MessagesSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
+import type { AgentStep, MultiAgentData, ToolCall } from '@/store/useAIAssistantStore';
-export interface AgentStep {
- subtask_id: string;
- agent_name: string;
- description: string;
- status: 'pending' | 'running' | 'completed' | 'failed' | 'retrying';
- result?: string;
- error?: string;
- tokens?: { input: number; output: number };
- // Harness: 重试/熔断信息
- retryCount?: number;
- maxRetries?: number;
- circuitBreaker?: boolean;
-}
-
-export interface MultiAgentData {
- steps: AgentStep[];
- finalResult: string;
- totalTokens: { input: number; output: number };
- creditCost: number;
-}
+// 重新导出以保持外部消费者向后兼容(旧代码可能 import 自本组件)
+export type { AgentStep, MultiAgentData };
interface MultiAgentStepsProps extends MultiAgentData {
className?: string;
}
-export default function MultiAgentSteps({ steps, finalResult, totalTokens, creditCost, className }: MultiAgentStepsProps) {
+// 状态图标映射表(避免 if-else 链)
+const STATUS_ICON_MAP: Record = {
+ completed: ,
+ failed: ,
+ running: ,
+ retrying: ,
+ pending: ,
+};
+
+// 媒体生成工具 → 展示图标映射(chip 展示)
+const MEDIA_TOOL_ICON_MAP: Record = {
+ generate_image: ,
+ edit_image: ,
+ generate_video: ,
+ edit_video: ,
+ generate_music: ,
+};
+
+const MEDIA_TOOL_LABEL_MAP: Record = {
+ generate_image: '图像',
+ edit_image: '编图',
+ generate_video: '视频',
+ edit_video: '改视频',
+ generate_music: '音乐',
+};
+
+/**
+ * 单个媒体工具 chip —— 展示图标 + 名称,tooltip 显示 prompt。
+ */
+function MediaToolChip({ toolCall }: { toolCall: ToolCall }) {
+ const icon = MEDIA_TOOL_ICON_MAP[toolCall.tool_name];
+ const label = MEDIA_TOOL_LABEL_MAP[toolCall.tool_name] || toolCall.tool_name;
+ const prompt = (toolCall.arguments?.prompt as string) || '';
+ const isRunning = toolCall.status === 'executing';
+
+ return (
+
+ {isRunning ? : icon}
+ {label}
+ {isRunning && 生成中}
+
+ );
+}
+
+export default function MultiAgentSteps({
+ steps, finalResult, totalTokens, creditCost, className,
+ orchestrationStyle, teamName, leaderName,
+}: MultiAgentStepsProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [expandedSteps, setExpandedSteps] = useState>(new Set());
@@ -41,86 +79,131 @@ export default function MultiAgentSteps({ steps, finalResult, totalTokens, credi
});
};
- const STATUS_ICON_MAP: Record = {
- completed: ,
- failed: ,
- running: ,
- retrying: ,
- pending: ,
- };
-
- const getStatusIcon = (status: AgentStep['status']) => STATUS_ICON_MAP[status] ?? STATUS_ICON_MAP.pending;
-
const completedCount = steps.filter(s => s.status === 'completed').length;
const isAllCompleted = completedCount === steps.length && steps.length > 0;
+ // team_tools 模式:显示"团队协作: {teamName}";legacy_json 模式:保持"多智能体协作"
+ const isTeamMode = orchestrationStyle === 'team_tools';
+ const workerCount = steps.filter(s => !s.isLeader).length;
+ const workerCompleted = steps.filter(s => !s.isLeader && s.status === 'completed').length;
+
+ const headerLabel = isTeamMode
+ ? `团队协作:${teamName || 'Team'}`
+ : `多智能体协作 ${isAllCompleted ? '已完成' : `(${completedCount}/${steps.length})`}`;
+ const subLabel = isTeamMode
+ ? (leaderName ? `Leader: ${leaderName} · ${workerCompleted}/${workerCount} Worker 完成` : `${workerCompleted}/${workerCount} Worker 完成`)
+ : '';
+
return (
{/* 协作概览 */}
-
setIsExpanded(!isExpanded)}
>
-
- 多智能体协作 {isAllCompleted ? '已完成' : `(${completedCount}/${steps.length})`}
-
+
+
{headerLabel}
+ {subLabel &&
{subLabel}
}
+
{isExpanded ?
:
}
{/* 步骤详情 */}
{isExpanded && (
- {steps.map((step, index) => (
-
-
toggleStep(step.subtask_id)}
- >
- {getStatusIcon(step.status)}
-
-
-
{step.agent_name}
-
步骤 {index + 1}
- {/* Harness: 重试计数标签 */}
- {step.retryCount != null && step.retryCount > 0 && (
-
- 重试 {step.retryCount}/{step.maxRetries ?? '?'}
-
- )}
- {/* Harness: 熔断标记 */}
- {step.circuitBreaker && (
-
- 熔断
+ {steps.map((step, index) => {
+ const mediaCalls = (step.tool_calls || []).filter(t => MEDIA_TOOL_ICON_MAP[t.tool_name]);
+ return (
+
+
toggleStep(step.subtask_id)}
+ >
+ {STATUS_ICON_MAP[step.status] ?? STATUS_ICON_MAP.pending}
+
+
+ {step.isLeader && }
+ {step.agent_name}
+
+ {step.isLeader ? 'Leader' : `步骤 ${index + 1}`}
+ {step.templateType && (
+
+ {step.templateType}
+
+ )}
+ {/* Harness: 重试计数标签 */}
+ {step.retryCount != null && step.retryCount > 0 && (
+
+ 重试 {step.retryCount}/{step.maxRetries ?? '?'}
+
+ )}
+ {/* Harness: 熔断标记 */}
+ {step.circuitBreaker && (
+
+ 熔断
+
+ )}
+ {/* worker_message 数量标记 */}
+ {step.messages && step.messages.length > 0 && (
+
+ {step.messages.length}
+
+ )}
+
+
{step.description}
+ {/* 媒体工具 chip 行 */}
+ {mediaCalls.length > 0 && (
+
+ {mediaCalls.map((tc, i) => (
+
+ ))}
+
)}
-
{step.description}
+ {expandedSteps.has(step.subtask_id) ?
+
:
+
+ }
- {expandedSteps.has(step.subtask_id) ?
-
:
-
- }
+
+ {/* 步骤展开区:worker_say 时间线 + 结果 + 错误 */}
+ {expandedSteps.has(step.subtask_id) && (step.result || step.error || (step.messages?.length ?? 0) > 0) && (
+
+ {/* worker_message 时间线(team_tools 特有) */}
+ {step.messages && step.messages.length > 0 && (
+
+ {step.messages.map((m, i) => (
+
+
→ Leader 追问
+
{m.request}
+ {m.reply && (
+ <>
+
← Worker 回复
+
{m.reply}
+ >
+ )}
+
+ ))}
+
+ )}
+ {step.error ? (
+
{step.error}
+ ) : step.result ? (
+
{step.result}
+ ) : null}
+ {step.tokens && (
+
+ Tokens: {step.tokens.input} in / {step.tokens.output} out
+
+ )}
+
+ )}
-
- {/* 步骤结果 */}
- {expandedSteps.has(step.subtask_id) && (step.result || step.error) && (
-
- {step.error ? (
-
{step.error}
- ) : (
-
{step.result}
- )}
- {step.tokens && (
-
- Tokens: {step.tokens.input} in / {step.tokens.output} out
-
- )}
-
- )}
-
- ))}
-
+ );
+ })}
+
{/* 统计信息 */}
总Tokens: {totalTokens.input} in / {totalTokens.output} out
diff --git a/frontend/src/store/useAIAssistantStore.ts b/frontend/src/store/useAIAssistantStore.ts
index 805daaf1..863e8adb 100644
--- a/frontend/src/store/useAIAssistantStore.ts
+++ b/frontend/src/store/useAIAssistantStore.ts
@@ -34,14 +34,28 @@ export interface AgentStep {
// 子任务工具调用记录
tool_calls?: ToolCall[];
circuitBreaker?: boolean;
+ // team_tools 编排扩展:worker 蓝图类型 / leader 标识 / worker_say 消息时间线
+ templateType?: string;
+ isLeader?: boolean;
+ messages?: { request: string; reply?: string }[];
}
+// 多智能体编排风格(后端 orchestrator 决定)
+// - legacy_json: 传统 subtask 分解模式
+// - team_tools: Leader 通过 team_create / worker_spawn / ... 工具增量组建团队
+export type OrchestrationStyle = 'legacy_json' | 'team_tools';
+
// 多智能体数据
export interface MultiAgentData {
steps: AgentStep[];
finalResult: string;
totalTokens: { input: number; output: number };
creditCost: number;
+ // team_tools 编排扩展
+ orchestrationStyle?: OrchestrationStyle;
+ teamName?: string;
+ teamDescription?: string;
+ leaderName?: string;
}
// 多模态内容
diff --git a/frontend/src/store/useCanvasStore.ts b/frontend/src/store/useCanvasStore.ts
index c47bf72a..0a290a33 100644
--- a/frontend/src/store/useCanvasStore.ts
+++ b/frontend/src/store/useCanvasStore.ts
@@ -191,6 +191,10 @@ interface CanvasState {
lastSavedAt: number | null;
isDirty: boolean;
isSyncing: boolean;
+
+ // AI 当前是否正在推理(多智能体/单智能体 SSE 流未完结)
+ // 供 auto-save 判断是否跳过保存,避免与后端 media_canvas_bridge 并发写入 SQLite 时撞锁
+ isAiBusy: boolean;
// History
history: HistoryState[];
@@ -251,6 +255,15 @@ interface CanvasState {
addGhostNode: (nodeType: string, positionX?: number, positionY?: number) => void;
removeGhostNodes: () => void;
+ // Local media placeholder for optimistic pre-creation.
+ // Backend media_canvas_bridge 会在异步任务完成后创建真实节点,
+ // syncTheater 的 local- 前缀合并逻辑会自动用后端节点替换本地占位。
+ // 返回占位节点的 id(便于后续追踪/取消)。
+ addLocalMediaPlaceholder: (mediaType: 'video' | 'audio' | 'image', taskId: string, prompt: string) => string;
+
+ // AI busy 开关:SSE handler 在推理开始/结束时调用
+ setAiBusy: (busy: boolean) => void;
+
// Streaming node: progressive storyboard creation from tool argument deltas
updateStreamingNode: (partialData: Partial) => void;
replaceGhostWithStreamingNode: (data: Partial) => void;
@@ -430,6 +443,8 @@ export const useCanvasStore = create()(
lastSavedAt: null,
isDirty: false,
isSyncing: false,
+ isAiBusy: false,
+ setAiBusy: (busy: boolean) => set({ isAiBusy: busy }),
history: [],
historyIndex: -1,
@@ -951,6 +966,49 @@ export const useCanvasStore = create()(
(filtered.length !== nodes.length) && set({ nodes: filtered });
},
+ // Optimistic media placeholder(video/audio/image)
+ // Backend media_canvas_bridge 会将异步任务结果回填到真实节点。
+ // 使用 local- 前缀命名,syncTheater 合并阶段将自动用后端节点替换本地占位。
+ addLocalMediaPlaceholder: (mediaType: 'video' | 'audio' | 'image', taskId: string, prompt: string) => {
+ const { nodes } = get();
+ // 防重:已存在相同 taskId 的占位就直接返回现有 id
+ const existing = nodes.find((n) => n.id === `local-${mediaType}-${taskId}`);
+ if (existing) return existing.id;
+
+ const { x, y } = calcAutoPosition(nodes);
+ const shortPrompt = prompt.length > 80 ? `${prompt.slice(0, 80)}...` : prompt;
+ const placeholderName = mediaType === 'video'
+ ? 'Generating Video'
+ : mediaType === 'audio' ? 'Generating Music' : 'Generating Image';
+
+ // 不同媒体类型的 data payload 字段与后端 media_canvas_bridge 保持一致
+ const dataBuilders: Record<'video' | 'audio' | 'image', () => Record> = {
+ video: () => ({ name: placeholderName, description: shortPrompt, videoUrl: '', fitMode: 'cover', _generating: true }),
+ audio: () => ({ name: placeholderName, description: shortPrompt, audioUrl: '', lyrics: '', _generating: true }),
+ image: () => ({ name: placeholderName, description: shortPrompt, imageUrl: '', fitMode: 'cover', _generating: true }),
+ };
+
+ const localId = `local-${mediaType}-${taskId}`;
+ const placeholderNode: CanvasNode = {
+ id: localId,
+ type: mediaType,
+ position: { x, y },
+ width: 420,
+ height: 300,
+ data: dataBuilders[mediaType]() as VideoNodeData | AudioNodeData | CharacterNodeData,
+ };
+ set({ nodes: [...nodes, placeholderNode] });
+
+ // 与 ghost node 一样触发自动居中,确保用户看到新创建的占位
+ typeof window !== 'undefined' && window.dispatchEvent(
+ new CustomEvent('ghost-node-added', {
+ detail: { x: x + 210, y: y + 150 },
+ })
+ );
+
+ return localId;
+ },
+
// Replace ghost node with a streaming storyboard node (first delta data arrived)
replaceGhostWithStreamingNode: (data: Partial) => {
const { nodes } = get();