diff --git a/PROGRESS.md b/PROGRESS.md index 71b60a4..5158314 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -712,7 +712,7 @@ **工作内容**: 全量 `todo.md`(~40 未完成项)汇总按可执行性分 8 组(A 卫生 / B 零风险减法 / C 需用户输入 / D 功能 / E 架构 / F 全局 review P2 / G 测试 / H 长期池),加「编排推进总览」章节供后续会话全局视图。首批推进 A+B 组。 -**ARC-08 转自研块级 memo**(前会话遗留补记,待 commit): 流式 Markdown 渲染——方案 D(markstream-vue)试装后样式还原成本高且脆(代码块 chrome / 暗色 `--ms-*` 变量 / prose 作用域 4 处对接随库漂移),转自研块级 memo(方案 B 增强版):保留 marked+DOMPurify+.ai-md 原样式零对接 + splitBlocks 块级 memo O(末块) + rAF 节流 + 末块不缓存处理未闭合 token。退役 AR-1 流式纯文本短路。详见 [aichat流式Markdown渲染调研-2026-06-15.md](docs/02-架构设计/aichat流式Markdown渲染调研-2026-06-15.md) §5。 +**ARC-08 转自研块级 memo**(前会话遗留补记,待 commit): 流式 Markdown 渲染——方案 D(markstream-vue)试装后样式还原成本高且脆(代码块 chrome / 暗色 `--ms-*` 变量 / prose 作用域 4 处对接随库漂移),转自研块级 memo(方案 B 增强版):保留 marked+DOMPurify+.ai-md 原样式零对接 + splitBlocks 块级 memo O(末块) + rAF 节流 + 末块不缓存处理未闭合 token。退役 AR-1 流式纯文本短路。详见 [aichat流式Markdown渲染调研-2026-06-15.md](docs/02-架构设计/构想审查/aichat流式Markdown渲染调研-2026-06-15.md) §5。 **A todo 卫生**: - AR-1 标退役(被 ARC-08 取代) diff --git a/URGENT.md b/URGENT.md index d181893..57dd623 100644 --- a/URGENT.md +++ b/URGENT.md @@ -58,7 +58,7 @@ ### 9. F-260614-07 df-ai-core trait 下沉拆 crate(架构前置) - **说明**:解锁 F-03(灵感对抗评估接 LLM)。统一为全局 AI trait 下沉独立 crate,df-ideas/df-nodes 依赖 trait 而非 df-ai 具体 impl -- **设计文档**:`docs/02-架构设计/F-07-df-ai-core-trait下沉设计-2026-06-14.md` +- **设计文档**:`docs/02-架构设计/已编号方案/F-07-df-ai-core-trait下沉设计-2026-06-14.md` ### 10. T-260614-01 多项未 tauri dev 实测 - **说明**:Sprint 9-18 积累的未实测项——评分 IPC / promote_idea / token 落库 / 知识库 Tier 1 全栈 / LLM 并发 Semaphore / 知识生命线 diff --git a/docs/02-架构设计/INDEX.md b/docs/02-架构设计/INDEX.md index 2b97744..b7d3e35 100644 --- a/docs/02-架构设计/INDEX.md +++ b/docs/02-架构设计/INDEX.md @@ -69,6 +69,8 @@ | [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影 | | [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 | | [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 | +| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) | +| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 | --- diff --git a/docs/02-架构设计/专项设计/消息拆分存储设计-2026-06-19.md b/docs/02-架构设计/专项设计/消息拆分存储设计-2026-06-19.md new file mode 100644 index 0000000..cb438a2 --- /dev/null +++ b/docs/02-架构设计/专项设计/消息拆分存储设计-2026-06-19.md @@ -0,0 +1,308 @@ +# 消息拆分存储设计 + +> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:📐 设计待实施 +> 关联任务:`消息拆分存储:ai_messages 表 + 全量迁移 + 三阶段渐进切换`(DevFlow 项目) +> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络 +> 关联设计:[消息级溯源设计-2026-06-19.md](./消息级溯源设计-2026-06-19.md)(本设计的前置依赖 + 消费方) +> 上级索引:[../INDEX.md](../INDEX.md) + +--- + +## 一、目标 + +将 `ai_conversations.messages`(整个对话 JSON 数组存于单 TEXT 列)拆分为独立的 `ai_messages` 表(每条消息一行),实现: + +- **增量写入**:长对话(50+ 轮)save 不再全量序列化覆盖写 +- **O(1) 删除/压缩/单条编辑**:clear/compress/replace_tool_result_content 改为 SQL 单行操作 +- **游标分页加载**:超长对话按 seq 范围拉取(未来) +- **持久化消息 ID**:为[消息级溯源](./消息级溯源设计-2026-06-19.md)提供 O(1) 查询基础 + +--- + +## 二、现状 + +### 2.1 存储模型 + +``` +ai_conversations.messages TEXT -- 存整个 Vec 序列化 JSON +``` + +- `save_conversation()`(`conversation.rs:135`)全量序列化覆盖写 +- 被 `chat.rs` / `agentic.rs` / 审批 / 压缩 / 编辑重生成等 **10+ 处**调用 +- `ContextManager`(`crates/df-ai/src/context.rs`)的 push/pop/replace_tool_result_content/compress_old_messages/insert_at/restore_from_messages 全部基于"内存 Vec"假设 + +### 2.2 ChatMessage 结构(当前 9 字段) + +定义在 `crates/df-ai-core/src/types.rs:72`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `role` | `MessageRole` | system/user/assistant/tool | +| `content` | `String` | 文本内容(parts 的 Text 片与 content 一致) | +| `parts` | `Option>` | 多模态片(F-05 Phase 2a) | +| `tool_call_id` | `Option` | role=Tool 时必填 | +| `tool_calls` | `Option>` | AI 发起的工具调用 | +| `model` | `Option` | 生成该消息的 model(仅 assistant) | +| `status` | `Option` | None/"active" 正常;"truncated" 软删 | +| `reasoning_content` | `Option` | DeepSeek thinking 推理内容 | +| `timestamp` | `Option` | Unix 毫秒(前端展示用) | + +> **本设计新增**:`id: Option`(ULID)——见[消息级溯源设计](./消息级溯源设计-2026-06-19.md) §三。 + +### 2.3 迁移基线 + +- 当前最新迁移版本:**V19**(`migrations.rs:45`) +- V20 预留给 F-260619-01(任务关联灵感) +- **本设计使用 V21**(建 ai_messages 表 + 全量迁移) +- V22 预留给 Phase 3 删旧列 + +--- + +## 三、数据模型 + +### 3.1 新建 `ai_messages` 表 + +```sql +CREATE TABLE IF NOT EXISTS ai_messages ( + id TEXT PRIMARY KEY, -- ULID 全局唯一 + 时间有序 + conversation_id TEXT NOT NULL, -- 关联对话 + seq INTEGER NOT NULL, -- 对话内序号(排序用,从 0 起) + role TEXT NOT NULL, -- system/user/assistant/tool + content TEXT NOT NULL DEFAULT '', + parts TEXT, -- 多模态 parts JSON(可空) + tool_call_id TEXT, + tool_calls TEXT, -- AI 发起的工具调用 JSON(可空) + model TEXT, -- 生成该消息的 model(仅 assistant) + status TEXT NOT NULL DEFAULT 'active', -- active/compressed/archived_segment/truncated + reasoning_content TEXT, + timestamp INTEGER, -- 消息创建时间(Unix 毫秒) + created_at TEXT NOT NULL, + UNIQUE(conversation_id, seq) +); + +CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq); +``` + +### 3.2 `ai_conversations` 表保留 messages 列 + +- Phase 2 双写期:旧列做回退保险 +- Phase 3 切读稳定后:V22 迁移删列 + +--- + +## 四、渐进路径(三阶段) + +### Phase 1:脏标记增量写入(不改 schema) + +**目标**:减少 save_conversation 的序列化开销。 + +- `ContextManager` 加 `dirty_min_seq: Option` / `dirty_max_seq: Option` 标记变化范围 +- `save_conversation` 只序列化变化范围内的消息(但仍写整列) +- push/pop/replace/compress/insert_at 各操作设置 dirty 标记 +- save 后清空 dirty 标记 + +**风险**:低,纯内存优化,不涉及 schema 变更。 + +### Phase 2:建表 + 双写 + 全量迁移 + +**目标**:数据落入新表,读仍走旧列。 + +#### 4.2.1 迁移 V21 + +```rust +fn migrate_v21(conn: &Connection) -> Result<()> { + // 1. 建表(IF NOT EXISTS 幂等) + conn.execute_batch(V21_SQL)?; + + // 2. COUNT 探测:ai_messages 已有数据 → 跳过迁移只写版本号 + let existing: i64 = conn.query_row( + "SELECT COUNT(*) FROM ai_messages", [], |row| row.get(0) + )?; + if existing > 0 { + tracing::info!("v21: ai_messages 已有 {} 条,跳过迁移", existing); + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [21])?; + return Ok(()); + } + + // 3. 遍历 ai_conversations + let mut stmt = conn.prepare("SELECT id, messages, created_at FROM ai_conversations")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + })?; + let all_rows: Vec<_> = rows.collect::, _>>()?; + + // 4. 分批 commit(每 50 个对话一批,避免长事务持有写锁) + const BATCH_SIZE: usize = 50; + for (batch_idx, batch) in all_rows.chunks(BATCH_SIZE).enumerate() { + let tx = conn.unchecked_transaction()?; + for (conv_id, messages_json, conv_created_at) in batch { + // 5. 逐对话反序列化 messages JSON → Vec + // (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core) + let messages: Vec = match serde_json::from_str(messages_json) { + Ok(v) => v, + Err(e) => { + tracing::warn!("v21: 对话 {} messages JSON 解析失败,跳过: {}", conv_id, e); + continue; // 坏数据跳过,不中断迁移 + } + }; + + for (seq, msg) in messages.iter().enumerate() { + // 6. 逐条消息提取字段 → INSERT INTO ai_messages + // 字段名硬编码("role"/"content" 等)——ChatMessage 改名会漏数据! + // 耦合点标注:见下方「迁移耦合点」 + let id = format!("msg_migrated_{}_{}", conv_id, seq); // 迁移期 ID,天然唯一 + let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user"); + let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let parts = msg.get("parts") + .filter(|v| !v.is_null()) + .map(|v| v.to_string()); + let tool_call_id = msg.get("tool_call_id") + .and_then(|v| v.as_str()) + .map(String::from); + let tool_calls = msg.get("tool_calls") + .filter(|v| !v.is_null()) + .map(|v| v.to_string()); + let model = msg.get("model") + .and_then(|v| v.as_str()) + .map(String::from); + // status 归一化:None/空 → "active"(列语义清晰,永不 NULL) + let status = msg.get("status") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("active"); + let reasoning_content = msg.get("reasoning_content") + .and_then(|v| v.as_str()) + .map(String::from); + // created_at:有 timestamp 用消息自己的,没有 fallback 到对话创建时间 + let timestamp = msg.get("timestamp").and_then(|v| v.as_i64()); + let created_at = timestamp + .map(|ts| ts.to_string()) + .unwrap_or_else(|| conv_created_at.clone()); + + tx.execute( + "INSERT OR IGNORE INTO ai_messages + (id, conversation_id, seq, role, content, parts, tool_call_id, + tool_calls, model, status, reasoning_content, timestamp, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + rusqlite::params![ + id, conv_id, seq as i64, role, content, parts, + tool_call_id, tool_calls, model, status, + reasoning_content, timestamp, created_at + ], + )?; + } + } + tx.commit()?; + tracing::info!("v21: 批次 {} 完成({} 对话)", batch_idx, batch.len()); + } + + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [21])?; + tracing::info!("迁移 v21 完成"); + Ok(()) +} +``` + +#### 4.2.2 迁移设计要点 + +| 要点 | 说明 | +|---|---| +| **幂等安全** | COUNT 探测 + INSERT OR IGNORE;中途崩溃重跑跳过已迁移数据 | +| **分批 commit** | 每 50 对话一批,避免长事务持有 SQLite 写锁导致应用不可用 | +| **迁移期 ID** | `msg_migrated_{conv_id}_{seq}` ——天然唯一(UNIQUE 是 conv_id+seq)、人类可读、零依赖(不调 new_id) | +| **裸 JSON 提取** | 用 `serde_json::Value` 而非 `ChatMessage`,因 df-storage 不依赖 df-ai-core(分层约束) | +| **坏数据跳过** | JSON 解析失败 → warn + continue,不中断迁移 | +| **status 归一化** | None/空 → "active",列语义清晰永不 NULL | +| **created_at 语义** | 有 timestamp 用消息自己的;没有 fallback 到对话 created_at | + +#### 4.2.3 迁移耦合点(⚠️ ChatMessage 改名会漏数据) + +迁移函数硬编码以下 JSON 字段名,与 `ChatMessage` 的 serde 序列化字段一一对应: + +| 硬编码字段名 | ChatMessage 来源 | serde 属性 | +|---|---|---| +| `"role"` | `role: MessageRole` | `#[serde(rename_all = "lowercase")]`(enum) | +| `"content"` | `content: String` | 默认 | +| `"parts"` | `parts: Option>` | `skip_serializing_if = "Option::is_none"` | +| `"tool_call_id"` | `tool_call_id: Option` | `skip_serializing_if = "Option::is_none"` | +| `"tool_calls"` | `tool_calls: Option>` | `skip_serializing_if = "Option::is_none"` | +| `"model"` | `model: Option` | `default, skip_serializing_if` | +| `"status"` | `status: Option` | `default, skip_serializing_if` | +| `"reasoning_content"` | `reasoning_content: Option` | `default, skip_serializing_if` | +| `"timestamp"` | `timestamp: Option` | `default, skip_serializing_if` | + +> **ChatMessage 如改字段名,必须同步更新迁移函数**。建议在 `types.rs` ChatMessage 定义处加注释标注此耦合点。 + +#### 4.2.4 双写改造 + +`save_conversation`(`conversation.rs:135`)改造: + +```rust +// 同一事务内:旧列全量写 + 新表增量写 +let tx = conn.transaction()?; +// 旧列(回退保险) +tx.execute("UPDATE ai_conversations SET messages = ? WHERE id = ?", params![json, conv_id])?; +// 新表(增量:DELETE 旧 dirty 范围 + INSERT 新) +if let Some((min_seq, max_seq)) = ctx_mgr.dirty_range() { + tx.execute( + "DELETE FROM ai_messages WHERE conversation_id = ? AND seq >= ? AND seq <= ?", + params![conv_id, min_seq, max_seq] + )?; + for (seq, msg) in ctx_mgr.messages()[min_seq..=max_seq].iter().enumerate() { + tx.execute("INSERT INTO ai_messages (...) VALUES (...)", params![...])?; + } +} +tx.commit()?; +``` + +**风险**:中。双写增加事务开销,但保证一致性。 + +### Phase 3:切读 + 删旧列 + +**目标**:读走新表,删旧列,清理双写代码。 + +| 操作 | 旧实现 | 新实现 | +|---|---|---| +| `restore_from_messages` | 反序列化 messages JSON | `SELECT * FROM ai_messages WHERE conversation_id = ? ORDER BY seq` | +| `clear_messages` | 写空 JSON `[]` | `DELETE FROM ai_messages WHERE conversation_id = ?` | +| `compress_old_messages` | 遍历改 status + 重写 JSON | `UPDATE ai_messages SET status = 'compressed' WHERE conversation_id = ? AND seq < ?` | +| `replace_tool_result_content` | 遍历找 tool_call_id + 重写 JSON | `UPDATE ai_messages SET content = ? WHERE conversation_id = ? AND tool_call_id = ?` | + +V22 迁移删 `ai_conversations.messages` 列(SQLite 不支持 DROP COLUMN,需重建表)。 + +**风险**:中。需全量回归测试对话加载/清空/压缩/编辑。 + +--- + +## 五、涉及文件 + +| 文件 | 改动 | +|---|---| +| `crates/df-storage/src/migrations.rs` | V21 迁移 + V1 建表 SQL 同步补 ai_messages + steps 数组追加 (21, migrate_v21) | +| `crates/df-storage/src/models.rs` | 新增 `AiMessageRecord` struct | +| `crates/df-storage/src/crud/message_repo.rs` | **新建**,AiMessageRepo CRUD(insert_batch / list_by_conversation / delete_range / update_status / update_content_by_tool_call_id) | +| `crates/df-storage/src/crud/mod.rs` | 注册 message_repo 子模块 | +| `src-tauri/src/commands/ai/conversation.rs` | save_conversation 双写改造(Phase 2)/ 切读改造(Phase 3) | +| `src-tauri/src/commands/ai/commands/conversation.rs` | ai_conversation_switch 切读改造(Phase 3) | +| `src-tauri/src/commands/ai/commands/chat.rs` | clear_messages / compress / replace_tool_result_content 改造(Phase 3) | +| `crates/df-ai/src/context.rs` | dirty 标记(Phase 1)+ 适配新存储(Phase 3) | + +--- + +## 六、验收标准 + +1. **V21 迁移幂等安全**:新库(空表直接建)/老库(全量迁移)/坏数据(JSON 解析失败跳过)均不崩 +2. **Phase 2 双写期**:旧列和新表数据一致(可对比校验脚本) +3. **Phase 3 切读后**:对话加载/清空/压缩/编辑行为零回归 +4. **长对话(50+ 轮)save 性能显著改善**(增量写入) +5. `cargo check --workspace EXIT 0` + `cargo test` 相关测试通过 + +--- + +## 七、风险与对策 + +| 风险 | 等级 | 对策 | +|---|---|---| +| 迁移函数字段名与 ChatMessage 脱节 | 中 | 迁移耦合点注释 + types.rs 标注 | +| SQLite 不支持 DROP COLUMN | 低 | V22 用重建表方式(CREATE new → INSERT → DROP old → RENAME) | +| 双写事务开销 | 低 | Phase 2 过渡期可接受,Phase 3 清理 | +| ContextManager dirty 标记遗漏 | 中 | 所有变更操作(push/pop/replace/compress/insert)统一设置 dirty | diff --git a/docs/02-架构设计/专项设计/消息级溯源设计-2026-06-19.md b/docs/02-架构设计/专项设计/消息级溯源设计-2026-06-19.md new file mode 100644 index 0000000..f71845a --- /dev/null +++ b/docs/02-架构设计/专项设计/消息级溯源设计-2026-06-19.md @@ -0,0 +1,235 @@ +# 消息级溯源设计 + +> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:📐 设计待实施 +> 关联任务:`消息级溯源:知识库/审计/灵感全链路从对话级升级到消息级`(DevFlow 项目) +> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络 +> 关联设计:[消息拆分存储设计-2026-06-19.md](./消息拆分存储设计-2026-06-19.md)(本设计的持久化基础) +> 上级索引:[../INDEX.md](../INDEX.md) + +--- + +## 一、目标 + +将 DevFlow 所有溯源场景从「对话级」升级为「消息级」,实现四个场景的精确定位: + +| 场景 | 升级后溯源字段 | 粒度 | +|---|---|---| +| 知识库提炼 | `source_ref = "conv_msg:{message_id}"` | 消息级 | +| 知识生命线·提炼/引用 | `source_ref = "conv_msg:{message_id}"` | 消息级 | +| 工具执行审计 | `message_id: Option` 独立列 | 消息级 | +| 灵感来源 | `source = "conv_msg:{message_id}"` | 消息级 | + +> **依赖关系**:本设计依赖[消息拆分存储](./消息拆分存储设计-2026-06-19.md)提供持久化消息 ID,但**中间态可不依赖拆表先上线**(见 §五)。 + +--- + +## 二、现状(4 个溯源场景全部只到对话级) + +| 场景 | 当前溯源字段 | 粒度 | 位置 | +|---|---|---|---| +| 知识库提炼 | `source_ref = "conv:{conversation_id}"` | 对话级 | `knowledge_inject.rs:434` | +| 知识生命线·提炼/引用 | `source_ref = "conv:{conversation_id}"` | 对话级 | `knowledge_timeline.rs:87,99` | +| 工具执行审计 | `conversation_id`(无 message_id 列) | 对话级 | `audit/mod.rs`(14 处写入) | +| 灵感来源 | `source`(自由文本,无结构) | 无结构 | `idea.rs:27` | + +### 痛点 + +- **知识溯源**:打开整个对话,不知道是哪一轮产出的 +- **工具审计(最痛)**:`tool_call_id` 埋在整个对话 JSON 里,排查"AI 为什么删这个文件"需反序列化全量 JSON 逐条搜 +- **知识引用追踪**:不知道知识在对话哪一轮被使用 + +--- + +## 三、前置:ChatMessage 加 id 字段 + +### 3.1 字段定义 + +`crates/df-ai-core/src/types.rs:72` 的 `ChatMessage` 加: + +```rust +/// 消息全局唯一 ID(ULID)。用于消息级溯源(source_ref / audit message_id / idea source)。 +/// 构造时生成;老 JSON 反序列化为 None(向前兼容)。 +/// 消息拆分存储(F-260619-03)后,此 ID 即 ai_messages.id 列主键。 +#[serde(default, skip_serializing_if = "Option::is_none")] +pub id: Option, +``` + +### 3.2 ID 生成时机 + +| 构造路径 | ID 生成 | +|---|---| +| `ChatMessage::new_user()` | 构造时 `Some(new_id())` | +| `ChatMessage::new_assistant()` | 构造时 `Some(new_id())` | +| `ChatMessage::new_tool()` | 构造时 `Some(new_id())` | +| `ChatMessage::new_system()` | 构造时 `Some(new_id())` | +| 老消息反序列化 | `None`(serde default) | + +> `new_id()` 来自 `df-types::types::new_id()`(ULID)。df-ai-core 已依赖 df-types。 + +### 3.3 向前兼容 + +- `#[serde(default)]`:老 JSON 无 id 字段 → 反序列化为 `None` +- `skip_serializing_if = "Option::is_none"`:id = None 时不序列化(不污染老格式) +- **round-trip 测试**:序列化 → 反序列化 → 字段一致 + +--- + +## 四、溯源字段升级 + +### 4.1 知识库提炼 + +**文件**:`src-tauri/src/commands/ai/knowledge_inject.rs:434` + +```rust +// 旧 +source_ref: Some(format!("conv:{}", conv_id)), + +// 新 +source_ref: Some(format!("conv_msg:{}", message_id)), +``` + +`message_id` 来源:提炼触发时,取当前 assistant 消息的 `id`。 + +### 4.2 知识生命线 + +**文件**:`src-tauri/src/commands/knowledge_timeline.rs:87,99` + +```rust +// 旧(timeline 提炼事件 + 引用事件) +source_ref: Some(format!("conv:{}", conv_id)), + +// 新 +source_ref: Some(format!("conv_msg:{}", message_id)), +``` + +### 4.3 工具执行审计 + +**文件**:`crates/df-storage/src/models.rs` + `crud/conversation_repo.rs` + `src-tauri/src/commands/ai/audit/mod.rs` + +#### 4.3.1 Model + 迁移 + +`AiToolExecutionRecord` 加 `message_id: Option`。 + +V21 迁移(与消息拆分同一版本,或拆为 V21a/V21b): + +```rust +// migrations.rs migrate_v21 +if !column_exists(conn, "ai_tool_executions", "message_id") { + conn.execute( + "ALTER TABLE ai_tool_executions ADD COLUMN message_id TEXT", + [], + )?; + tracing::info!("v21: 补建 ai_tool_executions.message_id 列(消息级溯源)"); +} +``` + +V9 建表 SQL 同步补 `message_id TEXT` 列(新库直接有)。 + +#### 4.3.2 CRUD 层 + +- `ai_tool_execution_from_row`:加 `message_id: row.get("message_id")?` +- insert/update SQL:加 message_id 列 + params 占位 + +#### 4.3.3 审计写入(14 处) + +`audit/mod.rs` 中 14 处写入 `AiToolExecutionRecord` 的位置,在 `conversation_id` 旁加 `message_id`: + +```rust +// audit 记录构造 +let record = AiToolExecutionRecord { + // ... 既有字段 ... + conversation_id: Some(conv_id.clone()), + message_id: current_message_id.clone(), // 新增 + // ... +}; +``` + +`current_message_id` 来源:audit 写入时,从 ContextManager 取当前 assistant 消息的 `id`。 + +### 4.4 灵感来源 + +**文件**:`src-tauri/src/commands/idea.rs:27` + +```rust +// 旧(AI 路径,自由文本) +source: Some(format!("AI 对话 {}", conv_id)), + +// 新(AI 路径,结构化) +source: Some(format!("conv_msg:{}", message_id)), +``` + +人工创建的灵感 source 保持自由文本不变。 + +--- + +## 五、中间态(不依赖消息拆表) + +本设计可**先于消息拆分存储上线**: + +### 5.1 内存 ID 已足够写入溯源 + +- ChatMessage 加 id 字段后,所有新消息有 ULID +- 溯源写入时带上 message_id —— **信息已存下** + +### 5.2 查询体验(中间态) + +| 操作 | 中间态(消息未拆表) | 最终态(消息拆表后) | +|---|---|---| +| 知识溯源点击 | 反序列化对话 JSON 搜 id | `SELECT * FROM ai_messages WHERE id = ?`(O(1)) | +| 工具审计查消息 | 反序列化对话 JSON 搜 id | `SELECT * FROM ai_messages WHERE id = ?`(O(1)) | + +中间态查询不快,但**溯源信息已完整存下**,消息拆表后自然升级为 O(1)。 + +### 5.3 source_ref 格式约定 + +| 格式 | 含义 | 示例 | +|---|---|---| +| `conv:{conversation_id}` | 对话级(旧格式,向前兼容) | `conv:abc123` | +| `conv_msg:{message_id}` | 消息级(新格式) | `conv_msg:01J...` | + +解析逻辑:前缀匹配 `conv_msg:` → 消息级;`conv:` → 对话级(兼容)。 + +--- + +## 六、涉及文件 + +| 文件 | 改动 | +|---|---| +| `crates/df-ai-core/src/types.rs` | ChatMessage 加 `id: Option` + 构造器生成 | +| `crates/df-storage/src/migrations.rs` | V21 迁移 ai_tool_executions 加 message_id 列 + V9 建表 SQL 同步 | +| `crates/df-storage/src/models.rs` | AiToolExecutionRecord 加 `message_id: Option` | +| `crates/df-storage/src/crud/conversation_repo.rs` | ai_tool_execution_from_row + insert/update SQL 补 message_id | +| `src-tauri/src/commands/ai/knowledge_inject.rs` | source_ref 升级为 `conv_msg:{id}` | +| `src-tauri/src/commands/knowledge_timeline.rs` | source_ref 升级(2 处) | +| `src-tauri/src/commands/ai/audit/mod.rs` | 14 处 conversation_id 写入旁加 message_id | +| `src-tauri/src/commands/idea.rs` | source 字段结构化(AI 路径) | + +--- + +## 七、验收标准 + +1. **ChatMessage 有稳定 id**:构造时生成 ULID,序列化/反序列化 round-trip 正确 +2. **知识库提炼的 source_ref 为 `conv_msg:{id}` 格式** +3. **工具审计记录携带 message_id**(14 处全覆盖) +4. **老数据(无 id)无回归**:source_ref 解析兼容 `conv:` 旧格式;ChatMessage id = None 不报错 +5. `cargo check --workspace EXIT 0` + +--- + +## 八、与消息拆分存储的协作 + +``` +消息级溯源(F-260619-04) 消息拆分存储(F-260619-03) +┌─────────────────────────┐ ┌─────────────────────────┐ +│ ChatMessage.id 字段 │◄────────│ ai_messages 表 │ +│ (内存 ULID,中间态) │ 提供 │ (持久化 id,最终态) │ +│ │ │ │ +│ source_ref = conv_msg:X │ 查询时 │ SELECT WHERE id = X │ +│ audit.message_id = X │───────►│ (O(1) 查询) │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +**实施顺序建议**: + +1. **先上消息级溯源**(本设计):ChatMessage 加 id + 溯源字段升级 —— 中间态可上线 +2. **再上消息拆分存储**:ai_messages 表 + 全量迁移 + 切读 —— 溯源查询升级为 O(1) diff --git a/docs/待审查.md b/docs/待审查.md index 9fd6a4e..e590869 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -910,6 +910,61 @@ --- +### CR-260619-08 df-ai 会话意图识别层 intent.rs 新建(纯函数模块·不接入 loop·commit 7724cb7) — ✅ 已审(PASS·⚪1 low·独立 grep/read 核验) + +- **结论(2026-06-19·独立 grep/read 核验 commit 7724cb7)**: ✅ **PASS** — 🔴0 🟡0 ⚪1 +- **范围**:`crates/df-ai/src/intent.rs`(新建 682 行)+ `crates/df-ai/src/lib.rs`(`pub mod intent;` 注册 4 行)。 + +**6 维度逐项核验(file:line 佐证 + 判定)**: + +| 维度 | 核验点 | 佐证 | 判定 | +|---|---|---|---| +| ① 识别规则 | 优先级取舍 + 置信度封顶 + 求和 | `recognize:289-301` 依次 best_in_group(SPECIFIC→ENTITY→GENERIC) 命中即 return 不向下累积 · `best_in_group:305-329` 组内 score=Σ命中权重 `:315` + `.min(1.0):322` 封顶 · 平局 `b>=conf 保留旧:324`(SPECIFIC_GROUP 数组 Code 首位 :200 同分 Code 胜) | ✅ | +| ② 工具名对齐 | ToolDomain 29 工具名逐条 vs tool_registry.rs | 自动化 perl 比对:registry 29 == intent 29,**in registry but NOT in intent: (none) / in intent but NOT in registry: (none)** · Data 18(list_projects..get_task_count)+File 10(read_file..rename_file)+Http 1(http_request)与 `tool_registry.rs:1838-1852` 基线测试 expected 完全一致 | ✅ | +| ③ ModelTier None 预留 | 恒 None,未误接 provider/model | `suggested_model_tier:376-379` body 仅 `None` + TODO 注释 · ModelTier 枚举 :80-87 仅 Fast/Standard/Heavy 定义,无 provider/model 关联 · 单测 :653-671 遍历 11 意图全断言 None | ✅ | +| ④ 独立性 | 不接入 loop/不读 registry/不碰 src-tauri | `git show --stat 7724cb7` 仅 2 文件(intent.rs 新增 + lib.rs +4)·lib.rs:12 仅 `pub mod intent;` · intent.rs grep 无 `use crate::`/`commands`/`tool_registry`/`agentic` 运行期依赖(仅文档注释 :6/:15-16/:222-223 提及)·无 src-tauri 改动 | ✅ | +| ⑤ 测试覆盖 | 36 单测覆盖关键路径 | `cargo test -p df-ai --lib intent::` **36 passed 0 failed**(EXIT 0 复跑印证无中间态漂移)·覆盖:枚举 as_str / recognize 中英文 / 优先级 SPECIFIC>ENTITY>GENERIC / 边界空串+空白+无关键词 / 置信度封顶 / tool_subset 各 domain + fallback 空 / 跨 domain 去重 / ModelTier None / Default 构造 | ✅ | +| ⑥ 设计文档对齐 | 方式 A 规则识别 | `意图识别层论证-2026-06-19.md` 第 11 条「方式 A 规则(零延迟零成本)落 intent.rs + tool domain 标签」+ 第 58 条「子集扩充非裁剪 / None fallback 全量零回归」+ 第 13 条「loop 入口生效一次不进 loop 体」全对齐实现 · 文档预估 ~200 行,实现纯逻辑约 380 行(682 含 36 单测 ~300 行)量级合理 | ✅ | + +**对抗核验印证**: +- **维度① 平局处理**:recognize_debug_zh 测试输入「bug 复现 调试 排查」(bug→Code=1.0 / 复现0.9+调试1.0+排查0.9=2.8→1.0→Debug)同分 1.0,Code 在 SPECIFIC_GROUP 首位先遍历,`b>=conf`(1.0>=1.0)保留 Code → 返 Code ✅ 测试 :420 断言 Code 正确反映此行为 +- **维度② 维度声明文档笔误**:登记项写「Data 18/File 10/Http 1」(暗示 28)但实际 18+10+1=**29**。tool_registry.rs:1830 基线测试断言 `29(18 data + 10 file + 1 http)`。intent.rs 正确对齐 29,非 28。**声明笔误不影响代码正确性**,仅文档表述 +- **关键词歧义点(规则识别固有局限,非 bug)**:"修改"(File 0.7)+ "项目"(Project 1.0)同命中 ENTITY 组,File 在 ENTITY_GROUP 首位先遍历 → "修改项目名称"会识别为 File 非 Project。方式 A 准确率 70%+(设计文档第 27 条声明),子集扩充非裁剪 + None fallback 零回归兜底,非 high/med +- **测试用例歧义容忍**:recognize_search_zh :454-461 输入「搜索 代码 grep」(Search 搜索1.0+grep1.0=2.0→1.0 vs Code 代码1.0→1.0 同分,Code 首位胜 → 返 Code),用 `matches!(Search|Code):460` 容忍已知歧义 ✅ 测试合理(见 low-1) + +**⚪ low(可选·非必修)**: +1. **recognize_search_zh 测试注释与用例自相矛盾**(`intent.rs:457-459`):注释说「调整用例避免歧义」但用例本身仍触发歧义(仅靠 `matches!(Search|Code)` 容忍)。建议要么改输入为纯 Search 关键词(如 "grep 查找" 已有 recognize_search_pure :464 覆盖,本用例冗余),要么删注释「调整用例」表述保留 `matches!` 容忍说明。纯测试可读性,非功能。 + +- **待修项回流 todo**: **无** 🔴/🟡 项 + +### CR-260619-09 F-260619-03 文件访问权限模型 Phase B+C + anthropic_compat 连续user合并(后端 state/audit/chat/conversation/mod/lib + 前端 DirAuthDialog 等 15 文件·commit bddbfd4 + 7c98134) — ✅ 已审(PASS·🟡1⚪2) + +- **结论(2026-06-19·独立 grep/read 核验 commit bddbfd4+7c98134)**: ✅ **PASS** — 🔴0 🟡1 ⚪2 +- **验证**: cargo test -p devflow --lib state:: **15 passed 0 failed**(Phase A/B/C 全覆盖) / cargo check -p devflow **EXIT 0 无 warning** / vue-tsc --noEmit **EXIT 0**。独立 grep 核验 6 维度源码当前形态,不信 agent 自报。 + +**6 维度逐项核验**: + +| # | 维度 | 判定 | +|---|------|------| +| 1 | 权限模型正确性 | ✅ session 进程级全局(`Arc>` state.rs:305),clear 时机完整(conversation.rs create:74/switch:151/delete:230);NeedsAuth 挂起恢复链路完整(pending_count+=1 audit/mod.rs:283 + 占位 tool_result :299 + emit AiDirAuthRequired :300 + ai_authorize_dir→try_continue chat.rs:628) | +| 2 | 黑名单完整性 | ✅ Win(System32/SysWOW64/System/Program Files×2) + Unix(/etc /usr /bin /sbin /boot /dev /proc /sys)覆盖合理;分段精确匹配防误伤(test_blacklist_no_false_positive 印证 "my program files backup" 不拒);黑名单优先于白名单双判(is_authorized state.rs:353 + check_path_authorization :441 独立判);**write_file 新建路径三层防护**:预校验 check_file_tool_auth(audit/mod.rs:253) + handler 词法层 is_authorized(tool_registry.rs:326) + handler canonicalize 层 is_authorized(:334),不存在路径也判黑名单,不绕过 | +| 3 | 写约束覆盖 | ✅ tool_registry.rs 逐条核验:delete_file=High(:1438) / write_file=Medium(:1061) / patch_file=Medium(:1175) / append_file=Medium(:1401) / rename_file=Medium(:1507);读类 read_file/list_directory/file_info/search_files=Low。路径授权放行后**仍走 RiskLevel 审批**(正交性:check_file_tool_auth 在 risk_level 分类**前**调 audit/mod.rs:233-259,Authorized drafts 才进下方 Low/Med/High 循环 :325) | +| 4 | 挂起恢复链路 | ✅ ai_authorize_dir(chat.rs:520)三分支完整:deny→Err+恢复 loop(:542) / once→add_session_allowed_dir(:575) / always→add_persistent_allowed_dir(:580,失败降级 session);复用 ai_approve 执行链(run_workflow 特殊处理 :589);**path_auth 守卫双向严密**:ai_approve :362 拦 path_auth 挂起回滚 pending+Err,ai_authorize_dir :534 拦普通审批 ok_or_else Err | +| 5 | 前端弹窗交互 | ✅ DirAuthDialog.vue 三选项(once/always/deny)+ 双重守卫(pendingDirAuth + isViewGenerating :55)+ dirAuthActing 防重入(:54);useAiEvents.ts pendingDirAuth 置位(:231)+ 三处清空(AiApprovalResult:335/AiCompleted:357/AiError:407);NO_RESET_WATCHDOG 含 AiDirAuthRequired(:47)不触发整流超时 | +| 6 | 跨会话隔离 | ✅ clear_session_allowed_dirs(state.rs:673)仅清 allowed_dirs.session,不动 per_conv/pending_approvals;conversation.rs create/switch/delete 的 retain 仅清目标 conv 的 pending;**session 全局单例不构成 F-09 回归**(Phase B 沿用 active 单全局模型,agent 风险点5 已承认,真多会话独立临时授权需迁 PerConvState 属后续工作) | + +**附带 7c98134 merge_consecutive_users 核验**: anthropic_compat.rs:265 合并相邻 user 块为一条 user 含 [tool_result..., text] blocks 数组,String/Array content 双形态规范化(:282-294),while 循环不增 i 续合并多连续 user(:297),逻辑正确,打破 GLM 1214 连续 user 恶性循环。 + +**🟡 MED-1**: `state.rs:673` + `audit/mod.rs:245` + `tool_registry.rs:1065` — 预校验(process_tool_calls)与 handler 闭包两端**独立 read lock** 取 allowed_dirs.session 快照,并发 clear_session(create/switch/delete)下存在"预校验放行→handler 拒绝"窄窗口不一致。mod.rs:251 注释声称"两端授权判定一致"未标注并发限制。后果仅工具返 Err(LLM 收错误自行调整,非数据破坏),触发条件极窄(用户在工具执行瞬间切会话+该会话有 session 临时授权目录)。建议:注释补充"并发 clear_session 下两端快照可能不一致,后果为工具 Err 非数据破坏"说明,或后续 session 字段迁 PerConvState 时顺带消除。 + +**⚪ LOW-1**: `state.rs:412` is_in_system_blacklist Unix 分支未覆盖 `/var`(日志/spool/cron)和 `/root`(root 家目录)。影响有限:黑名单是用户误授权兜底,/var/root 非系统核心不可替换目录,且需用户手动授权才触达。后续可按需补充。 + +**⚪ LOW-2**: `state.rs:327` AllowedDirs.session 进程级全局单例,F-09 多会话并发下各会话无法独立临时授权(切走即清,切回需重新授权)。agent 实施报告风险点5 已明确承认此限制,Phase B 对齐 active 单全局模型,非新回归。真多会话独立临时授权需迁 PerConvState.allowed_dirs,属后续工作。 + +- **待修项回流 todo**: **无** 🔴/🟡 项(MED-1 为观察级注释补充,非阻塞性代码修复;LOW 两项为已知限制/可选扩展) + +--- + ## 已审归档 > 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md)