Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c022e2d4d8 | ||
|
|
fee856129c | ||
|
|
ead490783f | ||
|
|
dcc3f0d230 | ||
|
|
d1b9488853 |
+56
-10
@@ -19,7 +19,9 @@
|
||||
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
|
||||
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
|
||||
|
||||
### 层级模型
|
||||
### 层级模型(业务层级)
|
||||
|
||||
DevFlow 的业务抽象分三层,自上而下层层实例化:
|
||||
|
||||
```
|
||||
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
|
||||
@@ -31,6 +33,40 @@
|
||||
└→ 🎯 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 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
@@ -497,15 +533,21 @@ CREATE TABLE app_settings (
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
## 七、三层模型:模板 → 工作流 → 人设
|
||||
|
||||
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。
|
||||
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
|
||||
|
||||
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
|
||||
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
|
||||
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复
|
||||
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
|
||||
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
|
||||
### 7.1 三层定义
|
||||
|
||||
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|
||||
|----|-----------|------|---------|---------|
|
||||
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
|
||||
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
|
||||
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
|
||||
|
||||
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
|
||||
|
||||
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
|
||||
|
||||
## 八、Phase 规划
|
||||
|
||||
@@ -528,9 +570,10 @@ CREATE TABLE app_settings (
|
||||
- 前端:想法池视图 + 多项目 Tab
|
||||
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
|
||||
- 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
|
||||
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
@@ -566,3 +609,6 @@ CREATE TABLE app_settings (
|
||||
8. **本地优先**:SQLite 嵌入,不依赖云服务
|
||||
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
|
||||
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
|
||||
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
|
||||
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
|
||||
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
/// Agent 协调器
|
||||
///
|
||||
/// TODO: 实现多 Agent 协作逻辑
|
||||
#[deprecated(note = "B 路线占位,勿用于生产")]
|
||||
pub struct AgentCoordinator;
|
||||
|
||||
impl AgentCoordinator {
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
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 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)),
|
||||
};
|
||||
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);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name,
|
||||
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
};
|
||||
let name = arg_str_or(&args, "name", "");
|
||||
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);
|
||||
Box::pin(async move {
|
||||
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),
|
||||
};
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
id: id.clone(),
|
||||
name,
|
||||
@@ -355,7 +357,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
list.retain(|t| t.project_id == *pid);
|
||||
}
|
||||
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() }))
|
||||
}
|
||||
@@ -384,7 +386,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status: "todo".to_owned(),
|
||||
status: TaskStatus::Todo,
|
||||
priority,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -529,7 +531,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
id: new_id(),
|
||||
title,
|
||||
description,
|
||||
status: "draft".to_owned(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -273,6 +273,7 @@ mod tests {
|
||||
use df_storage::crud::{ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
use serde_json::json;
|
||||
|
||||
// ============================================================
|
||||
@@ -287,7 +288,7 @@ mod tests {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -302,7 +303,7 @@ mod tests {
|
||||
project_id: "p1".to_string(),
|
||||
title: "t1".to_string(),
|
||||
description: "实现登录接口".to_string(),
|
||||
status: "testing".to_string(),
|
||||
status: TaskStatus::Testing,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
|
||||
@@ -174,14 +174,15 @@ mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
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 {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("t-{id}"),
|
||||
description: "".to_string(),
|
||||
status: status.to_string(),
|
||||
status,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -206,7 +207,7 @@ mod tests {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -221,43 +222,43 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn forward_path_todo_to_done() {
|
||||
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();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
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);
|
||||
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();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.status.as_str(), "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
||||
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();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_testing_to_in_review_bumps_rounds() {
|
||||
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();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_regressions_accumulate() {
|
||||
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)
|
||||
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
@@ -269,7 +270,7 @@ mod tests {
|
||||
async fn illegal_skip_rejected() {
|
||||
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
|
||||
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();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, expected } => {
|
||||
@@ -285,7 +286,7 @@ mod tests {
|
||||
async fn terminal_done_no_successor() {
|
||||
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
|
||||
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();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, .. } => {
|
||||
@@ -301,7 +302,7 @@ mod tests {
|
||||
// 同态属空操作,归 Validation「相同状态,无需推进」;
|
||||
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
|
||||
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();
|
||||
match err {
|
||||
df_types::error::Error::Validation(msg) => {
|
||||
@@ -314,7 +315,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn invalid_target_rejected() {
|
||||
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();
|
||||
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
||||
}
|
||||
@@ -332,7 +333,7 @@ mod tests {
|
||||
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
|
||||
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
|
||||
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
|
||||
repo.advance_status_atomic("t1", "todo", "in_progress", false)
|
||||
@@ -340,13 +341,13 @@ mod tests {
|
||||
.unwrap();
|
||||
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
|
||||
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]
|
||||
async fn cas_returns_none_when_status_mismatch() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "in_review", false)
|
||||
@@ -365,12 +366,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn blocked_round_trip_does_not_bump() {
|
||||
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();
|
||||
assert_eq!(r.status, "blocked");
|
||||
assert_eq!(r.status.as_str(), "blocked");
|
||||
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
||||
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 不累加");
|
||||
}
|
||||
|
||||
@@ -460,24 +461,24 @@ mod tests {
|
||||
async fn callback_completed_advance_lands_in_db() {
|
||||
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
|
||||
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 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");
|
||||
|
||||
// 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 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);
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
|
||||
@@ -487,32 +488,32 @@ mod tests {
|
||||
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
|
||||
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
|
||||
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();
|
||||
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
|
||||
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)");
|
||||
|
||||
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
|
||||
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
|
||||
// 即便当前推进链 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();
|
||||
assert_eq!(to, "in_progress");
|
||||
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);
|
||||
|
||||
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
|
||||
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
|
||||
// 验证: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");
|
||||
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
|
||||
// 任务状态未被改动(仍是 in_progress)
|
||||
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。
|
||||
|
||||
@@ -9,6 +9,7 @@ use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
|
||||
use df_types::types::IdeaStatus;
|
||||
|
||||
use super::impl_repo;
|
||||
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")?,
|
||||
title: row.get("title")?,
|
||||
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")?,
|
||||
score: row.get("score")?,
|
||||
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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
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.scores, rec.related_ids, rec.created_at, rec.updated_at
|
||||
],
|
||||
@@ -210,7 +214,7 @@ impl_repo!(
|
||||
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",
|
||||
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.scores, rec.related_ids, rec.updated_at, rec.id
|
||||
],
|
||||
@@ -297,10 +301,11 @@ impl IdeaRepo {
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
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 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));
|
||||
}
|
||||
@@ -541,7 +546,8 @@ impl KnowledgeRepo {
|
||||
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
|
||||
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
|
||||
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 limit_i = limit as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -549,7 +555,7 @@ impl KnowledgeRepo {
|
||||
let mut results = Vec::new();
|
||||
if let Some(k) = &kind {
|
||||
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)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -559,7 +565,7 @@ impl KnowledgeRepo {
|
||||
}
|
||||
} else {
|
||||
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)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -1403,7 +1409,7 @@ mod tests {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: String::new(),
|
||||
status: "draft".to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -219,6 +219,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectEventRepo) {
|
||||
@@ -232,7 +233,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
@@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{
|
||||
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
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")?,
|
||||
path: row.get("path")?,
|
||||
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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
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
|
||||
],
|
||||
)
|
||||
@@ -171,7 +175,7 @@ impl_repo!(
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
|
||||
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
|
||||
],
|
||||
)
|
||||
@@ -238,8 +242,9 @@ impl ProjectRepo {
|
||||
if let Some(kw) = &q.keyword {
|
||||
let trimmed = kw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
|
||||
let pattern = format!("%{trimmed}%");
|
||||
let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
|
||||
@@ -308,7 +309,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
@@ -328,6 +328,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::TaskRepo;
|
||||
use crate::models::TaskRecord;
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
|
||||
fn trec(id: &str, project_id: &str) -> TaskRecord {
|
||||
@@ -336,7 +337,7 @@ mod tests {
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: "todo".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -369,7 +370,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
use df_types::types::TaskStatus;
|
||||
|
||||
use crate::db::Database;
|
||||
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")?,
|
||||
title: row.get("title")?,
|
||||
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")?,
|
||||
branch_name: row.get("branch_name")?,
|
||||
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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
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.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
@@ -134,7 +138,7 @@ impl_repo!(
|
||||
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",
|
||||
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.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
@@ -374,10 +378,11 @@ impl TaskRepo {
|
||||
}
|
||||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||||
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 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));
|
||||
}
|
||||
@@ -566,20 +571,21 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
|
||||
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 时用)。
|
||||
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 {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: status.to_string(),
|
||||
status,
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -605,7 +611,7 @@ mod tests {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -778,13 +784,13 @@ mod tests {
|
||||
async fn set_status_for_aggregation_writes_status() {
|
||||
let repo = setup().await;
|
||||
// 父任务初始 todo(queue=todo, parent_id=None 容器模型)
|
||||
repo.insert(trec_full("parent", "todo", None, "todo"))
|
||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||
.await
|
||||
.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, "应命中写入");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -792,7 +798,7 @@ mod tests {
|
||||
async fn set_status_for_aggregation_skips_soft_deleted() {
|
||||
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
|
||||
let repo = setup().await;
|
||||
repo.insert(trec_full("parent", "todo", None, "todo"))
|
||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.soft_delete("parent").await.unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
|
||||
|
||||
use df_ai_core::model::{deserialize_model_configs, ModelConfig};
|
||||
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub status: IdeaStatus,
|
||||
pub priority: i32,
|
||||
pub score: Option<f64>,
|
||||
pub tags: Option<String>, // JSON 数组
|
||||
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub status: ProjectStatus,
|
||||
pub idea_id: Option<String>,
|
||||
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
|
||||
pub path: Option<String>,
|
||||
@@ -74,7 +75,7 @@ pub struct TaskRecord {
|
||||
pub project_id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub status: TaskStatus,
|
||||
pub priority: i32,
|
||||
pub branch_name: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
@@ -134,14 +135,14 @@ pub struct TaskRecord {
|
||||
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
|
||||
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
|
||||
/// - `remark`:可选备注。
|
||||
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
|
||||
/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。
|
||||
/// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
|
||||
/// 对标设计 D8:task_links 数据量小,检测成本高)。跨项目依赖允许。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskLinkRecord {
|
||||
pub id: String,
|
||||
pub source_id: String,
|
||||
pub target_id: String,
|
||||
pub link_type: String,
|
||||
pub link_type: LinkType,
|
||||
pub remark: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
|
||||
pub id: String,
|
||||
pub workflow_id: String,
|
||||
pub node_id: String,
|
||||
pub node_type: String,
|
||||
pub node_type: NodeType,
|
||||
pub status: String,
|
||||
pub input_json: Option<String>,
|
||||
pub output_json: Option<String>,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
@@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord {
|
||||
id: id.to_string(),
|
||||
name: format!("proj-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "active".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "todo".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() {
|
||||
|
||||
// 对照:status 未被改写,仍为初始值(task fixture 的初始 status)
|
||||
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]
|
||||
|
||||
@@ -26,6 +26,12 @@ pub type PluginId = String;
|
||||
pub type ExecutionId = String;
|
||||
/// 决策 ID
|
||||
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:节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义)。
|
||||
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null);
|
||||
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局)。
|
||||
/// - 非 Object/Null 两端:节点级直接覆盖全局级。
|
||||
/// - 节点级为 `Null` 时跳过(保留全局配置),对应语义「节点未指定该配置」。
|
||||
/// 若调用方希望「显式置空」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局),
|
||||
/// 而非传 JSON null。
|
||||
///
|
||||
/// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_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)
|
||||
}
|
||||
// 非两端 Object:节点级覆盖全局级(含 Null、标量、数组)
|
||||
_ => node.clone(),
|
||||
// 节点级为 Null → 保留全局配置(不覆盖)
|
||||
(_, 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]
|
||||
fn missing_node_config_treated_as_noop() {
|
||||
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global),此处覆盖语义对照:
|
||||
// 若强行把缺失视作 Null,应覆盖(显式空语义),与「缺失」不同(缺失走 if let Some 分支)。
|
||||
fn null_node_config_keeps_global() {
|
||||
// 节点级为 Null → 保留全局(不覆盖),区别于「缺失 key 不调 deep_merge」。
|
||||
let global = json!({"a": 1});
|
||||
assert_eq!(deep_merge(&global, &json!(null)), json!(null));
|
||||
assert_eq!(deep_merge(&global, &json!(null)), global);
|
||||
}
|
||||
|
||||
#[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 选型代码复用 |
|
||||
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
|
||||
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
|
||||
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
|
||||
| [消息级溯源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-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
|
||||
| [全局事件数据总线-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) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
|
||||
| [多主题并存补充论证-多轮模式-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-28.md)(人设层是本架构的下一阶段演进方向)
|
||||
> 用途: 作为「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 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
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);
|
||||
guard.reset().await;
|
||||
// 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 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
@@ -862,6 +871,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1432,6 +1442,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
incomplete: Some(true),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
// L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。
|
||||
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(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
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);
|
||||
guard.reset().await;
|
||||
// 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 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
@@ -1508,6 +1520,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1813,7 +1826,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
guard.reset().await;
|
||||
// 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 内,
|
||||
// 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。
|
||||
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(),
|
||||
incomplete: None,
|
||||
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 agent_language = conv.and_then(|c| c.agent_language.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 {
|
||||
is_generating,
|
||||
has_pending,
|
||||
pending_conv_id,
|
||||
agent_language,
|
||||
model_override,
|
||||
pinned_goals_snapshot,
|
||||
}
|
||||
};
|
||||
let should_continue = snap.is_generating && !snap.has_pending;
|
||||
@@ -1971,6 +1987,7 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
@@ -2062,6 +2079,7 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
@@ -2097,6 +2115,8 @@ struct ContinueSnapshot {
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
||||
pinned_goals_snapshot: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -142,6 +142,7 @@ mod idea_source_test_helpers {
|
||||
use df_storage::crud::IdeaRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::IdeaRecord;
|
||||
use df_types::types::IdeaStatus;
|
||||
|
||||
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
|
||||
///
|
||||
@@ -155,7 +156,7 @@ mod idea_source_test_helpers {
|
||||
id: id.to_string(),
|
||||
title: format!("fixture-{}", id),
|
||||
description: String::new(),
|
||||
status: "draft".to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -88,7 +88,7 @@ impl MentionResolver for ProjectResolver {
|
||||
ref_id: id.clone(),
|
||||
})?;
|
||||
// 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
|
||||
let path = record
|
||||
.path
|
||||
@@ -113,7 +113,7 @@ impl MentionResolver for ProjectResolver {
|
||||
}).await {
|
||||
if !tasks.is_empty() {
|
||||
let mut task_lines: Vec<String> = tasks.iter().map(|t| {
|
||||
format!(" - {} ({})", t.title, t.status)
|
||||
format!(" - {} ({})", t.title, t.status.as_str())
|
||||
}).collect();
|
||||
task_lines.insert(0, format!("进行中任务({}):", tasks.len()));
|
||||
lines.push(task_lines.join("\n"));
|
||||
@@ -126,9 +126,9 @@ impl MentionResolver for ProjectResolver {
|
||||
..Default::default()
|
||||
}).await {
|
||||
let related: Vec<_> = ideas.iter()
|
||||
.filter(|i| i.status == "pending_review")
|
||||
.filter(|i| i.status.as_str() == "pending_review")
|
||||
.take(3)
|
||||
.map(|i| format!(" - {} ({})", i.title, i.status))
|
||||
.map(|i| format!(" - {} ({})", i.title, i.status.as_str()))
|
||||
.collect();
|
||||
if !related.is_empty() {
|
||||
let mut idea_lines = vec![format!("待评估灵感({}):", related.len())];
|
||||
@@ -189,7 +189,7 @@ impl MentionResolver for TaskResolver {
|
||||
kind: "task".to_string(),
|
||||
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(非错误)
|
||||
let project_name = {
|
||||
let project_repo = ProjectRepo::new(&self.db);
|
||||
@@ -253,7 +253,7 @@ impl MentionResolver for IdeaResolver {
|
||||
kind: "idea".to_string(),
|
||||
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 {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
|
||||
@@ -1459,7 +1459,7 @@ pub async fn ai_chat_force_send(
|
||||
// F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
|
||||
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
|
||||
// 后台 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;
|
||||
// 目标 conv:入参优先 → active → 懒创建。
|
||||
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)
|
||||
@@ -1551,6 +1551,7 @@ pub async fn ai_chat_force_send(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
pinned_goals: old_pinned_goals.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// 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 误判重启
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
|
||||
conversation_id: Some(target),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// 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)"
|
||||
),
|
||||
}
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
@@ -1708,6 +1712,7 @@ pub async fn ai_chat_stop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: conv_id,
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
|
||||
@@ -1772,6 +1777,7 @@ pub async fn ai_stop_loop(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
let pinned_goals: Vec<String>;
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 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)"
|
||||
),
|
||||
}
|
||||
pinned_goals = conv.pinned_goals.clone();
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
@@ -1802,6 +1809,7 @@ pub async fn ai_stop_loop(
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conversation_id),
|
||||
pinned_goals,
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
|
||||
@@ -138,6 +138,8 @@ pub enum AiChatEvent {
|
||||
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
|
||||
incomplete: Option<bool>,
|
||||
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) {
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
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::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::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 record = ProjectRecord {
|
||||
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,
|
||||
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
|
||||
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 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(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
// 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,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
@@ -1032,13 +1032,13 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.clone()
|
||||
current.status.as_str().to_string()
|
||||
} else {
|
||||
"in_progress".to_string()
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(),
|
||||
"decision" => current.status.clone(),
|
||||
"decision" => current.status.as_str().to_string(),
|
||||
_ => 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?;
|
||||
}
|
||||
// 写 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?;
|
||||
}
|
||||
|
||||
@@ -1354,7 +1354,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
id: new_id(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
// 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()),
|
||||
source: args["source"].as_str().map(|s| s.to_string()),
|
||||
promoted_to: None, ai_analysis: None, scores: None,
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
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_storage::crud::{is_unique_constraint_err, IdeaQuery};
|
||||
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
|
||||
@@ -87,7 +87,7 @@ pub async fn create_idea(
|
||||
id: new_id(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: "draft".to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: input.priority,
|
||||
score: None,
|
||||
tags: input.tags,
|
||||
@@ -190,7 +190,7 @@ pub async fn promote_idea(
|
||||
id: project_id.clone(),
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: Some(id.clone()),
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -208,7 +208,7 @@ pub async fn promote_idea(
|
||||
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
|
||||
// 删除而非真事务(改动最小,工程投入产出比最高)。
|
||||
let updated = IdeaRecord {
|
||||
status: "promoted".to_string(),
|
||||
status: IdeaStatus::Promoted,
|
||||
promoted_to: Some(project_id.clone()),
|
||||
updated_at: now,
|
||||
..record
|
||||
@@ -338,7 +338,7 @@ pub async fn evaluate_idea(
|
||||
scores: Some(scores_json.clone()),
|
||||
ai_analysis: Some(ai_analysis.clone()),
|
||||
score: Some(score_value as f64),
|
||||
status: "pending_review".to_string(),
|
||||
status: IdeaStatus::PendingReview,
|
||||
updated_at: now_millis(),
|
||||
..record
|
||||
};
|
||||
@@ -460,7 +460,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
|
||||
title: record.title.clone(),
|
||||
description: record.description.clone(),
|
||||
// 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),
|
||||
scores: None,
|
||||
tags,
|
||||
|
||||
@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest};
|
||||
use df_ai::router::{
|
||||
select_model_id, Modality, TaskRequirements,
|
||||
};
|
||||
use df_types::types::new_id;
|
||||
use df_types::types::{new_id, ProjectStatus};
|
||||
use df_project::scan::{
|
||||
collect_sample, detect_stack, discover_projects, extract_description,
|
||||
normalize_path, DiscoveredProject,
|
||||
@@ -156,7 +156,7 @@ async fn create_with_binding(
|
||||
id: new_id(),
|
||||
name,
|
||||
description,
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id,
|
||||
path,
|
||||
stack,
|
||||
|
||||
@@ -284,7 +284,7 @@ pub async fn create_task(
|
||||
project_id: input.project_id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: "todo".to_string(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: input.priority,
|
||||
branch_name: input.branch_name,
|
||||
assignee: input.assignee,
|
||||
@@ -314,7 +314,7 @@ pub async fn create_task(
|
||||
Some("task"),
|
||||
Some(&record.id),
|
||||
None,
|
||||
Some(&record.status),
|
||||
Some(record.status.as_str()),
|
||||
)
|
||||
.await;
|
||||
Ok(record)
|
||||
@@ -466,8 +466,8 @@ pub async fn advance_task(
|
||||
"task_advanced",
|
||||
Some("task"),
|
||||
Some(&updated.id),
|
||||
from_state.as_deref(),
|
||||
Some(&updated.status),
|
||||
from_state.as_ref().map(TaskStatus::as_str),
|
||||
Some(updated.status.as_str()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -505,7 +505,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
|
||||
.get_by_id(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.map(|t| t.status)
|
||||
.map(|t| t.status.as_str().to_string())
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"));
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
|
||||
if current.status == new_status {
|
||||
if current.status.as_str() == new_status {
|
||||
return Ok(new_status);
|
||||
}
|
||||
state
|
||||
@@ -688,13 +688,13 @@ pub async fn move_task_queue(
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.clone() // 已在执行中三态,保留
|
||||
current.status.as_str().to_string() // 已在执行中三态,保留
|
||||
} else {
|
||||
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
|
||||
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态)
|
||||
"decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
|
||||
_ => unreachable!("validate_queue 已收口"),
|
||||
};
|
||||
|
||||
@@ -708,7 +708,7 @@ pub async fn move_task_queue(
|
||||
.map_err(err_str)?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
|
||||
if current.status != new_status {
|
||||
if current.status.as_str() != new_status {
|
||||
state
|
||||
.tasks
|
||||
.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 无条件清空,
|
||||
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
|
||||
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
|
||||
@@ -107,6 +114,7 @@ pub fn run() {
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
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 标记流中途失败保文(网络中断),
|
||||
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 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
|
||||
} | {
|
||||
|
||||
@@ -237,6 +237,7 @@ function scrollToFirstPending(): void {
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
async function confirmDeleteConversation(id: string) {
|
||||
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')
|
||||
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
|
||||
const isActive = id === store.state.activeConversationId
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="dag" class="wf-dag">
|
||||
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
|
||||
|
||||
<!-- 拓扑分层渲染:同层并行,层间串行 -->
|
||||
<!-- 拓扑分层渲染 -->
|
||||
<div class="wf-dag-layers">
|
||||
<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>
|
||||
@@ -19,19 +19,41 @@
|
||||
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 边条件列表(折叠态,hover 节点时参考) -->
|
||||
<!-- 边条件编辑列表 -->
|
||||
<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 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 v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">{{ edge.condition }}</span>
|
||||
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">{{ $t('taskDetail.workflowUnconditional') || '无条件' }}</span>
|
||||
<input
|
||||
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>
|
||||
</details>
|
||||
@@ -42,7 +64,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
interface DagNode {
|
||||
id: string
|
||||
@@ -63,6 +85,10 @@ const props = withDefaults(defineProps<{
|
||||
nodeStatuses: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:dagJson', val: string): void
|
||||
}>()
|
||||
|
||||
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
|
||||
try { return JSON.parse(props.dagJson) }
|
||||
catch { return null }
|
||||
@@ -89,11 +115,10 @@ function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<str
|
||||
}
|
||||
}
|
||||
|
||||
/** 拓扑分层:从无入边的根节点开始,逐层推进 */
|
||||
/** 拓扑分层 */
|
||||
const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
|
||||
if (!dag.value) return []
|
||||
const d = dag.value
|
||||
// 入边计数
|
||||
const inDegree: Record<string, number> = {}
|
||||
const edgeMap: Record<string, string[]> = {}
|
||||
for (const n of d.nodes) {
|
||||
@@ -105,8 +130,7 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
|
||||
edgeMap[e.source].push(e.target)
|
||||
inDegree[e.target] = (inDegree[e.target] || 0) + 1
|
||||
}
|
||||
// Kahn 拓扑排序
|
||||
const layers: (DagNode & { deps?: string[] })[][] = []
|
||||
const result: (DagNode & { deps?: string[] })[][] = []
|
||||
let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id)
|
||||
const visited = new Set<string>()
|
||||
while (queue.length > 0) {
|
||||
@@ -122,11 +146,59 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
|
||||
if (inDegree[target] === 0) next.push(target)
|
||||
}
|
||||
}
|
||||
if (layer.length > 0) layers.push(layer)
|
||||
if (layer.length > 0) result.push(layer)
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -180,14 +252,40 @@ const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
|
||||
|
||||
@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-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-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-cond {
|
||||
font-family: var(--df-font-mono); font-size: 11px; padding: 1px 6px;
|
||||
background: rgba(74,144,226,0.08); border-radius: 3px; color: var(--df-info, #4a90e2);
|
||||
.wf-dag-edge-arrow { color: var(--df-text-primary); white-space: nowrap; }
|
||||
.wf-dag-edge-input {
|
||||
flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px;
|
||||
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>
|
||||
|
||||
@@ -580,7 +580,11 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
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()
|
||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||
if (isShowTokenUsage()) {
|
||||
|
||||
Reference in New Issue
Block a user