diff --git a/CHANGELOG.md b/CHANGELOG.md index d98d383..f8116e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.7.4] - 2026-07-06 + +### Added +- 后端心跳事件(`llm:heartbeat`),agent loop 迭代及工具执行期间每 10 秒发送 +- 前端 watchdog 定时器,30 秒无事件自动恢复 streaming 状态 +- 事件缓冲队列:前置事件未到时缓冲后续事件,前置满足后自动冲刷 +- 记忆提案卡片展示完整 content 字段(之前仅显示推荐原因) + +### Changed +- Planning 模式从 Phase A/B 两阶段简化为单次 agent loop 执行 +- AiConversationPanel 拆分:提取 ToolCallItem、MessageBubble 组件和 useAgentEventHandlers hook(-31%) + +### Fixed +- 事件处理器幂等化:tool-call-start / skill-spawn / tool-call-done 防重复 +- context-guard 使用消息计数替代索引,避免快照漂移 +- 工具审批等待时间不再计入工具超时 + ## [0.7.3] - 2026-07-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eacb328 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# KnowForge Project Guidelines + +## Build & Test + +```bash +# Frontend type check +npx tsc --noEmit + +# Backend tests +cargo test --manifest-path src-tauri/Cargo.toml + +# Dev server +npm run dev +``` + +## Architecture: File Size Limits + +Single-file components and modules must not grow unboundedly. Hard limits (lines): + +| Threshold | Action | +|-----------|--------| +| > 800 | Consider extracting hooks or sub-components | +| > 1200 | Must extract before adding new logic | +| > 1800 | Treat as a bug — refactor before any other work | + +Current large files that must NOT grow further without extraction: + +- `src/App.tsx` (1921) — route/layout only; new features go in dedicated components +- `src/components/AiConversationPanel.tsx` (1758) — recently refactored; new event handlers → `useAgentEventHandlers`, new rendering → `MessageBubble` or new sub-component, new UI state → dedicated hook +- `src/components/AiLlmSettingsModal.tsx` (1680) — extract section components for new settings +- `src/hooks/useOpenDocs.ts` (1140) — extract sub-hooks for new doc operations +- `src-tauri/src/lib.rs` (1828) — Tauri command hub; new commands register here but implementation goes in domain modules +- `src-tauri/src/llm/agent_loop.rs` (1581) — core loop; new tool handling → tool modules, new context logic → context_guard + +### Where to put new code + +- New Tauri event listener → `src/hooks/useAgentEventHandlers.ts` +- New message rendering logic → `src/components/MessageBubble.tsx` or a new `src/components/.tsx` +- New tool call display → `src/components/ToolCallItem.tsx` +- New React hook → `src/hooks/use.ts` +- New Rust tool → `src-tauri/src/tools/built_in/.rs` +- New LLM feature → `src-tauri/src/llm/.rs`, not inline in `agent_loop.rs` diff --git a/package.json b/package.json index ab969dc..05b58df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "knowforge", "private": true, - "version": "0.7.3", + "version": "0.7.4", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/plan/INDEX.md b/plan/INDEX.md new file mode 100644 index 0000000..067437d --- /dev/null +++ b/plan/INDEX.md @@ -0,0 +1,76 @@ +# Plan Index + +> 总控表:所有待办 spec 的状态、优先级一览。 +> 最后更新:2026-07-06 + +--- + +## Active + +(暂无进行中的 spec) + +--- + +## Backlog + +(暂无 backlog — 当前架构已稳定,不再主动优化已知内部问题) + +--- + +## Done + +| Spec | 优先级 | 完成日期 | 说明 | +|------|--------|----------|------| +| graph 私密节点过滤 | P0 | 2026-06-30 | 图谱泄漏私密笔记信息 | +| note.read symlink 修复 | P0 | 2026-06-30 | 符号链接逃逸漏洞 | +| link 隐私覆盖确认 | P1 | 2026-06-30 | link.suggest_related 隐私过滤 | +| [thought.read](_archive/202506-tool-gap/spec-thought-read.md) | P0 | 2026-07-01 | 想法全文读取工具 | +| [网络瞬态重试](_archive/202506-agent-loop-enhance/spec-error-recovery.md) | P1 | 2026-07-01 | 指数退避 + cancel 感知重试 | +| [目标稳定性](_archive/202507-backlog-cleanup/goal-stability/README.md) | P1 | 2026-07-01 | 目标提取钉住 + 漂移警告 | +| [计划审批](_archive/202507-backlog-cleanup/spec-plan-approval.md) | P1 | 2026-07-02 | Phase A→B 审批门(后已合并为单阶段) | +| [主动压缩](_archive/202507-backlog-cleanup/spec-proactive-compaction.md) | P2 | 2026-07-03 | 修复快照索引漂移 | +| Planning 模式简化 | P1 | 2026-07-03 | Phase A/B 合并为单次执行 | +| God Component 拆分 | P1 | 2026-07-03 | AiConversationPanel 2570→1758 行 | +| [事件可靠性](_archive/fix-event-reliability/) | P1 | 2026-07-06 | 心跳 + watchdog + 幂等 + 缓冲队列 | +| 动态预算 | P1 | 已落地 | max_tool_calls 25 + budget 耗尽优雅降级 | +| Agent Memory v2 | P1 | 已落地 | MemoryManager + agent_memory.json + LLM 提取 | + +--- + +## Closed (Won't Fix) + +以下问题经评估后决定不修复,原因见括号: + +- D-2 隐式状态机(watchdog + 幂等 + 缓冲已从事件层面解决状态不一致) +- D-4 会话重连(桌面应用刷新概率极低,审批超时已兜底) +- D-6 压缩层边界不清(功能正确,重构风险 > 收益) +- D-7 Skill 隔离(vault 非敌对数据,资源消耗已有界) +- D-8 参数过多(纯美观,不影响功能) +- 消息优先级(128K context 下几乎不触发) +- 搜索重排序(LLM 本身是更强的 reranker) +- 深度研究 / 工作记忆 / 结构化追踪 / 会话回放 / 并行 Agent / 零配置搜索 / MCP / 子 Agent / 思考可视化(关闭理由见 2026-07-03 评审) + +--- + +## Archive + +历史计划文档已移至 `_archive/`,保留完整 git 历史。包含: + +- `iter1/` — 初始 AI 能力建设(P0-P5 工具 + agent loop) +- `iter1-ai-upgrade/` — ContextGuard + 计划审查等 spec +- `unified-provider/` — 统一 Provider 重构 +- `llm-latency/` — LLM 响应延迟优化 +- `agent-memory/` — Agent 记忆系统(20 个 spec) +- `context-management-redesign/` — 上下文管理重设计(Phase 1 已完成) +- `search-agent/` — 搜索 Agent 升级分析 +- `tool-gap-analysis/` — 工具缺口分析 +- `iter2-agent-evolution/` — Agent 架构进化路线(Phase 1-4) +- `web-search-evolution/` — Web 搜索竞品分析与路线 +- `202506-security-privacy-fix/` — 安全与隐私修复(3 spec,全部完成) +- `202506-tool-gap/` — 工具缺口(thought.read 完成,其余 wontfix) +- `202506-agent-loop-enhance/` — Agent Loop 增强(重试已完成,反思循环 P3 延后) +- `202507-backlog-cleanup/` — 2026-07 Backlog 清理 +- `fix-event-reliability/` — 事件可靠性三层修复(2026-07-06 完成) +- `refactor-god-component/` — God Component 拆分方案 +- `review-agent-loop-architecture.md` — Agent Loop 架构评审 +- `session-context-management-analysis.md` — 会话上下文管理分析 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e91742..75012fe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2664,7 +2664,7 @@ dependencies = [ [[package]] name = "knowforge" -version = "0.7.3" +version = "0.7.4" dependencies = [ "aho-corasick", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2502b56..4b22715 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "knowforge" -version = "0.7.3" +version = "0.7.4" description = "Knowforge desktop app" authors = ["caichangqing"] edition = "2024" diff --git a/src-tauri/src/ai_conversations.rs b/src-tauri/src/ai_conversations.rs index 41e3f1d..7dbfcd5 100644 --- a/src-tauri/src/ai_conversations.rs +++ b/src-tauri/src/ai_conversations.rs @@ -122,6 +122,8 @@ struct PersistedMessageDisk { skill_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] skill_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_calls: Option, } /// 会话级「想法聚焦」上下文(磁盘与 LLM 字段对齐) @@ -249,6 +251,8 @@ pub struct PersistedMessageOut { pub skill_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub skill_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option, } #[derive(Debug, Clone, Serialize)] @@ -299,6 +303,8 @@ pub struct PersistedMessageIn { pub skill_id: Option, #[serde(default)] pub skill_name: Option, + #[serde(default)] + pub tool_calls: Option, } #[derive(Debug, Clone, Deserialize)] @@ -358,6 +364,7 @@ fn validate_messages_for_save(msgs: &[PersistedMessageIn]) -> Result break r, + _ = heartbeat_interval.tick() => { + emit_heartbeat(&app, &session_id, iteration); + } + } + }; for (i, tc) in normalized_calls.iter().enumerate() { if let Some((result, duration_ms)) = results.get(i) { @@ -449,6 +477,8 @@ pub async fn run_agent_stream( ); let error_message = result.as_ref().err().map(|e| e.as_str()); emit_tool_call_done(&app, &session_id, &tc.id, success, &result_summary, *duration_ms, error_message); + + } } @@ -695,22 +725,21 @@ where result } +/// Human-in-the-loop approval gate for a tool call. Runs BEFORE and OUTSIDE the +/// tool-execution timeout, so time the user spends deciding is not charged against +/// the tool's execution budget (that timeout must only bound the actual invocation). +/// Returns Ok(()) to proceed, or Err to abort (forbidden / denied / cancelled / +/// approval backstop expired). #[allow(clippy::too_many_arguments)] -pub(crate) async fn execute_tool( +pub(crate) async fn await_tool_approval( app: &AppHandle, session_id: &str, registry: &Arc, - ctx_factory: &Arc, - workspace_root: &PathBuf, - app_cache_dir: Option, - app_bundle_resource_dir: Option, conversation_id: &str, tc: &NormalizedToolCall, approval_state: &Arc, cancel: &CancellationToken, - nesting_depth: u8, - provider: Option>, -) -> Result { +) -> Result<(), String> { let tool = registry .get(&tc.name) .ok_or_else(|| format!("tool not found: {}", tc.name))?; @@ -723,14 +752,13 @@ pub(crate) async fn execute_tool( manifest_policy }; match &policy { - ApprovalPolicy::Auto => { /* 直接放行 */ } - ApprovalPolicy::Forbidden => { - return Err(format!("tool '{}' is forbidden", tc.name)); - } + ApprovalPolicy::Auto => Ok(()), + ApprovalPolicy::Forbidden => Err(format!("tool '{}' is forbidden", tc.name)), ApprovalPolicy::ConfirmOncePerSession if approval_state.is_pre_approved(conversation_id, &tc.name) => { // 会话级缓存命中,放行 + Ok(()) } ApprovalPolicy::ConfirmEach | ApprovalPolicy::ConfirmOncePerSession => { let (approval_id, rx, _guard) = approval_state.register(); @@ -751,7 +779,7 @@ pub(crate) async fn execute_tool( ); let decision = tokio::select! { - res = tokio::time::timeout(Duration::from_secs(120), rx) => res, + res = tokio::time::timeout(TOOL_APPROVAL_TIMEOUT, rx) => res, _ = cancel.cancelled() => { return Err("cancelled".to_string()); } @@ -762,22 +790,34 @@ pub(crate) async fn execute_tool( if matches!(policy, ApprovalPolicy::ConfirmOncePerSession) { approval_state.remember_approval(conversation_id, &tc.name); } + Ok(()) } - Ok(Ok(false)) => { - return Err(format!("user denied approval for tool '{}'", tc.name)); - } - Ok(Err(_)) => { - return Err("approval channel closed unexpectedly".to_string()); - } - Err(_) => { - return Err(format!( - "approval timed out for tool '{}'", - tc.name - )); - } + Ok(Ok(false)) => Err(format!("user denied approval for tool '{}'", tc.name)), + Ok(Err(_)) => Err("approval channel closed unexpectedly".to_string()), + Err(_) => Err(format!("approval timed out for tool '{}'", tc.name)), } } } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn execute_tool( + app: &AppHandle, + session_id: &str, + registry: &Arc, + ctx_factory: &Arc, + workspace_root: &PathBuf, + app_cache_dir: Option, + app_bundle_resource_dir: Option, + conversation_id: &str, + tc: &NormalizedToolCall, + cancel: &CancellationToken, + nesting_depth: u8, + provider: Option>, +) -> Result { + let tool = registry + .get(&tc.name) + .ok_or_else(|| format!("tool not found: {}", tc.name))?; tool.validate_input(&tc.arguments) .map_err(|e| format!("validation failed: {}", e.message))?; @@ -906,7 +946,7 @@ pub fn list_for_llm_to_tools(manifests: &[Value]) -> Vec { .collect() } -fn emit_tool_call_start(app: &AppHandle, session_id: &str, tool_call_id: &str, tool_name: &str, input_summary: &str) { +fn emit_tool_call_start(app: &AppHandle, session_id: &str, tool_call_id: &str, tool_name: &str, input_summary: &str, display_summary: &str) { let _ = app.emit( "llm:tool-call-start", json!({ @@ -914,10 +954,47 @@ fn emit_tool_call_start(app: &AppHandle, session_id: &str, tool_call_id: &str, t "toolCallId": tool_call_id, "toolName": tool_name, "inputSummary": input_summary, + "displaySummary": display_summary, }), ); } +fn generate_tool_display_summary(name: &str, args: &Value) -> String { + let get_str = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or(""); + + match name { + "note.list" => "List notes".into(), + "note.read" => format!("Read {}", truncate_str(get_str("rel_path"), 40)), + "note.create" => format!("Create {}", truncate_str(get_str("rel_path"), 40)), + "note.append" => format!("Append to {}", truncate_str(get_str("rel_path"), 40)), + "note.write_section" => format!("Edit {}", truncate_str(get_str("rel_path"), 40)), + + "vault.search_keyword" => format!("Search: {}", truncate_str(get_str("query"), 40)), + "vault.semantic_search" => format!("Semantic search: {}", truncate_str(get_str("query"), 40)), + + "web.search" => format!("Web search: {}", truncate_str(get_str("query"), 40)), + "web.read_page" => format!("Read page: {}", truncate_str(get_str("url"), 50)), + "web.read_pdf" => format!("Read PDF: {}", truncate_str(get_str("url"), 50)), + "web.download" => format!("Download: {}", truncate_str(get_str("url"), 50)), + + "thought.list" => "List thoughts".into(), + "thought.read" => format!("Read thought {}", truncate_str(get_str("thought_id"), 20)), + "thought.create" => "Create thought".into(), + + "graph.query_topic_network" => format!("Query graph: {}", truncate_str(get_str("topic"), 30)), + "link.suggest_related" => "Suggest related links".into(), + + "memory.save" => format!("Save memory: {}", truncate_str(get_str("category"), 20)), + "memory.forget" => "Forget memory".into(), + + "tool.recall" => format!("Recall: {}", truncate_str(get_str("tool_call_id"), 20)), + + n if n.starts_with("skill.") => format!("Skill: {}", &n[6..]), + + _ => name.to_string(), + } +} + fn emit_tool_call_done(app: &AppHandle, session_id: &str, tool_call_id: &str, success: bool, result_summary: &str, duration_ms: u64, error_message: Option<&str>) { let _ = app.emit( "llm:tool-call-done", @@ -939,6 +1016,13 @@ fn emit_agent_done(app: &AppHandle, session_id: &str) { ); } +fn emit_heartbeat(app: &AppHandle, session_id: &str, loop_iteration: u32) { + let _ = app.emit( + "llm:heartbeat", + json!({ "sessionId": session_id, "loopIteration": loop_iteration }), + ); +} + /// Truncate a string to `max_len` bytes, appending "..." if truncated. /// Respects Unicode character boundaries. fn truncate_str(s: &str, max_len: usize) -> String { @@ -994,6 +1078,12 @@ fn summarize_tool_input(args: &Value) -> String { const TASK_CONTEXT_HEADER: &str = "# Task Context"; +/// Backstop timeout for tool-call approval. Approval is human-in-the-loop, so we +/// wait patiently for the user; this only guards against a request that is +/// abandoned entirely (never answered, never cancelled) holding the run open +/// forever. On expiry the tool is denied. +const TOOL_APPROVAL_TIMEOUT: Duration = Duration::from_secs(30 * 60); + const TASK_EXTRACT_PROMPT: &str = "\ From the conversation below, extract the user's task: diff --git a/src-tauri/src/llm/context_guard.rs b/src-tauri/src/llm/context_guard.rs index 5db4957..23266ae 100644 --- a/src-tauri/src/llm/context_guard.rs +++ b/src-tauri/src/llm/context_guard.rs @@ -40,6 +40,7 @@ pub struct ContextGuard { pub struct PrecomputedSummary { pub summary_text: String, pub summarized_up_to: usize, + pub summarized_count: usize, } impl ContextGuard { @@ -133,9 +134,12 @@ impl ContextGuard { .ok() .filter(|t| !t.trim().is_empty())?; + let summarized_count = removable_indices.len(); + Some(PrecomputedSummary { summary_text, summarized_up_to: tail_boundary, + summarized_count, }) } @@ -148,8 +152,9 @@ impl ContextGuard { return false; } - let removable_indices: Vec = (0..cached.summarized_up_to.min(messages.len())) + let removable_indices: Vec = (0..messages.len()) .filter(|&i| messages[i].role != "system") + .take(cached.summarized_count) .collect(); for &i in removable_indices.iter().rev() { @@ -816,6 +821,7 @@ mod tests { let cached = PrecomputedSummary { summary_text: "User asked about X, tool returned Y.".to_string(), summarized_up_to: 5, + summarized_count: 4, }; let mut msgs = vec![ sys("system prompt"), // 0: system, skipped @@ -843,6 +849,7 @@ mod tests { let cached = PrecomputedSummary { summary_text: "summary".to_string(), summarized_up_to: 10, + summarized_count: 0, }; // Only 5 messages — fewer than summarized_up_to let mut msgs = vec![ @@ -863,6 +870,7 @@ mod tests { let cached = PrecomputedSummary { summary_text: "conversation summary".to_string(), summarized_up_to: 4, + summarized_count: 2, }; let mut msgs = vec![ sys("core system prompt"), // 0: system, preserved @@ -882,4 +890,32 @@ mod tests { assert!(msgs.iter().any(|m| m.content == "extra system")); assert!(msgs.iter().any(|m| m.content.contains("conversation summary"))); } + + #[test] + fn cached_summary_immune_to_system_insertion_before_apply() { + let guard = ContextGuard::new(Some(4096)); + let cached = PrecomputedSummary { + summary_text: "summary of q1/a1".to_string(), + summarized_up_to: 3, + summarized_count: 2, + }; + let mut msgs = vec![ + sys("system prompt"), + sys("# User Model"), + sys("# Task Context"), + user("q1"), + assistant("a1"), + user("q2"), + assistant("a2"), + ]; + let applied = guard.apply_cached_summary(&mut msgs, &cached); + assert!(applied); + assert!(!msgs.iter().any(|m| m.content == "q1")); + assert!(!msgs.iter().any(|m| m.content == "a1")); + assert!(msgs.iter().any(|m| m.content == "q2")); + assert!(msgs.iter().any(|m| m.content == "a2")); + assert!(msgs.iter().any(|m| m.content == "# User Model")); + assert!(msgs.iter().any(|m| m.content == "# Task Context")); + assert!(msgs.iter().any(|m| m.content.contains("summary of q1/a1"))); + } } diff --git a/src-tauri/src/llm/planning.rs b/src-tauri/src/llm/planning.rs index d7c2256..0444d23 100644 --- a/src-tauri/src/llm/planning.rs +++ b/src-tauri/src/llm/planning.rs @@ -1,8 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter}; +use serde_json::Value; use tokio_util::sync::CancellationToken; use super::agent_loop::{self, AgentLoopConfig, SharedMemoryManager}; @@ -11,85 +10,17 @@ use super::provider::LlmProvider; use super::LlmChatMessage; use crate::tools::context::ToolContextFactory; use crate::tools::registry::ToolRegistry; +use tauri::AppHandle; const PLANNING_SYSTEM_PROMPT: &str = "\ -Analyze the user's request carefully. Output a numbered plan listing the exact tool calls \ -you would make and why. Do NOT call any tools — only output the plan as plain text.\n\n\ -Available tools:\n"; - -const MIN_PLAN_LENGTH: usize = 10; - -fn build_planning_messages( - messages: &[LlmChatMessage], - tool_descriptions: &str, -) -> Vec { - let mut out = Vec::with_capacity(messages.len() + 1); - for m in messages { - out.push(m.clone()); - } - out.push(LlmChatMessage { - role: "system".to_string(), - content: format!("{PLANNING_SYSTEM_PROMPT}{tool_descriptions}"), - ..Default::default() - }); - out -} - -fn inject_plan_into_messages( - messages: &[LlmChatMessage], - plan_text: &str, -) -> Vec { - let mut out = Vec::with_capacity(messages.len() + 1); - for m in messages { - out.push(m.clone()); - } - out.push(LlmChatMessage { - role: "system".to_string(), - content: format!( - "Execute the following plan step by step. Call the tools listed. \ - After all steps, provide the final answer to the user.\n\n\ - Plan:\n{plan_text}" - ), - ..Default::default() - }); - out -} - -fn build_tool_descriptions(tools_json: &[Value]) -> String { - let mut desc = String::new(); - for t in tools_json { - let name = t - .pointer("/function/name") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let description = t - .pointer("/function/description") - .and_then(|v| v.as_str()) - .unwrap_or(""); - desc.push_str(&format!("- {name}: {description}\n")); - } - desc -} - -pub(crate) fn emit_planning_start(app: &AppHandle, session_id: &str) { - let _ = app.emit( - "llm:planning-start", - json!({ "sessionId": session_id }), - ); -} - -pub(crate) fn emit_planning_done(app: &AppHandle, session_id: &str, plan_text: &str) { - let _ = app.emit( - "llm:planning-done", - json!({ "sessionId": session_id, "planText": plan_text }), - ); -} +Before making any tool calls, briefly explain your approach in 1-2 sentences.\n\ +Then proceed to execute step by step."; #[allow(clippy::too_many_arguments)] pub async fn run_planned_agent( app: AppHandle, session_id: String, - initial_messages: Vec, + mut initial_messages: Vec, tools_json: Vec, registry: Arc, ctx_factory: Arc, @@ -103,44 +34,16 @@ pub async fn run_planned_agent( approval_state: Arc, memory_manager: SharedMemoryManager, ) -> String { - // Phase A: Planning (no tools, text-only output) - let tool_desc = build_tool_descriptions(&tools_json); - let plan_messages = build_planning_messages(&initial_messages, &tool_desc); - - emit_planning_start(&app, &session_id); - - let plan_result = provider - .chat_stream( - &app, - &session_id, - plan_messages, - None, - cancel.clone(), - ) - .await; - - let plan_text = match plan_result { - Ok(r) => r.content, - Err(_) => String::new(), - }; - - emit_planning_done(&app, &session_id, &plan_text); - - if cancel.is_cancelled() { - return String::new(); - } - - // Phase B: Execution (inject plan, normal agent loop) - let exec_messages = if plan_text.trim().len() >= MIN_PLAN_LENGTH { - inject_plan_into_messages(&initial_messages, &plan_text) - } else { - initial_messages - }; + initial_messages.push(LlmChatMessage { + role: "system".to_string(), + content: PLANNING_SYSTEM_PROMPT.to_string(), + ..Default::default() + }); agent_loop::run_agent_stream( app, session_id, - exec_messages, + initial_messages, tools_json, registry, ctx_factory, @@ -156,61 +59,3 @@ pub async fn run_planned_agent( ) .await } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_planning_messages_appends_system() { - let msgs = vec![LlmChatMessage { - role: "user".to_string(), - content: "search my notes".to_string(), - ..Default::default() - }]; - let result = build_planning_messages(&msgs, "- note.list: list notes\n"); - assert_eq!(result.len(), 2); - assert_eq!(result[1].role, "system"); - assert!(result[1].content.contains("note.list")); - assert!(result[1].content.contains("Do NOT call any tools")); - } - - #[test] - fn inject_plan_adds_execution_guidance() { - let msgs = vec![LlmChatMessage { - role: "user".to_string(), - content: "find my notes".to_string(), - ..Default::default() - }]; - let plan = "1. Call note.list\n2. Read top result"; - let result = inject_plan_into_messages(&msgs, plan); - assert_eq!(result.len(), 2); - assert!(result[1].content.contains("Execute the following plan")); - assert!(result[1].content.contains("note.list")); - } - - #[test] - fn build_tool_descriptions_extracts_names() { - let tools = vec![ - json!({ - "type": "function", - "function": { - "name": "note.list", - "description": "List notes in the vault", - "parameters": {} - } - }), - json!({ - "type": "function", - "function": { - "name": "web.search", - "description": "Search the web", - "parameters": {} - } - }), - ]; - let desc = build_tool_descriptions(&tools); - assert!(desc.contains("- note.list: List notes in the vault")); - assert!(desc.contains("- web.search: Search the web")); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c9425e0..ffa9500 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Knowforge", - "version": "0.7.3", + "version": "0.7.4", "identifier": "com.knowforge.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AiConversationPanel.css b/src/components/AiConversationPanel.css index 94a9c7d..6f3d58b 100644 --- a/src/components/AiConversationPanel.css +++ b/src/components/AiConversationPanel.css @@ -290,19 +290,3 @@ } } -/* Planning phase: live streaming + fold */ -.ai-chat__planning-live { - opacity: 0.5; -} -.ai-chat__planning-fold { - margin-bottom: 0.5em; - border: 1px solid var(--border-subtle, #e0e0e0); - border-radius: 4px; - padding: 2px 8px; - font-size: 0.9em; -} -.ai-chat__planning-fold summary { - cursor: pointer; - color: var(--fg-muted, #888); - user-select: none; -} diff --git a/src/components/AiConversationPanel.tsx b/src/components/AiConversationPanel.tsx index aaf5792..1dcb7ae 100644 --- a/src/components/AiConversationPanel.tsx +++ b/src/components/AiConversationPanel.tsx @@ -1,22 +1,19 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { useAiConversationSession } from "../contexts/AiConversationSessionContext"; import type { ThoughtFocusContext } from "../types/aiConversation"; import { useAiNoteContext } from "../contexts/AiNoteContext"; -import type { ChatMessage, ToolCallDisplayInfo } from "../hooks/useWorkspaceAiConversations"; +import type { ChatMessage } from "../hooks/useWorkspaceAiConversations"; +import { useAgentEventHandlers, retractInterruptedTurn, finalizeStreamingAssistant } from "../hooks/useAgentEventHandlers"; import type { ReplyContextSources } from "../types/replyContextSources"; import { hasReplyContextSourcesToShow } from "../types/replyContextSources"; import type { AutoResolvedDepth, ThoughtRetrievalResult, - CountVaultThoughtsForReviewResponse, - GenerateChallengeQuestionResponse, } from "../types/cognitiveTypes"; import type { DetectPassiveHighlightResponse, PassiveHighlightMarked } from "../types/passiveHighlight"; import type { VaultConfigForUi, ProviderProfileForUi } from "../types/vaultAiConfig"; -import { isChallengeInlineLlmReady } from "../utils/isChallengeReviewLlmReady"; import { VAULT_CONFIG_UPDATED_EVENT } from "../utils/vaultConfigBroadcast"; import { markdownTreatAsKfPrivateForUi } from "../utils/kfPrivateMarkdown"; import { PrivacyChangeOverlay } from "./PrivacyChangeOverlay"; @@ -26,18 +23,14 @@ import { respondToolApproval } from "../utils/toolInvoke"; import { listSkills, invokeSkill } from "../utils/skillInvoke"; import type { SkillManifestJson } from "../types/skillTypes"; import { parseSlashCommand } from "../utils/parseSlashCommand"; -import { AiAssistantMarkdown } from "./AiAssistantMarkdown"; -import { StreamingTimer } from "./StreamingTimer"; +import { MessageBubble, IconSaveThought } from "./MessageBubble"; import { useCognitiveFrequencyControl } from "../hooks/useCognitiveFrequencyControl"; import { DepthSlider } from "./DepthSlider"; import { ChallengeReviewInline } from "./ChallengeReviewInline"; import { InviteAfterAnswer } from "./InviteAfterAnswer"; import { ThoughtSavePopover } from "./ThoughtSavePopover"; -import { PassiveHighlightSaveCue } from "./PassiveHighlightSaveCue"; -import { AiReplyContextSources } from "./AiReplyContextSources"; import { stripMarkedPassiveHighlightWithCount } from "../utils/passiveHighlightLifecycle"; import { trackKnowforgeEvent } from "../utils/knowforgeAnalytics"; -import { getAppLocale } from "../i18n"; import "./AiConversationPanel.css"; import "./MemoryProposals.css"; @@ -56,50 +49,6 @@ type MemoryProposalBatch = { created_at: string; }; -/** 用户中止且输入框无新草稿:去掉末尾 streaming 助手及其前一条用户消息 */ -function retractInterruptedTurn(prev: ChatMessage[]): ChatMessage[] { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next.pop(); - } - const u = next[next.length - 1]; - if (u?.role === "user") { - next.pop(); - } - return next; -} - -/** 用户中止但保留会话:仅将末尾 streaming 助手标为结束,保留已生成片段 */ -function finalizeStreamingAssistant(prev: ChatMessage[]): ChatMessage[] { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next[next.length - 1] = { ...last, streaming: false }; - } - return next; -} - -/** - * 合并流式增量,兼容重复监听或上游偶发返回「全量片段」导致的重复拼接。 - * - `delta` 与当前尾部完全重复:忽略; - * - `delta` 以当前全文开头:视为全量快照,直接替换为 `delta`; - * - 其余情况按最大前后缀重叠合并,避免出现「您您 在在」这类叠字。 - */ -function mergeStreamDelta(current: string, delta: string): string { - if (!delta) return current; - if (!current) return delta; - if (current.endsWith(delta)) return current; - if (delta.startsWith(current)) return delta; - - const maxOverlap = Math.min(current.length, delta.length); - for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { - if (current.endsWith(delta.slice(0, overlap))) { - return current + delta.slice(overlap); - } - } - return current + delta; -} type StartStreamResponse = { sessionId: string; @@ -159,44 +108,6 @@ function IconStopSquare() { ); } -/** 复制助手消息为 Markdown */ -function IconCopyClipboard() { - return ( - - - - - ); -} - -/** 保存为想法图标(书签) */ -function IconSaveThought() { - return ( - - - - ); -} /** 横幅提示手动关闭(X) */ function IconDismissBanner() { @@ -300,9 +211,8 @@ export function AiConversationPanel() { const [copyToast, setCopyToast] = useState<"copied" | "failed" | null>(null); const [privacyHint, setPrivacyHint] = useState(null); const [vaultSearchSummary, setVaultSearchSummary] = useState(null); - const [isPlanning, setIsPlanning] = useState(false); - const activeSessionRef = useRef(null); + const listEndRef = useRef(null); /** 是否吸附在底部:用户向上滚动查看历史时置 false,避免流式刷新强制拉回底部 */ const stickToBottomRef = useRef(true); @@ -310,15 +220,6 @@ export function AiConversationPanel() { /** Iter 5 #4:工具调用总开关从 vault config 读取(默认 true,旧 vault 缺字段时取 true)。 * 通过 VAULT_CONFIG_UPDATED_EVENT 在设置保存后实时同步,无需重开会话。 */ const [toolsEnabled, setToolsEnabled] = useState(true); - /** 发送时快照:agent 模式下 stream-done 仅是中间信号,不能最终化助手消息 */ - const isAgentModeRef = useRef(false); - - /** Iter 5 #4: while a `skill.` auto-invocation is in flight, save the - * parent session here so we can restore activeSessionRef once the skill - * sub-turn fires `agent-done`. Null when no skill is currently in progress. */ - const parentSessionRef = useRef(null); - /** 映射:skillSessionId → { parentToolCallId } */ - const skillSessionMapRef = useRef>(new Map()); /** Iter 5 #3:内置 skill manifest 缓存。skill 在后端 setup() 注册一次后不变,挂载时取一次即可。 */ const [skillsCache, setSkillsCache] = useState([]); @@ -595,673 +496,37 @@ export function AiConversationPanel() { } }, [messages, scrollToBottom]); - useEffect(() => { - if (!isTauri()) { - return; - } - - let disposed = false; - const pending: UnlistenFn[] = []; - - void Promise.all([ - listen<{ sessionId: string; delta: string }>("llm:stream-chunk", (e) => { - const p = e.payload; - if (p.sessionId !== activeSessionRef.current) { - return; - } - const skillMapping = skillSessionMapRef.current.get(p.sessionId); - if (skillMapping) { - // Skill 会话的 chunk → 追加到父气泡的工具调用项的 skillContent - setMessages((prev) => { - const next = [...prev]; - for (let i = next.length - 1; i >= 0; i--) { - const m = next[i]; - if (m.role !== "assistant") continue; - const tcs = m.meta?.toolCalls; - if (!tcs) continue; - const tcIdx = tcs.findIndex((tc) => tc.toolCallId === skillMapping.parentToolCallId); - if (tcIdx === -1) continue; - const updatedCalls = [...tcs]; - updatedCalls[tcIdx] = { - ...updatedCalls[tcIdx], - skillContent: (updatedCalls[tcIdx].skillContent || "") + p.delta, - }; - next[i] = { - ...m, - meta: { ...m.meta, toolCalls: updatedCalls }, - }; - break; - } - return next; - }); - return; - } - // 主会话的 chunk → 追加到气泡 content(原有逻辑) - setMessages((prev) => { - let next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - const isFirstToken = last.content === ""; - next[next.length - 1] = { - ...last, - content: mergeStreamDelta(last.content, p.delta), - meta: isFirstToken && last.meta?.timing - ? { ...last.meta, timing: { ...last.meta.timing, firstTokenMs: Date.now() } } - : last.meta, - }; - } - return next; - }); - }), - listen<{ sessionId: string }>("llm:stream-done", (e) => { - const p = e.payload; - if (p.sessionId !== activeSessionRef.current) { - return; - } - setIsVaultSearching(false); - // P2 Tool Calling Loop:agent 模式下 stream-done 只是轮次中的中间信号, - // 后续还会有 tool-call-* 与后续文本输出,只能由 llm:agent-done 最终化。 - if (isAgentModeRef.current) { - return; - } - markNeedPersist(); - activeSessionRef.current = null; - setIsStreaming(false); - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next[next.length - 1] = { - ...last, - streaming: false, - meta: last.meta?.timing - ? { ...last.meta, timing: { ...last.meta.timing, endMs: Date.now() } } - : last.meta, - }; - } - const strippedPack = stripMarkedPassiveHighlightWithCount(next); - if (strippedPack.stripped > 0) { - passiveHighlightMarkedCountRef.current = Math.max( - 0, - passiveHighlightMarkedCountRef.current - strippedPack.stripped, - ); - } - return strippedPack.messages; - }); - - // 后台:先答后邀优先;否则在满足频控时尝试挑战式回顾(通道二) - const epoch = ++inviteSearchEpochRef.current; - setInviteData(null); - setChallengeInlineData(null); - const query = lastSentQueryRef.current.trim(); - if (!query || enoughForThisChatRef.current) return; - const dm = depthModeForInviteRef.current; - const ar = autoResolvedForInviteRef.current; - if (dm === "shallow" || (dm === "auto" && ar === "shallow")) { - return; - } - void (async () => { - try { - type SearchResp = { - thought: ThoughtRetrievalResult | null; - thoughts?: ThoughtRetrievalResult[]; - meta: { scannedFiles: number; stoppedEarly: boolean; elapsedMs: number }; - }; - - const turnIdx = messagesRef.current.filter((m) => m.role === "user").length; - const inviteEligible = freqCtrl.shouldShowInvite( - dm, - enoughForThisChatRef.current, - turnIdx, - ar, - ); - - if (inviteEligible) { - const resp = await invoke("search_thought_for_invite", { - args: { - query, - excludeRelPaths: thoughtInviteExcludeRef.current, - maxResults: 1, - }, - }); - if (inviteSearchEpochRef.current !== epoch || disposed) return; - const excerptRaw = resp.thought?.excerpt; - const question = - excerptRaw && excerptRaw.length > 0 - ? t("invite.thoughtQuestion", { - excerpt: - excerptRaw.length > 60 ? `${excerptRaw.slice(0, 60)}...` : excerptRaw, - }) - : t("invite.defaultQuestion"); - setInviteData({ thought: resp.thought, question }); - attachCitationToLastAssistant(resp.thought ?? null); - const th = resp.thought; - if ( - th && - !th.privateOmitted && - th.thoughtId && - isTauri() && - th.relPath - ) { - void invoke("append_ai_thought_reference", { - args: { - relPath: th.relPath, - thoughtId: th.thoughtId, - context: query.slice(0, 2000), - relevance: "ai-conversation-invite", - }, - }).catch(() => {}); - } - return; - } - - let cfg: VaultCfgForSend; - try { - cfg = await invoke("get_vault_config_for_ui"); - } catch { - return; - } - if (inviteSearchEpochRef.current !== epoch || disposed) return; - if (!isChallengeInlineLlmReady(cfg as VaultConfigForUi)) return; - - const countResp = await invoke( - "count_vault_thoughts_for_review", - ); - if (inviteSearchEpochRef.current !== epoch || disposed) return; - const total = countResp.totalThoughts; - if ( - !freqCtrl.shouldShowChallengeInline(dm, ar, { - inviteWillShow: false, - thoughtId: null, - vaultThoughtTotal: total, - }) - ) { - return; - } - - const respMany = await invoke("search_thought_for_invite", { - args: { - query, - excludeRelPaths: thoughtInviteExcludeRef.current, - maxResults: 3, - }, - }); - if (inviteSearchEpochRef.current !== epoch || disposed) return; - const pick = - respMany.thoughts?.find((x) => x.excerpt && !x.privateOmitted) ?? respMany.thought; - if (!pick?.excerpt || pick.privateOmitted) return; - - const gen = await invoke("generate_challenge_question", { - args: { - thoughtExcerpt: pick.excerpt, - relPath: pick.relPath, - conversationQuery: query, - depthMode: dm, - uiLocale: getAppLocale(), - }, - }); - if (inviteSearchEpochRef.current !== epoch || disposed) return; - if (gen.shouldSkip || !gen.question.trim()) return; - - void freqCtrl.recordChallengeInlineShown(pick.thoughtId); - void trackKnowforgeEvent("review.inline_shown", { - thoughtId: pick.thoughtId, - templateKind: gen.templateKind, - }); - setChallengeInlineData({ - thought: pick, - question: gen.question, - templateKind: gen.templateKind, - }); - attachCitationToLastAssistant(pick); - if (!pick.privateOmitted && pick.thoughtId && isTauri()) { - void invoke("append_ai_thought_reference", { - args: { - relPath: pick.relPath, - thoughtId: pick.thoughtId, - context: query.slice(0, 2000), - relevance: "ai-challenge-inline", - }, - }).catch(() => {}); - } - } catch { - // 超时或其他失败:静默跳过 - } - })(); - }), - listen<{ sessionId: string; code?: string; message: string }>("llm:stream-error", (e) => { - const p = e.payload; - if (p.sessionId !== activeSessionRef.current) { - return; - } - // Iter 5 #4: error during a skill sub-turn — restore parent session. - // The parent agent_loop will receive the SkillAsTool tool error in its - // tool_result and decide whether to recover. Don't tear down top-level streaming UI here. - if (parentSessionRef.current) { - const parent = parentSessionRef.current; - parentSessionRef.current = null; - activeSessionRef.current = parent; - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next[next.length - 1] = { ...last, streaming: false }; - } - return next; - }); - return; - } - markNeedPersist(); - activeSessionRef.current = null; - isAgentModeRef.current = false; - setIsStreaming(false); - setIsVaultSearching(false); - if (p.code === "cancelled") { - setMessages((prev) => { - if (composerInputRef.current.trim().length > 0) { - return finalizeStreamingAssistant(prev); - } - let lastUser = ""; - for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].role === "user") { - lastUser = prev[i].content; - break; - } - } - const next = retractInterruptedTurn(prev); - if (lastUser.length > 0) { - queueMicrotask(() => setInput(lastUser)); - } - return next; - }); - return; - } - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next[next.length - 1] = { ...last, streaming: false }; - } - const strippedPack = stripMarkedPassiveHighlightWithCount(next); - if (strippedPack.stripped > 0) { - passiveHighlightMarkedCountRef.current = Math.max( - 0, - passiveHighlightMarkedCountRef.current - strippedPack.stripped, - ); - } - return strippedPack.messages; - }); - setErrorBanner(p.message); - }), - // P2 Tool Calling Loop:工具调用开始 → 在末尾 assistant 消息中插入 running 状态项 - // Skill 内部的工具调用路由到对应 toolCall 的 skillToolCalls - listen<{ sessionId: string; toolCallId: string; toolName: string; inputSummary?: string }>( - "llm:tool-call-start", - (e) => { - const p = e.payload; - if (p.sessionId !== activeSessionRef.current) { - return; - } - const skillMapping = skillSessionMapRef.current.get(p.sessionId); - const newCall: ToolCallDisplayInfo = { - toolCallId: p.toolCallId, - toolName: p.toolName, - status: "running", - inputSummary: p.inputSummary, - }; - if (skillMapping) { - // Skill 内部的工具调用 → 添加到对应的 skillToolCalls - setMessages((prev) => { - const next = [...prev]; - for (let i = next.length - 1; i >= 0; i--) { - const m = next[i]; - if (m.role !== "assistant") continue; - const tcs = m.meta?.toolCalls; - if (!tcs) continue; - const tcIdx = tcs.findIndex((tc) => tc.toolCallId === skillMapping.parentToolCallId); - if (tcIdx === -1) continue; - const updatedCalls = [...tcs]; - updatedCalls[tcIdx] = { - ...updatedCalls[tcIdx], - skillToolCalls: [...(updatedCalls[tcIdx].skillToolCalls || []), newCall], - }; - next[i] = { - ...m, - meta: { ...m.meta, toolCalls: updatedCalls }, - }; - break; - } - return next; - }); - return; - } - // 主会话的工具调用 → 添加到末尾 assistant 气泡的 toolCalls - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role !== "assistant") return prev; - const existing = last.meta?.toolCalls ?? []; - return [ - ...next.slice(0, -1), - { - ...last, - meta: { ...last.meta, toolCalls: [...existing, newCall] }, - }, - ]; - }); - }, - ), - // P2 Tool Calling Loop:工具调用完成 → 更新对应 toolCallId 的状态为 done/error - // 按新字段更新 resultSummary / durationMs / errorMessage,处理 Skill 内部工具调用 - listen<{ sessionId: string; toolCallId: string; success: boolean; resultSummary?: string; durationMs?: number; errorMessage?: string }>( - "llm:tool-call-done", - (e) => { - const p = e.payload; - if (p.sessionId !== activeSessionRef.current) { - return; - } - const skillMapping = skillSessionMapRef.current.get(p.sessionId); - if (skillMapping) { - // Skill 内部的工具完成 → 更新对应的 skillToolCalls - setMessages((prev) => { - const next = [...prev]; - for (let i = next.length - 1; i >= 0; i--) { - const m = next[i]; - if (m.role !== "assistant") continue; - const tcs = m.meta?.toolCalls; - if (!tcs) continue; - const tcIdx = tcs.findIndex((tc) => tc.toolCallId === skillMapping.parentToolCallId); - if (tcIdx === -1) continue; - const updatedCalls = [...tcs]; - const skillTcs = (updatedCalls[tcIdx].skillToolCalls || []).map((stc) => - stc.toolCallId === p.toolCallId - ? { ...stc, status: (p.success ? "done" : "error") as ToolCallDisplayInfo["status"], resultSummary: p.resultSummary, durationMs: p.durationMs, errorMessage: p.errorMessage } - : stc, - ); - updatedCalls[tcIdx] = { ...updatedCalls[tcIdx], skillToolCalls: skillTcs }; - next[i] = { ...m, meta: { ...m.meta, toolCalls: updatedCalls } }; - break; - } - return next; - }); - return; - } - // 主会话的工具完成 → 从后向前搜索 toolCallId - setMessages((prev) => { - let foundIndex = -1; - for (let i = prev.length - 1; i >= 0; i -= 1) { - const m = prev[i]; - if (m.role !== "assistant") continue; - if (m.meta?.toolCalls?.some((tc) => tc.toolCallId === p.toolCallId)) { - foundIndex = i; - break; - } - } - if (foundIndex === -1) { - return prev; - } - const target = prev[foundIndex]; - const updatedCalls = (target.meta?.toolCalls ?? []).map((tc) => - tc.toolCallId === p.toolCallId - ? { ...tc, status: (p.success ? "done" : "error") as ToolCallDisplayInfo["status"], resultSummary: p.resultSummary, durationMs: p.durationMs, errorMessage: p.errorMessage } - : tc, - ); - const next = [...prev]; - next[foundIndex] = { - ...target, - meta: { ...target.meta, toolCalls: updatedCalls }, - }; - return next; - }); - }, - ), - // P3 审批:后端在执行非 Auto 工具前请求用户决策 - listen("llm:tool-approval-request", (e) => { - const p = e.payload; - if (p.sessionId !== activeSessionRef.current) { - return; - } - setActiveApproval((cur) => { - if (cur === null) { - return p; - } - approvalQueueRef.current.push(p); - return cur; - }); - }), - listen("llm:memory-proposals", (e) => { - if (e.payload.session_id === activeSessionRef.current) { - setMemoryProposals(e.payload); - setProposalDecisions({}); - } - }), - // Iter 5 #4: 主对话自动调用 `skill.` 时,后端发出 skill-spawn, - // 携带新 sessionId — 这里保存父 session、切换 active、更新父气泡中对应工具调用项的 skill 字段。 - // 不再创建新气泡,Skill 内容内嵌到父气泡的工具调用项中。 - listen<{ - sessionId: string; - conversationId: string; - skillId: string; - skillName: string; - parentToolCallId: string; - }>("llm:skill-spawn", (e) => { - const p = e.payload; - if (!activeSessionRef.current || activeSessionRef.current === p.sessionId) { - return; - } - if (conversationId && p.conversationId !== conversationId) { - return; - } - parentSessionRef.current = activeSessionRef.current; - activeSessionRef.current = p.sessionId; - // 记录 skill session → parent tool call 的映射 - skillSessionMapRef.current.set(p.sessionId, { - parentToolCallId: p.parentToolCallId, - }); - // 更新父气泡中对应工具调用项的 skill 字段(不创建新气泡) - setMessages((prev) => { - const next = [...prev]; - for (let i = next.length - 1; i >= 0; i--) { - const m = next[i]; - if (m.role !== "assistant") continue; - const tcs = m.meta?.toolCalls; - if (!tcs) continue; - const tcIdx = tcs.findIndex((tc) => tc.toolCallId === p.parentToolCallId); - if (tcIdx === -1) continue; - const updatedCalls = [...tcs]; - updatedCalls[tcIdx] = { - ...updatedCalls[tcIdx], - skillId: p.skillId, - skillName: p.skillName, - skillContent: "", - skillToolCalls: [], - skillStreaming: true, - }; - next[i] = { - ...m, - meta: { ...m.meta, toolCalls: updatedCalls }, - }; - break; - } - return next; - }); - }), - listen<{ sessionId: string }>("llm:planning-start", (e) => { - if (e.payload.sessionId !== activeSessionRef.current) return; - setIsPlanning(true); - }), - listen<{ sessionId: string; planText: string }>("llm:planning-done", (e) => { - if (e.payload.sessionId !== activeSessionRef.current) return; - setIsPlanning(false); - const planText = e.payload.planText; - if (planText && planText.trim()) { - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next[next.length - 1] = { - ...last, - content: "", - meta: { ...last.meta, planningText: planText }, - }; - } - return next; - }); - } - }), - // Budget warning: tool call budget reaching 80% - listen<{ sessionId: string; used: number; limit: number; type: string }>( - "llm:budget-warning", - (e) => { - if (e.payload.sessionId !== activeSessionRef.current) return; - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant") { - next[next.length - 1] = { - ...last, - meta: { - ...last.meta, - budgetWarning: { used: e.payload.used, limit: e.payload.limit }, - }, - }; - } - return next; - }); - }, - ), - listen<{ - sessionId: string; - snippets: import("../types/vaultContextSearch").VaultSnippetRecord[]; - meta: import("../types/vaultContextSearch").SearchWorkspaceContextMeta | null; - replyContextSources: ReplyContextSources; - }>("llm:context-ready", (e) => { - const { sessionId, snippets, meta, replyContextSources } = e.payload; - if (sessionId !== activeSessionRef.current) return; - - if (snippets.length > 0 && meta) { - const paths = snippets.map((s) => s.relPath).join(", "); - const priv = snippets.filter((s) => s.kind === "privateOmitted").length; - let line = t("aiPanel.vaultLine", { - paths, - scannedFiles: meta.scannedFiles, - elapsedMs: meta.elapsedMs, - }); - if (priv > 0) { - line += ` ${t("aiPanel.vaultPrivateOmitted", { count: priv })}`; - } - if (meta.stoppedEarly) { - line += ` ${t("aiPanel.vaultStoppedEarly")}`; - } - setVaultSearchSummary(line); - } else { - setVaultSearchSummary(null); - } - - for (const s of snippets) { - if (s.kind !== "privateOmitted") { - sharedDocPathsRef.current.add(s.relPath); - } - } - - setMessages((prev) => { - const idx = prev.findIndex((m) => m.role === "assistant" && m.streaming); - if (idx < 0) return prev; - const next = [...prev]; - next[idx] = { ...next[idx], meta: { ...next[idx].meta, replyContextSources } }; - return next; - }); - - setIsVaultSearching(false); - }), - // P2 Tool Calling Loop:Agent 轮次结束 → 最终化助手消息、清理 streaming 状态 - listen<{ sessionId: string }>("llm:agent-done", (e) => { - const sid = e.payload.sessionId; - const skillMapping = skillSessionMapRef.current.get(sid); - - // Skill 子轮次完成:优先处理,不依赖 activeSessionRef - if (skillMapping) { - skillSessionMapRef.current.delete(sid); - if (activeSessionRef.current === sid) { - activeSessionRef.current = parentSessionRef.current; - } - parentSessionRef.current = null; - - setMessages((prev) => { - const next = [...prev]; - for (let i = next.length - 1; i >= 0; i--) { - const m = next[i]; - if (m.role !== "assistant") continue; - const tcs = m.meta?.toolCalls; - if (!tcs) continue; - const tcIdx = tcs.findIndex((tc) => tc.toolCallId === skillMapping.parentToolCallId); - if (tcIdx === -1) continue; - - const updatedCalls = [...tcs]; - updatedCalls[tcIdx] = { ...updatedCalls[tcIdx], skillStreaming: false }; - next[i] = { ...m, meta: { ...m.meta, toolCalls: updatedCalls } }; - break; - } - return next; - }); - return; - } - - // 其余情况:保持原有顶层 agent-done 逻辑 - if (sid !== activeSessionRef.current) { - return; - } - - // 顶层 agent 循环完成 - markNeedPersist(); - activeSessionRef.current = null; - isAgentModeRef.current = false; - setIsStreaming(false); - setIsVaultSearching(false); - setIsPlanning(false); - setMessages((prev) => { - const next = [...prev]; - const last = next[next.length - 1]; - if (last?.role === "assistant" && last.streaming) { - next[next.length - 1] = { - ...last, - streaming: false, - meta: last.meta?.timing - ? { ...last.meta, timing: { ...last.meta.timing, endMs: Date.now() } } - : last.meta, - }; - } - const strippedPack = stripMarkedPassiveHighlightWithCount(next); - if (strippedPack.stripped > 0) { - passiveHighlightMarkedCountRef.current = Math.max( - 0, - passiveHighlightMarkedCountRef.current - strippedPack.stripped, - ); - } - return strippedPack.messages; - }); - }), - ]).then((unlisteners) => { - if (disposed) { - unlisteners.forEach((u) => void u()); - return; - } - pending.push(...unlisteners); + const { activeSessionRef, isAgentModeRef, parentSessionRef, skillSessionMapRef } = + useAgentEventHandlers({ + setMessages, + setIsStreaming, + setIsVaultSearching, + setErrorBanner, + setInput, + setVaultSearchSummary, + setInviteData, + setChallengeInlineData, + setMemoryProposals, + setProposalDecisions, + setActiveApproval, + composerInputRef, + messagesRef, + sharedDocPathsRef, + approvalQueueRef, + inviteSearchEpochRef, + lastSentQueryRef, + enoughForThisChatRef, + depthModeForInviteRef, + autoResolvedForInviteRef, + thoughtInviteExcludeRef, + passiveHighlightMarkedCountRef, + markNeedPersist, + attachCitationToLastAssistant, + freqCtrl, + conversationId, + t, }); - return () => { - disposed = true; - pending.forEach((u) => void u()); - }; - }, [ - markNeedPersist, - setMessages, - setIsStreaming, - freqCtrl, - t, - attachCitationToLastAssistant, - conversationId, - ]); useEffect(() => { if (!isTauri()) return; @@ -2103,12 +1368,6 @@ export function AiConversationPanel() { ) : null} - {isPlanning ? ( -
- {t("aiPanel.planning")} -
- ) : null} - {passiveHighlightBanner ? (
) : ( messages.map((m) => ( -
-
- {m.role === "assistant" ? ( - <> - {m.meta?.toolCalls && m.meta.toolCalls.length > 0 ? ( -
- {m.meta.toolCalls.map((tc) => ( -
- - - {tc.status === "running" ? "⋯" : tc.status === "done" ? "✓" : "✗"} - - {tc.toolName} - {tc.durationMs != null && ( - - {(tc.durationMs / 1000).toFixed(1)}s - - )} - -
- {tc.inputSummary && ( -
- 输入 - {tc.inputSummary} -
- )} - {tc.skillId && ( -
- 🧠 {tc.skillName} - - {tc.skillStreaming && ▌} - {tc.skillToolCalls && tc.skillToolCalls.length > 0 && ( -
- {tc.skillToolCalls.map((stc) => ( -
- - - {stc.status === "running" ? "⋯" : stc.status === "done" ? "✓" : "✗"} - - {stc.toolName} - {stc.durationMs != null && ( - - {(stc.durationMs / 1000).toFixed(1)}s - - )} - -
- {stc.inputSummary && ( -
- 输入 - {stc.inputSummary} -
- )} - {stc.resultSummary && ( -
- 结果 - {stc.resultSummary} -
- )} - {stc.errorMessage && ( -
- 错误 - {stc.errorMessage} -
- )} -
-
- ))} -
- )} -
- )} - {tc.resultSummary && ( -
- 结果 - {tc.resultSummary} -
- )} - {tc.errorMessage && ( -
- 错误 - {tc.errorMessage} -
- )} -
-
- ))} -
- ) : null} - {m.meta?.budgetWarning && ( -
- Agent {m.meta.budgetWarning.used}/{m.meta.budgetWarning.limit} tool calls used -
- )} - {m.meta?.planningText && ( -
- Plan - -
- )} -
- -
- {!m.streaming && m.meta?.thoughtCitation && !m.meta.thoughtCitation.privateOmitted ? ( - - ) : null} - {!m.streaming && m.meta?.replyContextSources && hasReplyContextSourcesToShow(m.meta.replyContextSources) ? ( - - ) : null} - {m.streaming ? ( - - ▌ - - ) : null} - {!m.streaming && m.content.trim().length > 0 ? ( - <> - - - - ) : null} - {m.meta?.timing ? ( - - ) : null} - - ) : ( -
-

{m.content}

- {m.meta?.passiveHighlight?.phase === "marked" ? ( - { - // 落库用用户原文:detect 的 summary 易为英文,与中文输入下「保存想法」预期不符 - openPassiveHighlightSave(m.id, m.content); - }} - /> - ) : null} -
- )} -
-
+ message={m} + onCopy={copyAssistant} + onSaveAsThought={(msgId, content) => { + setSavePopoverVariant("default"); + setSavePopoverUserMsgId(null); + setSavePopoverContent(content); + setSavePopoverMsgId(msgId); + }} + onOpenThoughtCite={openMarkdownTab} + savePopoverMsgId={savePopoverMsgId} + onPassiveHighlightSave={openPassiveHighlightSave} + t={t} + dragExcludeProps={dragExcludeProps} + hasReplyContextSourcesToShow={hasReplyContextSourcesToShow} + /> )) )} @@ -2431,6 +1527,11 @@ export function AiConversationPanel() { {p.target} )}
+ {p.content != null && ( +
+                      {typeof p.content === "string" ? p.content : JSON.stringify(p.content, null, 2)}
+                    
+ )}

{p.reason}

+ ) : null} + {!m.streaming && m.meta?.replyContextSources && hasReplyContextSourcesToShow(m.meta.replyContextSources) ? ( + + ) : null} + {m.streaming ? ( + + ▌ + + ) : null} + {!m.streaming && m.content.trim().length > 0 ? ( + <> + + + + ) : null} + {m.meta?.timing ? ( + + ) : null} + + ) : ( +
+

{m.content}

+ {m.meta?.passiveHighlight?.phase === "marked" ? ( + { + onPassiveHighlightSave(m.id, m.content); + }} + /> + ) : null} +
+ )} +
+ + ); +} diff --git a/src/components/ToolCallItem.tsx b/src/components/ToolCallItem.tsx new file mode 100644 index 0000000..f98319b --- /dev/null +++ b/src/components/ToolCallItem.tsx @@ -0,0 +1,54 @@ +import type { ToolCallDisplayInfo } from "../hooks/useWorkspaceAiConversations"; +import { AiAssistantMarkdown } from "./AiAssistantMarkdown"; + +export function ToolCallItem({ tc }: { tc: ToolCallDisplayInfo }) { + return ( +
+ + + {tc.status === "running" ? "⋯" : tc.status === "done" ? "✓" : "✗"} + + {tc.displaySummary || tc.toolName} + {tc.durationMs != null && ( + + {(tc.durationMs / 1000).toFixed(1)}s + + )} + +
+ {tc.inputSummary && ( +
+ 输入 + {tc.inputSummary} +
+ )} + {tc.skillId && ( +
+ 🧠 {tc.skillName} + + {tc.skillStreaming && ▌} + {tc.skillToolCalls && tc.skillToolCalls.length > 0 && ( +
+ {tc.skillToolCalls.map((stc) => ( + + ))} +
+ )} +
+ )} + {tc.resultSummary && ( +
+ 结果 + {tc.resultSummary} +
+ )} + {tc.errorMessage && ( +
+ 错误 + {tc.errorMessage} +
+ )} +
+
+ ); +} diff --git a/src/hooks/useAgentEventHandlers.ts b/src/hooks/useAgentEventHandlers.ts new file mode 100644 index 0000000..51bed6c --- /dev/null +++ b/src/hooks/useAgentEventHandlers.ts @@ -0,0 +1,952 @@ +import { type MutableRefObject, useRef, useEffect } from "react"; +import { isTauri } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import type { ChatMessage, ToolCallDisplayInfo } from "./useWorkspaceAiConversations"; +import type { ReplyContextSources } from "../types/replyContextSources"; +import type { ApprovalRequest } from "../types/toolTypes"; +import type { AutoResolvedDepth, DepthMode, ThoughtRetrievalResult, CountVaultThoughtsForReviewResponse, GenerateChallengeQuestionResponse } from "../types/cognitiveTypes"; +import type { VaultConfigForUi } from "../types/vaultAiConfig"; +import { isChallengeInlineLlmReady } from "../utils/isChallengeReviewLlmReady"; +import { stripMarkedPassiveHighlightWithCount } from "../utils/passiveHighlightLifecycle"; +import { trackKnowforgeEvent } from "../utils/knowforgeAnalytics"; +import { getAppLocale } from "../i18n"; +import type { TFunction } from "i18next"; +import type { CognitiveFrequencyControl } from "./useCognitiveFrequencyControl"; + +// --------------------------------------------------------------------------- +// Helper functions (exported for use by handleStop in AiConversationPanel) +// --------------------------------------------------------------------------- + +export function retractInterruptedTurn(prev: ChatMessage[]): ChatMessage[] { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + next.pop(); + } + const u = next[next.length - 1]; + if (u?.role === "user") { + next.pop(); + } + return next; +} + +export function finalizeStreamingAssistant(prev: ChatMessage[]): ChatMessage[] { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + next[next.length - 1] = { ...last, streaming: false }; + } + return next; +} + +export function findAndPatchToolCall( + prev: ChatMessage[], + targetToolCallId: string, + patcher: (tc: ToolCallDisplayInfo) => ToolCallDisplayInfo, +): ChatMessage[] | null { + for (let i = prev.length - 1; i >= 0; i--) { + const m = prev[i]; + if (m.role !== "assistant") continue; + + const tcs = m.meta?.toolCalls; + if (tcs) { + const tcIdx = tcs.findIndex((tc) => tc.toolCallId === targetToolCallId); + if (tcIdx !== -1) { + const next = [...prev]; + const updated = [...tcs]; + updated[tcIdx] = patcher(updated[tcIdx]); + next[i] = { ...m, meta: { ...m.meta, toolCalls: updated } }; + return next; + } + } + } + return null; +} + +export function mergeStreamDelta(current: string, delta: string): string { + if (!delta) return current; + if (!current) return delta; + if (current.endsWith(delta)) return current; + if (delta.startsWith(current)) return delta; + + const maxOverlap = Math.min(current.length, delta.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + if (current.endsWith(delta.slice(0, overlap))) { + return current + delta.slice(overlap); + } + } + return current + delta; +} + +// --------------------------------------------------------------------------- +// Types used inside event listeners but not exported by other files +// --------------------------------------------------------------------------- + +type MemoryProposalBatch = { + session_id: string; + proposals: Array<{ + id: string; + action: string; + category: string; + target: string | null; + content: unknown; + reason: string; + }>; + created_at: string; +}; + +type InviteData = { thought: ThoughtRetrievalResult | null; question: string }; + +type ChallengeInlineData = { + thought: ThoughtRetrievalResult; + question: string; + templateKind: string; +}; + +type VaultCfgForSend = VaultConfigForUi; + +// --------------------------------------------------------------------------- +// Hook deps & return types +// --------------------------------------------------------------------------- + +export type AgentEventDeps = { + setMessages: React.Dispatch>; + setIsStreaming: (v: boolean) => void; + setIsVaultSearching: (v: boolean) => void; + setErrorBanner: (v: string | null) => void; + setInput: (v: string) => void; + setVaultSearchSummary: (v: string | null) => void; + + setInviteData: (v: InviteData | null) => void; + setChallengeInlineData: (v: ChallengeInlineData | null) => void; + setMemoryProposals: (v: MemoryProposalBatch | null) => void; + setProposalDecisions: (v: Record) => void; + setActiveApproval: React.Dispatch>; + + composerInputRef: MutableRefObject; + messagesRef: MutableRefObject; + sharedDocPathsRef: MutableRefObject>; + approvalQueueRef: MutableRefObject; + inviteSearchEpochRef: MutableRefObject; + lastSentQueryRef: MutableRefObject; + enoughForThisChatRef: MutableRefObject; + depthModeForInviteRef: MutableRefObject; + autoResolvedForInviteRef: MutableRefObject; + thoughtInviteExcludeRef: MutableRefObject; + passiveHighlightMarkedCountRef: MutableRefObject; + + markNeedPersist: () => void; + attachCitationToLastAssistant: (citation: ThoughtRetrievalResult | null) => void; + freqCtrl: CognitiveFrequencyControl; + conversationId: string | null; + t: TFunction; +}; + +export type AgentSessionState = { + activeSessionRef: MutableRefObject; + isAgentModeRef: MutableRefObject; + parentSessionRef: MutableRefObject; + skillSessionMapRef: MutableRefObject>; +}; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useAgentEventHandlers(deps: AgentEventDeps): AgentSessionState { + const activeSessionRef = useRef(null); + const isAgentModeRef = useRef(false); + const parentSessionRef = useRef(null); + const skillSessionMapRef = useRef>(new Map()); + + const { + setMessages, + setIsStreaming, + setIsVaultSearching, + setErrorBanner, + setInput, + setVaultSearchSummary, + setInviteData, + setChallengeInlineData, + setMemoryProposals, + setProposalDecisions, + setActiveApproval, + composerInputRef, + messagesRef, + sharedDocPathsRef, + approvalQueueRef, + inviteSearchEpochRef, + lastSentQueryRef, + enoughForThisChatRef, + depthModeForInviteRef, + autoResolvedForInviteRef, + thoughtInviteExcludeRef, + passiveHighlightMarkedCountRef, + markNeedPersist, + attachCitationToLastAssistant, + freqCtrl, + conversationId, + t, + } = deps; + + useEffect(() => { + if (!isTauri()) { + return; + } + + let disposed = false; + const pending: UnlistenFn[] = []; + + const WATCHDOG_TIMEOUT_MS = 30_000; + let watchdogTimer: ReturnType | null = null; + + function resetWatchdog() { + if (watchdogTimer) clearTimeout(watchdogTimer); + watchdogTimer = setTimeout(() => { + activeSessionRef.current = null; + isAgentModeRef.current = false; + parentSessionRef.current = null; + setIsStreaming(false); + setMessages((prev) => finalizeStreamingAssistant(prev)); + setErrorBanner("连接超时,请重试"); + }, WATCHDOG_TIMEOUT_MS); + } + + function clearWatchdog() { + if (watchdogTimer) { + clearTimeout(watchdogTimer); + watchdogTimer = null; + } + } + + // --- Event buffer: holds events whose prerequisites haven't arrived yet --- + type PendingEvent = { type: string; payload: unknown; receivedAt: number }; + const pendingEvents = new Map(); + const BUFFER_TIMEOUT_MS = 30_000; + + function bufferEvent(waitKey: string, type: string, payload: unknown) { + const queue = pendingEvents.get(waitKey) || []; + queue.push({ type, payload, receivedAt: Date.now() }); + pendingEvents.set(waitKey, queue); + } + + function drainBuffer(waitKey: string): PendingEvent[] { + const queue = pendingEvents.get(waitKey); + if (!queue?.length) return []; + pendingEvents.delete(waitKey); + const now = Date.now(); + return queue.filter((ev) => { + if (now - ev.receivedAt > BUFFER_TIMEOUT_MS) { + console.warn(`[event-buffer] dropped stale ${ev.type} for key=${waitKey}`); + return false; + } + return true; + }); + } + + function applyBufferedToolCallEvent(msgs: ChatMessage[], ev: PendingEvent): ChatMessage[] { + const p = ev.payload as Record; + switch (ev.type) { + case "tool-call-done": { + const toolCallId = p.toolCallId as string; + const success = p.success as boolean; + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i]; + if (m.role !== "assistant") continue; + if (m.meta?.toolCalls?.some((tc) => tc.toolCallId === toolCallId)) { + const next = [...msgs]; + next[i] = { + ...m, + meta: { + ...m.meta, + toolCalls: m.meta!.toolCalls!.map((tc) => { + if (tc.toolCallId !== toolCallId) return tc; + if (tc.status === "done" || tc.status === "error") return tc; + return { ...tc, status: (success ? "done" : "error") as ToolCallDisplayInfo["status"], resultSummary: p.resultSummary as string | undefined, durationMs: p.durationMs as number | undefined, errorMessage: p.errorMessage as string | undefined }; + }), + }, + }; + return next; + } + } + return msgs; + } + case "tool-call-done-skill": { + const parentToolCallId = p._parentToolCallId as string; + const toolCallId = p.toolCallId as string; + const success = p.success as boolean; + return findAndPatchToolCall(msgs, parentToolCallId, (tc) => ({ + ...tc, + skillToolCalls: (tc.skillToolCalls || []).map((stc) => + stc.toolCallId === toolCallId + ? (stc.status === "done" || stc.status === "error") ? stc : { ...stc, status: (success ? "done" : "error") as ToolCallDisplayInfo["status"], resultSummary: p.resultSummary as string | undefined, durationMs: p.durationMs as number | undefined, errorMessage: p.errorMessage as string | undefined } + : stc, + ), + })) ?? msgs; + } + case "stream-chunk-skill": { + const parentToolCallId = p._parentToolCallId as string; + const delta = p.delta as string; + return findAndPatchToolCall(msgs, parentToolCallId, (tc) => ({ + ...tc, + skillContent: (tc.skillContent || "") + delta, + })) ?? msgs; + } + case "tool-call-start-skill": { + const parentToolCallId = p._parentToolCallId as string; + const newCall = p._newCall as ToolCallDisplayInfo; + return findAndPatchToolCall(msgs, parentToolCallId, (tc) => { + if (tc.skillToolCalls?.some((stc) => stc.toolCallId === newCall.toolCallId)) return tc; + return { ...tc, skillToolCalls: [...(tc.skillToolCalls || []), newCall] }; + }) ?? msgs; + } + case "skill-spawn": { + const parentToolCallId = p.parentToolCallId as string; + return findAndPatchToolCall(msgs, parentToolCallId, (tc) => { + if (tc.skillId) return tc; + return { ...tc, skillId: p.skillId as string, skillName: p.skillName as string, skillContent: "", skillToolCalls: [], skillStreaming: true }; + }) ?? msgs; + } + case "agent-done-skill": { + const parentToolCallId = p._parentToolCallId as string; + return findAndPatchToolCall(msgs, parentToolCallId, (tc) => ({ + ...tc, + skillStreaming: false, + })) ?? msgs; + } + default: + return msgs; + } + } + + function flushToolCallBuffer(msgs: ChatMessage[], toolCallId: string): ChatMessage[] { + const events = drainBuffer(toolCallId); + let current = msgs; + for (const ev of events) { + current = applyBufferedToolCallEvent(current, ev); + } + return current; + } + + function applyBufferedAssistantEvent(msgs: ChatMessage[], ev: PendingEvent): ChatMessage[] { + const p = ev.payload as Record; + switch (ev.type) { + case "tool-call-start": { + const next = [...msgs]; + const last = next[next.length - 1]; + if (last?.role !== "assistant") return msgs; + const existing = last.meta?.toolCalls ?? []; + const toolCallId = p.toolCallId as string; + if (existing.some((tc) => tc.toolCallId === toolCallId)) return msgs; + const newCall: ToolCallDisplayInfo = { + toolCallId, + toolName: p.toolName as string, + displaySummary: (p.displaySummary as string) || undefined, + status: "running", + inputSummary: p.inputSummary as string | undefined, + }; + let result: ChatMessage[] = [ + ...next.slice(0, -1), + { ...last, meta: { ...last.meta, toolCalls: [...existing, newCall] } }, + ]; + result = flushToolCallBuffer(result, toolCallId); + return result; + } + case "context-ready": { + const replyContextSources = p.replyContextSources as ReplyContextSources; + const idx = msgs.findIndex((m) => m.role === "assistant" && m.streaming); + if (idx < 0) return msgs; + const next = [...msgs]; + next[idx] = { ...next[idx], meta: { ...next[idx].meta, replyContextSources } }; + return next; + } + default: + return msgs; + } + } + + function flushAssistantBuffer(msgs: ChatMessage[]): ChatMessage[] { + const events = drainBuffer("assistant-msg"); + let current = msgs; + for (const ev of events) { + current = applyBufferedAssistantEvent(current, ev); + } + return current; + } + + void Promise.all([ + listen<{ sessionId: string; delta: string }>("llm:stream-chunk", (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current) { + return; + } + resetWatchdog(); + const skillMapping = skillSessionMapRef.current.get(p.sessionId); + if (skillMapping) { + setMessages((prev) => { + const patched = findAndPatchToolCall(prev, skillMapping.parentToolCallId, (tc) => ({ + ...tc, + skillContent: (tc.skillContent || "") + p.delta, + })); + if (patched) return patched; + bufferEvent(skillMapping.parentToolCallId, "stream-chunk-skill", { ...p, _parentToolCallId: skillMapping.parentToolCallId }); + return prev; + }); + return; + } + setMessages((prev) => { + let next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + const isFirstToken = last.content === ""; + next[next.length - 1] = { + ...last, + content: mergeStreamDelta(last.content, p.delta), + meta: isFirstToken && last.meta?.timing + ? { ...last.meta, timing: { ...last.meta.timing, firstTokenMs: Date.now() } } + : last.meta, + }; + if (isFirstToken) return flushAssistantBuffer(next); + } + return next; + }); + }), + listen<{ sessionId: string }>("llm:stream-done", (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current) { + return; + } + clearWatchdog(); + setIsVaultSearching(false); + if (isAgentModeRef.current) { + return; + } + markNeedPersist(); + activeSessionRef.current = null; + setIsStreaming(false); + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + next[next.length - 1] = { + ...last, + streaming: false, + meta: last.meta?.timing + ? { ...last.meta, timing: { ...last.meta.timing, endMs: Date.now() } } + : last.meta, + }; + } + const strippedPack = stripMarkedPassiveHighlightWithCount(next); + if (strippedPack.stripped > 0) { + passiveHighlightMarkedCountRef.current = Math.max( + 0, + passiveHighlightMarkedCountRef.current - strippedPack.stripped, + ); + } + return strippedPack.messages; + }); + + const epoch = ++inviteSearchEpochRef.current; + setInviteData(null); + setChallengeInlineData(null); + const query = lastSentQueryRef.current.trim(); + if (!query || enoughForThisChatRef.current) return; + const dm = depthModeForInviteRef.current; + const ar = autoResolvedForInviteRef.current; + if (dm === "shallow" || (dm === "auto" && ar === "shallow")) { + return; + } + void (async () => { + try { + type SearchResp = { + thought: ThoughtRetrievalResult | null; + thoughts?: ThoughtRetrievalResult[]; + meta: { scannedFiles: number; stoppedEarly: boolean; elapsedMs: number }; + }; + + const turnIdx = messagesRef.current.filter((m) => m.role === "user").length; + const inviteEligible = freqCtrl.shouldShowInvite( + dm, + enoughForThisChatRef.current, + turnIdx, + ar, + ); + + if (inviteEligible) { + const resp = await invoke("search_thought_for_invite", { + args: { + query, + excludeRelPaths: thoughtInviteExcludeRef.current, + maxResults: 1, + }, + }); + if (inviteSearchEpochRef.current !== epoch || disposed) return; + const excerptRaw = resp.thought?.excerpt; + const question = + excerptRaw && excerptRaw.length > 0 + ? t("invite.thoughtQuestion", { + excerpt: + excerptRaw.length > 60 ? `${excerptRaw.slice(0, 60)}...` : excerptRaw, + }) + : t("invite.defaultQuestion"); + setInviteData({ thought: resp.thought, question }); + attachCitationToLastAssistant(resp.thought ?? null); + const th = resp.thought; + if ( + th && + !th.privateOmitted && + th.thoughtId && + isTauri() && + th.relPath + ) { + void invoke("append_ai_thought_reference", { + args: { + relPath: th.relPath, + thoughtId: th.thoughtId, + context: query.slice(0, 2000), + relevance: "ai-conversation-invite", + }, + }).catch(() => {}); + } + return; + } + + let cfg: VaultCfgForSend; + try { + cfg = await invoke("get_vault_config_for_ui"); + } catch { + return; + } + if (inviteSearchEpochRef.current !== epoch || disposed) return; + if (!isChallengeInlineLlmReady(cfg as VaultConfigForUi)) return; + + const countResp = await invoke( + "count_vault_thoughts_for_review", + ); + if (inviteSearchEpochRef.current !== epoch || disposed) return; + const total = countResp.totalThoughts; + if ( + !freqCtrl.shouldShowChallengeInline(dm, ar, { + inviteWillShow: false, + thoughtId: null, + vaultThoughtTotal: total, + }) + ) { + return; + } + + const respMany = await invoke("search_thought_for_invite", { + args: { + query, + excludeRelPaths: thoughtInviteExcludeRef.current, + maxResults: 3, + }, + }); + if (inviteSearchEpochRef.current !== epoch || disposed) return; + const pick = + respMany.thoughts?.find((x) => x.excerpt && !x.privateOmitted) ?? respMany.thought; + if (!pick?.excerpt || pick.privateOmitted) return; + + const gen = await invoke("generate_challenge_question", { + args: { + thoughtExcerpt: pick.excerpt, + relPath: pick.relPath, + conversationQuery: query, + depthMode: dm, + uiLocale: getAppLocale(), + }, + }); + if (inviteSearchEpochRef.current !== epoch || disposed) return; + if (gen.shouldSkip || !gen.question.trim()) return; + + void freqCtrl.recordChallengeInlineShown(pick.thoughtId); + void trackKnowforgeEvent("review.inline_shown", { + thoughtId: pick.thoughtId, + templateKind: gen.templateKind, + }); + setChallengeInlineData({ + thought: pick, + question: gen.question, + templateKind: gen.templateKind, + }); + attachCitationToLastAssistant(pick); + if (!pick.privateOmitted && pick.thoughtId && isTauri()) { + void invoke("append_ai_thought_reference", { + args: { + relPath: pick.relPath, + thoughtId: pick.thoughtId, + context: query.slice(0, 2000), + relevance: "ai-challenge-inline", + }, + }).catch(() => {}); + } + } catch { + // timeout or other failure: silently skip + } + })(); + }), + listen<{ sessionId: string; code?: string; message: string }>("llm:stream-error", (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current) { + return; + } + clearWatchdog(); + if (parentSessionRef.current) { + const parent = parentSessionRef.current; + parentSessionRef.current = null; + activeSessionRef.current = parent; + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + next[next.length - 1] = { ...last, streaming: false }; + } + return next; + }); + return; + } + markNeedPersist(); + activeSessionRef.current = null; + isAgentModeRef.current = false; + setIsStreaming(false); + setIsVaultSearching(false); + if (p.code === "cancelled") { + setMessages((prev) => { + if (composerInputRef.current.trim().length > 0) { + return finalizeStreamingAssistant(prev); + } + let lastUser = ""; + for (let i = prev.length - 1; i >= 0; i--) { + if (prev[i].role === "user") { + lastUser = prev[i].content; + break; + } + } + const next = retractInterruptedTurn(prev); + if (lastUser.length > 0) { + queueMicrotask(() => setInput(lastUser)); + } + return next; + }); + return; + } + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + next[next.length - 1] = { ...last, streaming: false }; + } + const strippedPack = stripMarkedPassiveHighlightWithCount(next); + if (strippedPack.stripped > 0) { + passiveHighlightMarkedCountRef.current = Math.max( + 0, + passiveHighlightMarkedCountRef.current - strippedPack.stripped, + ); + } + return strippedPack.messages; + }); + setErrorBanner(p.message); + }), + listen<{ sessionId: string; toolCallId: string; toolName: string; inputSummary?: string; displaySummary?: string }>( + "llm:tool-call-start", + (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current) { + return; + } + resetWatchdog(); + const skillMapping = skillSessionMapRef.current.get(p.sessionId); + const newCall: ToolCallDisplayInfo = { + toolCallId: p.toolCallId, + toolName: p.toolName, + displaySummary: p.displaySummary || undefined, + status: "running", + inputSummary: p.inputSummary, + }; + if (skillMapping) { + setMessages((prev) => { + const patched = findAndPatchToolCall(prev, skillMapping.parentToolCallId, (tc) => { + if (tc.skillToolCalls?.some((stc) => stc.toolCallId === p.toolCallId)) return tc; + return { ...tc, skillToolCalls: [...(tc.skillToolCalls || []), newCall] }; + }); + if (patched) return flushToolCallBuffer(patched, p.toolCallId); + bufferEvent(skillMapping.parentToolCallId, "tool-call-start-skill", { ...p, _parentToolCallId: skillMapping.parentToolCallId, _newCall: newCall }); + return prev; + }); + return; + } + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role !== "assistant") { + bufferEvent("assistant-msg", "tool-call-start", p); + return prev; + } + const existing = last.meta?.toolCalls ?? []; + if (existing.some((tc) => tc.toolCallId === p.toolCallId)) return prev; + let result: ChatMessage[] = [ + ...next.slice(0, -1), + { + ...last, + meta: { ...last.meta, toolCalls: [...existing, newCall] }, + }, + ]; + result = flushToolCallBuffer(result, p.toolCallId); + return result; + }); + }, + ), + listen<{ sessionId: string; toolCallId: string; success: boolean; resultSummary?: string; durationMs?: number; errorMessage?: string }>( + "llm:tool-call-done", + (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current) { + return; + } + resetWatchdog(); + const skillMapping = skillSessionMapRef.current.get(p.sessionId); + if (skillMapping) { + setMessages((prev) => { + const patched = findAndPatchToolCall(prev, skillMapping.parentToolCallId, (tc) => ({ + ...tc, + skillToolCalls: (tc.skillToolCalls || []).map((stc) => + stc.toolCallId === p.toolCallId + ? (stc.status === "done" || stc.status === "error") ? stc : { ...stc, status: (p.success ? "done" : "error") as ToolCallDisplayInfo["status"], resultSummary: p.resultSummary, durationMs: p.durationMs, errorMessage: p.errorMessage } + : stc, + ), + })); + if (patched) return patched; + bufferEvent(skillMapping.parentToolCallId, "tool-call-done-skill", { ...p, _parentToolCallId: skillMapping.parentToolCallId }); + return prev; + }); + return; + } + setMessages((prev) => { + const patchTc = (tc: ToolCallDisplayInfo): ToolCallDisplayInfo => { + if (tc.toolCallId !== p.toolCallId) return tc; + if (tc.status === "done" || tc.status === "error") return tc; + return { ...tc, status: (p.success ? "done" : "error") as ToolCallDisplayInfo["status"], resultSummary: p.resultSummary, durationMs: p.durationMs, errorMessage: p.errorMessage }; + }; + + for (let i = prev.length - 1; i >= 0; i -= 1) { + const m = prev[i]; + if (m.role !== "assistant") continue; + + if (m.meta?.toolCalls?.some((tc) => tc.toolCallId === p.toolCallId)) { + const next = [...prev]; + next[i] = { ...m, meta: { ...m.meta, toolCalls: m.meta.toolCalls!.map(patchTc) } }; + return next; + } + } + bufferEvent(p.toolCallId, "tool-call-done", p); + return prev; + }); + }, + ), + listen("llm:tool-approval-request", (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current) { + return; + } + resetWatchdog(); + setActiveApproval((cur) => { + if (cur === null) { + return p; + } + approvalQueueRef.current.push(p); + return cur; + }); + }), + listen("llm:memory-proposals", (e) => { + if (e.payload.session_id === activeSessionRef.current) { + setMemoryProposals(e.payload); + setProposalDecisions({}); + } + }), + listen<{ + sessionId: string; + conversationId: string; + skillId: string; + skillName: string; + parentToolCallId: string; + }>("llm:skill-spawn", (e) => { + const p = e.payload; + if (!activeSessionRef.current || activeSessionRef.current === p.sessionId) { + return; + } + if (conversationId && p.conversationId !== conversationId) { + return; + } + resetWatchdog(); + parentSessionRef.current = activeSessionRef.current; + activeSessionRef.current = p.sessionId; + skillSessionMapRef.current.set(p.sessionId, { + parentToolCallId: p.parentToolCallId, + }); + setMessages((prev) => { + const patched = findAndPatchToolCall(prev, p.parentToolCallId, (tc) => { + if (tc.skillId) return tc; + return { ...tc, skillId: p.skillId, skillName: p.skillName, skillContent: "", skillToolCalls: [], skillStreaming: true }; + }); + if (patched) return patched; + bufferEvent(p.parentToolCallId, "skill-spawn", p); + return prev; + }); + }), + listen<{ sessionId: string; used: number; limit: number; type: string }>( + "llm:budget-warning", + (e) => { + if (e.payload.sessionId !== activeSessionRef.current) return; + resetWatchdog(); + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant") { + next[next.length - 1] = { + ...last, + meta: { + ...last.meta, + budgetWarning: { used: e.payload.used, limit: e.payload.limit }, + }, + }; + } + return next; + }); + }, + ), + listen<{ + sessionId: string; + snippets: import("../types/vaultContextSearch").VaultSnippetRecord[]; + meta: import("../types/vaultContextSearch").SearchWorkspaceContextMeta | null; + replyContextSources: ReplyContextSources; + }>("llm:context-ready", (e) => { + const { sessionId, snippets, meta, replyContextSources } = e.payload; + if (sessionId !== activeSessionRef.current) return; + resetWatchdog(); + + if (snippets.length > 0 && meta) { + const paths = snippets.map((s) => s.relPath).join(", "); + const priv = snippets.filter((s) => s.kind === "privateOmitted").length; + let line = t("aiPanel.vaultLine", { + paths, + scannedFiles: meta.scannedFiles, + elapsedMs: meta.elapsedMs, + }); + if (priv > 0) { + line += ` ${t("aiPanel.vaultPrivateOmitted", { count: priv })}`; + } + if (meta.stoppedEarly) { + line += ` ${t("aiPanel.vaultStoppedEarly")}`; + } + setVaultSearchSummary(line); + } else { + setVaultSearchSummary(null); + } + + for (const s of snippets) { + if (s.kind !== "privateOmitted") { + sharedDocPathsRef.current.add(s.relPath); + } + } + + setMessages((prev) => { + const idx = prev.findIndex((m) => m.role === "assistant" && m.streaming); + if (idx < 0) { + bufferEvent("assistant-msg", "context-ready", { replyContextSources }); + return prev; + } + const next = [...prev]; + next[idx] = { ...next[idx], meta: { ...next[idx].meta, replyContextSources } }; + return next; + }); + + setIsVaultSearching(false); + }), + listen<{ sessionId: string }>("llm:agent-done", (e) => { + const sid = e.payload.sessionId; + clearWatchdog(); + const skillMapping = skillSessionMapRef.current.get(sid); + + if (skillMapping) { + skillSessionMapRef.current.delete(sid); + if (activeSessionRef.current === sid) { + activeSessionRef.current = parentSessionRef.current; + } + parentSessionRef.current = null; + + setMessages((prev) => { + const patched = findAndPatchToolCall(prev, skillMapping.parentToolCallId, (tc) => ({ + ...tc, + skillStreaming: false, + })); + if (patched) return patched; + bufferEvent(skillMapping.parentToolCallId, "agent-done-skill", { _parentToolCallId: skillMapping.parentToolCallId }); + return prev; + }); + return; + } + + if (sid !== activeSessionRef.current) { + return; + } + + markNeedPersist(); + activeSessionRef.current = null; + isAgentModeRef.current = false; + setIsStreaming(false); + setIsVaultSearching(false); + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant" && last.streaming) { + next[next.length - 1] = { + ...last, + streaming: false, + meta: last.meta?.timing + ? { ...last.meta, timing: { ...last.meta.timing, endMs: Date.now() } } + : last.meta, + }; + } + const strippedPack = stripMarkedPassiveHighlightWithCount(next); + if (strippedPack.stripped > 0) { + passiveHighlightMarkedCountRef.current = Math.max( + 0, + passiveHighlightMarkedCountRef.current - strippedPack.stripped, + ); + } + return strippedPack.messages; + }); + }), + listen<{ sessionId: string; loopIteration: number }>("llm:heartbeat", (e) => { + const p = e.payload; + if (p.sessionId !== activeSessionRef.current && + p.sessionId !== parentSessionRef.current) return; + resetWatchdog(); + }), + ]).then((unlisteners) => { + if (disposed) { + unlisteners.forEach((u) => void u()); + return; + } + pending.push(...unlisteners); + }); + + return () => { + disposed = true; + clearWatchdog(); + pendingEvents.clear(); + pending.forEach((u) => void u()); + }; + }, [ + markNeedPersist, + setMessages, + setIsStreaming, + freqCtrl, + t, + attachCitationToLastAssistant, + conversationId, + ]); + + return { activeSessionRef, isAgentModeRef, parentSessionRef, skillSessionMapRef }; +} diff --git a/src/hooks/useWorkspaceAiConversations.ts b/src/hooks/useWorkspaceAiConversations.ts index 1f8fe9b..7679862 100644 --- a/src/hooks/useWorkspaceAiConversations.ts +++ b/src/hooks/useWorkspaceAiConversations.ts @@ -6,6 +6,7 @@ import type { ConversationMeta, ListAiConversationsResponse, PersistedChatMessage, + PersistedToolCall, ThoughtFocusContext, } from "../types/aiConversation"; import type { ThoughtRetrievalResult } from "../types/cognitiveTypes"; @@ -22,6 +23,7 @@ export type ChatMessageTiming = { export type ToolCallDisplayInfo = { toolCallId: string; toolName: string; + displaySummary?: string; status: "running" | "done" | "error"; /** 可解释性字段 */ inputSummary?: string; @@ -61,16 +63,36 @@ export type ChatMessage = { * 即使关闭并重新打开 vault,徽章和过滤行为均保持一致。 */ skillId?: string; skillName?: string; - planningText?: string; }; }; +function normalizeToolCallForPersist(tc: ToolCallDisplayInfo): PersistedToolCall { + const result: PersistedToolCall = { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displaySummary: tc.displaySummary, + status: tc.status === "running" ? "done" : (tc.status as "done" | "error"), + inputSummary: tc.inputSummary, + resultSummary: tc.resultSummary, + durationMs: tc.durationMs, + errorMessage: tc.errorMessage, + skillId: tc.skillId, + skillName: tc.skillName, + skillContent: tc.skillContent, + }; + if (tc.skillToolCalls && tc.skillToolCalls.length > 0) { + result.skillToolCalls = tc.skillToolCalls.map(normalizeToolCallForPersist); + } + return result; +} + function bodyToMessages(body: ConversationBodyOut): ChatMessage[] { return body.messages.map((m) => { const meta: ChatMessage["meta"] = {}; if (m.replyContextSources) meta.replyContextSources = m.replyContextSources; if (m.skillId) meta.skillId = m.skillId; if (m.skillName) meta.skillName = m.skillName; + if (m.toolCalls) meta.toolCalls = m.toolCalls as unknown as ToolCallDisplayInfo[]; return { id: m.id, role: m.role === "assistant" ? ("assistant" as const) : ("user" as const), @@ -83,14 +105,20 @@ function bodyToMessages(body: ConversationBodyOut): ChatMessage[] { function toPersistPayload(messages: ChatMessage[]): PersistedChatMessage[] { return messages .filter((m) => !m.streaming) - .map((m) => ({ - id: m.id, - role: m.role, - content: m.content, - replyContextSources: m.meta?.replyContextSources, - skillId: m.meta?.skillId, - skillName: m.meta?.skillName, - })); + .map((m) => { + const msg: PersistedChatMessage = { + id: m.id, + role: m.role, + content: m.content, + replyContextSources: m.meta?.replyContextSources, + skillId: m.meta?.skillId, + skillName: m.meta?.skillName, + }; + if (m.meta?.toolCalls && m.meta.toolCalls.length > 0) { + msg.toolCalls = m.meta.toolCalls.map(normalizeToolCallForPersist); + } + return msg; + }); } type CreateResponse = { id: string }; diff --git a/src/types/aiConversation.ts b/src/types/aiConversation.ts index 789df6f..546d896 100644 --- a/src/types/aiConversation.ts +++ b/src/types/aiConversation.ts @@ -9,6 +9,21 @@ export type ConversationMeta = { updatedAt: number; }; +export type PersistedToolCall = { + toolCallId: string; + toolName: string; + displaySummary?: string; + status: "done" | "error"; + inputSummary?: string; + resultSummary?: string; + durationMs?: number; + errorMessage?: string; + skillId?: string; + skillName?: string; + skillContent?: string; + skillToolCalls?: PersistedToolCall[]; +}; + export type PersistedChatMessage = { id: string; role: "user" | "assistant"; @@ -20,6 +35,7 @@ export type PersistedChatMessage = { * (b) 主对话发送时按此过滤掉 skill 子轮次,避免污染 prompt context。 */ skillId?: string; skillName?: string; + toolCalls?: PersistedToolCall[]; }; /** 与 Rust `ThoughtFocusContextDisk` / LLM `thought_focus_context` 对齐 */ diff --git a/src/types/toolTypes.ts b/src/types/toolTypes.ts index 74c8270..5d81d08 100644 --- a/src/types/toolTypes.ts +++ b/src/types/toolTypes.ts @@ -114,3 +114,4 @@ export interface ApprovalRequest { risk: Risk; effects: Effect[]; } +