Compare commits

...
5 Commits
Author SHA1 Message Date
lxy c022e2d4d8 修复: 消息溯源P2切读确认完成 + 走查报告高优项
- 更新 P2 切读方案文档(确认批次B已上线)
- ㉑ query LIKE 通配符转义(project/idea/task/knowledge 4 处)
- ⑱ dag.rs deep_merge null 覆盖全局配置
- ⑲ dag.rs _ 通配 match 展开显式变体
- ⑩ INDEX.md 补漏 10 个文档索引
- ⑫ ARCHITECTURE.md 删除与新文档逐字重复
- ㉒ coordinator.rs 加 #[deprecated] 编译守卫
- ㉛ AiChat.vue 空值传播加 console.warn
2026-06-28 05:05:30 +08:00
lxy fee856129c 新增: 全量走查报告 — 代码/前端/文档体系
- Rust 后端 12 crate: 锁/LIKE 通配符/jitter/路径穿越 等 15 项
- Vue 3 前端: Arco 残留/confirm 重复/类型退化 等 13 项
- 文档体系: 工具清单过时/INDEX 漏列 9 文档/死链/SSOT 违反 等 9 项
2026-06-28 04:53:33 +08:00
lxy ead490783f 修复: AiCompleted 携带 pinned_goals 根治目标面板刷新 2026-06-28 04:51:08 +08:00
lxy dcc3f0d230 新增: 三层模型设计文档 — 模板/工作流/人设体系
- ARCHITECTURE.md 补充执行层级模型 + 重写 §七 三层模型章节
- 新建专项设计: 流程模板 YAML 规范/AgentPersona 数据结构/实例化流程
- 注册新文档到架构设计 INDEX
- Agent架构说明补充前向关联引用
2026-06-28 04:44:01 +08:00
lxy d1b9488853 修复: status→enum + type alias(SMELL-P1-6) 2026-06-28 04:42:59 +08:00
35 changed files with 1205 additions and 161 deletions
+56 -10
View File
@@ -19,7 +19,9 @@
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用 - **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板 - **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
### 层级模型 ### 层级模型(业务层级)
DevFlow 的业务抽象分三层,自上而下层层实例化:
``` ```
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估 💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
@@ -31,6 +33,40 @@
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布 └→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
``` ```
### 层级模型(执行层级)
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
```
┌──────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
│ │
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
│ 生命周期: 长期存在,跨项目复用 │
│ 存储: YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — DAG 实例、状态机、运行时 │
│ │
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
│ 生命周期: 随项目启动/结束,单次执行后归档 │
│ 载体: df-workflow (DAG + Executor + StateMachine) │
├──────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色、能力边界、行为风格 │
│ │
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
│ 生命周期: 长期存在,跨节点复用 │
│ 注入点: AINode 执行时载入对应人设 │
└──────────────────────────────────────────────────┘
```
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
- 人设 = Agent 的角色卡(system prompt + 工具集 + 行为规则)
### AI Working 定位体系 ### AI Working 定位体系
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。 DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
@@ -497,15 +533,21 @@ CREATE TABLE app_settings (
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
## 七、阶段模板 ## 七、三层模型:模板 → 工作流 → 人设
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。 > 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分 ### 7.1 三层定义
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复 | 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证 |----|-----------|------|---------|---------|
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查 | **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
## 八、Phase 规划 ## 八、Phase 规划
@@ -528,9 +570,10 @@ CREATE TABLE app_settings (
- 前端:想法池视图 + 多项目 Tab - 前端:想法池视图 + 多项目 Tab
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行 - 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) ### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP) - df-nodes (Docker/Git/Human/HTTP)
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理) - 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
- 条件分支 + 断点续跑 - 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板 - 验证:跑通标准产研流程模板
@@ -566,3 +609,6 @@ CREATE TABLE app_settings (
8. **本地优先**:SQLite 嵌入,不依赖云服务 8. **本地优先**:SQLite 嵌入,不依赖云服务
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型 9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端 10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
+1
View File
@@ -5,6 +5,7 @@
/// Agent 协调器 /// Agent 协调器
/// ///
/// TODO: 实现多 Agent 协作逻辑 /// TODO: 实现多 Agent 协作逻辑
#[deprecated(note = "B 路线占位,勿用于生产")]
pub struct AgentCoordinator; pub struct AgentCoordinator;
impl AgentCoordinator { impl AgentCoordinator {
+8 -6
View File
@@ -17,7 +17,7 @@ use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
use df_types::types::new_id; use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
use futures::future::BoxFuture; use futures::future::BoxFuture;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active"); let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name); medium_audit("create_project", &name);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: new_id(), id: new_id(),
name, name,
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
}; };
let name = arg_str_or(&args, "name", ""); let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active"); let status = arg_str_or(&args, "status", "planning");
medium_audit("update_project", &id); medium_audit("update_project", &id);
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); let repo = ProjectRepo::new(&db);
@@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(e) => return err_str(e), Err(e) => return err_str(e),
}; };
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
name, name,
@@ -355,7 +357,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
list.retain(|t| t.project_id == *pid); list.retain(|t| t.project_id == *pid);
} }
if let Some(st) = &status_filter { if let Some(st) = &status_filter {
list.retain(|t| t.status == *st); list.retain(|t| t.status.as_str() == st.as_str());
} }
json_ok(json!({ "tasks": list, "count": list.len() })) json_ok(json!({ "tasks": list, "count": list.len() }))
} }
@@ -384,7 +386,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
project_id, project_id,
title, title,
description, description,
status: "todo".to_owned(), status: TaskStatus::Todo,
priority, priority,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -529,7 +531,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
id: new_id(), id: new_id(),
title, title,
description, description,
status: "draft".to_owned(), status: IdeaStatus::Draft,
priority, priority,
score: None, score: None,
tags: None, tags: None,
+3 -2
View File
@@ -273,6 +273,7 @@ mod tests {
use df_storage::crud::{ProjectRepo, TaskRepo}; use df_storage::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use serde_json::json; use serde_json::json;
// ============================================================ // ============================================================
@@ -287,7 +288,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -302,7 +303,7 @@ mod tests {
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: "t1".to_string(), title: "t1".to_string(),
description: "实现登录接口".to_string(), description: "实现登录接口".to_string(),
status: "testing".to_string(), status: TaskStatus::Testing,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
+36 -35
View File
@@ -174,14 +174,15 @@ mod tests {
use super::*; use super::*;
use df_storage::crud::ProjectRepo; use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
fn rec(id: &str, status: &str) -> TaskRecord { fn rec(id: &str, status: TaskStatus) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: format!("t-{id}"), title: format!("t-{id}"),
description: "".to_string(), description: "".to_string(),
status: status.to_string(), status,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -206,7 +207,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -221,43 +222,43 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn forward_path_todo_to_done() { async fn forward_path_todo_to_done() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 主路径逐级推进 // 主路径逐级推进
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
assert_eq!(r.status, "testing"); assert_eq!(r.status.as_str(), "testing");
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
assert_eq!(r.status, "done"); assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
} }
#[tokio::test] #[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() { async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1, "退回应 +1"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() { async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "testing")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "退回应 +1"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn multiple_regressions_accumulate() { async fn multiple_regressions_accumulate() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2) // in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
@@ -269,7 +270,7 @@ mod tests {
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, expected } => { df_types::error::Error::InvalidState { current, expected } => {
@@ -285,7 +286,7 @@ mod tests {
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, .. } => { df_types::error::Error::InvalidState { current, .. } => {
@@ -301,7 +302,7 @@ mod tests {
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err { match err {
df_types::error::Error::Validation(msg) => { df_types::error::Error::Validation(msg) => {
@@ -314,7 +315,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_target_rejected() { async fn invalid_target_rejected() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_))); assert!(matches!(err, df_types::error::Error::Validation(_)));
} }
@@ -332,7 +333,7 @@ mod tests {
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖), // 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。 // 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径; // 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic) // F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false) repo.advance_status_atomic("t1", "todo", "in_progress", false)
@@ -340,13 +341,13 @@ mod tests {
.unwrap(); .unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功 // 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
} }
#[tokio::test] #[tokio::test]
async fn cas_returns_none_when_status_mismatch() { async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress) // 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo let r = repo
.advance_status_atomic("t1", "todo", "in_review", false) .advance_status_atomic("t1", "todo", "in_review", false)
@@ -365,12 +366,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
assert_eq!(r.status, "blocked"); assert_eq!(r.status.as_str(), "blocked");
assert_eq!(r.review_rounds, 0, "进 blocked 不累加"); assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
} }
@@ -460,24 +461,24 @@ mod tests {
async fn callback_completed_advance_lands_in_db() { async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成) // in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await; let repo = setup().await;
repo.insert(rec("c1", "todo")).await.unwrap(); repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
let to = callback_advance_target("completed", "in_progress").unwrap(); let to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds"); assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过) // testing: in_review → testing(②-3 testing 模板自审+核对通过)
repo.insert(rec("c2", "in_review")).await.unwrap(); repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("completed", "testing").unwrap(); let to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
assert_eq!(r.status, "testing"); assert_eq!(r.status.as_str(), "testing");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过) // done: testing → done(②-3 done 模板最终核对通过)
repo.insert(rec("c3", "testing")).await.unwrap(); repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("completed", "done").unwrap(); let to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
assert_eq!(r.status, "done"); assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
} }
@@ -487,32 +488,32 @@ mod tests {
async fn callback_failed_regression_lands_in_db_with_rounds_bump() { async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await; let repo = setup().await;
repo.insert(rec("f1", "testing")).await.unwrap(); repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("failed", "testing").unwrap(); let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review"); assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)"); assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射, // in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 // 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
repo.insert(rec("f2", "in_review")).await.unwrap(); repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("failed", "in_review").unwrap(); let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress"); assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1); assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。 // 验证:callback 返回 None,不调 advance_task_atomic。
repo.insert(rec("f3", "in_progress")).await.unwrap(); repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
let to = callback_advance_target("failed", "in_progress"); let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress) // 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap(); let still = repo.get_by_id("f3").await.unwrap().unwrap();
assert_eq!(still.status, "in_progress", "None 退回不应改动 status"); assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
} }
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。 /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
+15 -9
View File
@@ -9,6 +9,7 @@ use df_types::error::Result;
use crate::db::Database; use crate::db::Database;
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo; use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name}; use super::{now_millis_str, storage_err, validate_column_name};
@@ -93,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
id: row.get("id")?, id: row.get("id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
IdeaStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
score: row.get("score")?, score: row.get("score")?,
tags: row.get("tags")?, tags: row.get("tags")?,
@@ -200,7 +204,7 @@ impl_repo!(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at) "INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.created_at, rec.updated_at rec.scores, rec.related_ids, rec.created_at, rec.updated_at
], ],
@@ -210,7 +214,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13", "UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
params![ params![
rec.title, rec.description, rec.status, rec.priority, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id rec.scores, rec.related_ids, rec.updated_at, rec.id
], ],
@@ -297,10 +301,11 @@ impl IdeaRepo {
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -541,7 +546,8 @@ impl KnowledgeRepo {
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。 /// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> { pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let pattern = format!("%{}%", query); let escaped = query.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let kind = kind.map(|s| s.to_owned()); let kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64; let limit_i = limit as i64;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -549,7 +555,7 @@ impl KnowledgeRepo {
let mut results = Vec::new(); let mut results = Vec::new();
if let Some(k) = &kind { if let Some(k) = &kind {
let mut stmt = guard let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4")) .prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -559,7 +565,7 @@ impl KnowledgeRepo {
} }
} else { } else {
let mut stmt = guard let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3")) .prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1403,7 +1409,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
title: title.to_string(), title: title.to_string(),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,
@@ -219,6 +219,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectEventRepo) { async fn setup() -> (crate::db::Database, ProjectEventRepo) {
@@ -232,7 +233,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
+10 -5
View File
@@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::ProjectStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::{ use crate::models::{
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
id: row.get("id")?, id: row.get("id")?,
name: row.get("name")?, name: row.get("name")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
ProjectStatus::from_db_str(&s).unwrap_or_default()
},
idea_id: row.get("idea_id")?, idea_id: row.get("idea_id")?,
path: row.get("path")?, path: row.get("path")?,
stack: row.get("stack")?, stack: row.get("stack")?,
@@ -162,7 +166,7 @@ impl_repo!(
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at) "INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
rec.id, rec.name, rec.description, rec.status, rec.idea_id, rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.created_at, rec.updated_at rec.path, rec.stack, rec.created_at, rec.updated_at
], ],
) )
@@ -171,7 +175,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8", "UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
params![ params![
rec.name, rec.description, rec.status, rec.idea_id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id rec.path, rec.stack, rec.updated_at, rec.id
], ],
) )
@@ -238,8 +242,9 @@ impl ProjectRepo {
if let Some(kw) = &q.keyword { if let Some(kw) = &q.keyword {
let trimmed = kw.trim(); let trimmed = kw.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)"); let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{trimmed}%"); sql.push_str(" AND (name LIKE ? OR description LIKE ?) ESCAPE '\\'");
let pattern = format!("%{escaped}%");
params_vec.push(Box::new(pattern.clone())); params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern)); params_vec.push(Box::new(pattern));
} }
@@ -295,6 +295,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectServiceRepo) { async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
@@ -308,7 +309,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
+3 -2
View File
@@ -328,6 +328,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::TaskRepo; use crate::crud::TaskRepo;
use crate::models::TaskRecord; use crate::models::TaskRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 /// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord { fn trec(id: &str, project_id: &str) -> TaskRecord {
@@ -336,7 +337,7 @@ mod tests {
project_id: project_id.to_string(), project_id: project_id.to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: "todo".to_string(), status: TaskStatus::Todo,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -369,7 +370,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
+19 -13
View File
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::TaskStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::TaskRecord; use crate::models::TaskRecord;
@@ -24,7 +25,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
project_id: row.get("project_id")?, project_id: row.get("project_id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
TaskStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
branch_name: row.get("branch_name")?, branch_name: row.get("branch_name")?,
assignee: row.get("assignee")?, assignee: row.get("assignee")?,
@@ -122,7 +126,7 @@ impl_repo!(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -134,7 +138,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17", "UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -374,10 +378,11 @@ impl TaskRepo {
} }
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -566,20 +571,21 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。 /// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord { fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
trec_full(id, queue, parent_id, "todo") trec_full(id, queue, parent_id, TaskStatus::Todo)
} }
/// 全参 fixture(聚合测试需自定义 status 时用)。 /// 全参 fixture(聚合测试需自定义 status 时用)。
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord { fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: TaskStatus) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "proj-1".to_string(), project_id: "proj-1".to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: status.to_string(), status,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -605,7 +611,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -778,13 +784,13 @@ mod tests {
async fn set_status_for_aggregation_writes_status() { async fn set_status_for_aggregation_writes_status() {
let repo = setup().await; let repo = setup().await;
// 父任务初始 todo(queue=todo, parent_id=None 容器模型) // 父任务初始 todo(queue=todo, parent_id=None 容器模型)
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap(); let ok = repo.set_status_for_aggregation("parent", "in_progress");
assert!(ok, "应命中写入"); assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap(); let after = repo.get_by_id("parent").await.unwrap().unwrap();
assert_eq!(after.status, "in_progress", "status 应被聚合写入更新"); assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds"); assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
} }
@@ -792,7 +798,7 @@ mod tests {
async fn set_status_for_aggregation_skips_soft_deleted() { async fn set_status_for_aggregation_skips_soft_deleted() {
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false // 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
let repo = setup().await; let repo = setup().await;
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
repo.soft_delete("parent").await.unwrap(); repo.soft_delete("parent").await.unwrap();
+8 -7
View File
@@ -1,6 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体 //! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig}; use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================ // ============================================================
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: IdeaStatus,
pub priority: i32, pub priority: i32,
pub score: Option<f64>, pub score: Option<f64>,
pub tags: Option<String>, // JSON 数组 pub tags: Option<String>, // JSON 数组
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub status: String, pub status: ProjectStatus,
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用) /// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
pub path: Option<String>, pub path: Option<String>,
@@ -74,7 +75,7 @@ pub struct TaskRecord {
pub project_id: String, pub project_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: TaskStatus,
pub priority: i32, pub priority: i32,
pub branch_name: Option<String>, pub branch_name: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
@@ -134,14 +135,14 @@ pub struct TaskRecord {
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明 /// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示 /// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。 /// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束, /// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。 /// 对标设计 D8:task_links 数据量小,检测成本高)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord { pub struct TaskLinkRecord {
pub id: String, pub id: String,
pub source_id: String, pub source_id: String,
pub target_id: String, pub target_id: String,
pub link_type: String, pub link_type: LinkType,
pub remark: Option<String>, pub remark: Option<String>,
pub created_at: String, pub created_at: String,
} }
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
pub id: String, pub id: String,
pub workflow_id: String, pub workflow_id: String,
pub node_id: String, pub node_id: String,
pub node_type: String, pub node_type: NodeType,
pub status: String, pub status: String,
pub input_json: Option<String>, pub input_json: Option<String>,
pub output_json: Option<String>, pub output_json: Option<String>,
@@ -8,6 +8,7 @@
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo}; use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord}; use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
// ---------- fixtures ---------- // ---------- fixtures ----------
@@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord {
id: id.to_string(), id: id.to_string(),
name: format!("proj-{id}"), name: format!("proj-{id}"),
description: "desc".to_string(), description: "desc".to_string(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
project_id: project_id.to_string(), project_id: project_id.to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: "desc".to_string(), description: "desc".to_string(),
status: "todo".to_string(), status: TaskStatus::Todo,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() {
// 对照:status 未被改写,仍为初始值(task fixture 的初始 status) // 对照:status 未被改写,仍为初始值(task fixture 的初始 status)
let rec = tasks.get_by_id("t1").await.unwrap().unwrap(); let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
assert_ne!(rec.status, "done", "白名单拒绝后 status 不应被改写"); assert_ne!(rec.status.as_str(), "done", "白名单拒绝后 status 不应被改写");
} }
#[tokio::test] #[tokio::test]
+6
View File
@@ -26,6 +26,12 @@ pub type PluginId = String;
pub type ExecutionId = String; pub type ExecutionId = String;
/// 决策 ID /// 决策 ID
pub type DecisionId = String; pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review)
pub type NodeType = String;
/// 任务链接类型(如 depends_on / blocks / relates_to)
pub type LinkType = String;
/// 工具调用类型(如 function)
pub type ToolCallType = String;
// ============================================================ // ============================================================
// 时间工具 // 时间工具
+16 -9
View File
@@ -165,9 +165,10 @@ impl Default for Dag {
/// ///
/// 规则: /// 规则:
/// - 两端均为 Object:递归合并(同 key 时节点级覆盖全局级,新增 key 各自保留)。 /// - 两端均为 Object:递归合并(同 key 时节点级覆盖全局级,新增 key 各自保留)。
/// - 非两端 Object:节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义)。 /// - 非 Object/Null 两端:节点级直接覆盖全局级。
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null); /// - 节点级为 `Null` 时跳过(保留全局配置),对应语义「节点未指定该配置」。
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局)。 /// 若调用方希望「显式置空」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局),
/// 而非传 JSON null。
/// ///
/// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`, /// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`,
/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。 /// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。
@@ -187,8 +188,15 @@ pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde
} }
Value::Object(merged) Value::Object(merged)
} }
// 非两端 Object:节点级覆盖全局级(含 Null、标量、数组) // 节点级为 Null → 保留全局配置(不覆盖)
_ => node.clone(), (_, Value::Null) => global.clone(),
// 显式覆盖剩余变体,防止新变体被 _ 吞没
(Value::Number(_), _)
| (Value::String(_), _)
| (Value::Array(_), _)
| (Value::Bool(_), _)
| (Value::Null, _)
| (Value::Object(_), _) => node.clone(),
} }
} }
@@ -226,11 +234,10 @@ mod deep_merge_tests {
} }
#[test] #[test]
fn missing_node_config_treated_as_noop() { fn null_node_config_keeps_global() {
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global),此处覆盖语义对照: // 节点级为 Null → 保留全局(不覆盖),区别于「缺失 key 不调 deep_merge」。
// 若强行把缺失视作 Null,应覆盖(显式空语义),与「缺失」不同(缺失走 if let Some 分支)。
let global = json!({"a": 1}); let global = json!({"a": 1});
assert_eq!(deep_merge(&global, &json!(null)), json!(null)); assert_eq!(deep_merge(&global, &json!(null)), global);
} }
#[test] #[test]
+11
View File
@@ -49,6 +49,8 @@
| [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-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 选型代码复用 |
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 | | [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) + 修复方案 | | [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 事件同步 |
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | — | 📐 设计 | 消息级溯源 Phase 2:切读切换到 ai_messages 视图 |
--- ---
@@ -73,6 +75,14 @@
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) | | [消息拆分存储设计-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 四场景从对话级升级消息级 | | [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 | | [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 |
| [三层模型-流程模板与人设体系-2026-06-28.md](./专项设计/三层模型-流程模板与人设体系-2026-06-28.md) | 📐 设计 | 模板→工作流→人设三层架构:流程模板YAML定义、人设AgentPersona数据结构、三层实例化流程 |
| [aichat体验与agent能力系统化重构-2026-06-21.md](./专项设计/aichat体验与agent能力系统化重构-2026-06-21.md) | 📐 设计 | AI Chat 体验 + Agent 能力统一重构:消息模型 / 上下文管理 / 工具路由 |
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | 📐 设计 | 查询接口能力补全:多维度筛选 / 聚合 / 跨表关联 |
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | 📐 设计 | AST 符号解析:代码结构提取 / 符号索引 / 跳转支持 |
| [插件机制-设计-2026-06-24.md](./专项设计/插件机制-设计-2026-06-24.md) | 📐 设计 | 插件系统:动态加载 / 生命周期 / 权限沙箱 |
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
--- ---
@@ -97,6 +107,7 @@
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 | | [意图识别层论证-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) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 | | [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 |
--- ---
@@ -2,6 +2,7 @@
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想) > 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界) > 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计 > 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
--- ---
@@ -0,0 +1,393 @@
# 三层模型:流程模板 → 工作流 → 人设体系
> 创建: 2026-06-28 | 状态: 设计阶段
> 关联: [Agent架构说明-2026-06-14.md](./Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
> 关联: [ARCHITECTURE.md](../../../ARCHITECTURE.md)(项目架构总纲,本文为专项展开)
---
## 一、背景与问题
DevFlow 的工作流引擎(df-workflow)已具备 DAG 定义、拓扑排序、节点调度、状态流转等核心能力。AI Chat 已具备单链 ReAct、工具调用、审批机制。
但现有架构缺少两个关键抽象:
| 缺失 | 导致的问题 |
|------|-----------|
| **流程模板**(Template) | 工作流定义与具体项目绑定,无法复用标准流程。每个项目需从零搭建 DAG |
| **人设**(Persona) | 所有 AINode 使用通用 LLM 调用,无角色分工。编码、审查、测试节点行为无差异 |
**解决方案**:引入三层模型——**模板层定义蓝图、工作流层执行实例、人设层注入角色**。
---
## 二、三层模型总览
```
┌──────────────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 可复用的阶段蓝图 │
│ │
│ ├─ 节点 DAG 定义(拓扑 + 类型 + 数据流) │
│ ├─ 建议人设标注(persona_hint,实例化时可覆盖) │
│ ├─ 质量门禁(condition + on_fail) │
│ ├─ 产出物规范(artifacts 声明) │
│ └─ 存储:YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — 模板的运行时实例 │
│ │
│ ├─ 模板实例化(绑定具体项目参数、分支名、路径) │
│ ├─ DAG 执行(拓扑排序 + 并行调度 + 状态流转) │
│ ├─ 数据绑定(inputs/outputs 按 ID 映射) │
│ ├─ 条件分支(condition 求值 → 动态路由) │
│ ├─ 断点续跑(状态快照 + 恢复) │
│ └─ 载体:df-workflow(现有,✅ 核心完成) │
├──────────────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色卡 │
│ │
│ ├─ system prompt(角色定位、行为规范) │
│ ├─ allowed_tools(该角色可调用的工具集) │
│ ├─ output_format(输出约束) │
│ ├─ suggested_tier(推荐模型层级) │
│ ├─ behavior rules(如"每次输出前先检查...") │
│ └─ 注入点:AINode 执行时从 NodeContext 读取 persona_id │
└──────────────────────────────────────────────────────────┘
```
### 关键原则
1. **三层独立演化**:模板添加新节点类型、工作流优化调度算法、人设新增角色——互不阻塞
2. **交汇点单一**:三层只在 AINode 执行时交汇——工作流传递 persona_id,Node 端根据 id 载入人设配置
3. **模板标注建议而非绑定**:模板只写 `persona_hint: coder`,实例化时可改为 `coder-rust` 或 `coder-py`
4. **人设可跨模板复用**:`reviewer` 人设既可用于"功能开发模板"的审查节点,也可用于"Bug 修复模板"的审查节点
---
## 三、流程模板(Template Layer)
### 3.1 数据结构
```yaml
# templates/feature-dev.yaml
id: feature-dev # 模板唯一标识
name: 功能开发模板 # 显示名称
description: 标准功能开发全流程 # 描述
template_version: 1 # 模板版本(用于升级检测)
tags: ["feature", "standard"] # 分类标签
nodes:
- id: analysis # 节点 ID
type: ai # 节点类型(ai / script / human / subflow)
persona_hint: analyst # 建议人设(实例化时可覆盖)
prompt: "分析需求:{{inputs.requirement}}" # 提示词
inputs: # 数据输入映射
requirement: "$ctx.requirement" # $ctx = 工作流上下文参数
outputs: # 输出声明
prd: text
config: # 节点级配置(覆盖默认)
temperature: 0.3
- id: design
type: ai
persona_hint: architect
prompt: "基于 PRD 设计架构:{{inputs.analysis.prd}}"
inputs:
analysis: "$nodes.analysis" # $nodes = 上游节点输出
outputs:
arch_doc: text
- id: coding
type: ai
persona_hint: coder
prompt: "实现:{{inputs.design.arch_doc}}"
inputs:
design: "$nodes.design"
outputs:
code: text
- id: review
type: ai
persona_hint: reviewer
prompt: "审查代码:{{inputs.coding.code}}"
inputs:
coding: "$nodes.coding"
- id: test
type: script
prompt: "" # Script 节点用 command
command: "cargo test"
timeout_secs: 300
- id: release
type: human
prompt: "确认发布到生产?"
edges: # 显式边定义(可选,缺省按 nodes 顺序连接)
- from: analysis
to: design
- from: design
to: coding
- from: coding
to: review
- from: review
to: test
- from: test
to: release
conditions: # 条件分支
- node: review
if: "output.verdict != 'pass'"
goto: coding # 审查不通过,回编码节点
quality_gates: # 质量门禁
- node: review
condition: "output.verdict == 'pass'"
on_fail: "block" # block / goto / warn
- node: test
condition: "output.exit_code == 0"
on_fail: "goto coding"
artifacts: # 产出物声明
prd: "$nodes.analysis.prd"
arch: "$nodes.design.arch_doc"
code: "$nodes.coding.code"
review_report: "$nodes.review.text"
```
### 3.2 模板实例化流程
```
① 用户选择模板(如"功能开发模板")
② 填写实例化参数:
├─ project_id: 绑定到哪个项目
├─ requirement: 需求描述(注入 $ctx.requirement)
├─ persona_overrides: 按节点覆盖人设
│ └─ coding → coder-rust(该项目是 Rust 后端)
└─ branch: feature/search(绑定 Git 分支)
③ 实例化引擎执行:
├─ 复制 DAG 拓扑
├─ 绑定数据映射(替换 $ctx / $nodes 占位符)
├─ 应用人设覆盖
├─ 创建 WorkflowRun(状态 = pending)
└─ 写入 DB(workflow_runs + workflow_nodes 表)
④ 工作流引擎调度执行
```
### 3.3 内置模板清单
| 模板 ID | 名称 | 适用场景 | 节点链 |
|---------|------|---------|--------|
| `feature-dev` | 功能开发 | 新增功能 | 需求分析 → 架构设计 → 编码 → 审查 → 测试 → 发布 |
| `bug-fix` | Bug 修复 | 缺陷修复 | 问题复现 → 根因分析 → 修复编码 → 回归测试 → 发布 |
| `algorithm-dev` | 算法开发 | 算法类功能 | 需求分析 → 算法设计 → 实现 → 基准测试 → 验证 → 发布 |
| `refactor` | 代码重构 | 重构优化 | 代码分析 → 重构计划 → 编码 → 审查 → 回归测试 |
> 模板为内置预设,用户可自定义模板(复制内置模板修改后存为用户模板)。
---
## 四、人设层(Persona Layer)
### 4.1 数据结构
```rust
/// 智能体人设 — Agent 角色卡
pub struct AgentPersona {
/// 人设标识(如 "coder-rust"、"reviewer")
pub id: PersonaId,
/// 人设名称
pub name: String,
/// 人设描述
pub description: String,
/// 系统提示词(核心——定义 Agent 的角色、行为规范)
pub system_prompt: String,
/// 可用工具列表(空 = 继承自父级配置)
pub allowed_tools: Vec<ToolName>,
/// 推荐模型层级
pub suggested_tier: ModelTier,
/// 输出格式约束
pub output_format: OutputFormat,
/// 行为规则
pub rules: Vec<BehaviorRule>,
/// Few-shot 样例
pub examples: Vec<PersonaExample>,
}
/// 行为规则
pub struct BehaviorRule {
pub rule_type: RuleType, // PreCheck / PostCheck / Constraint
pub description: String,
pub check_prompt: String, // AI 检查提示
}
/// 输出格式约束
pub enum OutputFormat {
FreeText,
Markdown,
Json { schema: Value },
Code { language: String },
}
```
### 4.2 内置人设清单
| 人设 ID | 名称 | 核心 system_prompt 要点 | 建议工具 |
|---------|------|------------------------|---------|
| `analyst` | 需求分析师 | 拆解用户故事、识别歧义、输出 PRD | read_file, search_knowledge |
| `architect` | 系统架构师 | 模块划分、接口设计、技术选型 | read_file, search_knowledge, write_file |
| `coder-rust` | Rust 工程师 | 类型安全、错误处理、性能优先 | read_file, write_file, search_code, list_directory, run_command |
| `coder-ts` | TS/前端工程师 | 组件复用、类型定义、响应式 | read_file, write_file, search_code, list_directory, run_command |
| `reviewer` | 代码审查员 | 安全漏洞、性能问题、CRITICAL/MAJOR/MINOR | read_file, search_code, git_diff |
| `tester` | 测试工程师 | 边界条件、覆盖率、测试隔离 | read_file, write_file, run_command |
| `algorithm` | 算法工程师 | 复杂度分析、精度对比、优化策略 | read_file, write_file, run_command, benchmark |
| `devops` | DevOps 工程师 | 容器化、CI/CD、监控告警 | read_file, write_file, run_command |
### 4.3 人设的内部结构(persona.rs 设计)
```rust
// crates/df-ai/src/persona.rs(新建)
pub struct PersonaRegistry {
builtins: HashMap<PersonaId, AgentPersona>,
customs: HashMap<PersonaId, AgentPersona>,
}
impl PersonaRegistry {
pub fn new() -> Self { /* 载入内置人设 */ }
pub fn get(&self, id: &PersonaId) -> Option<&AgentPersona>;
pub fn register(&mut self, persona: AgentPersona); // 注册自定义人设
pub fn list(&self) -> Vec<&AgentPersona>;
pub fn get_system_prompt(&self, id: &PersonaId) -> Option<&str>;
pub fn filter_tools(&self, id: &PersonaId, all_tools: &[ToolDef]) -> Vec<ToolDef>;
}
```
### 4.4 人设注入时机
人设在两个入口注入,覆盖不同的使用场景:
```
场景 A: Workflow AINode 执行
DAG Executor → AiNode::execute()
→ NodeContext 中有 persona_id(来自模板实例化)
→ AiNode 调用 PersonaRegistry::get(persona_id)
→ 将 persona.system_prompt 附加到 LLM prompt 头部
→ 将 persona.allowed_tools 传入工具选择器
→ 执行 LLM complete()
场景 B: AI Chat Agentic Loop
run_agentic_loop()
→ 根据 intent 识别结果自动选人设
→ Code Intent → 自动绑定 coder 人设
→ 绑定后: system_prompt = coder.system_prompt
→ 绑定后: 工具面板 = coder.allowed_tools
→ 执行 ReAct 循环(带人设约束)
```
---
## 五、三层在现有代码中的落地映射
### 5.1 新增与修改文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `crates/df-ai/src/persona.rs` | **新建** | AgentPersona 结构体 + PersonaRegistry + 内置人设 |
| `crates/df-ai/src/lib.rs` | 修改 | 导出 `pub mod persona` |
| `crates/df-ai/src/coordinator.rs` | 修改 | 从空壳变为人设调度器——给子任务分配人设 |
| `crates/df-nodes/src/ai_node.rs` | 修改 | execute() 中读取 `NodeContext` 的 `persona_id`,加载人设配置 |
| `crates/df-workflow/src/node.rs` | 修改 | `NodeContext` 新增 `persona_id: Option<PersonaId>` 字段 |
| `crates/df-types/src/types.rs` | 修改 | 新增 `PersonaId` 类型 |
| `src-tauri/src/commands/ai/agentic.rs` | 修改 | `run_agentic_loop` 根据 intent 自动选人设 |
| `src-tauri/src/commands/ai/tool_registry.rs` | 修改 | 工具注册表支持按人设过滤 |
### 5.2 现有三条路径如何汇合
```
┌──────────────────────┐
│ 模板(YAML) │
│ persona_hint: coder │
└──────────┬───────────┘
│ 实例化
▼
┌──────────────────────┐
│ 工作流实例 │
│ NodeContext { │
│ persona_id: "coder"│
│ } │
└──────────┬───────────┘
│ 执行到 AINode
▼
┌──────────────────────────────────────────────┐
│ AiNode::execute() │
│ ├─ 从 ctx.persona_id 查到人设配置 │
│ ├─ 拼接 system_prompt → LLM │
│ ├─ 限制工具集 → allowed_tools │
│ └─ 输出格式约束 → output │
└──────────────────────────────────────────────┘
```
---
## 六、与现有 Agent 架构的关系
current [Agent架构说明-2026-06-14.md](./Agent架构说明-2026-06-14.md) 提到的四个"无"中,人设层直接回应了以下问题:
| 原缺口 | 人设层如何解决 |
|--------|---------------|
| coordinator 空壳 | 人设调度是 coordinator 的第一个实现步骤:子任务按类型分配人设 |
| 单链 ReAct 无角色区分 | 人设注入后,同一 loop 按绑定的人设输出不同风格的响应 |
| Agent 能力边界模糊 | 人设的 `allowed_tools` 显式声明能力边界,"能做什么"由人设而非通用配置决定 |
> 人设层不解决所有缺口(如执行类工具、MCP 外部工具),但它是多 Agent 协作的第一步。
---
## 七、Phase 落地建议
### Phase A:数据结构 + 内置人设(单独推进,不阻塞其他任务)
```
目标: 定义 AgentPersona 结构体 + 5 个内置人设 + PersonaRegistry
文件: crates/df-ai/src/persona.rs
验证: PersonaRegistry::get("coder") 返回正确的人设配置
```
### Phase B:AINode 接入人设
```
目标: AINode 执行时从 NodeContext 读 persona_id,拼接 system_prompt
文件: crates/df-workflow/src/node.rs + crates/df-nodes/src/ai_node.rs
验证: 带 persona_id 的 AiNode 输出带有人设风格的文本
```
### Phase C:流程模板系统
```
目标: YAML 模板定义 + 实例化引擎(模板 → 工作流 DAG)
文件: df-workflow 新增模板加载逻辑
验证: 加载 feature-dev.yaml → 实例化为带 persona_hint 的 DAG
```
### Phase D:AI Chat 接入人设
```
目标: run_agentic_loop 根据 intent 自动选人设,工具面板按人设过滤
文件: src-tauri/commands/ai/agentic.rs + tool_registry.rs
验证: Code Intent 下只暴露编码相关工具
```
---
## 八、设计决策
| 决策 | 选项 | 结论 | 理由 |
|------|------|------|------|
| 人设定义位置 | 编译期 vs 运行时 | 编译期内置 + 运行时扩展 | 内置人设保证基线质量,扩展性留给插件机制 |
| 模板格式 | YAML vs JSON vs Rust DSL | YAML | 人类可读写,适合非开发者定义模板 |
| 人设与 model 的关系 | 人设绑定 model vs 分离 | 分离(人设只建议 `suggested_tier`) | 模型选择由调用方决定,人设不越界 |
| 模板实例化时机 | 启动时 vs 使用时 | 使用时(lazy instantiation) | 启动时加载数百模板影响冷启动 |
@@ -0,0 +1,19 @@
# 消息级溯源 P2 切读方案
> 决策:2026-06-28 — 一次性切读 + 脚本迁移历史数据
## 现状(2026-06-28 代码核查)
**P2 切读已完成**。上一阶段「批次 B」已经实现了读写路径切换:
### ✅ 读路径
`ai_conversation_switch` 已优先从 `ai_messages` 表加载(`list_by_conversation`),
空时 fallback 旧 `messages` JSON 列。见 `conversation.rs:257-282`。
### ✅ 写路径
`save_conversation` 已走 `AiMessageRepo.replace_conversation`(`DELETE + INSERT` 事务原子),
旧 messages JSON 列保留不写作备份。见 `conversation.rs` 中 `save_conversation` 实现。
### 剩余工作
- `ai_conversations.messages` 列标记废弃(暂不删列)
- 后续 V22 可选删除该列
@@ -0,0 +1,392 @@
# 全量走查报告(2026-06-28)
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
> 方法:3 路并行(Rust 后端/Vue 前端/文档体系),主代理综合整理
> 原则:dry — 仅审查 + 文档,不改代码
---
## 🔴 安全
### ① relay.rs — 硬编码默认 Token
**文件**: `crates/df-relay/src/relay.rs:36-37`
中继服务使用硬编码 `DEFAULT_TOKEN = "devflow-relay-default-token"`,所有未设环境变量的部署均使用同一静态 token。通过 `strings` 即可从二进制提取。
**建议**:启动时要求必须设置 `DF_RELAY_TOKEN` 环境变量,移除默认值。
### ② models.rs — `AiProviderRecord` Debug 未脱敏 api_key,日志泄漏风险
**文件**: `crates/df-storage/src/models.rs:288`
`AiProviderRecord` 未实现自定义 `Debug` 对 `api_key` 脱敏。若上层代码意外 `{:?}` 打印(如 `tracing::debug!`),明文 API key 会写入日志。`model_configs`(L296)与 `config`(L298)等 JSON 字段同样不安全。
**建议**:为 `AiProviderRecord` 实现自定义 `Debug`,将 `api_key` 输出为 `"sk-****"`。
### ③ ScriptNode — 命令无白名单/沙箱
**文件**: `crates/df-nodes/src/script_node.rs:15-19` + `crates/df-execute/src/shell.rs:92-117`
ScriptNode 从 config 取 `command` 字符串透传给 shell(`cmd /C` / `sh -c`),无任何命令白名单或参数校验。工作流定义来自 AI 生成或外部导入时,恶意命令可通过 AINode 写入 ScriptNode config 注入执行。
**建议**:添加危险命令关键词告警(`rm -rf`/`DROP TABLE`/`Format`),可选白名单/环境变量隔离配置。
### ④ 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%)。
---
## 🔴 文档
### ⑨ AI对话引擎-2026-06-14.md — 工具清单严重过时
**文件**: `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` 等工具。
**建议**:全文同步至当前实际 22 个工具的最新清单。
### ⑩ 02-架构设计/INDEX.md — 漏索引 9 个已有文档
**文件**: `docs/02-架构设计/INDEX.md`
| 子目录 | 漏列数 | 典型遗漏 |
|-------|-------|---------|
| 专项设计 | 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)`。
### ⑫ ARCHITECTURE.md §七 + 新设计文档 — 大段逐字重复
**文件**: `ARCHITECTURE.md:538-665` / `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
YAML 模板示例(52行)、`AgentPersona` struct 定义(11行)、三层定义表、实例化流程——**大段逐字重复**,违反文档记录规范 SSOT 原则。
**建议**:`ARCHITECTURE.md §七` 保留概要(三层定义 + 关系图 + 关键原则),YAML 全文和 struct 定义仅在专项设计文档展开,ARCH 以引用链接替代。
### ⑬ 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`。
---
## ✅ 亮点
### ① 密钥管理分层设计(secret.rs)
`resolve_provider_secret` 三阶段回落:DB 明文(向前兼容)→ OS keyring → 空字符串。迁移非阻断、幂等、失败计数告警。单测通过 `cfg` gate 仅桌面 OS 跑 keyring 测试。
### ② SQL 注入防护体系
列名白名单(`validate_column_name` + `impl_repo!` 宏统一调用),`build_order_clause` 对排序方向也做白名单校验。软删除/恢复/清空全部参数化 SQL。
### ③ 前端竞态保护(knowledge.ts)
递增序列号丢弃过期响应。`ai.ts` watch 双重守卫(`oldLen > 0 && newLen - oldLen <= 2`)精确区分局部增长和整体替换。
### ④ `useConfirm` 统一确认弹层
从 4 个视图中提取重复 Promise+resolve 模式,正确处理并发覆盖场景(前一个 Promise 先 resolve false)。
### ⑤ Store 内部分拆 4 子 store
`stores/project/` 拆分为 projects/tasks/ideas/workflow,共享 `state.ts` 单例,对外 `useProjectStore()` 不变——「内部拆分,外部零改动」。
### ⑥ 取消路径 TOCTOU 三层防护
`state.rs:86-100` + `executor.rs:180-232`:Ok 后检查、Err 分支检查、阻塞节点轮询。注释详细记录了 TOCTOU 时序窗口。
### ⑦ MCP 多层防御
三级过滤:`visible()` 读模式仅 Low 工具、`tools/list` 剔除 High、`dispatch` 再拦截 read-only + Medium 拒 + High 兜底拒。
### ⑧ V21 大版本迁移谨慎
`BATCH_SIZE=50` 分页、空库跳过、解析失败 skip 而非崩溃、`column_exists` 守卫 ALTER TABLE 防重复执行。
### ⑨ 子进程清理 + Windows 黑窗抑制
`kill_on_drop(true)` 超时后子进程不残留;`creation_flags(0x08000000)` 抑制 Windows 黑窗闪现。
---
## 📊 汇总
| 分类 | 🔴 | 🟡 | ✅ |
|------|----|----|----|
| 安全 | 8 | — | — |
| 文档 | 9 | — | — |
| 代码质量 | — | 7 | — |
| 前端 | — | 13 | — |
| 亮点 | — | — | 9 |
| **合计** | **17** | **20** | **9** |
**总体评价**:核心安全架构扎实(SQL 注入防护、密钥管理、MCP 多层防御、取消 TOCTOU)。主要风险集中在 `state.rs` 同步锁在 async 上下文中的潜在死锁、文档与代码脱节(AI对话引擎工具数过时、INDEX 漏列 9 文档、~100 行已移除模块残留)。前端整体质量高,但需清理少量遗留(Arco 残留、内联 confirm 重复)。
+23 -3
View File
@@ -823,6 +823,15 @@ pub(crate) async fn run_agentic_loop(
} }
} }
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
let pinned_goals_snapshot: Vec<String> = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.map(|c| c.pinned_goals.clone())
.unwrap_or_default()
};
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
@@ -854,7 +863,7 @@ pub(crate) async fn run_agentic_loop(
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await; guard.reset().await;
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
// L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 // L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens, total_tokens: usage.total_tokens,
@@ -862,6 +871,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
return; return;
} }
@@ -1432,6 +1442,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
incomplete: Some(true), incomplete: Some(true),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
// L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。 // L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
@@ -1440,6 +1451,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
return; return;
} }
@@ -1500,7 +1512,7 @@ pub(crate) async fn run_agentic_loop(
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await; guard.reset().await;
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
// L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。 // L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens, total_tokens: usage.total_tokens,
@@ -1508,6 +1520,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
return; return;
} }
@@ -1813,7 +1826,7 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await; guard.reset().await;
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
// L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内, // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内,
// 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 // 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
@@ -1822,6 +1835,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
} }
@@ -1939,12 +1953,14 @@ pub(crate) async fn try_continue_agent_loop(
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false); let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
let agent_language = conv.and_then(|c| c.agent_language.clone()); let agent_language = conv.and_then(|c| c.agent_language.clone());
let model_override = conv.and_then(|c| c.model_override.clone()); let model_override = conv.and_then(|c| c.model_override.clone());
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
ContinueSnapshot { ContinueSnapshot {
is_generating, is_generating,
has_pending, has_pending,
pending_conv_id, pending_conv_id,
agent_language, agent_language,
model_override, model_override,
pinned_goals_snapshot,
} }
}; };
let should_continue = snap.is_generating && !snap.has_pending; let should_continue = snap.is_generating && !snap.has_pending;
@@ -1971,6 +1987,7 @@ pub(crate) async fn try_continue_agent_loop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(emit_conv_id), conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
@@ -2062,6 +2079,7 @@ pub(crate) async fn try_continue_agent_loop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(conv_id_owned.clone()), conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
@@ -2097,6 +2115,8 @@ struct ContinueSnapshot {
pending_conv_id: Option<String>, pending_conv_id: Option<String>,
agent_language: Option<String>, agent_language: Option<String>,
model_override: Option<String>, model_override: Option<String>,
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
pinned_goals_snapshot: Vec<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -142,6 +142,7 @@ mod idea_source_test_helpers {
use df_storage::crud::IdeaRepo; use df_storage::crud::IdeaRepo;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::IdeaRecord; use df_storage::models::IdeaRecord;
use df_types::types::IdeaStatus;
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。 /// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
/// ///
@@ -155,7 +156,7 @@ mod idea_source_test_helpers {
id: id.to_string(), id: id.to_string(),
title: format!("fixture-{}", id), title: format!("fixture-{}", id),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,
@@ -88,7 +88,7 @@ impl MentionResolver for ProjectResolver {
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
// status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入) // status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入)
let status = ProjectStatus::from_db_str(&record.status).unwrap_or(ProjectStatus::Planning); let status = ProjectStatus::from_db_str(record.status.as_str()).unwrap_or(ProjectStatus::Planning);
// path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype // path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype
let path = record let path = record
.path .path
@@ -113,7 +113,7 @@ impl MentionResolver for ProjectResolver {
}).await { }).await {
if !tasks.is_empty() { if !tasks.is_empty() {
let mut task_lines: Vec<String> = tasks.iter().map(|t| { let mut task_lines: Vec<String> = tasks.iter().map(|t| {
format!(" - {} ({})", t.title, t.status) format!(" - {} ({})", t.title, t.status.as_str())
}).collect(); }).collect();
task_lines.insert(0, format!("进行中任务({}):", tasks.len())); task_lines.insert(0, format!("进行中任务({}):", tasks.len()));
lines.push(task_lines.join("\n")); lines.push(task_lines.join("\n"));
@@ -126,9 +126,9 @@ impl MentionResolver for ProjectResolver {
..Default::default() ..Default::default()
}).await { }).await {
let related: Vec<_> = ideas.iter() let related: Vec<_> = ideas.iter()
.filter(|i| i.status == "pending_review") .filter(|i| i.status.as_str() == "pending_review")
.take(3) .take(3)
.map(|i| format!(" - {} ({})", i.title, i.status)) .map(|i| format!(" - {} ({})", i.title, i.status.as_str()))
.collect(); .collect();
if !related.is_empty() { if !related.is_empty() {
let mut idea_lines = vec![format!("待评估灵感({}):", related.len())]; let mut idea_lines = vec![format!("待评估灵感({}):", related.len())];
@@ -189,7 +189,7 @@ impl MentionResolver for TaskResolver {
kind: "task".to_string(), kind: "task".to_string(),
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
let status = TaskStatus::from_db_str(&task.status).unwrap_or(TaskStatus::Todo); let status = TaskStatus::from_db_str(task.status.as_str()).unwrap_or(TaskStatus::Todo);
// join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误) // join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误)
let project_name = { let project_name = {
let project_repo = ProjectRepo::new(&self.db); let project_repo = ProjectRepo::new(&self.db);
@@ -253,7 +253,7 @@ impl MentionResolver for IdeaResolver {
kind: "idea".to_string(), kind: "idea".to_string(),
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
let status = IdeaStatus::from_db_str(&record.status).unwrap_or(IdeaStatus::Draft); let status = IdeaStatus::from_db_str(record.status.as_str()).unwrap_or(IdeaStatus::Draft);
Ok(Augmentation::Idea { Ok(Augmentation::Idea {
id: record.id, id: record.id,
title: record.title, title: record.title,
+10 -2
View File
@@ -1459,7 +1459,7 @@ pub async fn ai_chat_force_send(
// F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板), // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他 // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。 // 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
let (old_conv_id, conv_id, _user_message_id) = { let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active → 懒创建。 // 目标 conv:入参优先 → active → 懒创建。
let target = conversation_id.clone().filter(|s| !s.is_empty()) let target = conversation_id.clone().filter(|s| !s.is_empty())
@@ -1540,7 +1540,7 @@ pub async fn ai_chat_force_send(
} }
} }
} }
(was_gen.then_some(target.clone()), target, user_msg_id) (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
}; };
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit) // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
@@ -1551,6 +1551,7 @@ pub async fn ai_chat_force_send(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
@@ -1649,10 +1650,12 @@ pub async fn ai_chat_stop(
), ),
} }
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let pinned_goals = conv.pinned_goals.clone();
drop(session); drop(session);
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
conversation_id: Some(target), conversation_id: Some(target),
pinned_goals,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
@@ -1701,6 +1704,7 @@ pub async fn ai_chat_stop(
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)" "[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
), ),
} }
let pinned_goals = conv.pinned_goals.clone();
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, total_tokens: 0,
@@ -1708,6 +1712,7 @@ pub async fn ai_chat_stop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: conv_id, conversation_id: conv_id,
pinned_goals,
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
@@ -1772,6 +1777,7 @@ pub async fn ai_stop_loop(
state: State<'_, AppState>, state: State<'_, AppState>,
conversation_id: String, conversation_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let pinned_goals: Vec<String>;
{ {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
@@ -1794,6 +1800,7 @@ pub async fn ai_stop_loop(
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)" "[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
), ),
} }
pinned_goals = conv.pinned_goals.clone();
} }
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
@@ -1802,6 +1809,7 @@ pub async fn ai_stop_loop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
pinned_goals,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
+2
View File
@@ -138,6 +138,8 @@ pub enum AiChatEvent {
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>, incomplete: Option<bool>,
conversation_id: Option<String>, conversation_id: Option<String>,
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
pinned_goals: Vec<String>,
}, },
/// 错误 /// 错误
/// ///
+2 -2
View File
@@ -162,7 +162,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_project_ids.iter().any(|id| id == &p.id) { if excluded_project_ids.iter().any(|id| id == &p.id) {
continue; continue;
} }
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description)); prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description));
if let Some(ref dir) = p.path { if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir)); prompt.push_str(&format!(" 目录: {}\n", dir));
} }
@@ -181,7 +181,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_task_ids.iter().any(|id| id == &tk.id) { if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue; continue;
} }
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description)); prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description));
} }
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang)); prompt.push_str(&tasks_listed_note(lang));
+8 -8
View File
@@ -11,7 +11,7 @@ use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord}; use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord};
use df_types::types::new_id; use df_types::types::{new_id, IdeaStatus, ProjectStatus, TaskStatus};
use crate::commands::now_millis; use crate::commands::now_millis;
use crate::state::AllowedDirs; use crate::state::AllowedDirs;
@@ -573,7 +573,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let repo = df_storage::crud::ProjectRepo::new(&db); let repo = df_storage::crud::ProjectRepo::new(&db);
let record = ProjectRecord { let record = ProjectRecord {
id: new_id(), name: name.to_string(), description: description.to_string(), id: new_id(), name: name.to_string(), description: description.to_string(),
status: "planning".to_string(), idea_id: None, status: ProjectStatus::Planning, idea_id: None,
path: None, stack: None, path: None, stack: None,
created_at: now_millis(), updated_at: now_millis(), created_at: now_millis(), updated_at: now_millis(),
}; };
@@ -683,7 +683,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
}; };
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
if let Some(status) = args.get("status").and_then(|v| v.as_str()) { if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
tasks.retain(|t| t.status == status); tasks.retain(|t| t.status.as_str() == status);
} }
let total = tasks.len(); let total = tasks.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize; let offset = args["offset"].as_u64().unwrap_or(0) as usize;
@@ -767,7 +767,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(), id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32, status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
@@ -1032,13 +1032,13 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
"backlog" => "todo".to_string(), "backlog" => "todo".to_string(),
"active" => { "active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) { if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() current.status.as_str().to_string()
} else { } else {
"in_progress".to_string() "in_progress".to_string()
} }
} }
"todo" => "todo".to_string(), "todo" => "todo".to_string(),
"decision" => current.status.clone(), "decision" => current.status.as_str().to_string(),
_ => unreachable!("queue 白名单已收口"), _ => unreachable!("queue 白名单已收口"),
}; };
@@ -1047,7 +1047,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
repo.update_field(id, "queue", &new_queue).await?; repo.update_field(id, "queue", &new_queue).await?;
} }
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
if current.status != new_status { if current.status.as_str() != new_status {
repo.set_status_for_aggregation(id, &new_status).await?; repo.set_status_for_aggregation(id, &new_status).await?;
} }
@@ -1354,7 +1354,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
id: new_id(), title: title.to_string(), id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32, status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()), score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()), source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None, promoted_to: None, ai_analysis: None, scores: None,
+6 -6
View File
@@ -6,7 +6,7 @@ use serde::Deserialize;
use tauri::State; use tauri::State;
use df_ai::provider::LlmProvider; use df_ai::provider::LlmProvider;
use df_types::types::{new_id, Priority}; use df_types::types::{new_id, IdeaStatus, Priority, ProjectStatus};
use df_ideas::capture::Idea; use df_ideas::capture::Idea;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery}; use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord}; use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
@@ -87,7 +87,7 @@ pub async fn create_idea(
id: new_id(), id: new_id(),
title: input.title, title: input.title,
description: input.description, description: input.description,
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: input.priority, priority: input.priority,
score: None, score: None,
tags: input.tags, tags: input.tags,
@@ -190,7 +190,7 @@ pub async fn promote_idea(
id: project_id.clone(), id: project_id.clone(),
name: project.name, name: project.name,
description: project.description, description: project.description,
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: Some(id.clone()), idea_id: Some(id.clone()),
path: None, path: None,
stack: None, stack: None,
@@ -208,7 +208,7 @@ pub async fn promote_idea(
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 // 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)。 // 删除而非真事务(改动最小,工程投入产出比最高)。
let updated = IdeaRecord { let updated = IdeaRecord {
status: "promoted".to_string(), status: IdeaStatus::Promoted,
promoted_to: Some(project_id.clone()), promoted_to: Some(project_id.clone()),
updated_at: now, updated_at: now,
..record ..record
@@ -338,7 +338,7 @@ pub async fn evaluate_idea(
scores: Some(scores_json.clone()), scores: Some(scores_json.clone()),
ai_analysis: Some(ai_analysis.clone()), ai_analysis: Some(ai_analysis.clone()),
score: Some(score_value as f64), score: Some(score_value as f64),
status: "pending_review".to_string(), status: IdeaStatus::PendingReview,
updated_at: now_millis(), updated_at: now_millis(),
..record ..record
}; };
@@ -460,7 +460,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性) // IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
status: status_from_str(&record.status), status: status_from_str(record.status.as_str()),
priority: priority_from_i32(record.priority), priority: priority_from_i32(record.priority),
scores: None, scores: None,
tags, tags,
+2 -2
View File
@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest};
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_types::types::new_id; use df_types::types::{new_id, ProjectStatus};
use df_project::scan::{ use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description, collect_sample, detect_stack, discover_projects, extract_description,
normalize_path, DiscoveredProject, normalize_path, DiscoveredProject,
@@ -156,7 +156,7 @@ async fn create_with_binding(
id: new_id(), id: new_id(),
name, name,
description, description,
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id, idea_id,
path, path,
stack, stack,
+9 -9
View File
@@ -284,7 +284,7 @@ pub async fn create_task(
project_id: input.project_id, project_id: input.project_id,
title: input.title, title: input.title,
description: input.description, description: input.description,
status: "todo".to_string(), status: TaskStatus::Todo,
priority: input.priority, priority: input.priority,
branch_name: input.branch_name, branch_name: input.branch_name,
assignee: input.assignee, assignee: input.assignee,
@@ -314,7 +314,7 @@ pub async fn create_task(
Some("task"), Some("task"),
Some(&record.id), Some(&record.id),
None, None,
Some(&record.status), Some(record.status.as_str()),
) )
.await; .await;
Ok(record) Ok(record)
@@ -466,8 +466,8 @@ pub async fn advance_task(
"task_advanced", "task_advanced",
Some("task"), Some("task"),
Some(&updated.id), Some(&updated.id),
from_state.as_deref(), from_state.as_ref().map(TaskStatus::as_str),
Some(&updated.status), Some(updated.status.as_str()),
) )
.await; .await;
@@ -505,7 +505,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
.get_by_id(parent_id) .get_by_id(parent_id)
.await .await
.map_err(err_str)? .map_err(err_str)?
.map(|t| t.status) .map(|t| t.status.as_str().to_string())
.ok_or_else(|| format!("父任务 {parent_id} 不存在")); .ok_or_else(|| format!("父任务 {parent_id} 不存在"));
} }
@@ -541,7 +541,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
.await .await
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?; .ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
if current.status == new_status { if current.status.as_str() == new_status {
return Ok(new_status); return Ok(new_status);
} }
state state
@@ -688,13 +688,13 @@ pub async fn move_task_queue(
"backlog" => "todo".to_string(), "backlog" => "todo".to_string(),
"active" => { "active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) { if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() // 已在执行中三态,保留 current.status.as_str().to_string() // 已在执行中三态,保留
} else { } else {
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态) "in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
} }
} }
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态) "todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态) "decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
_ => unreachable!("validate_queue 已收口"), _ => unreachable!("validate_queue 已收口"),
}; };
@@ -708,7 +708,7 @@ pub async fn move_task_queue(
.map_err(err_str)?; .map_err(err_str)?;
} }
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径) // 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
if current.status != new_status { if current.status.as_str() != new_status {
state state
.tasks .tasks
.set_status_for_aggregation(&id, &new_status) .set_status_for_aggregation(&id, &new_status)
+8
View File
@@ -89,6 +89,13 @@ pub fn run() {
} }
} }
} }
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
.iter()
.filter_map(|cid| {
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
})
.collect();
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending), // 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。 // 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
@@ -107,6 +114,7 @@ pub fn run() {
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
}, },
); );
} }
+1 -1
View File
@@ -372,7 +372,7 @@ export type AiChatEvent = ({
} | { } | {
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断), // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。 // 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[]
} | { } | {
type: 'AiError'; error: string; error_type?: AiErrorType type: 'AiError'; error: string; error_type?: AiErrorType
} | { } | {
+1
View File
@@ -237,6 +237,7 @@ function scrollToFirstPending(): void {
const { confirmState, confirmDialog, answerConfirm } = useConfirm() const { confirmState, confirmDialog, answerConfirm } = useConfirm()
async function confirmDeleteConversation(id: string) { async function confirmDeleteConversation(id: string) {
const conv = store.state.conversations.find(c => c.id === id) const conv = store.state.conversations.find(c => c.id === id)
if (!conv) console.warn('confirmDeleteConversation: conv not found, id=', id)
const title = conv?.title || t('aiChat.newConversation') const title = conv?.title || t('aiChat.newConversation')
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。 // UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
const isActive = id === store.state.activeConversationId const isActive = id === store.state.activeConversationId
+115 -17
View File
@@ -2,7 +2,7 @@
<div v-if="dag" class="wf-dag"> <div v-if="dag" class="wf-dag">
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4> <h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
<!-- 拓扑分层渲染:同层并行,层间串行 --> <!-- 拓扑分层渲染 -->
<div class="wf-dag-layers"> <div class="wf-dag-layers">
<div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer"> <div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer">
<div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div> <div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div>
@@ -19,19 +19,41 @@
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span> <span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
</div> </div>
</div> </div>
<!-- 出边条件标签(同层节点出站边展示条件) -->
<div v-if="layerOutEdges[li]" class="wf-dag-outedges">
<div v-for="edge in layerOutEdges[li]" :key="edge.source + '→' + edge.target" class="wf-dag-outedge">
<span class="wf-dag-outedge-arrow">→ {{ edge.target }}</span>
<span class="wf-dag-cond-label" :class="{ 'wf-dag-cond-label--uncond': !edge.condition }">{{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }}</span>
</div>
</div>
<!-- 层间箭头 --> <!-- 层间箭头 -->
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow">↓</div> <div v-if="li < layers.length - 1" class="wf-dag-layer-arrow">↓</div>
</div> </div>
</div> </div>
<!-- 边条件列表(折叠态,hover 节点时参考) --> <!-- 边条件编辑列表 -->
<details class="wf-dag-edges-detail"> <details class="wf-dag-edges-detail">
<summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件详情' }} ({{ dag.edges.length }})</summary> <summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件编辑' }} ({{ dag.edges.length }})</summary>
<div class="wf-dag-edges"> <div class="wf-dag-edges">
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge"> <div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
<span class="wf-dag-edge-arrow">{{ edge.source }} → {{ edge.target }}</span> <span class="wf-dag-edge-arrow">{{ edge.source }} → {{ edge.target }}</span>
<span v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">{{ edge.condition }}</span> <input
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">{{ $t('taskDetail.workflowUnconditional') || '无条件' }}</span> v-if="editingIndex === i"
ref="editInputRef"
class="wf-dag-edge-input"
:value="editValue"
@input="editValue = ($event.target as HTMLInputElement).value"
@keydown.enter="saveCondition(i)"
@keydown.escape="cancelEdit"
@blur="saveCondition(i)"
:placeholder="$t('taskDetail.workflowCondPlaceholder') || '输入条件表达式'"
/>
<span
v-else
class="wf-dag-cond-label"
:class="{ 'wf-dag-cond-label--uncond': !edge.condition, 'wf-dag-cond-label--editable': true }"
@click="startEdit(i, edge)"
>{{ edge.condition || ($t('taskDetail.workflowUnconditional') || '无条件') }}</span>
</div> </div>
</div> </div>
</details> </details>
@@ -42,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed, nextTick } from 'vue'
interface DagNode { interface DagNode {
id: string id: string
@@ -63,6 +85,10 @@ const props = withDefaults(defineProps<{
nodeStatuses: () => ({}), nodeStatuses: () => ({}),
}) })
const emit = defineEmits<{
(e: 'update:dagJson', val: string): void
}>()
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => { const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try { return JSON.parse(props.dagJson) } try { return JSON.parse(props.dagJson) }
catch { return null } catch { return null }
@@ -89,11 +115,10 @@ function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<str
} }
} }
/** 拓扑分层:从无入边的根节点开始,逐层推进 */ /** 拓扑分层 */
const layers = computed<(DagNode & { deps?: string[] })[][]>(() => { const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
if (!dag.value) return [] if (!dag.value) return []
const d = dag.value const d = dag.value
// 入边计数
const inDegree: Record<string, number> = {} const inDegree: Record<string, number> = {}
const edgeMap: Record<string, string[]> = {} const edgeMap: Record<string, string[]> = {}
for (const n of d.nodes) { for (const n of d.nodes) {
@@ -105,8 +130,7 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
edgeMap[e.source].push(e.target) edgeMap[e.source].push(e.target)
inDegree[e.target] = (inDegree[e.target] || 0) + 1 inDegree[e.target] = (inDegree[e.target] || 0) + 1
} }
// Kahn 拓扑排序 const result: (DagNode & { deps?: string[] })[][] = []
const layers: (DagNode & { deps?: string[] })[][] = []
let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id) let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id)
const visited = new Set<string>() const visited = new Set<string>()
while (queue.length > 0) { while (queue.length > 0) {
@@ -122,11 +146,59 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
if (inDegree[target] === 0) next.push(target) if (inDegree[target] === 0) next.push(target)
} }
} }
if (layer.length > 0) layers.push(layer) if (layer.length > 0) result.push(layer)
queue = next queue = next
} }
return layers return result
}) })
/**
* 每层出边索引(缓存版):layers 变化时重新计算,避免模板内重复调用函数。
* 返回 { [layerIndex]: EdgeDef[] },仅含出边非空的层。
*/
const layerOutEdges = computed<Record<number, EdgeDef[]>>(() => {
if (!dag.value) return {}
const bySource: Record<string, EdgeDef[]> = {}
for (const e of dag.value.edges) {
if (!bySource[e.source]) bySource[e.source] = []
bySource[e.source].push(e)
}
const result: Record<number, EdgeDef[]> = {}
for (let li = 0; li < layers.value.length; li++) {
const edges: EdgeDef[] = []
for (const node of layers.value[li]) {
const out = bySource[node.id]
if (out) edges.push(...out)
}
if (edges.length > 0) result[li] = edges
}
return result
})
// ── 条件编辑 ──
const editingIndex = ref<number | null>(null)
const editValue = ref('')
const editInputRef = ref<HTMLInputElement | null>(null)
function startEdit(i: number, edge: EdgeDef) {
editingIndex.value = i
editValue.value = edge.condition || ''
nextTick(() => editInputRef.value?.focus())
}
function saveCondition(i: number) {
if (!dag.value || editingIndex.value !== i) return
const edges = [...dag.value.edges]
const val = editValue.value.trim()
edges[i] = { ...edges[i], condition: val || null }
const updated = { ...dag.value, edges }
emit('update:dagJson', JSON.stringify(updated, null, 2))
editingIndex.value = null
}
function cancelEdit() {
editingIndex.value = null
}
</script> </script>
<style scoped> <style scoped>
@@ -180,14 +252,40 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* 出边条件标签 */
.wf-dag-outedges {
display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 12px;
padding: 2px 0;
}
.wf-dag-outedge {
display: inline-flex; align-items: center; gap: 4px; font-size: 10px;
}
.wf-dag-outedge-arrow { color: var(--df-text-primary); white-space: nowrap; }
/* 条件标签(共享:出边展示 + 编辑列表) */
.wf-dag-cond-label {
font-family: var(--df-font-mono); font-size: 10px; padding: 0 4px;
background: rgba(74,144,226,0.08); border-radius: 2px; color: var(--df-info, #4a90e2);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
.wf-dag-cond-label--editable {
cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 3px;
transition: background 0.15s;
}
.wf-dag-cond-label--editable:hover { background: rgba(74,144,226,0.18); }
.wf-dag-cond-label--uncond.wf-dag-cond-label--editable:hover { background: var(--df-bg-card); }
/* 边条件编辑列表 */
.wf-dag-edges-detail { margin-top: 8px; } .wf-dag-edges-detail { margin-top: 8px; }
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); } .wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); }
.wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; } .wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; } .wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
.wf-dag-edge-arrow { color: var(--df-text-primary); } .wf-dag-edge-arrow { color: var(--df-text-primary); white-space: nowrap; }
.wf-dag-edge-cond { .wf-dag-edge-input {
font-family: var(--df-font-mono); font-size: 11px; padding: 1px 6px; flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px;
background: rgba(74,144,226,0.08); border-radius: 3px; color: var(--df-info, #4a90e2); padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 3px;
background: var(--df-bg); color: var(--df-text); outline: none;
} }
.wf-dag-edge-cond--uncond { background: transparent; color: var(--df-text-dim, #aaa); } .wf-dag-edge-input:focus { border-color: var(--df-accent); }
</style> </style>
+5 -1
View File
@@ -580,7 +580,11 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
timestamp: Date.now(), timestamp: Date.now(),
}) })
} }
// 对话透明化 L1:刷新会话列表(更新 pinned_goals) // 对话透明化 L1:直接从事件更新 pinned_goals,不等 loadConversations 异步刷新
if (event.pinned_goals && state.activeConversationId) {
const conv = state.conversations.find(c => c.id === state.activeConversationId)
if (conv) conv.pinned_goals = event.pinned_goals
}
void loadConversations() void loadConversations()
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) { if (isShowTokenUsage()) {