diff --git a/crates/flock-agent/src/engine/run/run.rs b/crates/flock-agent/src/engine/run/run.rs index f3c1a441..f63ebae8 100644 --- a/crates/flock-agent/src/engine/run/run.rs +++ b/crates/flock-agent/src/engine/run/run.rs @@ -227,6 +227,29 @@ impl AgentEngine { pub async fn run(&mut self, user_input: &str, msg_id: &str) -> Result { let (initial_json, config) = prepare_run(self, user_input, msg_id).await?; + // ── 批量 token 缓冲 ────────────────────────────────────────────────── + // 每积累 BATCH_SIZE 个 token 才统一 emit 一次,减少 IPC 调用开销。 + // 值越小越流畅但 IPC 越频繁,值越大越节省 IPC 但打字机延迟增大。 + // 4 表示约每 4 个 token emit 一次,对 deepseek/claude 高吞吐场景效果明显。 + const BATCH_SIZE: usize = 4; + let mut text_buf = String::new(); + let mut thinking_buf = String::new(); + let mut batch_count: usize = 0; + + macro_rules! flush_batch { + () => { + if !thinking_buf.is_empty() { + self.output.emit_thinking(&thinking_buf, msg_id); + thinking_buf.clear(); + } + if !text_buf.is_empty() { + self.output.emit_text_delta(&text_buf, msg_id); + text_buf.clear(); + } + batch_count = 0; + }; + } + let mut stream = self.graph.as_ref().unwrap().astream( &initial_json, &config, @@ -236,6 +259,7 @@ impl AgentEngine { while let Some(part_res) = stream.next().await { // Check if execution was canceled if self.cancel_flag.load(Ordering::Relaxed) { + flush_batch!(); self.output.emit_info("[engine] cancel_flag is set during stream, aborting run"); self.sync_and_save_session(&config).await; return Err(AgentError::UserAborted); @@ -247,16 +271,27 @@ impl AgentEngine { let type_str = part_res.data.get("type").and_then(|v| v.as_str()).unwrap_or("content"); if let Some(chunk) = part_res.data.get("chunk").and_then(|v| v.as_str()) { if type_str == "thinking" { - self.output.emit_thinking(chunk, msg_id); + thinking_buf.push_str(chunk); } else { - self.output.emit_text_delta(chunk, msg_id); + text_buf.push_str(chunk); + } + batch_count += 1; + if batch_count >= BATCH_SIZE { + flush_batch!(); } } } } + } else { + // 非 token 事件到达(Updates mode:tool_request、stream_end 等),先 flush 缓冲 + flush_batch!(); } } + // 流结束后确保剩余缓冲全部发出 + flush_batch!(); + + // Stream completed, check if any internal error was raised (like user cancellation in FlockToolNode) let err_opt = self.has_error.lock().unwrap().clone(); if let Some(err) = err_opt { diff --git a/crates/flock-core/src/db/mod.rs b/crates/flock-core/src/db/mod.rs index e19b5f72..f8a7701d 100644 --- a/crates/flock-core/src/db/mod.rs +++ b/crates/flock-core/src/db/mod.rs @@ -83,6 +83,10 @@ impl DbManager { .execute(&pool) .await?; + sqlx::query("PRAGMA synchronous=NORMAL") + .execute(&pool) + .await?; + let mgr = Self { pool, db_path }; mgr.run_migrations().await?; mgr.seed_builtin_providers().await?; diff --git a/flock-ui/package-lock.json b/flock-ui/package-lock.json index 57e6bc6d..3721ed22 100644 --- a/flock-ui/package-lock.json +++ b/flock-ui/package-lock.json @@ -15,6 +15,7 @@ "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-virtual": "^3.14.2", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@types/react-syntax-highlighter": "^15.5.13", @@ -1444,6 +1445,31 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tauri-apps/api": { "version": "2.11.0", "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz", diff --git a/flock-ui/package.json b/flock-ui/package.json index fc6fbdd6..49b967d0 100644 --- a/flock-ui/package.json +++ b/flock-ui/package.json @@ -18,6 +18,7 @@ "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-virtual": "^3.14.2", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/flock-ui/src-tauri/src/ipc/emitter.rs b/flock-ui/src-tauri/src/ipc/emitter.rs index 6c1798b3..b782e058 100644 --- a/flock-ui/src-tauri/src/ipc/emitter.rs +++ b/flock-ui/src-tauri/src/ipc/emitter.rs @@ -17,17 +17,17 @@ impl TauriProtocolEmitter { pub fn new(app: AppHandle, session_id: String) -> Self { Self { app, session_id } } + + fn get_session_id(&self) -> String { + flock_core::CURRENT_SESSION_ID + .try_with(|id| id.clone()) + .unwrap_or_else(|_| self.session_id.clone()) + } } impl ProtocolEmitter for TauriProtocolEmitter { fn emit(&self, event: &ProtocolEvent) -> std::io::Result<()> { - // 优先使用 task-local(engine.run 内正确反映当前并发 session) - // 若不在作用域(如 start_agent 阶段、drop 清理 task)则退回到构造时绑定的 session_id - let session_id = flock_core::CURRENT_SESSION_ID - .try_with(|id| id.clone()) - .unwrap_or_else(|_| self.session_id.clone()); - - // log::info!("[TauriProtocolEmitter::emit] Emitting event: {:?}, session_id: {}", event, session_id); + let session_id = self.get_session_id(); let mut value = serde_json::to_value(event).map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to serialize event: {e}")) @@ -127,3 +127,60 @@ impl OutputSink for TauriProtocolEmitter { }); } } + +/// 批量 token emitter,将高频 text_delta / thinking 合并后再发给前端, +/// 显著减少 IPC 调用次数,提高打字机流畅度。 +/// +/// 使用方式:在 engine run 循环里构造 BatchEmitter, +/// 每次收到 token chunk 调用 push_text / push_thinking, +/// 在下一个非流 chunk(tool_request/stream_end)到来前调用 flush。 +pub struct BatchEmitter<'a> { + inner: &'a TauriProtocolEmitter, + msg_id: String, + text_buf: String, + thinking_buf: String, +} + +impl<'a> BatchEmitter<'a> { + pub fn new(inner: &'a TauriProtocolEmitter, msg_id: &str) -> Self { + Self { + inner, + msg_id: msg_id.to_string(), + text_buf: String::new(), + thinking_buf: String::new(), + } + } + + /// 追加一段 text_delta token + pub fn push_text(&mut self, text: &str) { + self.text_buf.push_str(text); + } + + /// 追加一段 thinking token + pub fn push_thinking(&mut self, text: &str) { + self.thinking_buf.push_str(text); + } + + /// 将缓冲区内容一次性发给前端,清空缓冲 + pub fn flush(&mut self) { + if !self.thinking_buf.is_empty() { + let _ = self.inner.emit(&ProtocolEvent::Thinking { + text: std::mem::take(&mut self.thinking_buf), + msg_id: self.msg_id.clone(), + }); + } + if !self.text_buf.is_empty() { + let _ = self.inner.emit(&ProtocolEvent::TextDelta { + text: std::mem::take(&mut self.text_buf), + msg_id: self.msg_id.clone(), + }); + } + } +} + +impl<'a> Drop for BatchEmitter<'a> { + fn drop(&mut self) { + // 确保析构时缓冲区不丢失 + self.flush(); + } +} diff --git a/flock-ui/src/components/chat/assistant/AssistantChatPanel.tsx b/flock-ui/src/components/chat/assistant/AssistantChatPanel.tsx index 7c72d760..b2b8cf11 100644 --- a/flock-ui/src/components/chat/assistant/AssistantChatPanel.tsx +++ b/flock-ui/src/components/chat/assistant/AssistantChatPanel.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef } from 'react'; -import { ScrollArea, Stack } from '@mantine/core'; +import { useEffect, useRef, useCallback } from 'react'; +import { Box } from '@mantine/core'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { ChatMessage } from '@/types/protocol'; import { EmptyState } from './components/EmptyState'; import { MessageBubble } from './components/MessageBubble'; @@ -9,24 +10,108 @@ interface ChatPanelProps { } export function AssistantChatPanel({ messages }: ChatPanelProps) { - const bottomRef = useRef(null); + const parentRef = useRef(null); + const scrollDebounceRef = useRef | null>(null); + const isStreamingRef = useRef(false); + // 检测是否正在流式输出(最后一条消息的 streaming 字段) + const lastMsg = messages[messages.length - 1]; + const isStreaming = !!lastMsg?.streaming; + isStreamingRef.current = isStreaming; + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 120, // 每条消息预估高度,虚拟化引擎会动态修正 + overscan: 5, // 上下多渲染 5 条,避免滚动时白屏 + measureElement: (el) => el?.getBoundingClientRect().height ?? 120, + }); + + // 平滑滚动到底部,防抖避免每个 token 都触发 + const scrollToBottom = useCallback((instant = false) => { + if (!parentRef.current) return; + const el = parentRef.current; + if (instant) { + el.scrollTop = el.scrollHeight; + } else { + if (scrollDebounceRef.current) clearTimeout(scrollDebounceRef.current); + scrollDebounceRef.current = setTimeout(() => { + el.scrollTop = el.scrollHeight; + }, 80); // 80ms debounce,约 12fps 的滚动触发频率,流畅且不跟不上 + } + }, []); + + // 消息数量变化时(新消息到来)立刻滚到底部 + useEffect(() => { + scrollToBottom(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]); + + // 流式 streaming 期间,内容增长时 debounce scroll + useEffect(() => { + if (isStreaming) { + scrollToBottom(false); + } else { + // streaming 刚结束,立刻滚一次确保看到完整回复 + scrollToBottom(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isStreaming, lastMsg?.chunks?.length]); + + // 组件卸载时清理 debounce timer useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + return () => { + if (scrollDebounceRef.current) clearTimeout(scrollDebounceRef.current); + }; + }, []); if (messages.length === 0) { return ; } + const virtualItems = virtualizer.getVirtualItems(); + return ( - - - {messages.map((msg) => ( - - ))} -
- - + + {/* 虚拟列表容器,高度由 virtualizer 管理 */} + + + {virtualItems.map((virtualRow) => ( + + + + ))} + + + ); } diff --git a/flock-ui/src/components/chat/assistant/components/MessageBubble.tsx b/flock-ui/src/components/chat/assistant/components/MessageBubble.tsx index d42ff0f7..bd345fa4 100644 --- a/flock-ui/src/components/chat/assistant/components/MessageBubble.tsx +++ b/flock-ui/src/components/chat/assistant/components/MessageBubble.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { Box, Stack, Paper, Text, Group, Avatar, Loader, Badge, Button } from '@mantine/core'; import { IconUser, IconRobot } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; @@ -56,7 +57,9 @@ interface ChunkRendererProps { isStreaming: boolean; } -function ChunkRenderer({ chunk, isStreaming }: ChunkRendererProps) { +// memo 包裹:只有当 chunk 内容或 isStreaming 变化时才重新渲染 +// 对于历史 chunk(非最后一个)isStreaming=false,内容不变,完全跳过渲染 +const ChunkRenderer = memo(function ChunkRenderer({ chunk, isStreaming }: ChunkRendererProps) { if (chunk.kind === 'text') { return ( @@ -111,7 +114,7 @@ function ChunkRenderer({ chunk, isStreaming }: ChunkRendererProps) { return ; } return null; -} +}); interface MessageBubbleProps { message: ChatMessage; diff --git a/flock-ui/src/components/chat/assistant/components/ThinkingBlock.tsx b/flock-ui/src/components/chat/assistant/components/ThinkingBlock.tsx index 2565e2cc..5d01125a 100644 --- a/flock-ui/src/components/chat/assistant/components/ThinkingBlock.tsx +++ b/flock-ui/src/components/chat/assistant/components/ThinkingBlock.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { Box, Group, Text, ActionIcon, Collapse } from '@mantine/core'; +import { useState, memo } from 'react'; +import { Box, Group, Text, ActionIcon } from '@mantine/core'; import { IconBrain, IconChevronRight, IconChevronDown } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; @@ -8,10 +8,12 @@ interface ThinkingBlockProps { defaultCollapsed: boolean; } -export function ThinkingBlock({ text, defaultCollapsed }: ThinkingBlockProps) { +// memo 包裹:thinking 流式期间 text 持续变化,但 collapsed 不变, +// 仅在 text 或 collapsed 变化时重渲染 +export const ThinkingBlock = memo(function ThinkingBlock({ text, defaultCollapsed }: ThinkingBlockProps) { const [collapsed, setCollapsed] = useState(defaultCollapsed); const { t } = useTranslation(); - + return ( setCollapsed((v) => !v)} > @@ -35,21 +37,37 @@ export function ThinkingBlock({ text, defaultCollapsed }: ThinkingBlockProps) { {collapsed ? : } - - + {/* 用
 直接渲染纯文本,无 Markdown 解析开销 */}
