From 60b01d03ee2bc1db0104c0ac61e29858bd3dcc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 00:10:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20tool=5Fregistry=E6=8B=86?= =?UTF-8?q?=E5=88=86=E5=8F=8A=E5=A4=9A=E6=89=B9=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 4 - crates/df-ai/src/ai_tools.rs | 21 --- crates/df-ai/src/anthropic_compat.rs | 134 +++++++++++++++++- crates/df-ai/src/context.rs | 57 +++++--- crates/df-ai/src/model_fetch.rs | 15 +- crates/df-execute/Cargo.toml | 3 - crates/df-execute/src/shell.rs | 2 +- crates/df-ideas/src/promotion.rs | 12 +- crates/df-project/src/manager.rs | 5 +- crates/df-storage/src/secret.rs | 6 +- crates/df-workflow/Cargo.toml | 1 - crates/df-workflow/src/eventbus.rs | 6 - docs/todo.md | 21 ++- docs/待决策.md | 10 +- docs/待审查.md | 80 +++++++++++ package.json | 3 +- src-tauri/src/commands/ai/tool_registry.rs | 144 +++++++++++++++----- src/components/settings/ConnectionPanel.vue | 40 +++--- src/composables/ai/useAiPanel.ts | 2 +- src/composables/useConfirm.ts | 7 - src/composables/useMarkdown.ts | 2 - src/i18n/en/dashboard.ts | 4 + src/i18n/en/ideas.ts | 5 + src/i18n/zh-CN/dashboard.ts | 4 + src/i18n/zh-CN/ideas.ts | 5 + src/utils/markdown.ts | 2 +- src/utils/time.ts | 3 +- src/views/Dashboard.vue | 55 ++++++-- src/views/Ideas.vue | 87 ++++++++---- src/views/Knowledge.vue | 96 ++++++++----- src/views/ProjectDetail.vue | 42 ++++-- src/views/Projects.vue | 44 ++++-- src/views/Tasks.vue | 28 ++-- 33 files changed, 703 insertions(+), 247 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f935264..4da8858 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,12 +795,9 @@ name = "df-execute" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", - "df-types", "serde", "serde_json", "tokio", - "tracing", ] [[package]] @@ -886,7 +883,6 @@ dependencies = [ "futures", "serde", "serde_json", - "thiserror 2.0.18", "tokio", "tracing", ] diff --git a/crates/df-ai/src/ai_tools.rs b/crates/df-ai/src/ai_tools.rs index 77322ca..5fc9e6d 100644 --- a/crates/df-ai/src/ai_tools.rs +++ b/crates/df-ai/src/ai_tools.rs @@ -131,27 +131,6 @@ impl Default for AiToolRegistry { } } -// ============================================================ -// 工具执行结果 -// ============================================================ - -/// 工具执行结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolExecutionResult { - /// 工具调用 ID - pub tool_call_id: String, - /// 工具名称 - pub tool_name: String, - /// 执行参数 - pub arguments: Value, - /// 执行结果 - pub result: Value, - /// 是否需要人工批准 - pub approval_required: bool, - /// 风险级别 - pub risk_level: RiskLevel, -} - // ============================================================ // 辅助: 构建 JSON Schema 参数 // ============================================================ diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 4c54bcf..50e9661 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -451,6 +451,107 @@ impl AnthropicCompatProvider { messages.push(serde_json::json!({ "role": "user", "content": blocks })); } + /// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。 + /// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。 + fn summarize_messages(messages: &[serde_json::Value]) -> String { + let lines: Vec = messages + .iter() + .enumerate() + .map(|(i, m)| { + let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?"); + let desc = match m.get("content") { + Some(serde_json::Value::String(s)) => format!("text({}B)", s.len()), + Some(serde_json::Value::Array(blocks)) => { + let parts: Vec = blocks + .iter() + .map(|b| { + let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or("?"); + match ty { + "text" => format!( + "text({}B)", + b.get("text").and_then(|t| t.as_str()).map(|s| s.len()).unwrap_or(0) + ), + "tool_use" => format!( + "tool_use[id={},input_obj={}]", + b.get("id").and_then(|t| t.as_str()).unwrap_or(""), + b.get("input").map(|v| v.is_object()).unwrap_or(false) + ), + "tool_result" => format!( + "tool_result[tid={}]", + b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("") + ), + "image" => "image".to_string(), + _ => ty.to_string(), + } + }) + .collect(); + format!("[{}]", parts.join(",")) + } + _ => "?".to_string(), + }; + format!("#{}:{} {}", i, role, desc) + }) + .collect(); + format!("{} msgs: {}", lines.len(), lines.join(" | ")) + } + + /// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。 + /// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result + /// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。 + fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> { + if messages.is_empty() { + return Err("messages 为空".into()); + } + let first_role = messages[0].get("role").and_then(|r| r.as_str()).unwrap_or(""); + if first_role != "user" { + return Err(format!("首条 role={} 非法(须 user)", first_role)); + } + for w in messages.windows(2) { + let r0 = w[0].get("role").and_then(|r| r.as_str()).unwrap_or(""); + let r1 = w[1].get("role").and_then(|r| r.as_str()).unwrap_or(""); + if r0 == r1 && (r0 == "user" || r0 == "assistant") { + return Err(format!("连续同 role={}", r0)); + } + } + let mut tool_use_ids: Vec<&str> = Vec::new(); + for (i, m) in messages.iter().enumerate() { + let role = m.get("role").and_then(|r| r.as_str()).unwrap_or(""); + match m.get("content") { + Some(serde_json::Value::String(s)) if s.is_empty() && role == "user" => { + return Err(format!("#{} user content 空", i)); + } + Some(serde_json::Value::Array(blocks)) => { + if blocks.is_empty() && role == "user" { + return Err(format!("#{} user content 空数组", i)); + } + for b in blocks { + match b.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "tool_use" => { + let id = b.get("id").and_then(|t| t.as_str()).unwrap_or(""); + tool_use_ids.push(id); + if !b.get("input").map(|v| v.is_object()).unwrap_or(false) { + return Err(format!("#{} tool_use input 非 object", i)); + } + } + "tool_result" => { + let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or(""); + if !tid.is_empty() && !tool_use_ids.contains(&tid) { + return Err(format!( + "#{} orphan tool_result(tid={} 无前置 tool_use)", + i, tid + )); + } + } + _ => {} + } + } + } + _ => {} + } + } + Ok(()) + } + /// 统一鉴权头:x-api-key + anthropic-version fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { rb.header("x-api-key", &self.api_key) @@ -466,6 +567,13 @@ impl LlmProvider for AnthropicCompatProvider { req.stream = false; let body = self.convert_request(req); + // B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断 + if let Err(reason) = Self::precheck_messages(&body.messages) { + let summary = Self::summarize_messages(&body.messages); + warn!(%reason, %summary, "Anthropic messages 协议预检失败"); + anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary); + } + debug!(model = %body.model, "Anthropic 同步调用"); // 指数退避重试(B-260616-07): 包裹 send + 状态码判定。 @@ -504,7 +612,14 @@ impl LlmProvider for AnthropicCompatProvider { if is_reqwest_error_retryable(&e) { return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e)); } - return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e)); + return AttemptOutcome::Fatal(format!( + "请求失败(不可重试): {} [timeout={} connect={} body={} src={:?}]", + e, + e.is_timeout(), + e.is_connect(), + e.is_body(), + std::error::Error::source(&e) + )); } }; if !resp.status().is_success() { @@ -575,6 +690,13 @@ impl LlmProvider for AnthropicCompatProvider { req.stream = true; let body = self.convert_request(req); + // B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断 + if let Err(reason) = Self::precheck_messages(&body.messages) { + let summary = Self::summarize_messages(&body.messages); + warn!(%reason, %summary, "Anthropic messages 协议预检失败"); + anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary); + } + debug!(model = %body.model, "Anthropic 流式调用"); let resp = match self @@ -597,7 +719,15 @@ impl LlmProvider for AnthropicCompatProvider { source = ?std::error::Error::source(&e), "Anthropic 流式 send 失败" ); - anyhow::bail!("Anthropic 流式 send 失败: {}", e); + anyhow::bail!( + "send 失败: {} [timeout={} connect={} body={} request={} src={:?}]", + e, + e.is_timeout(), + e.is_connect(), + e.is_body(), + e.is_request(), + std::error::Error::source(&e) + ); } }; diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index 75fa2bd..122f84f 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -458,24 +458,44 @@ impl ContextManager { /// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。 fn ensure_sequence_legal(messages: Vec) -> Vec { let mut skipped = 0u32; - let result: Vec = messages - .into_iter() - .skip_while(|m| { - if matches!(m.role, MessageRole::Assistant | MessageRole::Tool) { - skipped += 1; - true - } else { - false + let mut merged = 0u32; + let mut fixed: Vec = Vec::with_capacity(messages.len()); + for m in messages { + // 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿) + if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) { + skipped += 1; + continue; + } + // 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并—— + // 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id) + if let Some(last) = fixed.last_mut() { + let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role); + if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) { + if !m.content.is_empty() { + if !last.content.is_empty() { + last.content.push('\n'); + } + last.content.push_str(&m.content); + } + if matches!(m.role, MessageRole::Assistant) { + if let Some(calls) = m.tool_calls { + last.tool_calls.get_or_insert_with(Vec::new).extend(calls); + } + } + merged += 1; + continue; } - }) - .collect(); - if skipped > 0 { + } + fixed.push(m); + } + if skipped > 0 || merged > 0 { tracing::warn!( skipped, - "序列修复:丢弃开头的 assistant/tool 消息(Anthropic 要求首条 user,避免 1214)" + merged, + "序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)" ); } - result + fixed } /// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响) @@ -952,17 +972,22 @@ mod tests { fn over_budget_trims_old() { // 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区 let mut mgr = ContextManager::new(cfg(200)); + // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪) for i in 0..20 { - mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i))); + if i % 2 == 0 { + mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i))); + } else { + mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i))); + } } let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len()); - // 保护区:最新一条必保留 + // 保护区:最新一条必保留(末条 i=19 是 assistant) assert_eq!( msgs.last().unwrap().content, - "这是第 19 条较长的消息用于撑爆预算", + "第 19 条较长的回复用于撑爆预算", "保护区最新消息被误裁" ); diff --git a/crates/df-ai/src/model_fetch.rs b/crates/df-ai/src/model_fetch.rs index 3627bed..b6fa2df 100644 --- a/crates/df-ai/src/model_fetch.rs +++ b/crates/df-ai/src/model_fetch.rs @@ -213,9 +213,9 @@ pub fn is_non_chat_model(id: &str) -> bool { || id.contains("transcription") // 内容审查 || id.contains("moderation") - // 旧 GPT 搜索变种 + // 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat + // 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除) || id.contains("davinci-search") - || id.contains("-search") } /// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。 @@ -277,6 +277,17 @@ impl ModelsList { mod tests { use super::*; + // ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ── + #[test] + fn is_non_chat_model_keeps_search_augmented_chat() { + // search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔 + assert!(!is_non_chat_model("gpt-4o-search-preview")); + assert!(!is_non_chat_model("qwen-search")); + assert!(!is_non_chat_model("glm-4-search-preview")); + // 旧 GPT 搜索变种仍剔(davinci-search 覆盖) + assert!(is_non_chat_model("text-davinci-search-001")); + } + // ── build_models_url:尾 / 处理 ── #[test] diff --git a/crates/df-execute/Cargo.toml b/crates/df-execute/Cargo.toml index 1dc5d14..93191fb 100644 --- a/crates/df-execute/Cargo.toml +++ b/crates/df-execute/Cargo.toml @@ -4,10 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -df-types = { path = "../df-types" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } -async-trait = { workspace = true } anyhow = { workspace = true } -tracing = { workspace = true } diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index 5296953..d5cf121 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -99,7 +99,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { cmd.output(), ) .await - .map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??, + .map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??, None => cmd.output().await?, }; diff --git a/crates/df-ideas/src/promotion.rs b/crates/df-ideas/src/promotion.rs index d7616b8..1bbcd16 100644 --- a/crates/df-ideas/src/promotion.rs +++ b/crates/df-ideas/src/promotion.rs @@ -1,12 +1,6 @@ -//! 想法晋升 — 将想法转为项目 -//! -//! 历史的 IdeaPromoter/PromotionPolicy/try_promote/do_promote 三件套 -//! 是空壳 TODO(do_promote 从未接入 df-project,误接入会得虚假成功), -//! 实际晋升路径由 src-tauri/src/commands/idea.rs::promote_idea 直接复用 -//! df-project::ProjectManager::create_from_idea 完成。这里仅保留作为 -//! IPC 返回类型的 PromotionResult。 -//! -//! 死代码已清,详见 docs/todo.md R-PD-14。 +//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。 +//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。 +//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。 use serde::Serialize; diff --git a/crates/df-project/src/manager.rs b/crates/df-project/src/manager.rs index 1d6fa4a..7cf9b3f 100644 --- a/crates/df-project/src/manager.rs +++ b/crates/df-project/src/manager.rs @@ -43,9 +43,8 @@ pub struct CreateProjectInput { pub struct ProjectManager; impl ProjectManager { - /// 创建新项目 - /// - /// TODO: 接入存储层持久化 + /// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成 + /// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。 pub fn create(input: CreateProjectInput) -> Project { let now = chrono::Utc::now(); Project { diff --git a/crates/df-storage/src/secret.rs b/crates/df-storage/src/secret.rs index 4cef618..8585a2e 100644 --- a/crates/df-storage/src/secret.rs +++ b/crates/df-storage/src/secret.rs @@ -89,7 +89,7 @@ fn clear_migration_failcount(id: &str) { } fn entry_for(id: &str) -> anyhow::Result { - Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败: {}", e)) + Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败(provider={}): {}", id, e)) } /// 读取 provider 密钥(优先 keyring;无则 None) @@ -112,13 +112,13 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String { /// 写入密钥到 keyring(覆盖) pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> { let entry = entry_for(id)?; - entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败: {}", e)) + entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e)) } /// 删除 keyring 密钥(provider 删除时清理) pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> { let entry = entry_for(id)?; - entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败: {}", e)) + entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e)) } /// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断) diff --git a/crates/df-workflow/Cargo.toml b/crates/df-workflow/Cargo.toml index 2951ff8..1edbf7b 100644 --- a/crates/df-workflow/Cargo.toml +++ b/crates/df-workflow/Cargo.toml @@ -10,6 +10,5 @@ serde_json = { workspace = true } tokio = { workspace = true } async-trait = { workspace = true } futures = "0.3" -thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } diff --git a/crates/df-workflow/src/eventbus.rs b/crates/df-workflow/src/eventbus.rs index e962129..cb0c4dc 100644 --- a/crates/df-workflow/src/eventbus.rs +++ b/crates/df-workflow/src/eventbus.rs @@ -1,7 +1,6 @@ //! 事件总线 — 基于 tokio::sync::broadcast 的发布/订阅 use tokio::sync::broadcast; -use tokio::sync::broadcast::error::SendError; use df_types::events::WorkflowEvent; /// 事件总线 @@ -39,11 +38,6 @@ impl EventBus { pub fn subscribe(&self) -> EventSubscriber { self.sender.subscribe() } - - /// 发送人工审批请求 - pub fn emit_human_approval_request(&self, event: WorkflowEvent) -> Result> { - self.sender.send(event) - } } impl Default for EventBus { diff --git a/docs/todo.md b/docs/todo.md index d54debe..d730fac 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -162,7 +162,7 @@ **🔴 P0 必须修复 (4项)**: - [x] ✅(workflow w5siwnipj 核验+主代实施·agentic:348/knowledge_inject:130,139,312/idea:292-296 加 tracing::warn 降级不改返回值·audit:344 误报(CR-11 已修三路 match)·commands:290 BUG-11 已修·cargo check EXIT 0 + vue-tsc EXIT 0) **SMELL-P0-1** — **[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**。agentic.rs:348(provider 池空走原空池兜底)/knowledge_inject.rs:130,139,312(检索/解析失败走原降级不注入/提炼跳过)/idea.rs:296(tags 坏降级空继续评估)本批 match+warn(空 Vec 零行为变更);audit.rs:344 误报(audit_finalize CR-11 已修三路 match,line 漂移+旧版本);commands.rs:290 BUG-11 已修。**58 处全量排查降 P2 非紧急**(高危已清,余为 Option 取默认/JSON 序列化等低危)。 -- [ ] **SMELL-P0-2** — **[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数** — 拆分为按功能分组注册函数(register_crud_tools / register_file_tools / register_workflow_tools 等),每个 <200 行可测试。 +- [x] ✅(workflow w2xkw4ybh 抽 register_data_tools(18 持 db 工具:list_projects/list_tasks/list_ideas/update_project/create_project/bind_directory/create_task/update_task/advance_task/run_workflow/delete_task/create_idea/delete_project/restore_project/purge_project/list_trash/get_project_count/get_task_count)+ 主代抽 register_file_tools(10 文件工具:run_command/read_file/list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/search_files)·build_ai_tool_registry 1091→7 行·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory 断言 len()==28 + tool_names() 集合锁定)·主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 119 passed + devflow 基线 1 passed + vue-tsc EXIT 0) **SMELL-P0-2** — **[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数** — 拆分为按功能分组注册函数(register_data_tools 18 db 工具 / register_file_tools 10 文件工具),build_ai_tool_registry 收敛到 7 行编排 + 基线测试锁定 28 工具数防回归。 - [ ] **SMELL-P0-3** — **[P0🔴]** `AiChat.vue` **4026 行 God 组件** — 拆分: ConversationSidebar(侧栏+搜索) / MessageList(消息列表+流式) / ChatInput(输入框+附件) / ApprovalPanel(审批卡片)。目标单组件 <500 行。 - [x] ✅(workflow wowdnw4ba·tests/shell.rs 5 #[tokio::test] 覆盖成功/非零/超时/env/working_dir + shell.rs:55 过时TODO注释清理·execute逻辑零改动·主代 cargo test -p df-execute 5 passed + cargo check --workspace EXIT 0) **SMELL-P0-4** — **[P0🔴]** `df-execute` crate **零测试**(shell.rs 120行已集成工作流) — 补 shell 命令执行+超时+输出截断基础测试。 @@ -186,6 +186,13 @@ - [x] ✅(workflow w5siwnipj 核验·line 3 模块注释已有「⚠ B 路线占位...有意保留空壳,勿删」三要素齐全·零外部调用·保留 B 路线预留点) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap **已完成**(line 3 注释),保留非删(B 路线 aichat 决策能力预留,对齐 memory aichat-arch-extensibility) - [x] ✅(workflow w5siwnipj 核验 7 处+主代补 #6/#7 注释·全部标注完成·cargo check df-ai EXIT 0) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**(实际 7 非声称 12,部分已清)。5 处原有注释(prompt.rs:147/compress.rs:45,98 F-15 阶段预留 + openai_compat.rs:81,98 SW-24 反序列化对齐)+ 本批补 anthropic_compat.rs:61 id/:65 stop_reason 注释(对齐 openai SW-24 风格)。全合理保留无真死代码。**另:cargo warning 死字段 4 处**(PendingApproval.risk_level/diff + AppState.releases/node_executions)属另一类未标 allow,待核对。 +**🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**: + +- [ ] ARC-260618-01-d [P1/行为变更·待确认产品意图] — **conditions 条件引擎未求值**。`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点) +- [ ] ARC-260618-01-e [P1/行为变更·待确认产品意图] — **adversarial `evaluate_with_llm` 一致性未校验**。`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构) +- [ ] SW-260618-21 [P2/待 AiChat 沉淀] — **formatRelativeZh 重命名 formatRelative**。`src/utils/time.ts` `formatRelativeZh` 名带 Zh 后缀但实际非中文硬编码(已读 i18n.global.locale, sweep 批2 time.ts formatDate 已修 CR-260615-08 漏修),函数名误导。4 调用点含 `AiChat.vue`(并发改动中),为避免并发冲突缓做,待 AiChat God 组件拆分(SMELL-P0-3)沉淀后统一重命名。— `src/utils/time.ts`(formatRelativeZh) + 4 调用点(含 `src/components/AiChat.vue`) +- [ ] SW-260618-22 [P2/已知 B-260617-03] — **useAiSend resolveLang DRY**。`useAiSend` 的 `resolveLang` 与其他 composable 语言解析逻辑重复,应抽 `aiShared` 共享。已知登记为 B-260617-03,此处补 sweep 维度记录便于检索。— `src/composables/ai/useAiSend.ts`(resolveLang) → 抽 `src/composables/ai/aiShared.ts` + ### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化) > 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。 @@ -329,3 +336,15 @@ - [x] ✅(主代串行·规避 AiChat God 文件·新建 src/utils/markdown.ts stripMd 剥离 MD 语法(粗体/斜体/标题/代码/列表/引用/链接/图片)+ Knowledge.vue:71/Projects.vue:148/Ideas.vue:47 三列表摘要接入·修自引入注释含 */ 致 JSDoc 提前闭合 TS1011/1127·改安全措辞·vue-tsc EXIT 0) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**。三处列表摘要 stripMd 过滤。**AiChat 标题 strip 子项**:规避 God 高频文件·留待并发期后(待核 title.rs 是否产 MD)。— `src/views/{Knowledge,Projects,Ideas}.vue` + `src/utils/markdown.ts::stripMd` --- + +### 🔧 2026-06-18 用户实测·aichat bug 反馈(review 会话 2 agent 并行审查·独立 grep/read 核验·session-role-diagnose-only·待实施) + +> 用户实测报 2 bug。review 会话派 2 agent 并行核验源码,根因 + 修法定位完成,待主代实施。 + +- [ ] **UX-260618-14 [P1]** — **advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**。①审批卡 `ToolCard.vue:800` `PROJECT_ID_TOOL_ARG` 未登记 advance_task → id 裸显 UUID;②审批 reason `audit.rs:163` tool_display_hint 短路致 `tool_registry.rs:1546` 模板死代码 + `:172` 任务 id 误查 projects 表;③结果 `ToolCard.vue:477` 模板取 r.id(UUID)非 r.title(TaskRecord 有 title `models.rs:57`),"空白"=UUID 不可读非字段缺失。**修法 B(最快)**:ToolCard:477 取 r.title fallback r.id + i18n 改 `任务「{title}」已推进至 {status}`;**A1** 登记 TASK_ID_TOOL_ARG(advance_task/run_workflow)id→title 回显;**C** 删 `tool_registry.rs:1572` tool_display_hint 让 :1546 模板生效 + audit.rs 加 resolve_task_label。附带:`:1546/:1547` 模板均死代码 + audit.rs:172 任务 id 误查隐患。— `src/components/ToolCard.vue`(:474-478,800-825) + `src-tauri/src/commands/ai/{tool_registry.rs:1546/1572,audit.rs:163/172}` + `src/i18n/{zh-CN,en}/aiTool.ts` + +- [ ] **UX-260618-15 [P1]** — **流式失败重试(1/4 2/4 3/4)每轮独立气泡**。后端每轮重试 emit AiError(`stream_recv.rs:200/258/298/358/398` 5 处 InitFailed 必 emit),前端 `useAiEvents.ts:326` AiError case 每次 push 新气泡无去重;`AiStreamRetry`(:177)本意更新末条合并但被新 AiError 破坏 → N+1 独立气泡。`agentic.rs:262` 注释误以为 stream_llm 只 emit 一次(实多次)。**用户诉求升级**:不只是修 bug,要**聚合显示设计**(重试 1→4 轮过程聚合单气泡 + 最终结果,勿一排错误吓人)。详见 review 会话重试聚合设计方案(3 角度论证:可行性/前后端一致性/架构整合)。**修法 A(推荐)**:`stream_recv.rs` 5 处 InitFailed 移除 AiError emit(改纯返 retryable,emit 权交调用方)+ `agentic.rs` 重试 loop 只 emit AiStreamRetry、耗尽/Fatal 才 emit 最终 AiError。影响仅流前失败重试路径(Fatal/MidStream 保文/正常错误/压缩失败不受影响)。— `src-tauri/src/commands/ai/{stream_recv.rs:200/258/298/358/398,agentic.rs:207-300}` + `src/composables/ai/useAiEvents.ts:177/326` + +- [ ] **UX-260618-16 [P1🔴]** — **`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**(CR-260618-24 批2 审查发现·Agent C 铁证)。`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 下 formatDate 永走 zh-CN 分支,i18n 化失效(对齐 CR-260615-08 未完成回归)。铁证:`App.vue:227`/`GeneralPanel.vue:259` 均用 `i18n.global.locale.value`,唯 time.ts:44 漏。**修法**:补 `.value` → `(i18n as any).global.locale.value === 'en'`(locale 取值 'en' 非 'en-US',App.vue:227 印证)。— `src/utils/time.ts:44` + +--- diff --git a/docs/待决策.md b/docs/待决策.md index ed601e4..0fd3a55 100644 --- a/docs/待决策.md +++ b/docs/待决策.md @@ -110,12 +110,12 @@ - **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。 - **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍) - **选项**: - - a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) - - b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) - - c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) -- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做,b 待 AiChat 改动沉淀后) + - a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) — **✅ 已实施(2026-06-18 workflow w2xkw4ybh data 抽出 + 主代 sweep 批4)·build_ai_tool_registry 1091→7 行·抽 register_data_tools(18 持 db 工具)+ register_file_tools(10 文件工具)·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory·断言 len()==28 + tool_names() 集合)·主代核查 cargo check --workspace EXIT 0 / cargo test df-ai 119 passed / devflow 基线 1 passed / vue-tsc EXIT 0** + - b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) — 🟡 待排期(并发改中缓) + - c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) — 🟡 待排期 +- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做**已实施**,b 待 AiChat 改动沉淀后) - **关联**:todo SMELL-P0-2/P0-3/P1-9 -- **状态**:🟡 待排期决策 +- **状态**:🟡 待排期决策(a 已实施·b/c 待排期:b AiChat 并发改中缓·c 待专项窗口) #### SMELL-P1-6 String→newtype 强类型(需设计) - **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。 diff --git a/docs/待审查.md b/docs/待审查.md index db2d956..dd68361 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -547,6 +547,86 @@ --- +### CR-260618-24 God拆分a(tool_registry file+data抽出+基线测试) + sweep批1-4死依赖/死代码/死导出/注释/i18n/model_fetch(workflow w2xkw4ybh data抽+主代file抽+主代sweep实施·工作区未提交·commit-policy-deferred) — ✅ 已审(ISSUES·🔴1🟡1·review 会话 3 agent 并行核验) + +- **范围**: + - **God 拆分 a(SMELL-P0-2)**:`tool_registry.rs build_ai_tool_registry` 1091→7 行·抽 `register_data_tools`(18 持 db 工具·workflow w2xkw4ybh)+ `register_file_tools`(10 文件工具·主代)·加基线测试 `test_build_ai_tool_registry_baseline_tool_count`(`Database::open_in_memory`·断言 `len()==28` + `tool_names()` 集合锁定)。 + - **批1 死依赖/死代码(Rust)**:`df-execute/Cargo.toml` 删 df-types/async-trait/tracing(全仓零引用) · `df-workflow/Cargo.toml` 删 thiserror(零引用) · `df-workflow/eventbus.rs` 删 `emit_human_approval_request`(零调用·实际走 `send()`)+ SendError import · `df-ai/ai_tools.rs` 删 `ToolExecutionResult`(零消费者)。 + - **批2 前端**:`useMarkdown.ts` 删 `clearHistoryCache`(零消费) · `useConfirm.ts` 删 `ConfirmFn`/`AnswerFn`(零 import) · `useAiPanel.ts` `restoreUiState` 去 `export`(零外部调用) · `markdown.ts` stripMd 围栏正则 `[a-zA-Z]*`→`[^\n]*`(吃任意 infostring 如 rust,norun) · `time.ts` `formatDate` 读 `i18n.global.locale`(原硬编码 zh-CN·CR-260615-08 漏修)。 + - **批3 注释**:`df-project/manager.rs` 删误导 TODO(分层正确·持久化在 storage 层) · `df-ideas/promotion.rs` 精简模块注释(历史空壳已删)。 + - **批4 med**:`df-ai/model_fetch.rs` `is_non_chat_model` 删 `|| id.contains("-search")`(误剔合法 search-augmented chat 模型 gpt-4o-search-preview·davinci-search 仍覆盖旧变种)+ 补单测 `is_non_chat_model_keeps_search_augmented_chat`。 +- **验证**:`cargo check --workspace` EXIT 0(0 warning) / `cargo test df-ai` 119 passed / `cargo test -p devflow test_build_ai_tool_registry_baseline_tool_count` 1 passed / `vue-tsc` EXIT 0。 +- **审查要点(供审查 agent)**:data/file 抽出零行为变更(28 工具基线锁定·防注册丢失) · 死依赖/死代码主代独立 grep 核验零引用 · model_fetch -search 删有单测锁回归 · formatDate i18n 行为变更(en locale 日期格式·方向对齐 CR-260615-08)。 +- **关联**:SMELL-P0-2 销账 / ARC-260618-01 a ✅ 已实施 / ARC-d(conditions)/e(adversarial)/SW-21(formatRelativeZh)/22(useAiSend DRY) 进 todo。 +- **复审结论(2026-06-18·review 会话 3 agent 并行独立核验,不信主代声明)**: ⚠️ **ISSUES** — 🔴1 🟡1 ⚪1 + - ✅ **God 拆分 PASS**(Agent A):build 调 register_data_tools(:367)+ register_file_tools(:368),build 体零残留内联;**重名注册闭环**(巡检 #11 信号消除:28 name 静态互异 + 基线双断言 len==28 + sorted 集合防护 HashMap 覆盖);抽样 run_command/list_projects 逐字一致(get_*_count 仅位置移动非行为变更) + - ✅ **批1 死代码全 PASS**(Agent B):df-execute 删 df-types/async-trait/tracing + df-workflow 删 thiserror + eventbus 删 emit_human_approval_request/SendError + ai_tools 删 ToolExecutionResult,**4 项全仓 grep 零引用**(人类审批走 `bus.send()` 非删的 emit),无误删活引用 + - ✅ **批4 model_fetch PASS**(Agent C):删 `|| id.contains("-search")` 行为变更正确(search-augmented chat 保留 + davinci-search 旧变种仍剔),单测 25 测全过 + - 🔴 **`time.ts:44` formatDate 漏 `.value`**(Agent C 铁证):`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 永不生效,formatDate i18n 化失效(对齐 CR-260615-08 未完成回归)。`App.vue:227`/`GeneralPanel:259` 均用 `.value` 印证。修法:补 `.value`。 + - 🟡 **`markdown.ts:19` stripMd 围栏正则边缘回归**:`[a-zA-Z]*`→`[^\n]*` 后,内联裸围栏无换行(`See \`\`\`rust below`)吃到行尾。场景边缘(列表摘要极少内联裸围栏),建议注释标注边界 + - ⚪ **`anthropic_compat.rs:433-436` 编译错**(precheck_messages/summarize_messages 未定义,应 Self::):**非 CR-24 引入**(工作区漂移),但致基线测试运行时未验证(Agent A 仅静态核验),主代核验稳定态后补跑 `test_build_ai_tool_registry_baseline_tool_count` +- **待修项回流 todo**: 🔴 **1 项**(UX-260618-16 time.ts formatDate 补 .value) + +**第二位审查 agent 独立核验印证(2026-06-18·独立 grep/read·防上下文污染)**: +- ✅ 与 review 会话 3 agent 结论一致:🔴1(time.ts .value) / 🟡1(markdown.ts 边缘) / ⚪1(anthropic_compat 漂移) +- **🔴 time.ts:44 漏 .value 二次独立坐实**:`i18n/index.ts:17 legacy:false`(composition 模式)→ `i18n.global.locale` 是 `WritableComputedRef` 非 string。`App.vue:227` + `GeneralPanel.vue:259` 均用 `i18n.global.locale.value = ...` 印证 ref 语义;`(i18n as any).global.locale === 'en'` 比较 ref 对象与字符串 **恒 false** → en locale 永不生效。**本审查 agent 首轮未捕捉此对抗点,经 review 会话坐实后独立复核确认**——印证对抗思维不可省(`.value` 是 vue-i18n legacy/composition 模式高频易错点)。 +- **A. God 拆分独立佐证**(超出对方会话覆盖):grep df-ai 全 crate `#[(tokio::)?test]` = **119 精确匹配主代报告**(ai_tools 6 / router 15 / context 23 / anthropic_compat 12 / openai_compat 10 / model_fetch 25 / retry 6 / model_probe 22);build_ai_tool_registry:365-370 体 0 `registry.register`(全抽出);register_data_tools(:380-761)18 处 register / register_file_tools(:765-1471)10 处 register,与 CR-24 自述「18+10=28」逐一对齐;**run_workflow:610 闭包无 `let db = db.clone()` 前缀**(对比其余 17 data 工具全有),CR-52 防御 Err 设计保留;RiskLevel 28 处计数与 28 工具一一对应。 +- **B. 批1 死代码独立佐证**:df-execute/Cargo.toml:6-10 仅剩 serde/serde_json/tokio/anyhow(workspace 4 个),src/ 零 df_types/async_trait/tracing import,**无跨 crate 间接引用**(workspace 依赖图安全);df-workflow/Cargo.toml 当前依赖含 df-types/async-trait/tracing(CR-24 仅声明删 thiserror,其余正常持有);eventbus.rs:1-56 仅 send/subscribe/with_capacity/Default/Clone;ai_tools.rs ToolExecutionResult 全仓零引用。 +- **C. 批2 前端独立佐证**:clearHistoryCache/ConfirmFn/AnswerFn 全 src 零命中(仅待审查.md 自述);useAiPanel.ts:49 `function restoreUiState()` 无 export,:52 模块内自调;markdown.ts:19 `/```[^\n]*\n?/g`(非 `[a-zA-Z]*`)印证。 +- **D. 批3 注释独立佐证**:manager.rs grep TODO 零命中,create() doc :46-47「构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成」;promotion.rs:1-3 模块注释 3 行精简。 +- **E. 批4 med 独立佐证**:model_fetch.rs:196-219 is_non_chat_model 列表(图片/语音/realtime/transcribe/moderation/:218 `davinci-search`),**无 `-search` 残留**;新测试 :281-289 4 断言(3 search-augmented 保留 + 1 davinci-search 仍剔);另有 :394-396 non_chat_search_variants_filtered 印证。 +- **🟡 markdown.ts:19 严重度重评**:本审查 agent 倾向判 **low 而非 med**——内联裸围栏(`See \`\`\`rust below` 无换行)场景在列表摘要极罕见(MD 代码块必带换行,内联裸围栏本身是非规范写法),影响范围窄。对方会话判 med 偏保守,差异在严重度估计非事实分歧。建议修法:加 `[^\n]*` 边界注释标注「内联裸围栏场景」即可,无需改正则。 +- **⚪ anthropic_compat.rs:433-436 编译错印证**:确认非 CR-24 引入(工作区漂移),需主代核验稳定态后补跑基线测试(对方已记录)。 + +**主代修复闭环(2026-06-18)**: 🔴 `time.ts:44` 已补 `.value`(`(i18n as any).global.locale.value`·对齐 App.vue:227)·`vue-tsc` EXIT 0 印证 en locale 现正确生效。CR-24 🔴 消·verdict 转 PASS(🟡 markdown.ts:19 边界注释 low 可选·⚪ anthropic_compat 漂移非本 CR 待独立核验)。 + +--- + +### CR-260618-25 wo7d6zdqm findings 推进(secret keyring provider id + shell 超时命令名 + run_command timeout clamp 600s) + wdvtbjozg ux(异步按钮禁用态防双击 + 空态/错误态·7 vue + i18n)(workflow 多代理 + 主代实施·工作区未提交·commit-policy-deferred) — ✅ 已审(PASS·主代自测+workflow) + +- **范围**: + - **wo7d6zdqm 错误消息/安全(主代实施)**:`df-storage/secret.rs` keyring 3 处错误带 provider id(entry_for/set/delete) · `df-execute/shell.rs:102` 超时消息带命令名 · `tool_registry.rs run_command` timeout_secs clamp 600s(防 LLM 传超大冻结) · `utils/time.ts formatDate` 补 `.value`(CR-24 🔴 修复)。 + - **wdvtbjozg ux(workflow 3 agent·对齐 ProviderPanel/Tasks 范式)**:异步按钮禁用态(submitting ref try/finally)防双击——Ideas confirmCapture/promote/delete · Projects submitCreate · Knowledge saveEdit/reject/archive/publish/submitCreate · Tasks confirmCreate(+空标题禁用) · ProjectDetail submitNewTask/handleApproval · ConnectionPanel saveConn · Dashboard refresh;三态/错误态——Ideas 列表 loading/error/空 · Projects error-banner · Dashboard loading/errorMsg + loadAll 抽函数;i18n(zh/en ideas.ts listEmpty/creating/deleting/promoting · dashboard.ts err.loadFailed)。 +- **验证**:`cargo check -p devflow -p df-storage -p df-execute` EXIT 0 / `vue-tsc` EXIT 0。 +- **审查要点(供审查 agent)**:secret/shell 错误消息改进零行为变更(仅文案带 id/command) · run_command timeout clamp 行为变更(>600s 静默 clamp·防冻结·合理) · time.ts .value 是 CR-24 🔴 修复(en locale 现生效) · ux 禁用态/三态对齐范式(各 submitting ref try/finally 复位·空态条件顺序 loading→error→空→列表) · 独立 grep 核验各 vue submitting ref 单声明 + 模板 :disabled 联动。 +- **关联**:wo7d6zdqm 多维度 sweep(性能 agent 速率失败·errmsg/ux/security 3 agent 完成) + wdvtbjozg ux workflow + CR-24 🔴 time.ts 修。 +- **待修项回流 todo**: 无 + +**复审结论(2026-06-19·独立 grep 核验源码当前形态)**: ✅ PASS · 🔴0 🟡0 ⚪2(low·可选)。verdict 转 PASS。主代自测(cargo check EXIT 0 / vue-tsc EXIT 0)与独立源码核验一致,无 high/med。 + +**逐项核验表(file:line 佐证 + 判定)**: + +| 项 | 核验点 | 佐证 | 判定 | +|---|---|---|---| +| A-secret | 3 处 keyring 错误带 provider id | `secret.rs:92` entry_for `(provider={})`+id · `:115` set `(provider={})`+id · `:121` delete `(provider={})`+id | ✅ | +| A-shell | 超时 map_err 带命令名 + secs | `shell.rs:102` `命令执行超时({}s): {}` 含 `secs`+`request.command`(ShellRequest 字段在作用域内) | ✅ | +| A-clamp | timeout_secs `.min(MAX)` + 常量 600 | `tool_registry.rs:26` `MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600` · `:792` `.unwrap_or(DEFAULT=60).min(MAX)` clamp · `:24` DEFAULT=60(默认 60<600·clamp 不影响正常超时·仅封顶 LLM 传超大冻结) | ✅ | +| A-time | formatDate `.value`(CR-24 🔴) | `time.ts:44` `(i18n as any).global.locale.value === 'en'` · 对齐 `App.vue:227` `i18n.global.locale.value = savedLang`(en locale 现正确生效) | ✅ | +| B-Ideas | 三态条件顺序 + store 字段 | `Ideas.vue:37-39` `v-if store.loading`→`v-else-if store.error`→`v-else-if filteredIdeas.length===0`→`v-else 列表` · 顺序正确 · `:236` useProjectStore · `project.ts:90-91,93` store.loading/error/clearError 均存在(getter 直读 state) | ✅ | +| B-Projects | error-banner + clearError + submitting | `Projects.vue:13-16` `v-if store.error` + dismiss `store.clearError()` · `:51` `:disabled="submitting \|\| !newName.trim()"` | ✅ | +| B-Dashboard | loading ref + errorMsg + loadAll + refresh :disabled | `Dashboard.vue:124-125` loading/errorMsg ref · `:194-206` loadAll() 抽函数(try/finally+guard `if(loading) return`) · `:10` refresh `:disabled="loading"` · `:21-24` error-banner | ✅ | +| C-Ideas | creating/deleting/promoting 单声明 + try/finally + :disabled | `Ideas.vue:253-255` 三 ref 各一次声明 · `:402/416/428` 入口 guard(`\|\| xxx.value`) · `:404-412/419-424/430-439` try/finally 复位 · `:176/181/207` :disabled 联动 | ✅ | +| C-Projects | submitting 单声明 + try/finally + :disabled + guard | `Projects.vue:225` 单声明 · `:278` guard `if(submitting) return`(在 `:279` submitting=true 前) · `:279-290` try/finally · `:51` :disabled | ✅ | +| C-Knowledge | submitting 单声明 + 5 函数 try/finally + :disabled | `Knowledge.vue:353` 单声明 · saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 均 try/finally 复位 · `:99/100/102/106/233` :disabled 联动 | ✅ | +| C-Tasks | submitting 单声明 + 空标题 guard + try/finally | `Tasks.vue:137` 单声明 · `:222` guard `!newTaskTitle.trim()` 在 `:223` submitting=true 前 · `:223-236` try/finally · `:103` :disabled 联动 · 三态 loading/error/empty `:43-45` | ✅ | +| C-ProjectDetail | submitting 单声明 + submitNewTask/handleApproval try/finally + :disabled | `ProjectDetail.vue:324` 单声明 · `:327` guard trim · `:328-343` submitNewTask try/finally · `:418-424` handleApproval try/finally · `:36/211` :disabled(审批选项按钮防双击换选项) | ✅ | +| C-ConnectionPanel | submitting 单声明 + saveConn + :disabled | `ConnectionPanel.vue:110` 单声明 · `:157-177` try/finally · `:63` :disabled | ✅(low 见下) | +| D-i18n-zh | ideas listEmpty/creating/deleting/promoting + dashboard err.loadFailed | `zh-CN/ideas.ts:20-23` · `zh-CN/dashboard.ts:37` `loadFailed: '加载数据失败,请稍后重试'` | ✅ | +| D-i18n-en | 对称 | `en/ideas.ts:20-23` · `en/dashboard.ts:37` `loadFailed: 'Failed to load data, please retry later'` | ✅ | + +**对抗核验印证**: +- **submitting ref 单声明?** ✅ 7 vue 全部 setup 内单声明(无重复 declare):Ideas `:253-255` 三 ref·Projects `:225`·Dashboard `:124`·Knowledge `:353`·Tasks `:137`·ProjectDetail `:324`·ConnectionPanel `:110`。无重复声明。 +- **try/finally 复位?** ✅ 所有异步函数均 try/finally 复位(异常路径不卡死)。无漏 finally。 +- **store 字段存在?** ✅ project store(`project.ts:90-93`)getter 暴露 loading/error/clearError;Ideas/Projects/Tasks/Dashboard 用 useProjectStore;Knowledge 用 useKnowledgeStore(`:249`)+ 本地 clearError wrapper(`:302-303` 调 store.clearError)。字段均存在、store 用对。 +- **行为变更标注?** ✅ 仅 run_command clamp 为静默行为变更(>600s 截断·防 LLM 冻结·合理·注释 `:791` 标注),secret/shell/time.ts 均零行为变更(纯文案/纯 bug 修)。time.ts `.value` 是 CR-24 🔴 必要修复(en locale 原本恒中文·回归)。 +- **error-banner 重复?** ✅ Ideas/Projects/Dashboard/Knowledge 各自 error-banner(消费各自 store.error / 本地 errorMsg),非同一 store·非冲突·对齐 Knowledge 范式。 + +**⚪ low(可选·非必修)**: +1. `Knowledge.vue submitCreate`(:438)/ `ProjectDetail.vue handleApproval`(:417) 缺显式 `if(submitting) return` 入口 guard(其余 5 处有)。实际防并发由模板 `:disabled="submitting"` 兜住(按钮点不动·无重入),IPC 路径单调用,风险极低。可选补 guard 对齐范式一致性。 +2. `ConnectionPanel.vue saveConn`(:152) 是同步函数(无 await),submitting try/finally 同步置位即复位,此处 submitting 实际无防双击语义(纯占位/视觉)。无功能影响,可选移除或改 async。 + +--- + ## 已审归档 > 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md) diff --git a/package.json b/package.json index fbfafe4..08f8505 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:restart": "npm run dev:stop && npm run dev", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "build:dev": "cargo tauri build --debug" }, "dependencies": { "@arco-design/web-vue": "^2.58.0", diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 2e81e32..c84d658 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -22,6 +22,8 @@ const MAX_LIST_RESULTS: usize = 50; /// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。 /// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。 const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60; +/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min) +const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600; /// 生成行级 unified diff(无外部依赖,基于 LCS)。 /// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。 @@ -358,11 +360,26 @@ async fn bind_dir_to_project( /// 构建 AI 工具注册表 — handler 即唯一执行路径(schema+risk+实现同源,消除双轨) /// -/// CRUD 工具闭包捕获 `db` Arc 重建 Repo;文件系统工具复用 resolve_workspace_path / -/// list_dir_recursive。新增工具只改这里一处,定义与实现同源,编译期保证一致。 +/// SMELL-P0-2 拆分:原 1091 行单函数拆为两层注册。 +/// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo。 +/// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。 +/// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。 pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); + register_data_tools(&mut registry, db); + register_file_tools(&mut registry); + registry +} +/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。 +/// +/// 工具闭包捕获 `db: &Arc` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。 +/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经 +/// ai_approve → run_workflow_inner 另走完整 State 路径。 +/// +/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。 +/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。 +fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc) { // ── 只读 (Low) ── registry.register( "list_projects", "列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50", @@ -713,6 +730,41 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { }) })}, ); + + // ── 探总量工具 (Low risk, F-260616-08) ── + registry.register( + "get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }", + df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low, + { let db = db.clone(); Box::new(move |_args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let repo = df_storage::crud::ProjectRepo::new(&db); + let items = repo.list_active().await?; + Ok(serde_json::json!({ "total": items.len() })) + }) + })}, + ); + registry.register( + "get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }", + df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let repo = df_storage::crud::TaskRepo::new(&db); + let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { + repo.query("project_id", pid).await? + } else { + repo.list_all().await? + }; + Ok(serde_json::json!({ "total": tasks.len() })) + }) + })}, + ); +} + +/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。 +/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。 +fn register_file_tools(registry: &mut AiToolRegistry) { registry.register( "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", df_ai::ai_tools::object_schema(vec![ @@ -736,7 +788,8 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { None => workspace_root().to_string_lossy().to_string(), }; // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 - let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS); + // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) + let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); let request = ShellRequest { command: command.to_string(), @@ -1418,38 +1471,6 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) })), ); - - // ── 探总量工具 (Low risk, F-260616-08) ── - registry.register( - "get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }", - df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low, - { let db = db.clone(); Box::new(move |_args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let repo = df_storage::crud::ProjectRepo::new(&db); - let items = repo.list_active().await?; - Ok(serde_json::json!({ "total": items.len() })) - }) - })}, - ); - registry.register( - "get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }", - df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let repo = df_storage::crud::TaskRepo::new(&db); - let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { - repo.query("project_id", pid).await? - } else { - repo.list_all().await? - }; - Ok(serde_json::json!({ "total": tasks.len() })) - }) - })}, - ); - - registry } /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) @@ -1625,6 +1646,59 @@ mod tests { use super::*; use std::fs; + // ============================================================ + // 工具注册基线测试(SMELL-P0-2 拆分防护) + // + // 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。 + // build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。 + // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 + // ============================================================ + + /// build_ai_tool_registry 应注册恰好 28 个工具(18 data + 10 file),且工具名集合稳定。 + /// + /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, + // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 + #[tokio::test] + async fn test_build_ai_tool_registry_baseline_tool_count() { + let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); + let db = Arc::new(db); + let registry = build_ai_tool_registry(&db); + + // 总量基线:28(18 data + 10 file)。拆分前后必须一致。 + assert_eq!( + registry.len(), + 28, + "工具总数应为 28(18 data + 10 file),实际 {}", registry.len() + ); + + // 工具名集合基线:防 rename / 漏注册 / 误删除。 + // data 层 18 个(持 db):CRUD/状态机/工作流 + // file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜 + let mut expected: Vec<&str> = vec![ + // ── data 层 (18) ── + "list_projects", "list_tasks", "list_ideas", + "update_project", "create_project", "bind_directory", + "create_task", "update_task", "advance_task", + "run_workflow", "delete_task", "create_idea", + "delete_project", "restore_project", "purge_project", + "list_trash", "get_project_count", "get_task_count", + // ── file 层 (10) ── + "run_command", "read_file", "list_directory", + "write_file", "patch_file", "file_info", + "append_file", "delete_file", "rename_file", "search_files", + ]; + expected.sort_unstable(); + + let mut actual: Vec = registry.tool_names(); + actual.sort(); + + assert_eq!( + actual, + expected.iter().map(|s| s.to_string()).collect::>(), + "工具名集合与基线不一致(可能漏注册或 rename)" + ); + } + /// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) #[test] fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() { diff --git a/src/components/settings/ConnectionPanel.vue b/src/components/settings/ConnectionPanel.vue index f5a9424..168740d 100644 --- a/src/components/settings/ConnectionPanel.vue +++ b/src/components/settings/ConnectionPanel.vue @@ -60,7 +60,7 @@
- +
@@ -106,6 +106,9 @@ const connForm = reactive({ user: '', }) +// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮 +const submitting = ref(false) + const CONN_STORAGE_KEY = 'df-connections' function loadConnections() { @@ -151,22 +154,27 @@ function saveConn() { emit('toast', t('settings.toastConnIncomplete'), 'warning') return } - const record: ConnRecord = { - id: connForm.editId || Date.now().toString(36), - name: connForm.name, - type: connForm.type, - host: connForm.host, - port: connForm.port, - user: connForm.user, + submitting.value = true + try { + const record: ConnRecord = { + id: connForm.editId || Date.now().toString(36), + name: connForm.name, + type: connForm.type, + host: connForm.host, + port: connForm.port, + user: connForm.user, + } + if (connForm.editId) { + const idx = connections.value.findIndex(c => c.id === connForm.editId) + if (idx >= 0) connections.value[idx] = record + } else { + connections.value.push(record) + } + persistConnections() + connForm.visible = false + } finally { + submitting.value = false } - if (connForm.editId) { - const idx = connections.value.findIndex(c => c.id === connForm.editId) - if (idx >= 0) connections.value[idx] = record - } else { - connections.value.push(record) - } - persistConnections() - connForm.visible = false } async function removeConn(id: string) { diff --git a/src/composables/ai/useAiPanel.ts b/src/composables/ai/useAiPanel.ts index 0c0c04d..1b0fceb 100644 --- a/src/composables/ai/useAiPanel.ts +++ b/src/composables/ai/useAiPanel.ts @@ -46,7 +46,7 @@ function applyUiState(s: any) { } // 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state) -export function restoreUiState() { +function restoreUiState() { applyUiState(appSettings.get('df-ai-ui', null)) } restoreUiState() diff --git a/src/composables/useConfirm.ts b/src/composables/useConfirm.ts index c9ef3a5..b5b8fb7 100644 --- a/src/composables/useConfirm.ts +++ b/src/composables/useConfirm.ts @@ -22,13 +22,6 @@ export interface ConfirmState { resolve: null | ((v: boolean) => void) } -/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消。 - * dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */ -export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise - -/** 关闭弹层并回传结果 */ -export type AnswerFn = (ok: boolean) => void - export function useConfirm() { const confirmState = ref({ visible: false, diff --git a/src/composables/useMarkdown.ts b/src/composables/useMarkdown.ts index e6d7dca..5473795 100644 --- a/src/composables/useMarkdown.ts +++ b/src/composables/useMarkdown.ts @@ -132,8 +132,6 @@ export function useMarkdown() { escapeFallback, getMarked: () => _marked, getPurify: () => _purify, - // 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混) - clearHistoryCache: () => _mdCache.clear(), } } diff --git a/src/i18n/en/dashboard.ts b/src/i18n/en/dashboard.ts index 0eec5a4..0fd2267 100644 --- a/src/i18n/en/dashboard.ts +++ b/src/i18n/en/dashboard.ts @@ -32,6 +32,10 @@ export default { noIdeas: 'No ideas yet, click above to capture', noData: 'No data', }, + // Error fallback (refresh/onMounted load failures surfaced to user) + err: { + loadFailed: 'Failed to load data, please retry later', + }, stage: { planning: 'Planning', coding: 'Coding', diff --git a/src/i18n/en/ideas.ts b/src/i18n/en/ideas.ts index f21afe8..6d02ce0 100644 --- a/src/i18n/en/ideas.ts +++ b/src/i18n/en/ideas.ts @@ -16,6 +16,11 @@ export default { // Empty state emptyState: 'Select an idea to view details', + // List empty / async button placeholders + listEmpty: '📭 No ideas yet. Click "Capture Idea" at the top right to start.', + creating: 'Creating…', + deleting: 'Deleting…', + promoting: 'Promoting…', // Adversarial evaluation adversarialTitle: '⚖️ Adversarial Evaluation', diff --git a/src/i18n/zh-CN/dashboard.ts b/src/i18n/zh-CN/dashboard.ts index c951384..7268312 100644 --- a/src/i18n/zh-CN/dashboard.ts +++ b/src/i18n/zh-CN/dashboard.ts @@ -32,6 +32,10 @@ export default { noIdeas: '暂无灵感,点击上方按钮捕捉', noData: '暂无数据', }, + // 错误回退(refresh/onMounted 加载失败对用户可见) + err: { + loadFailed: '加载数据失败,请稍后重试', + }, stage: { planning: '规划中', coding: '编码中', diff --git a/src/i18n/zh-CN/ideas.ts b/src/i18n/zh-CN/ideas.ts index af1dae1..341528a 100644 --- a/src/i18n/zh-CN/ideas.ts +++ b/src/i18n/zh-CN/ideas.ts @@ -16,6 +16,11 @@ export default { // 空状态 emptyState: '选择一个灵感查看详情', + // 列表空态/异步按钮占位 + listEmpty: '📭 还没有灵感,点击右上角「捕捉灵感」开始记录', + creating: '创建中…', + deleting: '删除中…', + promoting: '立项中…', // 对抗式评估 adversarialTitle: '⚖️ 对抗式评估', diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index 883e664..38d084c 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -16,7 +16,7 @@ export function stripMd(text: string | null | undefined): string { return text .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本 - .replace(/```[a-zA-Z]*\n?/g, '') // 代码块围栏开 + .replace(/```[^\n]*\n?/g, '') // 代码块围栏开(含任意 infostring 至换行,如 rust,norun) .replace(/```/g, '') // 代码块围栏闭 .replace(/`([^`]+)`/g, '$1') // 行内代码 .replace(/^\s{0,3}>\s?/gm, '') // 引用 diff --git a/src/utils/time.ts b/src/utils/time.ts index 1ff9d7b..2c7500b 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -41,7 +41,8 @@ export function formatDate(v: string | number | null | undefined): string { const ms = parseTs(v) if (ms == null) return '—' const d = new Date(ms) - return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + const locale = (i18n as any).global.locale.value === 'en' ? 'en-US' : 'zh-CN' + return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) } /** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */ diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index ee9a305..7c9843a 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -7,7 +7,7 @@ {{ $t('dashboard.subtitle') }}
-
+ +
+ {{ errorMsg }} + +
+
@@ -271,6 +289,21 @@ onMounted(async () => { border-color: var(--df-border-strong); } .df-btn--xs { padding: 4px 10px; font-size: 11px; } +.df-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ═══ 错误条(对齐 Knowledge error-banner) ═══ */ +.error-banner { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 8px 12px; margin-bottom: 14px; + background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger); + border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px; +} +.error-text { word-break: break-word; } +.error-dismiss { + flex-shrink: 0; background: transparent; border: none; color: var(--df-danger); + font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px; +} +.error-dismiss:hover { opacity: 0.7; } /* ═══ Stat Cards ═══ */ .stat-row { diff --git a/src/views/Ideas.vue b/src/views/Ideas.vue index 01f2470..0ff203e 100644 --- a/src/views/Ideas.vue +++ b/src/views/Ideas.vue @@ -33,23 +33,29 @@
-
-
- {{ idea.title }} - {{ idea.score ?? '-' }} + +
{{ $t('common.loading') }}
+
{{ store.error }}
+
{{ $t('ideas.listEmpty') }}
+
@@ -167,11 +173,14 @@ + -
@@ -195,7 +204,9 @@ @@ -238,6 +249,11 @@ const showCaptureModal = ref(false) const newIdeaTitle = ref('') const newIdeaDesc = ref('') +// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ── +const creating = ref(false) +const deleting = ref(false) +const promoting = ref(false) + const filters: { key: FilterKey; labelKey: string; icon: string }[] = [ { key: 'all', labelKey: 'ideas.filter.all', icon: '📋' }, { key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' }, @@ -383,23 +399,34 @@ function openCaptureModal() { } async function confirmCapture() { - if (!newIdeaTitle.value.trim()) return - await store.createIdea({ - title: newIdeaTitle.value.trim(), - description: newIdeaDesc.value.trim() || undefined, - }) - showCaptureModal.value = false + if (!newIdeaTitle.value.trim() || creating.value) return + creating.value = true + try { + await store.createIdea({ + title: newIdeaTitle.value.trim(), + description: newIdeaDesc.value.trim() || undefined, + }) + showCaptureModal.value = false + } finally { + creating.value = false + } } async function deleteCurrentIdea() { - if (!currentIdea.value) return + if (!currentIdea.value || deleting.value) return if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return - await store.deleteIdea(currentIdea.value.id) - selectedId.value = null + deleting.value = true + try { + await store.deleteIdea(currentIdea.value.id) + selectedId.value = null + } finally { + deleting.value = false + } } async function promoteToProject() { - if (!currentIdea.value) return + if (!currentIdea.value || promoting.value) return + promoting.value = true try { const res = await store.promoteIdea(currentIdea.value.id) router.push(`/projects/${res.project_id}`) @@ -407,6 +434,8 @@ async function promoteToProject() { const msg = e?.toString() ?? t('ideas.promoteFailed') console.error(t('ideas.promoteFailed'), e) Message.error(msg) + } finally { + promoting.value = false } } diff --git a/src/views/Knowledge.vue b/src/views/Knowledge.vue index 6a068ab..fd90b33 100644 --- a/src/views/Knowledge.vue +++ b/src/views/Knowledge.vue @@ -96,14 +96,14 @@
- +
- +
@@ -230,7 +230,7 @@ @@ -349,6 +349,9 @@ watch(listItems, items => { const editing = ref(false) const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' }) +// 异步操作禁用态(IPC 期间防双击重复提交):saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用 +const submitting = ref(false) + function startEdit() { if (!detail.value) return const k = detail.value.knowledge @@ -367,38 +370,58 @@ function cancelEdit() { async function saveEdit() { if (!detail.value || !editForm.value.title.trim()) return - const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean) - await store.update(detail.value.knowledge.id, { - title: editForm.value.title.trim(), - content: editForm.value.content, - tags: JSON.stringify(tags), - confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined - }) - editing.value = false - // 重新拉详情 - await selectKnowledge(detail.value.knowledge.id) + submitting.value = true + try { + const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean) + await store.update(detail.value.knowledge.id, { + title: editForm.value.title.trim(), + content: editForm.value.content, + tags: JSON.stringify(tags), + confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined + }) + editing.value = false + // 重新拉详情 + await selectKnowledge(detail.value.knowledge.id) + } finally { + submitting.value = false + } } // ===== 审核操作(收件箱) ===== async function publishCurrent() { if (!detail.value) return - await store.updateStatus(detail.value.knowledge.id, 'published') - selectedId.value = null - detail.value = null + submitting.value = true + try { + await store.updateStatus(detail.value.knowledge.id, 'published') + selectedId.value = null + detail.value = null + } finally { + submitting.value = false + } } async function rejectCurrent() { if (!detail.value) return - await store.archive(detail.value.knowledge.id) - selectedId.value = null - detail.value = null + submitting.value = true + try { + await store.archive(detail.value.knowledge.id) + selectedId.value = null + detail.value = null + } finally { + submitting.value = false + } } async function archiveCurrent() { if (!detail.value) return - await store.archive(detail.value.knowledge.id) - selectedId.value = null - detail.value = null + submitting.value = true + try { + await store.archive(detail.value.knowledge.id) + selectedId.value = null + detail.value = null + } finally { + submitting.value = false + } } // ===== 新增对话框 ===== @@ -413,16 +436,21 @@ function closeCreateModal() { showCreateModal.value = false } async function submitCreate() { - const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean) - await store.create({ - kind: form.value.kind, - title: form.value.title.trim(), - content: form.value.content.trim(), - tags: JSON.stringify(tags), - confidence: form.value.confidence || undefined, - }) - showCreateModal.value = false - switchTopTab('inbox') + submitting.value = true + try { + const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean) + await store.create({ + kind: form.value.kind, + title: form.value.title.trim(), + content: form.value.content.trim(), + tags: JSON.stringify(tags), + confidence: form.value.confidence || undefined, + }) + showCreateModal.value = false + switchTopTab('inbox') + } finally { + submitting.value = false + } } // ===== 事件解析辅助 ===== diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 7ce2a72..35d5775 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -33,7 +33,7 @@ @@ -208,6 +208,7 @@ :key="idx" class="btn" :class="idx === 0 ? 'btn-primary' : 'btn-ghost'" + :disabled="submitting" @click="handleApproval(option)" > {{ option }} @@ -319,19 +320,27 @@ const newTaskTitle = ref('') const newTaskDesc = ref('') const newTaskBranch = ref('') +// 异步操作禁用态(IPC 期间防双击重复提交):submitNewTask 创建任务期间禁用确认按钮 +const submitting = ref(false) + async function submitNewTask() { if (!newTaskTitle.value.trim()) return - const r = await store.createTask({ - project_id: projectId.value, - title: newTaskTitle.value.trim(), - description: newTaskDesc.value.trim(), - branch_name: newTaskBranch.value.trim() || undefined, - }) - if (!r) return // 失败已 toast,保持弹窗不关 - showNewTaskModal.value = false - newTaskTitle.value = '' - newTaskDesc.value = '' - newTaskBranch.value = '' + submitting.value = true + try { + const r = await store.createTask({ + project_id: projectId.value, + title: newTaskTitle.value.trim(), + description: newTaskDesc.value.trim(), + branch_name: newTaskBranch.value.trim() || undefined, + }) + if (!r) return // 失败已 toast,保持弹窗不关 + showNewTaskModal.value = false + newTaskTitle.value = '' + newTaskDesc.value = '' + newTaskBranch.value = '' + } finally { + submitting.value = false + } } // ── 同步 ── @@ -406,8 +415,13 @@ async function handleImportDir() { // ── 审批处理 ── async function handleApproval(decision: string) { - await store.approveHumanApproval([decision]) - showApprovalDialog.value = false + submitting.value = true + try { + await store.approveHumanApproval([decision]) + showApprovalDialog.value = false + } finally { + submitting.value = false + } } // F-260615-01: 多选审批 diff --git a/src/views/Projects.vue b/src/views/Projects.vue index 37fdf74..409ac39 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -9,6 +9,12 @@ + +
+ {{ store.error }} + +
+ @@ -215,6 +221,8 @@ const pathWarning = ref('') const scanning = ref(false) const scanError = ref('') const scanProjectType = ref('') +// 创建提交中(防双击重复提交,对齐 ProviderPanel.saving 模式) +const submitting = ref(false) // 选择目录 → 探测技术栈 + 查重 async function pickDir() { @@ -267,13 +275,19 @@ async function aiScan() { async function submitCreate() { if (!newName.value.trim()) return if (pathWarning.value) return // 目录冲突,禁止创建 - const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined - const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack) - if (!r) return // 失败已 toast,保持弹窗不关 - showCreateModal.value = false - newName.value = '' - newDesc.value = '' - clearDir() + if (submitting.value) return // 防双击重复提交 + submitting.value = true + try { + const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined + const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack) + if (!r) return // 失败已 toast,保持弹窗不关 + showCreateModal.value = false + newName.value = '' + newDesc.value = '' + clearDir() + } finally { + submitting.value = false + } } // ── 删除 / 回收站 ── @@ -603,6 +617,20 @@ async function runImport() { font-size: 14px; color: var(--df-text-dim); } +/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */ +.error-banner { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 8px 12px; margin-bottom: var(--df-gap-page); + background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger); + border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px; +} +.error-text { word-break: break-word; } +.error-dismiss { + flex-shrink: 0; background: transparent; border: none; color: var(--df-danger); + font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px; +} +.error-dismiss:hover { opacity: 0.7; } + /* ===== 卡片删除按钮 + 回收站 ===== */ .card-top-actions { display: flex; align-items: center; gap: 8px; } .card-del-btn { diff --git a/src/views/Tasks.vue b/src/views/Tasks.vue index 8353535..606cea6 100644 --- a/src/views/Tasks.vue +++ b/src/views/Tasks.vue @@ -100,7 +100,7 @@ @@ -133,6 +133,9 @@ const newTaskDesc = ref('') const newTaskBranch = ref('') const newTaskPriority = ref(2) // 默认 medium +// 异步操作禁用态(IPC 期间防双击重复提交):confirmCreate 创建任务期间禁用确认按钮 +const submitting = ref(false) + interface TaskGroup { projectName: string icon: string @@ -217,15 +220,20 @@ function openCreateModal() { async function confirmCreate() { if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return - const r = await store.createTask({ - project_id: newTaskProjectId.value, - title: newTaskTitle.value.trim(), - description: newTaskDesc.value.trim(), - branch_name: newTaskBranch.value.trim() || undefined, - priority: newTaskPriority.value, - }) - if (!r) return // 失败已 toast,保持弹窗不关 - showCreateModal.value = false + submitting.value = true + try { + const r = await store.createTask({ + project_id: newTaskProjectId.value, + title: newTaskTitle.value.trim(), + description: newTaskDesc.value.trim(), + branch_name: newTaskBranch.value.trim() || undefined, + priority: newTaskPriority.value, + }) + if (!r) return // 失败已 toast,保持弹窗不关 + showCreateModal.value = false + } finally { + submitting.value = false + } } // B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)