Compare commits
5
Commits
48c966f6f7
...
c022e2d4d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c022e2d4d8 | ||
|
|
fee856129c | ||
|
|
ead490783f | ||
|
|
dcc3f0d230 | ||
|
|
d1b9488853 |
+56
-10
@@ -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. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
/// Agent 协调器
|
/// Agent 协调器
|
||||||
///
|
///
|
||||||
/// TODO: 实现多 Agent 协作逻辑
|
/// TODO: 实现多 Agent 协作逻辑
|
||||||
|
#[deprecated(note = "B 路线占位,勿用于生产")]
|
||||||
pub struct AgentCoordinator;
|
pub struct AgentCoordinator;
|
||||||
|
|
||||||
impl AgentCoordinator {
|
impl AgentCoordinator {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 时间工具
|
// 时间工具
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 重复)。
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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>,
|
||||||
},
|
},
|
||||||
/// 错误
|
/// 错误
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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(¤t.status.as_str()) {
|
if ACTIVE_OK_STATUSES.contains(¤t.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,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(¤t.status.as_str()) {
|
if ACTIVE_OK_STATUSES.contains(¤t.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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
} | {
|
} | {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user