+        
           {text}
-        
-      
+        
+ ); -} +}); diff --git a/flock-ui/src/components/chat/shared/MarkdownRenderer.tsx b/flock-ui/src/components/chat/shared/MarkdownRenderer.tsx index a5b3dcd3..22318d61 100644 --- a/flock-ui/src/components/chat/shared/MarkdownRenderer.tsx +++ b/flock-ui/src/components/chat/shared/MarkdownRenderer.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { Box, Text, @@ -66,7 +67,7 @@ function normalizeFileSrc(src: string): string { return convertFileSrc(normPath); } -export function MarkdownRenderer({ content }: MarkdownRendererProps) { +export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: MarkdownRendererProps) { const { t } = useTranslation(); const theme = useUiStore((s) => s.theme); const isDark = theme === 'dark'; @@ -196,4 +197,4 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) { {processedContent} ); -} +}); diff --git a/flock-ui/src/hooks/useEventStream.ts b/flock-ui/src/hooks/useEventStream.ts index 1516f4e3..db850995 100644 --- a/flock-ui/src/hooks/useEventStream.ts +++ b/flock-ui/src/hooks/useEventStream.ts @@ -51,11 +51,21 @@ export function useEventStream() { console.error('[agent-event] JSON parse error:', e, event.payload); } }); + if (cancelled) { + agentUnlisten(); + return; + } + unlistenRefs.current.push(agentUnlisten); const stoppedUnlisten = await listen('agent-stopped', () => { if (cancelled) return; setStatus('disconnected'); }); + if (cancelled) { + stoppedUnlisten(); + return; + } + unlistenRefs.current.push(stoppedUnlisten); const stderrUnlisten = await listen('agent-stderr', (event) => { if (cancelled) return; @@ -64,6 +74,11 @@ export function useEventStream() { setError(`Agent stderr: ${line}`); } }); + if (cancelled) { + stderrUnlisten(); + return; + } + unlistenRefs.current.push(stderrUnlisten); const workflowUnlisten = await listen('workflow-event', (event) => { if (cancelled) return; @@ -99,23 +114,22 @@ export function useEventStream() { console.error('[workflow-event] map error:', e, event.payload); } }); + if (cancelled) { + workflowUnlisten(); + return; + } + unlistenRefs.current.push(workflowUnlisten); const cronUnlisten = await listen('cron-job-updated', () => { if (cancelled) return; queryClient.invalidateQueries({ queryKey: ['cron_jobs'] }); queryClient.invalidateQueries({ queryKey: ['conversations'] }); }); - - if (!cancelled) { - unlistenRefs.current = [agentUnlisten, stoppedUnlisten, stderrUnlisten, workflowUnlisten, cronUnlisten]; - } else { - // 如果组件已经卸载,立刻清理 - agentUnlisten(); - stoppedUnlisten(); - stderrUnlisten(); - workflowUnlisten(); + if (cancelled) { cronUnlisten(); + return; } + unlistenRefs.current.push(cronUnlisten); } catch (e) { // 在非 Tauri 环境(如浏览器 dev)下忽略 console.warn('[useEventStream] Failed to set up Tauri listeners:', e); diff --git a/flock-ui/src/hooks/useWorkflowRuntime.ts b/flock-ui/src/hooks/useWorkflowRuntime.ts index 428e2258..a73bb6b2 100644 --- a/flock-ui/src/hooks/useWorkflowRuntime.ts +++ b/flock-ui/src/hooks/useWorkflowRuntime.ts @@ -65,29 +65,33 @@ interface UseWorkflowRuntimeOptions { isDebug?: boolean; } +const EMPTY_ARRAY: WorkflowExecutionMessage[] = []; + export function useWorkflowRuntime({ workflowId, threadId, isDebug = false, }: UseWorkflowRuntimeOptions) { const { t } = useTranslation(); - const store = useWorkflowStore(); + + // ── 细粒度 Selector 订阅,使用静态的 EMPTY_ARRAY 避免 [] 引用变化引起的无限重绘 ── + const messages = useWorkflowStore(useCallback((s) => (threadId && s.threadExecutions[threadId]?.messages) || EMPTY_ARRAY, [threadId])); + const status = useWorkflowStore(useCallback((s) => (threadId && s.threadExecutions[threadId]?.status) || 'idle', [threadId])); + const activeInterrupt = useWorkflowStore(useCallback((s) => (threadId && s.threadExecutions[threadId]?.interrupt) || null, [threadId])); + + const appendThreadMessage = useWorkflowStore((s) => s.appendThreadMessage); + const setThreadStatus = useWorkflowStore((s) => s.setThreadStatus); + const setThreadInterrupt = useWorkflowStore((s) => s.setThreadInterrupt); + const clearExecutionAction = useWorkflowStore((s) => s.clearExecution); + const clearThreadExecution = useWorkflowStore((s) => s.clearThreadExecution); + const setActiveExecutionThreadId = useWorkflowStore((s) => s.setActiveExecutionThreadId); + const setDebugResult = useWorkflowStore((s) => s.setDebugResult); // ── 当前 threadId 的快捷读取 ── const getThread = useCallback(() => { if (!threadId) return null; - return store.threadExecutions[threadId] ?? { messages: [], status: 'idle', interrupt: null }; - }, [threadId, store.threadExecutions]); - - const messages = threadId - ? (store.threadExecutions[threadId]?.messages ?? []) - : []; - const status = threadId - ? (store.threadExecutions[threadId]?.status ?? 'idle') - : 'idle'; - const activeInterrupt = threadId - ? (store.threadExecutions[threadId]?.interrupt ?? null) - : null; + return useWorkflowStore.getState().threadExecutions[threadId] ?? { messages: [], status: 'idle', interrupt: null }; + }, [threadId]); // ── 追踪本轮已接收的消息(用于 node_done 补偿去重,不走 store 避免异步问题) ── const sentMessagesRef = useRef([]); @@ -103,10 +107,10 @@ export function useWorkflowRuntime({ // ── 分发消息(写 store + 更新 ref;SQLite 保存延迟到节点完成时批量写入) ── const dispatch = useCallback((tid: string, msg: WorkflowExecutionMessage) => { sentMessagesRef.current = [...sentMessagesRef.current, msg]; - store.appendThreadMessage(tid, msg); + appendThreadMessage(tid, msg); // NOTE: do NOT invoke save_workflow_messages here — calling IPC on every token // floods the JS microtask queue and freezes the UI on macOS. - }, [store]); + }, [appendThreadMessage]); // ── 批量 save(仅在节点/工作流完成时调用,不在每个 token 调用) ── const saveMessagesOnce = useCallback((tid: string) => { @@ -121,11 +125,11 @@ export function useWorkflowRuntime({ // ── 切换工作流时清理旧调试数据(仅调试模式) ── useEffect(() => { if (isDebug) { - store.clearExecution(); + clearExecutionAction(); } - }, [workflowId]); + }, [workflowId, isDebug, clearExecutionAction]); - // ── 追踪当前的 threadId 动态值,避免监听器 useEffect 因 threadId 变化频繁销毁和异步重建造成事件漏单 ── + // ── 追踪当前的 threadId 动态值,避免监听器 useEffect 因 threadId 变化频繁销毁 and 异步重建造成事件漏单 ── const threadIdRef = useRef(threadId); useEffect(() => { threadIdRef.current = threadId; @@ -137,7 +141,7 @@ export function useWorkflowRuntime({ if (!activeTid || isDebug) return; // 如果内存中已有消息记录,不重复加载 - const currentExec = store.threadExecutions[activeTid]; + const currentExec = useWorkflowStore.getState().threadExecutions[activeTid]; if (currentExec && currentExec.messages.length > 0) return; async function loadHistory(tid: string) { @@ -209,8 +213,8 @@ export function useWorkflowRuntime({ switch (payload.type) { case 'workflow_start': - store.setThreadStatus(activeTid, 'running'); - store.setThreadInterrupt(activeTid, null); + setThreadStatus(activeTid, 'running'); + setThreadInterrupt(activeTid, null); dispatch(activeTid, { type: 'info', content: `🚀 Workflow started...`, @@ -233,13 +237,13 @@ export function useWorkflowRuntime({ if (payload.text) { if (isDebugSubflow) { // 调试子流:写入 debugResults - const targetId = store.debugTarget?.nodeId; + const targetId = useWorkflowStore.getState().debugTarget?.nodeId; if (targetId) { - const prev = store.debugResults[targetId]; + const prev = useWorkflowStore.getState().debugResults[targetId]; if (prev) { const currentOutput = (prev.output && typeof prev.output === 'object') ? { ...prev.output } : {}; currentOutput.response = (currentOutput.response || '') + payload.text; - store.setDebugResult(targetId, { ...prev, output: currentOutput }); + setDebugResult(targetId, { ...prev, output: currentOutput }); } } } else { @@ -268,13 +272,13 @@ export function useWorkflowRuntime({ case 'thinking': if (payload.text) { if (isDebugSubflow) { - const targetId = store.debugTarget?.nodeId; + const targetId = useWorkflowStore.getState().debugTarget?.nodeId; if (targetId) { - const prev = store.debugResults[targetId]; + const prev = useWorkflowStore.getState().debugResults[targetId]; if (prev) { const currentOutput = (prev.output && typeof prev.output === 'object') ? { ...prev.output } : {}; currentOutput.thinking = (currentOutput.thinking || '') + payload.text; - store.setDebugResult(targetId, { ...prev, output: currentOutput }); + setDebugResult(targetId, { ...prev, output: currentOutput }); } } } else { @@ -349,8 +353,9 @@ export function useWorkflowRuntime({ case 'workflow_interrupted': { const rawInterrupt = payload.interrupt as any; const interruptData = rawInterrupt?.value ?? rawInterrupt; - store.setThreadStatus(activeTid, 'idle'); - store.setThreadInterrupt(activeTid, interruptData); + console.log(`[WorkflowRuntime] workflow_interrupted event details:`, interruptData); + setThreadStatus(activeTid, 'idle'); + setThreadInterrupt(activeTid, interruptData); dispatch(activeTid, { type: 'interrupt' as any, content: JSON.stringify(interruptData), @@ -360,14 +365,14 @@ export function useWorkflowRuntime({ } case 'workflow_done': - store.setThreadStatus(activeTid, 'done'); - store.setThreadInterrupt(activeTid, null); + setThreadStatus(activeTid, 'done'); + setThreadInterrupt(activeTid, null); dispatch(activeTid, { type: 'info', content: `🎉 Workflow execution completed successfully.`, timestamp, }); - // 将完整消息数组保存到 SQLite(原生格式),确保重启后历史界面与当前完全一致 + // 将完整消息数组保存 to SQLite(原生格式),确保重启后历史界面与当前完全一致 if (!isDebug) { const msgs = useWorkflowStore.getState().threadExecutions[activeTid]?.messages ?? []; invoke('save_workflow_messages', { @@ -381,11 +386,11 @@ export function useWorkflowRuntime({ case 'error': const isUserCancel = (payload.error || payload.text || (payload as any).message || '').includes('Workflow execution cancelled by user'); if (isUserCancel) { - store.setThreadStatus(activeTid, 'idle'); + setThreadStatus(activeTid, 'idle'); break; } - store.setThreadStatus(activeTid, 'error'); - store.setThreadInterrupt(activeTid, null); + setThreadStatus(activeTid, 'error'); + setThreadInterrupt(activeTid, null); dispatch(activeTid, { type: 'error', content: `❌ Execution error: ${payload.error || payload.text || (payload as any).message || 'Unknown error'}`, @@ -423,7 +428,7 @@ export function useWorkflowRuntime({ // ── 调试专属事件 ── case 'debug_start': if (isDebug) { - store.setThreadStatus(activeTid, 'running'); + setThreadStatus(activeTid, 'running'); dispatch(activeTid, { type: 'info', content: `🔍 Debugging node [${payload.node_id}]...`, @@ -435,12 +440,12 @@ export function useWorkflowRuntime({ case 'debug_done': { if (isDebug) { - store.setThreadStatus(activeTid, 'done'); - const targetId = payload.node_id || store.debugTarget?.nodeId || ''; - const prev = targetId ? store.debugResults[targetId] : null; + setThreadStatus(activeTid, 'done'); + const targetId = payload.node_id || useWorkflowStore.getState().debugTarget?.nodeId || ''; + const prev = targetId ? useWorkflowStore.getState().debugResults[targetId] : null; const startTime = prev?.startTime || Date.now(); if (targetId) { - store.setDebugResult(targetId, { + setDebugResult(targetId, { status: 'done', input: prev?.input || null, output: payload.output, @@ -460,11 +465,11 @@ export function useWorkflowRuntime({ case 'debug_error': { if (isDebug) { - store.setThreadStatus(activeTid, 'error'); - const targetId = payload.node_id || store.debugTarget?.nodeId || ''; - const prev = targetId ? store.debugResults[targetId] : null; + setThreadStatus(activeTid, 'error'); + const targetId = payload.node_id || useWorkflowStore.getState().debugTarget?.nodeId || ''; + const prev = targetId ? useWorkflowStore.getState().debugResults[targetId] : null; if (targetId) { - store.setDebugResult(targetId, { + setDebugResult(targetId, { status: 'error', input: prev?.input || null, output: null, @@ -503,15 +508,15 @@ export function useWorkflowRuntime({ cancelled = true; unlisten?.(); }; - }, [workflowId, isDebug]); + }, [workflowId, isDebug, setThreadStatus, setThreadInterrupt, setDebugResult, dispatch]); // ── startWorkflow ── const startWorkflow = useCallback(async (input: string) => { if (!workflowId) return; // 触发强制自动保存如果存在未保存的修改 - const saveRef = store.saveDraftRef?.current; - if (saveRef && store.isDirty) { + const saveRef = useWorkflowStore.getState().saveDraftRef?.current; + if (saveRef && useWorkflowStore.getState().isDirty) { await saveRef(); } @@ -519,16 +524,16 @@ export function useWorkflowRuntime({ if (isDebug && !activeTid) { // 调试模式:无活跃 threadId 时才全新生成并清空 activeTid = `${workflowId}_run_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; - store.clearExecution(); - store.setActiveExecutionThreadId(activeTid); + clearExecutionAction(); + setActiveExecutionThreadId(activeTid); } if (!activeTid) return; // 清空本轮消息追踪 sentMessagesRef.current = []; - store.setThreadStatus(activeTid, 'running'); - store.setThreadInterrupt(activeTid, null); + setThreadStatus(activeTid, 'running'); + setThreadInterrupt(activeTid, null); // 提取 user 显示文本 let userMsgContent = input; @@ -562,14 +567,14 @@ export function useWorkflowRuntime({ useDraft: isDebug, }); } catch (e) { - store.setThreadStatus(activeTid, 'error'); + setThreadStatus(activeTid, 'error'); dispatch(activeTid, { type: 'error', content: `Failed to start workflow: ${e}`, timestamp: Date.now(), }); } - }, [workflowId, threadId, isDebug, store, dispatch]); + }, [workflowId, threadId, isDebug, dispatch, clearExecutionAction, setActiveExecutionThreadId, setThreadStatus, setThreadInterrupt]); // ── resumeWorkflow ── const resumeWorkflow = useCallback(async ( @@ -579,11 +584,11 @@ export function useWorkflowRuntime({ ) => { if (!workflowId) return; const activeTid = isDebug - ? (store.activeExecutionThreadId ?? `${workflowId}:debug`) + ? (useWorkflowStore.getState().activeExecutionThreadId ?? `${workflowId}:debug`) : threadId; if (!activeTid) return; - store.setThreadStatus(activeTid, 'running'); + setThreadStatus(activeTid, 'running'); // 将 resume 动作派出为一条 user 消息保入 store,并附带 resolvedActionLabel 平套字段 // 这样 save_workflow_messages 能完整保存 Human 节点展示所需的信息 @@ -606,14 +611,14 @@ export function useWorkflowRuntime({ useDraft: isDebug, }); } catch (e) { - store.setThreadStatus(activeTid, 'error'); + setThreadStatus(activeTid, 'error'); dispatch(activeTid, { type: 'error', content: `Failed to resume workflow: ${e}`, timestamp: Date.now(), }); } - }, [workflowId, threadId, isDebug, store, dispatch]); + }, [workflowId, threadId, isDebug, dispatch, setThreadStatus]); // ── stopWorkflow ── const stopWorkflow = useCallback(async () => { @@ -622,7 +627,7 @@ export function useWorkflowRuntime({ return; } const activeTid = isDebug - ? (store.activeExecutionThreadId ?? `${workflowId}:debug`) + ? (useWorkflowStore.getState().activeExecutionThreadId ?? `${workflowId}:debug`) : threadId; try { await taskService.stopWorkflow(workflowId); @@ -631,7 +636,7 @@ export function useWorkflowRuntime({ useAgentStore.getState().setStatus('ready'); if (activeTid) { - store.setThreadStatus(activeTid, 'idle'); + setThreadStatus(activeTid, 'idle'); dispatch(activeTid, { type: 'text_delta', content: t('chat.aborted'), @@ -648,30 +653,30 @@ export function useWorkflowRuntime({ }); } } - }, [workflowId, threadId, isDebug, store, dispatch]); + }, [workflowId, threadId, isDebug, dispatch, setThreadStatus, t]); // ── debugNode(调试专属) ── const debugNode = useCallback(async (nodeId: string, input?: string) => { if (!workflowId || !isDebug) return; // 调试单个节点也触发强制自动保存 - const saveRef = store.saveDraftRef?.current; - if (saveRef && store.isDirty) { + const saveRef = useWorkflowStore.getState().saveDraftRef?.current; + if (saveRef && useWorkflowStore.getState().isDirty) { await saveRef(); } const activeTid = isDebug - ? (store.activeExecutionThreadId ?? `${workflowId}:debug`) + ? (useWorkflowStore.getState().activeExecutionThreadId ?? `${workflowId}:debug`) : threadId; if (!activeTid) return; - store.clearExecution(); - store.setThreadStatus(activeTid, 'running'); + clearExecutionAction(); + setThreadStatus(activeTid, 'running'); let parsedInput = null; try { if (input) parsedInput = JSON.parse(input); } catch {} - store.setDebugResult(nodeId, { + setDebugResult(nodeId, { status: 'running', input: parsedInput, output: null, @@ -692,8 +697,8 @@ export function useWorkflowRuntime({ input: input ?? '', }); } catch (e) { - store.setThreadStatus(activeTid, 'error'); - store.setDebugResult(nodeId, { + setThreadStatus(activeTid, 'error'); + setDebugResult(nodeId, { status: 'error', input: parsedInput, output: null, @@ -706,17 +711,17 @@ export function useWorkflowRuntime({ timestamp: Date.now(), }); } - }, [workflowId, threadId, isDebug, store, dispatch]); + }, [workflowId, threadId, isDebug, dispatch, clearExecutionAction, setThreadStatus, setDebugResult]); // ── clearExecution(清理当前 thread) ── const clearExecution = useCallback(() => { if (isDebug) { - store.clearExecution(); // 同时重置 activeExecutionThreadId + clearExecutionAction(); // 同时重置 activeExecutionThreadId } else if (threadId) { - store.clearThreadExecution(threadId); + clearThreadExecution(threadId); } sentMessagesRef.current = []; - }, [isDebug, threadId, store]); + }, [isDebug, threadId, clearExecutionAction, clearThreadExecution]); return { messages, diff --git a/flock-ui/src/store/agentStore.ts b/flock-ui/src/store/agentStore.ts index 64eea984..b7da3b8b 100644 --- a/flock-ui/src/store/agentStore.ts +++ b/flock-ui/src/store/agentStore.ts @@ -172,7 +172,7 @@ export const useAgentStore = create((set, get) => { const updatedSession = { ...prevSession, ...sessionUpdate }; const nextSessions = { ...state.sessions, [eventSessionId]: updatedSession }; const currentActiveId = getActiveSessionId(); - + if (eventSessionId === currentActiveId) { return { ...globalUpdate,