From b8f711d96b9d1d78f8abebae724566f15ae4cc97 Mon Sep 17 00:00:00 2001 From: Onelevenvy Date: Sat, 13 Jun 2026 22:40:00 +0800 Subject: [PATCH 1/3] perf: resolve tauri event listener leak, use zustand selectors, and optimize sqlite synchronous mode --- crates/flock-core/src/db/mod.rs | 4 + flock-ui/src/hooks/useEventStream.ts | 32 +++-- flock-ui/src/hooks/useWorkflowRuntime.ts | 148 ++++++++++++----------- 3 files changed, 103 insertions(+), 81 deletions(-) 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/src/hooks/useEventStream.ts b/flock-ui/src/hooks/useEventStream.ts index cf837633..e678a705 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; @@ -65,6 +75,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; @@ -100,23 +115,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 9e07ad8b..ef503bd3 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) { @@ -211,8 +215,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...`, @@ -235,13 +239,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 { @@ -270,13 +274,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 { @@ -352,8 +356,8 @@ export function useWorkflowRuntime({ const rawInterrupt = payload.interrupt as any; const interruptData = rawInterrupt?.value ?? rawInterrupt; console.log(`[WorkflowRuntime] workflow_interrupted event details:`, interruptData); - store.setThreadStatus(activeTid, 'idle'); - store.setThreadInterrupt(activeTid, interruptData); + setThreadStatus(activeTid, 'idle'); + setThreadInterrupt(activeTid, interruptData); dispatch(activeTid, { type: 'interrupt' as any, content: JSON.stringify(interruptData), @@ -363,14 +367,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', { @@ -384,11 +388,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'}`, @@ -427,7 +431,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}]...`, @@ -439,12 +443,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, @@ -464,11 +468,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, @@ -507,15 +511,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(); } @@ -523,16 +527,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; @@ -566,14 +570,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 ( @@ -583,11 +587,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 节点展示所需的信息 @@ -610,14 +614,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 () => { @@ -627,7 +631,7 @@ export function useWorkflowRuntime({ return; } const activeTid = isDebug - ? (store.activeExecutionThreadId ?? `${workflowId}:debug`) + ? (useWorkflowStore.getState().activeExecutionThreadId ?? `${workflowId}:debug`) : threadId; try { console.log("[useWorkflowRuntime] Invoking stop_workflow with workflowId:", workflowId); @@ -638,7 +642,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'), @@ -655,30 +659,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, @@ -699,8 +703,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, @@ -713,17 +717,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, From 1ad2fc27b94e90982bf1a4872be5f70ecc3ea3c6 Mon Sep 17 00:00:00 2001 From: Onelevenvy Date: Sat, 13 Jun 2026 22:53:25 +0800 Subject: [PATCH 2/3] feat: optimize streaming typewriter performance via React.memo, virtual list, scroll debounce, and backend batch IPC emit --- crates/flock-agent/src/engine/run/run.rs | 39 +++++- flock-ui/package-lock.json | 26 ++++ flock-ui/package.json | 1 + flock-ui/src-tauri/src/ipc/emitter.rs | 71 +++++++++-- .../chat/assistant/AssistantChatPanel.tsx | 111 ++++++++++++++++-- .../assistant/components/MessageBubble.tsx | 7 +- .../assistant/components/ThinkingBlock.tsx | 42 +++++-- .../chat/shared/MarkdownRenderer.tsx | 5 +- flock-ui/src/store/agentStore.ts | 15 +-- 9 files changed, 265 insertions(+), 52 deletions(-) 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/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/store/agentStore.ts b/flock-ui/src/store/agentStore.ts index d4d8a801..7fc51e28 100644 --- a/flock-ui/src/store/agentStore.ts +++ b/flock-ui/src/store/agentStore.ts @@ -143,12 +143,6 @@ export const useAgentStore = create((set, get) => { handleEvent: (event: ProtocolEvent) => { const eventSessionId = (event as any).session_id || getActiveSessionId(); - console.log('[handleEvent] Routing event:', event.type, { - event, - eventSessionId, - activeConversationId: getActiveSessionId(), - }); - const customGet = () => { const state = get(); const session = getSessionState(state, eventSessionId); @@ -178,14 +172,7 @@ export const useAgentStore = create((set, get) => { const updatedSession = { ...prevSession, ...sessionUpdate }; const nextSessions = { ...state.sessions, [eventSessionId]: updatedSession }; const currentActiveId = getActiveSessionId(); - - console.log('[handleEvent] customSet updating session:', eventSessionId, { - prevMessages: prevSession.messages, - sessionUpdateMessages: sessionUpdate.messages, - updatedSessionMessages: updatedSession.messages, - currentActiveId, - }); - + if (eventSessionId === currentActiveId) { return { ...globalUpdate, From fc53fa3e78d025b72970af62b9304ce42a693821 Mon Sep 17 00:00:00 2001 From: Onelevenvy Date: Sat, 13 Jun 2026 23:07:24 +0800 Subject: [PATCH 3/3] fix: resolve merge conflicts and fix undefined store variable in useWorkflowRuntime --- Cargo.lock | 1 + crates/flock-agent/src/engine/run/session.rs | 2 +- crates/flock-agent/src/graph/nodes/helpers.rs | 4 +- crates/flock-agent/src/graph/nodes/llm.rs | 59 ------------------- crates/flock-agent/src/graph/nodes/routes.rs | 3 - crates/flock-tools/Cargo.toml | 1 + .../src/tools/sandbox/code_execution.rs | 2 +- .../src/tools/sandbox/sandbox_exec.rs | 2 +- .../src-tauri/src/commands/common/sandbox.rs | 2 +- flock-ui/src/components/Layout/MainLayout.tsx | 10 ++-- .../Layout/Sidebar/WorkspaceSection.tsx | 5 -- .../PetSettings}/Pet/XiaofCharacter.tsx | 0 .../PetSettings}/Pet/XiaofOverlayApp.tsx | 0 .../PetSettings}/Pet/XiaofPet.tsx | 13 +++- .../PetSettings}/Pet/XiaofSyncManager.tsx | 45 ++++++++++++-- .../{ => Settings/PetSettings}/Pet/xiaof.css | 0 .../components/Settings/PetSettings/index.tsx | 22 +++---- flock-ui/src/hooks/useEventStream.ts | 1 - flock-ui/src/hooks/useWorkflowRuntime.ts | 6 -- flock-ui/src/hooks/useXiaofState.ts | 12 ++-- flock-ui/src/main.tsx | 2 +- flock-ui/src/pages/Home/index.tsx | 2 +- .../EnvironmentPanel/hooks/usePreviewState.ts | 2 +- flock-ui/src/store/agentStore.ts | 2 +- 24 files changed, 85 insertions(+), 113 deletions(-) rename flock-ui/src/components/{ => Settings/PetSettings}/Pet/XiaofCharacter.tsx (100%) rename flock-ui/src/components/{ => Settings/PetSettings}/Pet/XiaofOverlayApp.tsx (100%) rename flock-ui/src/components/{ => Settings/PetSettings}/Pet/XiaofPet.tsx (98%) rename flock-ui/src/components/{ => Settings/PetSettings}/Pet/XiaofSyncManager.tsx (77%) rename flock-ui/src/components/{ => Settings/PetSettings}/Pet/xiaof.css (100%) diff --git a/Cargo.lock b/Cargo.lock index f93f8f88..6b48e09d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1352,6 +1352,7 @@ dependencies = [ "glob", "ignore", "langgraph", + "log", "lru", "md-5", "meval", diff --git a/crates/flock-agent/src/engine/run/session.rs b/crates/flock-agent/src/engine/run/session.rs index 94a5ddec..4fbc38ef 100644 --- a/crates/flock-agent/src/engine/run/session.rs +++ b/crates/flock-agent/src/engine/run/session.rs @@ -88,7 +88,7 @@ impl AgentEngine { let enable_summary: Option = db.get_config("enable_title_summary").await; if enable_summary.unwrap_or(false) { if let Err(e) = crate::engine::summary::run_background_summary(db, thread_id, messages, default_provider, protocol_writer).await { - eprintln!("[summary] Background auto summary failed: {}", e); + log::warn!("[summary] Background auto summary failed: {}", e); } } else { log::info!("[summary] Chat list title summary is disabled, skipping auto-summary."); diff --git a/crates/flock-agent/src/graph/nodes/helpers.rs b/crates/flock-agent/src/graph/nodes/helpers.rs index 168b6fa5..e853eedd 100644 --- a/crates/flock-agent/src/graph/nodes/helpers.rs +++ b/crates/flock-agent/src/graph/nodes/helpers.rs @@ -5,8 +5,8 @@ pub fn parse_state(input: &JsonValue) -> AgentState { match serde_json::from_value(input.clone()) { Ok(state) => state, Err(e) => { - eprintln!( - "[WARN][parse_state] failed to deserialize AgentState: {}. \ + log::warn!( + "[parse_state] failed to deserialize AgentState: {}. \ Hint: add #[serde(default)] to all fields in AgentState. \ Returning default state (messages will be empty!).", e diff --git a/crates/flock-agent/src/graph/nodes/llm.rs b/crates/flock-agent/src/graph/nodes/llm.rs index 19c8da4f..bed26d3f 100644 --- a/crates/flock-agent/src/graph/nodes/llm.rs +++ b/crates/flock-agent/src/graph/nodes/llm.rs @@ -20,24 +20,6 @@ pub fn make_llm_node( move |input: JsonValue, config: RunnableConfig| { let ctx = ctx.clone(); Box::pin(async move { - ctx.output.emit_info("[node] >>> entering llm"); - // ── DEBUG STEP 1: 打印 raw input ── - if ctx.debug_mode { - let msgs_raw = input.get("messages"); - let msgs_len = msgs_raw.and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - let msgs_type = match msgs_raw { - None => "MISSING", - Some(v) if v.is_array() => "Array", - Some(v) if v.is_null() => "Null", - _ => "Other", - }; - eprintln!("[DEBUG][llm] raw input.messages type={} len={}", msgs_type, msgs_len); - // 也打印 input 里所有 key - if let Some(obj) = input.as_object() { - let keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect(); - eprintln!("[DEBUG][llm] raw input keys={:?}", keys); - } - } let state = parse_state(&input); let _msg_id = ctx.msg_id.lock().unwrap().clone(); @@ -49,33 +31,6 @@ pub fn make_llm_node( } } - // ── DEBUG: 打印 state.messages 信息帮助诊断记忆混乱 ────────────── - if ctx.debug_mode { - eprintln!("[DEBUG][llm] state.messages count = {}", state.messages.len()); - for (i, raw_msg) in state.messages.iter().enumerate() { - let role = raw_msg.get("role").and_then(|v| v.as_str()).unwrap_or( - raw_msg.get("type").and_then(|v| v.as_str()).unwrap_or("?") - ); - // 内容摘要:char-safe,取前60字 - let content_summary = { - let content_str = raw_msg.get("content") - .map(|c| c.to_string()) - .unwrap_or_default(); - let truncated: String = content_str.chars().take(60).collect(); - if content_str.chars().count() > 60 { - format!("{}...", truncated) - } else { - truncated - } - }; - let has_tc = raw_msg.get("tool_calls") - .and_then(|v| v.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - eprintln!("[DEBUG][llm] msg[{}] role={} tool_calls={} content={}", i, role, has_tc, content_summary); - } - } - let messages: Vec = state.messages.iter() .filter_map(|v| { let flock_msg: Message = serde_json::from_value(v.clone()).ok()?; @@ -83,20 +38,6 @@ pub fn make_llm_node( }) .collect(); - if ctx.debug_mode { - eprintln!("[DEBUG][llm] converted LgMessages count = {}", messages.len()); - for (i, lm) in messages.iter().enumerate() { - let type_str = match lm { - LgMessage::Human { .. } => "Human", - LgMessage::Ai { tool_calls, .. } => if tool_calls.is_empty() { "Ai" } else { "Ai+ToolCalls" }, - LgMessage::Tool { tool_call_id: _tool_call_id, .. } => "Tool", - LgMessage::System { .. } => "System", - _ => "Other", - }; - eprintln!("[DEBUG][llm] lmsg[{}] type={}", i, type_str); - } - } - let system = if state.plan_mode_active { format!( "{}\n\n{}", diff --git a/crates/flock-agent/src/graph/nodes/routes.rs b/crates/flock-agent/src/graph/nodes/routes.rs index 7bb5de58..5c1069f3 100644 --- a/crates/flock-agent/src/graph/nodes/routes.rs +++ b/crates/flock-agent/src/graph/nodes/routes.rs @@ -28,8 +28,6 @@ pub fn route_after_llm(input: &JsonValue) -> String { }) .unwrap_or(false); - eprintln!("[route] route_after_llm: has_tools={}", has_tools); - if has_tools { "tools".to_string() } else { @@ -41,7 +39,6 @@ pub fn route_after_llm(input: &JsonValue) -> String { pub fn route_after_tools(input: &JsonValue) -> String { // quit 时 FlockToolNode 设置了 quit_requested=true if input.get("quit_requested").and_then(|v| v.as_bool()).unwrap_or(false) { - eprintln!("[route] route_after_tools: quit_requested=true, routing to END"); return langgraph::constants::END.to_string(); } "compaction".to_string() diff --git a/crates/flock-tools/Cargo.toml b/crates/flock-tools/Cargo.toml index af115ade..8c63f08a 100644 --- a/crates/flock-tools/Cargo.toml +++ b/crates/flock-tools/Cargo.toml @@ -22,6 +22,7 @@ async-trait.workspace = true anyhow.workspace = true thiserror.workspace = true reqwest.workspace = true +log.workspace = true glob.workspace = true lru.workspace = true meval.workspace = true diff --git a/crates/flock-tools/src/tools/sandbox/code_execution.rs b/crates/flock-tools/src/tools/sandbox/code_execution.rs index 66f4fedc..c3bcc125 100644 --- a/crates/flock-tools/src/tools/sandbox/code_execution.rs +++ b/crates/flock-tools/src/tools/sandbox/code_execution.rs @@ -47,7 +47,7 @@ pub async fn code_execution(code: String) -> Result { let sandbox_id_clone = sandbox_id.clone(); tokio::spawn(async move { if let Err(e) = crate::daytona::sync::sync_down(&db_clone, &sandbox_id_clone, &ws_path).await { - eprintln!("自动 Sync Down 失败: {}", e); + log::warn!("自动 Sync Down 失败: {}", e); } }); } diff --git a/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs b/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs index 6a16725e..b00c29bb 100644 --- a/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs +++ b/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs @@ -70,7 +70,7 @@ pub async fn sandbox_exec( let sandbox_id_clone = sandbox_id.clone(); tokio::spawn(async move { if let Err(e) = crate::daytona::sync::sync_down(&db_clone, &sandbox_id_clone, &ws_path).await { - eprintln!("自动 Sync Down 失败: {}", e); + log::warn!("自动 Sync Down 失败: {}", e); } }); } diff --git a/flock-ui/src-tauri/src/commands/common/sandbox.rs b/flock-ui/src-tauri/src/commands/common/sandbox.rs index bb2bc5d8..d1da0172 100644 --- a/flock-ui/src-tauri/src/commands/common/sandbox.rs +++ b/flock-ui/src-tauri/src/commands/common/sandbox.rs @@ -97,7 +97,7 @@ pub async fn get_active_sandbox_vnc_url( match flock_tools::daytona::get_sandbox_vnc_url(&*db, &sandbox_id).await { Ok(url) => Ok(Some(url)), Err(e) => { - println!("{}", flock_core::tr( + log::warn!("{}", flock_core::tr( &format!("获取动态 VNC URL 失败: {}。使用静态备用 URL...", e), &format!("Failed to retrieve dynamic VNC URL: {}. Using static fallback URL...", e) )); diff --git a/flock-ui/src/components/Layout/MainLayout.tsx b/flock-ui/src/components/Layout/MainLayout.tsx index 64887bd9..aef7192e 100644 --- a/flock-ui/src/components/Layout/MainLayout.tsx +++ b/flock-ui/src/components/Layout/MainLayout.tsx @@ -13,14 +13,14 @@ import { useWorkspaceStore } from '@/store/workspaceStore'; import { usePetStore } from '@/store/petStore'; import { IconBoxMultiple, IconLego } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { XiaofSyncManager } from '@/components/Pet/XiaofSyncManager'; -import { XiaofPet } from '@/components/Pet/XiaofPet'; +import { XiaofSyncManager } from '@/components/Settings/PetSettings/Pet/XiaofSyncManager'; +import { XiaofPet } from '@/components/Settings/PetSettings/Pet/XiaofPet'; export function MainLayout() { const { t } = useTranslation(); const { currentView } = useUiStore(); const { activeWorkspaceId, activeConversationId } = useWorkspaceStore(); - const { mode } = usePetStore(); + const { enabled, mode } = usePetStore(); // Explorer 启动后直接进入工作区视图;Home 自身只负责发现和启动应用。 const showWorkspace = currentView === 'home' && !!activeWorkspaceId && !!activeConversationId; @@ -87,8 +87,8 @@ export function MainLayout() { {/* XiaoF Pet State Sync Manager — orchestrates desktop overlay in the background */} - {/* Render the inline React pet component only when pet mode is in-app */} - {mode === 'in-app' && } + {/* Render the inline React pet component only when pet mode is in-app and pet is enabled */} + {enabled && mode === 'in-app' && } ); } diff --git a/flock-ui/src/components/Layout/Sidebar/WorkspaceSection.tsx b/flock-ui/src/components/Layout/Sidebar/WorkspaceSection.tsx index 33dd4bd4..8d55bec6 100644 --- a/flock-ui/src/components/Layout/Sidebar/WorkspaceSection.tsx +++ b/flock-ui/src/components/Layout/Sidebar/WorkspaceSection.tsx @@ -85,11 +85,6 @@ export function WorkspaceSection() { session.status === 'connecting' || session.status === 'thinking') ) { - console.log( - '[startAgentForWorkspace] Session is already active or ready, skipping start_agent:', - sessionId, - session.status - ); return; } } diff --git a/flock-ui/src/components/Pet/XiaofCharacter.tsx b/flock-ui/src/components/Settings/PetSettings/Pet/XiaofCharacter.tsx similarity index 100% rename from flock-ui/src/components/Pet/XiaofCharacter.tsx rename to flock-ui/src/components/Settings/PetSettings/Pet/XiaofCharacter.tsx diff --git a/flock-ui/src/components/Pet/XiaofOverlayApp.tsx b/flock-ui/src/components/Settings/PetSettings/Pet/XiaofOverlayApp.tsx similarity index 100% rename from flock-ui/src/components/Pet/XiaofOverlayApp.tsx rename to flock-ui/src/components/Settings/PetSettings/Pet/XiaofOverlayApp.tsx diff --git a/flock-ui/src/components/Pet/XiaofPet.tsx b/flock-ui/src/components/Settings/PetSettings/Pet/XiaofPet.tsx similarity index 98% rename from flock-ui/src/components/Pet/XiaofPet.tsx rename to flock-ui/src/components/Settings/PetSettings/Pet/XiaofPet.tsx index 3b987778..c331d895 100644 --- a/flock-ui/src/components/Pet/XiaofPet.tsx +++ b/flock-ui/src/components/Settings/PetSettings/Pet/XiaofPet.tsx @@ -29,7 +29,7 @@ const MOOD_DOT_COLOR: Record = { error: '#ef4444', }; -export function XiaofPet() { +function XiaofActivePet() { const { t } = useTranslation(); const { enabled, minimized, position, bubbleEnabled, setMinimized, setPosition } = usePetStore(); const { mood, bubbleText, pendingCount } = useXiaofState(); @@ -172,8 +172,6 @@ export function XiaofPet() { await invoke('deny_tool', { callId: approval.call_id, reason: 'User denied via XiaoF' }); }, [pendingApprovals, removePendingApproval]); - if (!enabled) return null; - const firstPending = pendingApprovals[0]; const showApprovePopup = showPopup && pendingCount > 0 && !isDragging; const showActiveBubble = showBubble && !showPopup && !isDragging && bubbleText; @@ -261,3 +259,12 @@ export function XiaofPet() {
); } + +export function XiaofPet() { + const { enabled } = usePetStore(); + + if (!enabled) return null; + + return ; +} + diff --git a/flock-ui/src/components/Pet/XiaofSyncManager.tsx b/flock-ui/src/components/Settings/PetSettings/Pet/XiaofSyncManager.tsx similarity index 77% rename from flock-ui/src/components/Pet/XiaofSyncManager.tsx rename to flock-ui/src/components/Settings/PetSettings/Pet/XiaofSyncManager.tsx index e4f9d207..9cb16a9f 100644 --- a/flock-ui/src/components/Pet/XiaofSyncManager.tsx +++ b/flock-ui/src/components/Settings/PetSettings/Pet/XiaofSyncManager.tsx @@ -6,11 +6,32 @@ import { useXiaofState } from '@/hooks/useXiaofState'; import { useAgentStore } from '@/store/agentStore'; /** - * XiaofSyncManager - * 作为一个无 DOM 渲染的背景管理器,将主窗口的状态和审批任务通过 Tauri Command 实时同步到 Rust。 - * 桌面悬浮宠物窗口 (XiaofOverlayApp) 接收全局事件并完成状态渲染及交互。 + * XiaofDisabledSyncer + * 仅在桌宠关闭或不是桌面模式时,单次发送已关闭状态给 Rust 以关闭/隐藏悬浮窗口。 */ -export function XiaofSyncManager() { +function XiaofDisabledSyncer() { + useEffect(() => { + invoke('sync_pet_state', { + state: { + enabled: false, + mood: 'sleeping', + pendingCount: 0, + bubbleText: null, + minimized: false, + pendingTool: null, + pendingCallId: null, + } + }).catch((err) => console.error('[Pet Sync] Failed to sync disabled state:', err)); + }, []); + + return null; +} + +/** + * XiaofActiveSyncManager + * 处于活跃状态下的同步逻辑,调用各类 Hook 监听状态变更并与后台进行通信。 + */ +function XiaofActiveSyncManager() { const { enabled, minimized, mode, setMinimized } = usePetStore(); const { mood, bubbleText, pendingCount } = useXiaofState(); const pendingApprovals = useAgentStore((s) => s.pendingApprovals); @@ -92,3 +113,19 @@ export function XiaofSyncManager() { return null; } + +/** + * XiaofSyncManager + * 作为一个无 DOM 渲染的背景管理器,根据是否启用有条件地加载活跃的管理器或静默关闭器。 + */ +export function XiaofSyncManager() { + const { enabled, mode } = usePetStore(); + const isSyncActive = enabled && mode === 'desktop'; + + if (!isSyncActive) { + return ; + } + + return ; +} + diff --git a/flock-ui/src/components/Pet/xiaof.css b/flock-ui/src/components/Settings/PetSettings/Pet/xiaof.css similarity index 100% rename from flock-ui/src/components/Pet/xiaof.css rename to flock-ui/src/components/Settings/PetSettings/Pet/xiaof.css diff --git a/flock-ui/src/components/Settings/PetSettings/index.tsx b/flock-ui/src/components/Settings/PetSettings/index.tsx index 2db46fa6..669b7d92 100644 --- a/flock-ui/src/components/Settings/PetSettings/index.tsx +++ b/flock-ui/src/components/Settings/PetSettings/index.tsx @@ -2,18 +2,18 @@ import { Switch, Slider, Stack, Text, Group, Button, Box, Paper, Divider, Segmen import { IconPaw, IconBubble, IconRefresh } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { usePetStore } from '@/store/petStore'; -import { XiaofCharacter } from '@/components/Pet/XiaofCharacter'; +import { XiaofCharacter } from '@/components/Settings/PetSettings/Pet/XiaofCharacter'; import type { XiaofMood } from '@/hooks/useXiaofState'; import { useState } from 'react'; const MOOD_PREVIEWS: { mood: XiaofMood; labelKey: string }[] = [ - { mood: 'idle', labelKey: 'pet.status.idle' }, + { mood: 'idle', labelKey: 'pet.status.idle' }, { mood: 'thinking', labelKey: 'pet.status.thinking' }, - { mood: 'working', labelKey: 'pet.status.working' }, - { mood: 'waiting', labelKey: 'pet.status.waiting' }, - { mood: 'error', labelKey: 'pet.status.error' }, + { mood: 'working', labelKey: 'pet.status.working' }, + { mood: 'waiting', labelKey: 'pet.status.waiting' }, + { mood: 'error', labelKey: 'pet.status.error' }, { mood: 'sleeping', labelKey: 'pet.status.sleeping' }, - { mood: 'waking', labelKey: 'pet.status.waking' }, + { mood: 'waking', labelKey: 'pet.status.waking' }, { mood: 'takeover', labelKey: 'pet.status.takeover' }, ]; @@ -201,11 +201,11 @@ export default function PetSettings() { const PREVIEW_GLOW: Record = { sleeping: '0 0 20px rgba(107,114,128,0.3)', - waking: '0 0 24px rgba(139,92,246,0.5)', - idle: '0 0 24px rgba(6,182,212,0.4)', + waking: '0 0 24px rgba(139,92,246,0.5)', + idle: '0 0 24px rgba(6,182,212,0.4)', thinking: '0 0 24px rgba(245,158,11,0.5)', - working: '0 0 24px rgba(16,185,129,0.5)', - waiting: '0 0 28px rgba(249,115,22,0.7)', + working: '0 0 24px rgba(16,185,129,0.5)', + waiting: '0 0 28px rgba(249,115,22,0.7)', takeover: '0 0 28px rgba(236,72,153,0.6)', - error: '0 0 24px rgba(239,68,68,0.5)', + error: '0 0 24px rgba(239,68,68,0.5)', }; diff --git a/flock-ui/src/hooks/useEventStream.ts b/flock-ui/src/hooks/useEventStream.ts index e678a705..db850995 100644 --- a/flock-ui/src/hooks/useEventStream.ts +++ b/flock-ui/src/hooks/useEventStream.ts @@ -70,7 +70,6 @@ export function useEventStream() { const stderrUnlisten = await listen('agent-stderr', (event) => { if (cancelled) return; const line = event.payload; - console.debug('[flock stderr]', line); if (line.toLowerCase().includes('error') || line.toLowerCase().includes('failed')) { setError(`Agent stderr: ${line}`); } diff --git a/flock-ui/src/hooks/useWorkflowRuntime.ts b/flock-ui/src/hooks/useWorkflowRuntime.ts index ef503bd3..a73bb6b2 100644 --- a/flock-ui/src/hooks/useWorkflowRuntime.ts +++ b/flock-ui/src/hooks/useWorkflowRuntime.ts @@ -209,8 +209,6 @@ export function useWorkflowRuntime({ return; } - console.log(`[WorkflowRuntime] Received event type: "${payload.type}" for threadId: "${activeTid}", nodeId: "${payload.node_id ?? ''}"`); - const timestamp = Date.now(); switch (payload.type) { @@ -402,7 +400,6 @@ export function useWorkflowRuntime({ break; case 'tool_request': { - console.log("[useWorkflowRuntime] tool_request payload:", payload); if (payload.call_id && payload.tool_name) { useAgentStore.getState().addPendingApproval({ call_id: payload.call_id!, @@ -625,7 +622,6 @@ export function useWorkflowRuntime({ // ── stopWorkflow ── const stopWorkflow = useCallback(async () => { - console.log("[useWorkflowRuntime] stopWorkflow clicked! workflowId:", workflowId, "threadId:", threadId, "isDebug:", isDebug); if (!workflowId) { console.warn("[useWorkflowRuntime] stopWorkflow failed: workflowId is null or empty"); return; @@ -634,9 +630,7 @@ export function useWorkflowRuntime({ ? (useWorkflowStore.getState().activeExecutionThreadId ?? `${workflowId}:debug`) : threadId; try { - console.log("[useWorkflowRuntime] Invoking stop_workflow with workflowId:", workflowId); await taskService.stopWorkflow(workflowId); - console.log("[useWorkflowRuntime] stop_workflow command executed successfully on backend."); // 同时重置 AgentStore 的状态,以防全局状态处于 thinking 或连接中 useAgentStore.getState().setStatus('ready'); diff --git a/flock-ui/src/hooks/useXiaofState.ts b/flock-ui/src/hooks/useXiaofState.ts index 11f55165..e198d7cd 100644 --- a/flock-ui/src/hooks/useXiaofState.ts +++ b/flock-ui/src/hooks/useXiaofState.ts @@ -21,19 +21,18 @@ export function useXiaofState(): XiaofState { const status = useAgentStore((s) => s.status); const pendingApprovals = useAgentStore((s) => s.pendingApprovals); const humanTakeover = useAgentStore((s) => s.humanTakeover); - const messages = useAgentStore((s) => s.messages); - // Detect if any tool is currently running - const isToolRunning = useMemo(() => { - for (const msg of messages) { + // Detect if any tool is currently running via a fine-grained selector + const isToolRunning = useAgentStore((s) => { + for (const msg of s.messages) { for (const chunk of msg.chunks) { if (chunk.kind === 'tool_request' && chunk.status === 'running') { - return chunk.tool?.name ?? null; + return chunk.tool?.name ?? 'unknown'; } } } return null; - }, [messages]); + }); const pendingCount = pendingApprovals.length; @@ -61,3 +60,4 @@ export function useXiaofState(): XiaofState { return { mood, bubbleText, pendingCount }; } + diff --git a/flock-ui/src/main.tsx b/flock-ui/src/main.tsx index 9a262dbb..e1b3e18d 100644 --- a/flock-ui/src/main.tsx +++ b/flock-ui/src/main.tsx @@ -7,7 +7,7 @@ import App from './App' import { ErrorBoundary } from './components/Common/ErrorBoundary'; import './index.css'; import './i18n'; -import { XiaofOverlayApp } from './components/Pet/XiaofOverlayApp' +import { XiaofOverlayApp } from './components/Settings/PetSettings/Pet/XiaofOverlayApp' // 捕获顶层未捕获的错误 window.onerror = (msg, url, line, col, error) => { diff --git a/flock-ui/src/pages/Home/index.tsx b/flock-ui/src/pages/Home/index.tsx index 04b1a969..567c7b96 100644 --- a/flock-ui/src/pages/Home/index.tsx +++ b/flock-ui/src/pages/Home/index.tsx @@ -12,7 +12,7 @@ import { XIAOF_AGENT } from './AssistantPicker'; import { WorkspacePicker } from './WorkspacePicker'; import { ExplorerAppCard } from './components/ExplorerAppCard'; import { useStartAgent } from '@/hooks/useStartAgent'; -import { XiaofCharacter } from '@/components/Pet/XiaofCharacter'; +import { XiaofCharacter } from '@/components/Settings/PetSettings/Pet/XiaofCharacter'; import { useXiaofState } from '@/hooks/useXiaofState'; export function HomeView() { diff --git a/flock-ui/src/pages/Workspace/components/EnvironmentPanel/hooks/usePreviewState.ts b/flock-ui/src/pages/Workspace/components/EnvironmentPanel/hooks/usePreviewState.ts index 12338f1d..71c6a70a 100644 --- a/flock-ui/src/pages/Workspace/components/EnvironmentPanel/hooks/usePreviewState.ts +++ b/flock-ui/src/pages/Workspace/components/EnvironmentPanel/hooks/usePreviewState.ts @@ -88,7 +88,7 @@ export function usePreviewFileState( relativePath: targetScreenshotPath, }) .then((path) => { setScreenshotAbsPath(path); }) - .catch((e) => { console.log('Failed to get screenshot path:', e); }); + .catch((e) => { console.error('Failed to get screenshot path:', e); }); } }, [activeWorkspaceId, targetScreenshotPath]); diff --git a/flock-ui/src/store/agentStore.ts b/flock-ui/src/store/agentStore.ts index 7fc51e28..b7da3b8b 100644 --- a/flock-ui/src/store/agentStore.ts +++ b/flock-ui/src/store/agentStore.ts @@ -203,7 +203,7 @@ export const useAgentStore = create((set, get) => { const session = getSessionState(get(), convId); if (session && (session.status === 'thinking' || session.status === 'connecting')) { - console.log('[loadHistory] Session is active/running, skipping DB history load to prevent overwrite:', convId); + console.warn('[loadHistory] Session is active/running, skipping DB history load to prevent overwrite:', convId); return; }