Compare commits

..

31 Commits

Author SHA1 Message Date
696e34407c 更新: 关闭已完成的若干待办项
经代码核验,五项候选待办实际上都已实现或已满足决策,本次仅
同步文档关闭:

- 工具卡片分组标题:已实现文件路径摘要展示(跨轮自动收起保留
  作为低优先增量)
- 重试持锁问题:多会话并发改造后全局锁已移除,重试仅持单会话锁
  (同循环内合理),原说法已过时
- 对抗式评估批量命令:单项评估接口已接入,批量评估无前端消费方
  暂不做(避免过度设计)
- 残留诊断代码:聊天命令已无硬编码绝对路径,调试输出已降级为
  普通日志
- 工程内路径授权政策:用户决策确认为白名单制(工程外走三档授权
  + 系统目录黑名单兜底),已落地
2026-06-28 19:06:02 +08:00
19b647e650 修复: 知识提炼改读消息表,不再依赖旧 JSON 列
消息存储拆分改造早前已覆盖主要读写路径(保存写消息表 / 切换 /
导出 / 跨端同步读消息表),但知识提炼仍在读旧的消息 JSON 列,
与改造目标不一致。

本次补全:
- 知识提炼改为优先读消息表
- 表空时回退旧 JSON 列(老数据库兼容,与其他读路径一致)
- 复用既有的记录转消息映射函数,与其他读路径同源

至此消息存储拆分全部完成,所有读写路径统一走消息表,旧 JSON
列仅作老库兼容回退,不再写入。
2026-06-28 18:47:21 +08:00
10629f88e8 新增: 工作流条件表达式 UI 补齐中英文翻译
条件表达式引擎和执行器集成早已完成,工作流可视化组件也提供边
条件编辑入口。但前端用「中文兜底」说明翻译缺失,本次补齐:

中英文同步新增:
- 工作流结构标题 / 层标签 / 空态提示
- 边条件编辑器入口 / 无条件标签 / 输入提示

核验确认条件引擎全部链路(引擎/特性开关/执行器集成/UI/翻译)
均已落地,多会话并发架构的会话级隔离改造也已完成。
2026-06-28 18:34:22 +08:00
b5e159f190 更新: 关闭已完成的工具卡片拆分等待办
代码核验发现工具卡片和大文件重构任务实际已完成,文档标记滞后,
本次同步关闭对应待办:

工具卡片拆分(原 1527 行 → 373 行):
- 审批状态机独立为单独文件
- 头部逻辑 / 渲染逻辑 / 通用工具函数各自独立

大型文件拆分:
- 主聊天组件抽子组件后大幅瘦身
- 命令注册按域拆为多个函数
- 通用 CRUD 拆为按表独立的仓库文件

类型强类型化重构评估为低优先级纯重构,留待后续技术债窗口处理。
2026-06-28 18:31:34 +08:00
55542abf41 新增: 审批超时后端兜底,覆盖重启恢复场景
前端已有审批超时定时器(设置页可选 0/5/15/30/60 分钟),负责会话
内运行的审批超时检测。但应用重启后前端定时器丢失,从数据库恢复
的待审批无人计时,会永久卡死会话。

本次补充后端兜底机制:

- 待审批记录创建时刻,用于超时判定
- 在审批恢复入口检测超时,超时自动取消并写入审计
- 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源

同时更新待办文档,把已实现项标记为完成。
2026-06-28 18:16:34 +08:00
3fac0a618d 修复: system_prompt 追加行为准则,减少 LLM 写脚本偏好 2026-06-28 17:21:35 +08:00
9839935782 新增: AI 上下文信息面板(系统提示/增强上下文可见) 2026-06-28 14:13:20 +08:00
4a396f4f88 新增: EnvSnapshot 环境感知注入 system_prompt
- EnvSnapshot 结构: OS/shell/工具版本/编码/路径分隔符
- OnceLock 全局缓存,启动时 spawn_blocking 探测一次
- to_prompt() 生成环境摘要注入 agentic loop system_prompt
- 探测: Windows 注册表/macOS sw_vers/Linux os-release + chcp 编码
- 工具版本: python/node/rust/go/docker/git 逐个探测,失败不阻塞
- 8 个单测覆盖版本号解析 + 平台 fallback
2026-06-28 14:05:16 +08:00
7adaf97377 修复: PowerShell 路径转义 + shell 失败提示 + 标题合并同 role + L0 防抖
- run_command 路径反斜杠传递修正
- 命令失败追加 PowerShell 适配提示引导 LLM 改正
- 标题生成前合并连续同 role 消息避免 Anthropic 1214
- V33 迁移追加 workflow_executions.updated_at 列
- ai-client-ready 3 秒防抖避免重复握手
2026-06-28 13:57:53 +08:00
6ffcba7e4d 新增: 审批超时可配置 + ScriptNode 命令白/黑名单
- 审批超时: Settings 下拉选择(不限时/5/15/30/60min),默认 15min
- getApprovalTimeoutMs() 从 KV 读取,startApprovalTimer 动态取值
- ScriptNode: 黑名单 > 白名单策略,从环境变量读取
- Settings 加命令执行安全面板(白/黑名单文本框)
- i18n 中英文案 + 搜索索引补全
2026-06-28 13:49:19 +08:00
b157bc9077 修复: relay token 改环境变量 + api_key 脱敏 + 路径规范化 + create_project 自动建目录
- relay token 必设 DF_RELAY_TOKEN 环境变量,移除硬编码默认
- AiProviderRecord Debug 脱敏 api_key/model_configs/config
- ScriptNode 危险命令告警(rm -rf/DROP TABLE 等)
- bind_directory 路径规范化,拒绝含 .. 的原始路径
- create_project 目录不存在时自动创建
- probe_pwsh OnceLock 全局缓存,异步探测不阻塞 tokio
- retry jitter 改 rand,范围扩大到 ±50%
- EventBus send 错误改 tracing::warn
- StateMachine 文档警告不得在 await 期间持锁
2026-06-28 13:39:22 +08:00
f776336eb1 更新: 对话诊断待办项
- 最新对话 d9aef24f 分析: 审批死锁 + Windows 兼容 + 标题失败
- P0: pending 超时自动取消 + create_project auto_create_dir
- P1: run_command shell 适配提示 + PowerShell 反斜杠转义
- P2: 标题摘要合并连续同 role 防 GLM 1214
- P3: migration V33 + L0-handshake 防抖
2026-06-28 13:14:54 +08:00
ad1821bc14 修复: 审批不超时+重启恢复 + 其他小改
- APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时)
- V33 迁移: ai_conversations 加 pending_approvals 列
- save_conversation 持久化 pending_approvals
- ai_conversation_switch 从 DB 恢复 pending_approvals
- switchConversation 加 try/catch(对话不存在→建新)
- @项目 关联添加取消按钮(×)
- TopBar 底部无标题时图标右对齐
- TaskDetail.vue 删除重复 case
2026-06-28 13:09:55 +08:00
c4b02b5370 修复: 消息溯源P2切读确认完成 + 走查报告高优项
- P2 切读方案文档更新(确认批次B已上线)
- query LIKE 通配符转义(project/idea/task/knowledge 4 repo)
- dag.rs deep_merge null 覆盖全局配置
- dag.rs 通配 match 展开显式变体
- INDEX.md 补漏 10 个文档索引
- ARCHITECTURE.md 删除与新文档逐字重复
- coordinator.rs 加 #[deprecated] 编译守卫
- AiChat.vue 空值传播加 console.warn
2026-06-28 05:06:51 +08:00
fee856129c 新增: 全量走查报告 — 代码/前端/文档体系
- Rust 后端 12 crate: 锁/LIKE 通配符/jitter/路径穿越 等 15 项
- Vue 3 前端: Arco 残留/confirm 重复/类型退化 等 13 项
- 文档体系: 工具清单过时/INDEX 漏列 9 文档/死链/SSOT 违反 等 9 项
2026-06-28 04:53:33 +08:00
ead490783f 修复: AiCompleted 携带 pinned_goals 根治目标面板刷新 2026-06-28 04:51:08 +08:00
dcc3f0d230 新增: 三层模型设计文档 — 模板/工作流/人设体系
- ARCHITECTURE.md 补充执行层级模型 + 重写 §七 三层模型章节
- 新建专项设计: 流程模板 YAML 规范/AgentPersona 数据结构/实例化流程
- 注册新文档到架构设计 INDEX
- Agent架构说明补充前向关联引用
2026-06-28 04:44:01 +08:00
d1b9488853 修复: status→enum + type alias(SMELL-P1-6) 2026-06-28 04:42:59 +08:00
48c966f6f7 更新: 归档今日决策 + 标记已解决项 + 执行计划 2026-06-28 04:12:16 +08:00
7e3fe1961a 修复: execution_id String→ExecutionId 类型别名 + 更新待决策状态 2026-06-28 03:50:25 +08:00
8723075360 重构: TopBar 布局 — header 中间区 + 底部工具区(标题/目标/历史/摘要) 2026-06-28 03:31:41 +08:00
53e1c1da77 重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
2026-06-28 03:31:40 +08:00
e4ceb0015b 新增: Conditions 启用 + 工作流 DAG 展示 + 决策记录
- 开启 conditions-eval feature flag(df-workflow 默认启用)
- 新增 WorkflowDagDisplay.vue 展示工作流节点/边/条件
- TaskDetail 工作流推进后自动获取并显示 DAG 结构
- 记录 2026-06-27 讨论决策到待决策.md
- 记录 Inbox 设计想法到 todo.md
2026-06-28 01:17:36 +08:00
22362a77b8 新增: 审批挂起时长提示(BUG-260624-02 方案 C)
背景:AI 调用需审批的工具后,ToolCard 弹审批卡常驻直到用户批/拒,
用户走开忘了批则 AI loop 卡死,且卡片无任何时长提示。

方案选型(A/B/C 三选一,选 C):
  A 不做 - 挂起审批容易被遗忘
  B 加超时+倒计时 - 超时后行为难定义(自动拒丢改动/自动批危险)
  C 显示挂起时长(本方案) - 纯前端展示,不动后端,不改审批行为

实现:
- ToolCard pending_approval 态显示已等待 mm:ss
- >2分钟橙色脉冲,>5分钟红色脉冲
- 注释中标注方案选型理由,便于后人理解决策上下文
2026-06-28 00:41:00 +08:00
cf223fc08b 修复: .trash WARN 降级 + ToolCard 分组标题增强
- .trash 跨盘迁移从 WARN 降为 DEBUG(预期行为,非异常)
- ToolCard 分组标题增加文件路径摘要(取前3个文件名),
  收起态也能看到影响了哪些文件
2026-06-28 00:25:37 +08:00
b7b004dd68 新增: Phase 4 注入 — @[项目] 关联任务/灵感 + 三个工具 path 可选
- Augmentation 新增 extra 字段(项目关联信息/任务关联信息/灵感关联信息)
- ProjectResolver: @[项目] 时自动查前5条进行中任务+前3条待评估灵感
- TaskResolver/IdeaResolver: extra 预留(后续按需追加)
- render_one 渲染 extra 行
- search_files/grep/list_directory path 改为可选,不传时返回引导提示
2026-06-28 00:19:13 +08:00
b7b54eb2a6 修复: patch_file 小改动自动放行 + tool_result JSON 压缩 + prompt 项目路径
- C-260627: patch_file ≤5 行自动放行,不阻塞 AI 工作流
- tool_result 压缩: JSON 数组截断到 10 条 + _truncated 标记
- system_prompt: 项目清单增加「目录:」路径信息
2026-06-27 23:59:07 +08:00
6212002270 修复: read_symbol 搜不到 const 声明的符号
- 新增 variable_declarator 到 DEFINITION_KINDS
- Vue 3 + TS 大量使用 const fn = () => {} 箭头函数
  AST 节点是 variable_declarator,原本不在列表中
  导致 read_symbol 对 Vue 文件频繁 fallback 回 read_file
2026-06-27 23:42:14 +08:00
7cd833b1e5 修复: 文件日志 + G1 日志增强 + 清理死代码
- 文件日志: tracing-subscriber + tracing-appender 写入 %TEMP%/devflow-trace.log
- G1 日志: 增加 first_goal 字段显示首条目标内容
- 清理: 删 workspace_root_str / data_dir_path 死代码
- 标注: default_with_root 加 cfg_attr 抑制 non-test 编译警告
2026-06-27 23:27:31 +08:00
5c539fe764 修复: 前端 TS 阻断 + G1/G2 单测 + 归档已完成项
- 修复 vue-tsc 3 阻断: i18n 重复 err key 删除 + store 补 relateIdeas
- G1: extract_pinned_goal 单测 10 条(mention剥离/截断/空输入/全角冒号)
- G2: is_empty_tool_result 单测 11 条(JSON标记/中文/英文/非空判定)
- 归档: todo.md/docs/todo.md/待决策.md/待审查.md 已完成项迁入归档
2026-06-27 22:35:37 +08:00
6e1485e4f9 修复: BUG-260620-05 工程内路径弹窗 + G1 多目标累积 + 测试编译债
- BUG-260620-05: reload_allowed_dirs 无条件插入 workspace_root,工程内路径免授权
- G1: pinned_goal → pinned_goals Vec<String>,多目标追加去重 + MAX_GOALS=5
- tool_registry.rs 测试 4 处补 data_dir 形参
- 更新 todo.md / docs/todo.md 状态
2026-06-27 22:02:41 +08:00
96 changed files with 4899 additions and 3443 deletions

View File

@@ -19,7 +19,9 @@
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用 - **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板 - **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
### 层级模型 ### 层级模型(业务层级)
DevFlow 的业务抽象分三层,自上而下层层实例化:
``` ```
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估 💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
@@ -31,6 +33,40 @@
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布 └→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
``` ```
### 层级模型(执行层级)
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
```
┌──────────────────────────────────────────────────┐
│ 模板层 (Template) │
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
│ │
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
│ 生命周期: 长期存在,跨项目复用 │
│ 存储: YAML 文件 / DB 模板库 │
├──────────────────────────────────────────────────┤
│ 工作流层 (Workflow) │
│ "怎么执行" — DAG 实例、状态机、运行时 │
│ │
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
│ 生命周期: 随项目启动/结束,单次执行后归档 │
│ 载体: df-workflow (DAG + Executor + StateMachine) │
├──────────────────────────────────────────────────┤
│ 人设层 (Persona) │
│ "谁来做" — Agent 角色、能力边界、行为风格 │
│ │
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
│ 生命周期: 长期存在,跨节点复用 │
│ 注入点: AINode 执行时载入对应人设 │
└──────────────────────────────────────────────────┘
```
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
- 人设 = Agent 的角色卡system prompt + 工具集 + 行为规则)
### AI Working 定位体系 ### AI Working 定位体系
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的主要操作者,人是监督者与决策者**。 DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的主要操作者,人是监督者与决策者**。
@@ -497,15 +533,21 @@ CREATE TABLE app_settings (
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
## 七、阶段模板 ## 七、三层模型:模板 → 工作流 → 人设
5 个内置阶段作为工作流模板YAML 定义),用户可自定义。 > 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分 ### 7.1 三层定义
- 📋 **需求**AI 生成 PRD → 人工审阅 → 任务拆解
- 💻 **编码**AI 编码 → 代码审查 → 自动修复 | 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证 |----|-----------|------|---------|---------|
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查 | **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
| **人设 (Persona)** | 谁来做 | Agent 角色卡system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
## 八、Phase 规划 ## 八、Phase 规划
@@ -528,9 +570,10 @@ CREATE TABLE app_settings (
- 前端:想法池视图 + 多项目 Tab - 前端:想法池视图 + 多项目 Tab
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行 - 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) ### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
- df-nodes (Docker/Git/Human/HTTP) - df-nodes (Docker/Git/Human/HTTP)
- ~~df-stages (5 阶段模板)~~ — 已移除2026-06-14 零引用清理 - 流程模板系统YAML 定义 + 模板库 + 实例化引擎
- 人设系统AgentPersona 数据结构 + 内置人设 + 工具过滤)
- 条件分支 + 断点续跑 - 条件分支 + 断点续跑
- 验证:跑通标准产研流程模板 - 验证:跑通标准产研流程模板
@@ -566,3 +609,6 @@ CREATE TABLE app_settings (
8. **本地优先**SQLite 嵌入,不依赖云服务 8. **本地优先**SQLite 嵌入,不依赖云服务
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型 9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
10. **流式优先**AI 输出、Shell 输出全部流式推送到前端 10. **流式优先**AI 输出、Shell 输出全部流式推送到前端
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设

22
Cargo.lock generated
View File

@@ -859,6 +859,8 @@ dependencies = [
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber",
"tree-sitter", "tree-sitter",
"tree-sitter-go", "tree-sitter-go",
"tree-sitter-java", "tree-sitter-java",
@@ -879,6 +881,7 @@ dependencies = [
"df-types", "df-types",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"rand",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@@ -4201,6 +4204,12 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -5039,6 +5048,19 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"

View File

@@ -1031,6 +1031,18 @@
--- ---
### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败
> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
> 7 次工具连续失败后 L1 断路器熔断1 条 pending 审批导致全链路死锁。
- [ ] **P0 — 审批 pending 超时自动取消**`try_continue` 入口清理 >5min 的 pending。
- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。
- [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。
- [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。
- [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。
- [ ] **P3 — migration V33 + L0-handshake 防抖**
## 七、开发约定 ## 七、开发约定
### 构建命令 ### 构建命令

View File

@@ -17,3 +17,4 @@ tracing = { workspace = true }
reqwest = { version = "0.12", features = ["stream", "json"] } reqwest = { version = "0.12", features = ["stream", "json"] }
futures = "0.3" futures = "0.3"
eventsource-stream = "0.2" eventsource-stream = "0.2"
rand = "0.8"

View File

@@ -341,6 +341,14 @@ pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
pub const TOOL_RESULT_HEAD_LINES: usize = 5; pub const TOOL_RESULT_HEAD_LINES: usize = 5;
/// extract_key_info 保留的尾部行数。 /// extract_key_info 保留的尾部行数。
pub const TOOL_RESULT_TAIL_LINES: usize = 5; pub const TOOL_RESULT_TAIL_LINES: usize = 5;
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
/// ///
@@ -374,6 +382,54 @@ pub fn should_summarize_tool_result(
/// ///
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。 /// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String { pub fn extract_key_info(content: &str, tool_name: &str) -> String {
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(obj) = val.as_object_mut() {
let mut truncated = false;
for (_key, field) in obj.iter_mut() {
// 数组截断
if let Some(arr) = field.as_array() {
if arr.len() > TOOL_RESULT_MAX_ARRAY {
*field = serde_json::Value::Array(
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
);
truncated = true;
}
}
// 字符串字段:先按行数截断,若不足再按字符数截断
if let Some(s) = field.as_str() {
let lines: Vec<&str> = s.lines().collect();
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if lines.len() > kept {
let mut out: Vec<&str> = Vec::new();
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
out.push("... (压缩中间内容) ...");
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
*field = serde_json::Value::String(out.join("\n"));
truncated = true;
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
*field = serde_json::Value::String(format!(
"{}...(截断,原始 {} 字符)...{}",
head, s.chars().count(), tail
));
truncated = true;
}
}
}
if truncated {
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
}
}
// JSON 解析成功但无需截断 → 原样返回
return content.to_string();
}
// 非 JSON 纯文本:按行数截断
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() { if lines.is_empty() {
return String::new(); return String::new();
@@ -383,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let total = lines.len(); let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary { if total <= kept_boundary {
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
// 按字符数截断保证压缩至少生效。
let char_count = content.chars().count();
if char_count > TOOL_RESULT_CHAR_LIMIT {
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
return format!(
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
tool_name, char_count, head, tail
);
}
return content.to_string(); return content.to_string();
} }
@@ -877,18 +944,35 @@ mod tests {
} }
#[test] #[test]
fn extract_key_info_single_line_no_newline_unchanged() { fn extract_key_info_single_line_short_no_newline_unchanged() {
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回 // 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
let content = "single line no newline"; let content = "single line no newline";
assert_eq!(extract_key_info(content, "read_file"), content); assert_eq!(extract_key_info(content, "read_file"), content);
} }
#[test] #[test]
fn extract_key_info_single_huge_line_no_newline_unchanged() { fn extract_key_info_single_huge_line_no_newline_compressed() {
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分) // BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
let content = "x".repeat(50_000); let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file"); let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "单行无换行应原样返回(即使超长)"); assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("已压缩"), "应含压缩标记");
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
assert!(result.contains("(截断)"), "应含截断标记");
}
#[test]
fn extract_key_info_json_huge_string_field_truncated() {
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
assert!(result.contains("_truncated"), "应含 _truncated 标记");
assert!(result.contains("src/main.rs"), "应保留 path 字段");
assert!(result.contains("(截断)"), "应含截断标记");
} }
#[test] #[test]

View File

@@ -5,6 +5,7 @@
/// Agent 协调器 /// Agent 协调器
/// ///
/// TODO: 实现多 Agent 协作逻辑 /// TODO: 实现多 Agent 协作逻辑
#[deprecated(note = "B 路线占位,勿用于生产")]
pub struct AgentCoordinator; pub struct AgentCoordinator;
impl AgentCoordinator { impl AgentCoordinator {

View File

@@ -15,8 +15,9 @@
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。 //! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
use std::future::Future; use std::future::Future;
use std::time::{Duration, SystemTime}; use std::time::Duration;
use rand::Rng;
use tracing::warn; use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。 /// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。 /// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
const JITTER_RATIO: f64 = 0.2; /// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
/// ///
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
} }
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。 /// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s各 ±20% jitter。 /// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s各 ±50% jitter。
/// ///
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。 /// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// ///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1 /// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 /// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter")避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration { pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000; let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例 // ±50% jitter相对 base 时长的浮动比例
let nanos = SystemTime::now() let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
.duration_since(SystemTime::UNIX_EPOCH) let factor = 1.0 + jitter_ratio * JITTER_RATIO;
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
let ms = (base_ms as f64 * factor).max(0.0) as u64; let ms = (base_ms as f64 * factor).max(0.0) as u64;
Duration::from_millis(ms) Duration::from_millis(ms)
} }

View File

@@ -0,0 +1,432 @@
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
//!
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
//!
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
//! 阻断主流程)。
use std::sync::OnceLock;
/// 环境快照 — 系统环境的不可变视图。
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnvSnapshot {
/// 操作系统族:"windows" / "macos" / "linux"
pub os: String,
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
pub os_version: String,
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
pub shell: String,
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
pub path_sep: String,
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
pub encoding: String,
/// 工具版本(逐个探测,缺失为 None)
pub tools: ToolVersions,
}
/// 各开发工具的版本信息。
#[derive(Debug, Clone, serde::Serialize, Default)]
pub struct ToolVersions {
pub python: Option<String>,
pub node: Option<String>,
pub rust: Option<String>,
pub go: Option<String>,
pub docker: Option<String>,
pub git: Option<String>,
}
impl EnvSnapshot {
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
///
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
pub async fn detect() -> &'static EnvSnapshot {
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
if let Some(snap) = SNAPSHOT.get() {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
.await
.unwrap_or_else(|_| EnvSnapshot::fallback());
// 多任务竞态:均等价,以先到者为准。
let _ = SNAPSHOT.set(snap);
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
}
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
fn do_detect() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: detect_os_version(),
shell: detect_shell(),
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: detect_encoding(),
tools: ToolVersions {
python: probe_version("python", &["--version"]),
node: probe_version("node", &["--version"]),
rust: probe_version("rustc", &["--version"]),
go: probe_version("go", &["version"]),
docker: probe_version("docker", &["--version"]),
git: probe_version("git", &["--version"]),
},
}
}
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
fn fallback() -> EnvSnapshot {
EnvSnapshot {
os: std::env::consts::OS.to_string(),
os_version: String::new(),
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
path_sep: std::path::MAIN_SEPARATOR.to_string(),
encoding: "utf-8".into(),
tools: ToolVersions::default(),
}
}
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
///
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
pub fn to_prompt(&self) -> String {
let mut lines: Vec<String> = vec![
"## 系统环境".to_string(),
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
format!("- Shell: {}", self.shell),
format!("- 路径分隔符: {}", self.path_sep),
format!("- 终端编码: {}", self.encoding),
];
if let Some(v) = &self.tools.python {
lines.push(format!("- Python: {}", v));
}
if let Some(v) = &self.tools.node {
lines.push(format!("- Node: {}", v));
}
if let Some(v) = &self.tools.rust {
lines.push(format!("- Rust: {}", v));
}
if let Some(v) = &self.tools.go {
lines.push(format!("- Go: {}", v));
}
if let Some(v) = &self.tools.docker {
lines.push(format!("- Docker: {}", v));
}
if let Some(v) = &self.tools.git {
lines.push(format!("- Git: {}", v));
}
lines.push(String::new());
lines.push("注意: 请生成本平台兼容的命令。".to_string());
lines.join("\n")
}
}
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
fn detect_os_version() -> String {
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
#[cfg(windows)]
{
if let Some(v) = read_windows_version() {
return v;
}
return String::new();
}
// macOS:sw_vers -productVersion 输出如 "12.5"
#[cfg(target_os = "macos")]
{
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
if out.status.success() {
return String::from_utf8_lossy(&out.stdout).trim().to_string();
}
}
return String::new();
}
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
#[cfg(target_os = "linux")]
{
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
return rest.trim_matches('"').to_string();
}
}
}
return String::new();
}
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
{
String::new()
}
}
#[cfg(windows)]
fn read_windows_version() -> Option<String> {
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
let out = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v",
"DisplayVersion",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
// 输出形如: " DisplayVersion REG_SZ 22H2"
for line in text.lines() {
let trimmed = line.trim();
if let Some(idx) = trimmed.find("REG_SZ") {
let val = trimmed[idx + "REG_SZ".len()..].trim();
if !val.is_empty() {
return Some(format!("Windows {}", val));
}
}
}
None
}
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
fn detect_shell() -> String {
#[cfg(windows)]
{
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
return "pwsh".to_string();
}
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
return "powershell".to_string();
}
return "cmd".to_string();
}
#[cfg(not(windows))]
{
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
if let Ok(sh) = std::env::var("SHELL") {
// 取 basename(/bin/zsh → zsh)
let name = sh.rsplit('/').next().unwrap_or(&sh);
if !name.is_empty() {
return name.to_string();
}
}
"sh".to_string()
}
}
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
fn detect_encoding() -> String {
#[cfg(windows)]
{
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
if let Ok(out) = std::process::Command::new("chcp")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(0x0800_0000)
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
if let Some(code) = extract_codepage(&text) {
return match code.as_str() {
"65001" => "utf-8".to_string(),
"936" => "gbk".to_string(),
"950" => "big5".to_string(),
other => format!("cp{}", other),
};
}
}
return "utf-8".to_string();
}
#[cfg(not(windows))]
{
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
if let Ok(lang) = std::env::var("LANG") {
if lang.to_ascii_uppercase().contains("UTF-8") {
return "utf-8".to_string();
}
}
"utf-8".to_string()
}
}
#[cfg(windows)]
fn extract_codepage(text: &str) -> Option<String> {
// 提取末尾数字("...936" / "...: 65001")。
let mut num = String::new();
for c in text.chars().rev() {
if c.is_ascii_digit() {
num.insert(0, c);
} else if !num.is_empty() {
break;
}
}
if num.is_empty() {
None
} else {
Some(num)
}
}
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
///
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
let mut cmd = std::process::Command::new(tool);
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let first_line = text.lines().next()?;
Some(extract_version_token(first_line))
}
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
///
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
fn extract_version_token(line: &str) -> String {
for token in line.split_whitespace() {
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
// "2.41.0.windows.1",实际应止于 "2.41.0")。
let bytes = token.as_bytes();
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
Some(i) => i,
None => continue,
};
let mut head = String::new();
loop {
// 收数字段
let seg_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
head.push_str(&token[seg_start..i]);
// 点号:仅当下一字符为数字时才续,否则收尾
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit()
{
head.push('.');
i += 1;
continue;
}
break;
}
if head.contains('.') {
return head;
}
}
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
line.trim().to_string()
}
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::Stdio;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_version_python_style() {
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
}
#[test]
fn extract_version_node_style() {
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
}
#[test]
fn extract_version_git_style() {
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
}
#[test]
fn extract_version_go_style() {
assert_eq!(
extract_version_token("go version go1.21.0 windows/amd64"),
"1.21.0"
);
}
#[test]
fn extract_version_no_match_returns_line() {
assert_eq!(extract_version_token("no version here"), "no version here");
}
#[cfg(windows)]
#[test]
fn extract_codepage_parsing() {
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
assert_eq!(
extract_codepage("Active code page: 65001"),
Some("65001".into())
);
assert_eq!(extract_codepage("no digits here"), None);
}
#[tokio::test]
async fn detect_returns_cached_static_ref() {
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
let a = EnvSnapshot::detect().await as *const _;
let b = EnvSnapshot::detect().await as *const _;
assert_eq!(a, b, "detect() 应返回同一静态引用");
}
#[test]
fn to_prompt_contains_os_and_shell() {
let snap = EnvSnapshot {
os: "test_os".into(),
os_version: "v1".into(),
shell: "test_shell".into(),
path_sep: "/".into(),
encoding: "utf-8".into(),
tools: ToolVersions {
python: Some("3.11".into()),
node: None,
rust: None,
go: None,
docker: None,
git: None,
},
};
let prompt = snap.to_prompt();
assert!(prompt.contains("test_os"));
assert!(prompt.contains("test_shell"));
assert!(prompt.contains("Python: 3.11"));
assert!(!prompt.contains("Node"));
assert!(prompt.contains("请生成本平台兼容的命令"));
}
}

View File

@@ -1,3 +1,6 @@
//! df-execute: 执行运行时 — Shell //! df-execute: 执行运行时 — Shell + 环境感知
pub mod env_snapshot;
pub mod shell; pub mod shell;
pub use env_snapshot::EnvSnapshot;

View File

@@ -37,22 +37,36 @@ impl Default for ShellType {
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`, // BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。 // PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。 // 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
if cfg!(windows) { if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell } if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
} else { } else {
ShellType::Sh ShellType::Sh
} }
} }
} }
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
fn probe_pwsh_cached() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
CACHE.get().copied().unwrap_or(false)
}
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。 /// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
/// ///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。 /// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快), /// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。 /// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
fn probe_pwsh() -> bool { ///
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
async fn probe_pwsh() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| { if let Some(cached) = CACHE.get() {
return *cached;
}
let result = tokio::task::spawn_blocking(|| {
let mut cmd = std::process::Command::new("pwsh"); let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0"); cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
@@ -63,6 +77,11 @@ fn probe_pwsh() -> bool {
} }
cmd.status().map(|s| s.success()).unwrap_or(false) cmd.status().map(|s| s.success()).unwrap_or(false)
}) })
.await
.unwrap_or(false);
// 多任务竞态时以先到者为准,均等价
let _ = CACHE.set(result);
result
} }
/// Shell 命令执行请求 /// Shell 命令执行请求
@@ -88,6 +107,10 @@ pub struct ShellRequest {
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> { pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
#[cfg(windows)]
let _ = probe_pwsh().await;
let shell_type = request.shell_type.unwrap_or_default(); let shell_type = request.shell_type.unwrap_or_default();
let mut cmd = match shell_type { let mut cmd = match shell_type {
ShellType::PowerShell => { ShellType::PowerShell => {

View File

@@ -17,7 +17,7 @@ use std::sync::Arc;
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
use df_types::types::new_id; use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
use futures::future::BoxFuture; use futures::future::BoxFuture;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active"); let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name); medium_audit("create_project", &name);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: new_id(), id: new_id(),
name, name,
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
}; };
let name = arg_str_or(&args, "name", ""); let name = arg_str_or(&args, "name", "");
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "active"); let status = arg_str_or(&args, "status", "planning");
medium_audit("update_project", &id); medium_audit("update_project", &id);
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); let repo = ProjectRepo::new(&db);
@@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Err(e) => return err_str(e), Err(e) => return err_str(e),
}; };
let now = now_millis(); let now = now_millis();
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
name, name,
@@ -322,6 +324,10 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}")); medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move { Box::pin(async move {
let repo = ProjectRepo::new(&db); let repo = ProjectRepo::new(&db);
// 拒绝原始路径含 `..`(防穿越)
if path.contains("..") {
return CallToolResult::error(format!("路径不得包含 '..': {}", path));
}
let norm = normalize_path(&path); let norm = normalize_path(&path);
// 路径冲突检测 // 路径冲突检测
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() { if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
@@ -330,8 +336,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
conflict.name, conflict.id conflict.name, conflict.id
)); ));
} }
// 仅更新 path 字段(用 update_field,保留其它) // 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) { if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
return CallToolResult::error(format!("项目不存在: {id}")); return CallToolResult::error(format!("项目不存在: {id}"));
} }
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
@@ -355,7 +361,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
list.retain(|t| t.project_id == *pid); list.retain(|t| t.project_id == *pid);
} }
if let Some(st) = &status_filter { if let Some(st) = &status_filter {
list.retain(|t| t.status == *st); list.retain(|t| t.status.as_str() == st.as_str());
} }
json_ok(json!({ "tasks": list, "count": list.len() })) json_ok(json!({ "tasks": list, "count": list.len() }))
} }
@@ -384,7 +390,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
project_id, project_id,
title, title,
description, description,
status: "todo".to_owned(), status: TaskStatus::Todo,
priority, priority,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -529,7 +535,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
id: new_id(), id: new_id(),
title, title,
description, description,
status: "draft".to_owned(), status: IdeaStatus::Draft,
priority, priority,
score: None, score: None,
tags: None, tags: None,

View File

@@ -273,6 +273,7 @@ mod tests {
use df_storage::crud::{ProjectRepo, TaskRepo}; use df_storage::crud::{ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
use serde_json::json; use serde_json::json;
// ============================================================ // ============================================================
@@ -287,7 +288,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -302,7 +303,7 @@ mod tests {
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: "t1".to_string(), title: "t1".to_string(),
description: "实现登录接口".to_string(), description: "实现登录接口".to_string(),
status: "testing".to_string(), status: TaskStatus::Testing,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,

View File

@@ -39,6 +39,32 @@ impl Node for ScriptNode {
shell_type: Default::default(), shell_type: Default::default(),
}; };
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
// - 黑名单匹配时:直接拒绝执行
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
if let Some(denied) = check_command_policy(&cmd_name) {
tracing::warn!(
command = %command,
reason = %denied,
"ScriptNode 命令被策略拒绝"
);
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
}
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
for &kw in &dangerous_keywords {
if command.contains(kw) {
tracing::warn!(
keyword = %kw,
command = %command,
"ScriptNode 即将执行包含危险关键词的命令"
);
}
}
tracing::info!("ScriptNode 执行命令: {}", command); tracing::info!("ScriptNode 执行命令: {}", command);
let result = df_execute::shell::execute(request).await?; let result = df_execute::shell::execute(request).await?;
@@ -93,3 +119,41 @@ impl Node for ScriptNode {
"script" "script"
} }
} }
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
///
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
///
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝
///
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
///
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令)
if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") {
let blacklist: Vec<String> = blacklist_raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if blacklist.iter().any(|c| c == cmd_name) {
return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)");
}
}
// 白名单:非空时命令名必须在白名单中才放行
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
let whitelist: Vec<String> = whitelist_raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
return Some("命令不在白名单中 (DF_SCRIPT_WHITELIST)");
}
}
None
}

View File

@@ -174,14 +174,15 @@ mod tests {
use super::*; use super::*;
use df_storage::crud::ProjectRepo; use df_storage::crud::ProjectRepo;
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
fn rec(id: &str, status: &str) -> TaskRecord { fn rec(id: &str, status: TaskStatus) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "p1".to_string(), project_id: "p1".to_string(),
title: format!("t-{id}"), title: format!("t-{id}"),
description: "".to_string(), description: "".to_string(),
status: status.to_string(), status,
priority: 2, priority: 2,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -206,7 +207,7 @@ mod tests {
id: "p1".to_string(), id: "p1".to_string(),
name: "proj".to_string(), name: "proj".to_string(),
description: "".to_string(), description: "".to_string(),
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -221,43 +222,43 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn forward_path_todo_to_done() { async fn forward_path_todo_to_done() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 主路径逐级推进 // 主路径逐级推进
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
assert_eq!(r.status, "testing"); assert_eq!(r.status.as_str(), "testing");
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
assert_eq!(r.status, "done"); assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
} }
#[tokio::test] #[tokio::test]
async fn regression_in_review_to_in_progress_bumps_rounds() { async fn regression_in_review_to_in_progress_bumps_rounds() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1, "退回应 +1"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn regression_testing_to_in_review_bumps_rounds() { async fn regression_testing_to_in_review_bumps_rounds() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "testing")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "退回应 +1"); assert_eq!(r.review_rounds, 1, "退回应 +1");
} }
#[tokio::test] #[tokio::test]
async fn multiple_regressions_accumulate() { async fn multiple_regressions_accumulate() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_review")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2) // in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
@@ -269,7 +270,7 @@ mod tests {
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, expected } => { df_types::error::Error::InvalidState { current, expected } => {
@@ -285,7 +286,7 @@ mod tests {
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
match err { match err {
df_types::error::Error::InvalidState { current, .. } => { df_types::error::Error::InvalidState { current, .. } => {
@@ -301,7 +302,7 @@ mod tests {
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
match err { match err {
df_types::error::Error::Validation(msg) => { df_types::error::Error::Validation(msg) => {
@@ -314,7 +315,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_target_rejected() { async fn invalid_target_rejected() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err(); let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
assert!(matches!(err, df_types::error::Error::Validation(_))); assert!(matches!(err, df_types::error::Error::Validation(_)));
} }
@@ -332,7 +333,7 @@ mod tests {
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖), // 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
// 而是验证读后改路径在 from=当前库内 status 时正常推进。 // 而是验证读后改路径在 from=当前库内 status 时正常推进。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径; // 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic) // F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
repo.advance_status_atomic("t1", "todo", "in_progress", false) repo.advance_status_atomic("t1", "todo", "in_progress", false)
@@ -340,13 +341,13 @@ mod tests {
.unwrap(); .unwrap();
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功 // 读出来是 in_progress,推进到 in_review 合法 → 正常成功
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
} }
#[tokio::test] #[tokio::test]
async fn cas_returns_none_when_status_mismatch() { async fn cas_returns_none_when_status_mismatch() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap(); repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress) // 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
let r = repo let r = repo
.advance_status_atomic("t1", "todo", "in_review", false) .advance_status_atomic("t1", "todo", "in_review", false)
@@ -365,12 +366,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn blocked_round_trip_does_not_bump() { async fn blocked_round_trip_does_not_bump() {
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap(); repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
assert_eq!(r.status, "blocked"); assert_eq!(r.status.as_str(), "blocked");
assert_eq!(r.review_rounds, 0, "进 blocked 不累加"); assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap(); let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加"); assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
} }
@@ -460,24 +461,24 @@ mod tests {
async fn callback_completed_advance_lands_in_db() { async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成) // in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await; let repo = setup().await;
repo.insert(rec("c1", "todo")).await.unwrap(); repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
let to = callback_advance_target("completed", "in_progress").unwrap(); let to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds"); assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过) // testing: in_review → testing(②-3 testing 模板自审+核对通过)
repo.insert(rec("c2", "in_review")).await.unwrap(); repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("completed", "testing").unwrap(); let to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
assert_eq!(r.status, "testing"); assert_eq!(r.status.as_str(), "testing");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过) // done: testing → done(②-3 done 模板最终核对通过)
repo.insert(rec("c3", "testing")).await.unwrap(); repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("completed", "done").unwrap(); let to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap(); let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
assert_eq!(r.status, "done"); assert_eq!(r.status.as_str(), "done");
assert_eq!(r.review_rounds, 0); assert_eq!(r.review_rounds, 0);
} }
@@ -487,32 +488,32 @@ mod tests {
async fn callback_failed_regression_lands_in_db_with_rounds_bump() { async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1) // testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await; let repo = setup().await;
repo.insert(rec("f1", "testing")).await.unwrap(); repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
let to = callback_advance_target("failed", "testing").unwrap(); let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review"); assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap(); let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
assert_eq!(r.status, "in_review"); assert_eq!(r.status.as_str(), "in_review");
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)"); assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定): // in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射, // in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。 // 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
repo.insert(rec("f2", "in_review")).await.unwrap(); repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
let to = callback_advance_target("failed", "in_review").unwrap(); let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress"); assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap(); let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
assert_eq!(r.status, "in_progress"); assert_eq!(r.status.as_str(), "in_progress");
assert_eq!(r.review_rounds, 1); assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。 // 验证:callback 返回 None,不调 advance_task_atomic。
repo.insert(rec("f3", "in_progress")).await.unwrap(); repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
let to = callback_advance_target("failed", "in_progress"); let to = callback_advance_target("failed", "in_progress");
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
// 任务状态未被改动(仍是 in_progress) // 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap(); let still = repo.get_by_id("f3").await.unwrap().unwrap();
assert_eq!(still.status, "in_progress", "None 退回不应改动 status"); assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
} }
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。 /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。

View File

@@ -32,13 +32,12 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState}; use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result}; use crate::error::{RelayError, Result};
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。 /// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。 /// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
/// 读取期望 token(env 优先,fallback 硬编码)
fn expected_token() -> String { fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string()) std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
panic!("必须设置环境变量 DF_RELAY_TOKEN")
})
} }
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)

View File

@@ -84,6 +84,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
pinned: row.get::<_, i32>("pinned")? != 0, pinned: row.get::<_, i32>("pinned")? != 0,
prompt_tokens: row.get("prompt_tokens")?, prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?, completion_tokens: row.get("completion_tokens")?,
pinned_goals: row.get("pinned_goals")?,
pending_approvals: row.get("pending_approvals")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -159,24 +161,24 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at) "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.created_at, rec.updated_at rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
params![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.updated_at, rec.id rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
], ],
) )
} }

View File

@@ -9,6 +9,7 @@ use df_types::error::Result;
use crate::db::Database; use crate::db::Database;
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo; use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name}; use super::{now_millis_str, storage_err, validate_column_name};
@@ -93,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
id: row.get("id")?, id: row.get("id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
IdeaStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
score: row.get("score")?, score: row.get("score")?,
tags: row.get("tags")?, tags: row.get("tags")?,
@@ -200,7 +204,7 @@ impl_repo!(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at) "INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
rec.id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.created_at, rec.updated_at rec.scores, rec.related_ids, rec.created_at, rec.updated_at
], ],
@@ -210,7 +214,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13", "UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
params![ params![
rec.title, rec.description, rec.status, rec.priority, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis, rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id rec.scores, rec.related_ids, rec.updated_at, rec.id
], ],
@@ -297,10 +301,11 @@ impl IdeaRepo {
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -541,7 +546,8 @@ impl KnowledgeRepo {
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。 /// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> { pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let pattern = format!("%{}%", query); let escaped = query.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let kind = kind.map(|s| s.to_owned()); let kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64; let limit_i = limit as i64;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -549,7 +555,7 @@ impl KnowledgeRepo {
let mut results = Vec::new(); let mut results = Vec::new();
if let Some(k) = &kind { if let Some(k) = &kind {
let mut stmt = guard let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4")) .prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -559,7 +565,7 @@ impl KnowledgeRepo {
} }
} else { } else {
let mut stmt = guard let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3")) .prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row)) .query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1403,7 +1409,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
title: title.to_string(), title: title.to_string(),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,

View File

@@ -219,6 +219,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectEventRepo) { async fn setup() -> (crate::db::Database, ProjectEventRepo) {
@@ -232,7 +233,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,

View File

@@ -6,6 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::ProjectStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::{ use crate::models::{
@@ -80,7 +81,10 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
id: row.get("id")?, id: row.get("id")?,
name: row.get("name")?, name: row.get("name")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
ProjectStatus::from_db_str(&s).unwrap_or_default()
},
idea_id: row.get("idea_id")?, idea_id: row.get("idea_id")?,
path: row.get("path")?, path: row.get("path")?,
stack: row.get("stack")?, stack: row.get("stack")?,
@@ -162,7 +166,7 @@ impl_repo!(
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at) "INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
rec.id, rec.name, rec.description, rec.status, rec.idea_id, rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.created_at, rec.updated_at rec.path, rec.stack, rec.created_at, rec.updated_at
], ],
) )
@@ -171,7 +175,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8", "UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
params![ params![
rec.name, rec.description, rec.status, rec.idea_id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id rec.path, rec.stack, rec.updated_at, rec.id
], ],
) )
@@ -238,8 +242,9 @@ impl ProjectRepo {
if let Some(kw) = &q.keyword { if let Some(kw) = &q.keyword {
let trimmed = kw.trim(); let trimmed = kw.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)"); let escaped = trimmed.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{trimmed}%"); sql.push_str(" AND (name LIKE ? OR description LIKE ?) ESCAPE '\\'");
let pattern = format!("%{escaped}%");
params_vec.push(Box::new(pattern.clone())); params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern)); params_vec.push(Box::new(pattern));
} }

View File

@@ -295,6 +295,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::ProjectStatus;
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。 /// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectServiceRepo) { async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
@@ -308,7 +309,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,

View File

@@ -328,6 +328,7 @@ mod tests {
use super::*; use super::*;
use crate::crud::TaskRepo; use crate::crud::TaskRepo;
use crate::models::TaskRecord; use crate::models::TaskRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 /// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord { fn trec(id: &str, project_id: &str) -> TaskRecord {
@@ -336,7 +337,7 @@ mod tests {
project_id: project_id.to_string(), project_id: project_id.to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: "todo".to_string(), status: TaskStatus::Todo,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -369,7 +370,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_types::error::Result; use df_types::error::Result;
use df_types::types::TaskStatus;
use crate::db::Database; use crate::db::Database;
use crate::models::TaskRecord; use crate::models::TaskRecord;
@@ -24,7 +25,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
project_id: row.get("project_id")?, project_id: row.get("project_id")?,
title: row.get("title")?, title: row.get("title")?,
description: row.get("description")?, description: row.get("description")?,
status: row.get("status")?, status: {
let s: String = row.get("status")?;
TaskStatus::from_db_str(&s).unwrap_or_default()
},
priority: row.get("priority")?, priority: row.get("priority")?,
branch_name: row.get("branch_name")?, branch_name: row.get("branch_name")?,
assignee: row.get("assignee")?, assignee: row.get("assignee")?,
@@ -122,7 +126,7 @@ impl_repo!(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -134,7 +138,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17", "UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.queue, rec.parent_id, rec.content_json,
@@ -374,10 +378,11 @@ impl TaskRepo {
} }
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -566,20 +571,21 @@ mod tests {
use super::*; use super::*;
use crate::crud::ProjectRepo; use crate::crud::ProjectRepo;
use crate::models::ProjectRecord; use crate::models::ProjectRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。 /// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord { fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
trec_full(id, queue, parent_id, "todo") trec_full(id, queue, parent_id, TaskStatus::Todo)
} }
/// 全参 fixture(聚合测试需自定义 status 时用)。 /// 全参 fixture(聚合测试需自定义 status 时用)。
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord { fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: TaskStatus) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
project_id: "proj-1".to_string(), project_id: "proj-1".to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: String::new(), description: String::new(),
status: status.to_string(), status,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -605,7 +611,7 @@ mod tests {
id: "proj-1".to_string(), id: "proj-1".to_string(),
name: "proj-1".to_string(), name: "proj-1".to_string(),
description: String::new(), description: String::new(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -778,13 +784,13 @@ mod tests {
async fn set_status_for_aggregation_writes_status() { async fn set_status_for_aggregation_writes_status() {
let repo = setup().await; let repo = setup().await;
// 父任务初始 todo(queue=todo, parent_id=None 容器模型) // 父任务初始 todo(queue=todo, parent_id=None 容器模型)
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap(); let ok = repo.set_status_for_aggregation("parent", "in_progress");
assert!(ok, "应命中写入"); assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap(); let after = repo.get_by_id("parent").await.unwrap().unwrap();
assert_eq!(after.status, "in_progress", "status 应被聚合写入更新"); assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds"); assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
} }
@@ -792,7 +798,7 @@ mod tests {
async fn set_status_for_aggregation_skips_soft_deleted() { async fn set_status_for_aggregation_skips_soft_deleted() {
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false // 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
let repo = setup().await; let repo = setup().await;
repo.insert(trec_full("parent", "todo", None, "todo")) repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
.await .await
.unwrap(); .unwrap();
repo.soft_delete("parent").await.unwrap(); repo.soft_delete("parent").await.unwrap();

View File

@@ -43,7 +43,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表, // V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了 // 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [ // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -75,6 +77,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(29, migrate_v29), (29, migrate_v29),
(30, migrate_v30), (30, migrate_v30),
(31, migrate_v31), (31, migrate_v31),
(32, migrate_v32),
(33, migrate_v33),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -902,6 +906,61 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
///
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
fn migrate_v32(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
)?;
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?;
tracing::info!("迁移 v32 完成");
Ok(())
}
fn migrate_v33(conn: &Connection) -> Result<()> {
// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)
let has_col: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_col {
conn.execute_batch(
"ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';"
)?;
tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)");
} else {
tracing::info!("v33: pending_approvals 列已存在,跳过");
}
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
let has_updated_at: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_updated_at {
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
}
} else {
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
tracing::info!("迁移 v33 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -1,6 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体 //! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig}; use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================ // ============================================================
@@ -13,7 +14,7 @@ pub struct IdeaRecord {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: IdeaStatus,
pub priority: i32, pub priority: i32,
pub score: Option<f64>, pub score: Option<f64>,
pub tags: Option<String>, // JSON 数组 pub tags: Option<String>, // JSON 数组
@@ -53,7 +54,7 @@ pub struct ProjectRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub status: String, pub status: ProjectStatus,
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用) /// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
pub path: Option<String>, pub path: Option<String>,
@@ -74,7 +75,7 @@ pub struct TaskRecord {
pub project_id: String, pub project_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: String, pub status: TaskStatus,
pub priority: i32, pub priority: i32,
pub branch_name: Option<String>, pub branch_name: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
@@ -134,14 +135,14 @@ pub struct TaskRecord {
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明 /// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示 /// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。 /// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束, /// 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。 /// 对标设计 D8:task_links 数据量小,检测成本)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord { pub struct TaskLinkRecord {
pub id: String, pub id: String,
pub source_id: String, pub source_id: String,
pub target_id: String, pub target_id: String,
pub link_type: String, pub link_type: LinkType,
pub remark: Option<String>, pub remark: Option<String>,
pub created_at: String, pub created_at: String,
} }
@@ -265,7 +266,7 @@ pub struct NodeExecutionRecord {
pub id: String, pub id: String,
pub workflow_id: String, pub workflow_id: String,
pub node_id: String, pub node_id: String,
pub node_type: String, pub node_type: NodeType,
pub status: String, pub status: String,
pub input_json: Option<String>, pub input_json: Option<String>,
pub output_json: Option<String>, pub output_json: Option<String>,
@@ -279,7 +280,9 @@ pub struct NodeExecutionRecord {
// ============================================================ // ============================================================
/// AI 提供商配置记录 /// AI 提供商配置记录
#[derive(Debug, Clone, Serialize, Deserialize)] ///
/// 自定义 Debug: api_key 脱敏为 `"sk-****"`, model_configs / config 脱敏为 `"***"`。
#[derive(Clone, Serialize, Deserialize)]
pub struct AiProviderRecord { pub struct AiProviderRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -307,6 +310,27 @@ pub struct AiProviderRecord {
pub weight: u32, pub weight: u32,
} }
impl std::fmt::Debug for AiProviderRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiProviderRecord")
.field("id", &self.id)
.field("name", &self.name)
.field("provider_type", &self.provider_type)
.field("api_key", &"sk-****")
.field("base_url", &self.base_url)
.field("default_model", &self.default_model)
.field("models", &self.models)
.field("model_configs", &"***")
.field("is_default", &self.is_default)
.field("config", &"***")
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("enabled", &self.enabled)
.field("weight", &self.weight)
.finish()
}
}
/// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。 /// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。
fn default_enabled() -> bool { fn default_enabled() -> bool {
true true
@@ -336,6 +360,8 @@ pub struct AiConversationRecord {
pub pinned: bool, // 是否置顶(排序置前;UX-17) pub pinned: bool, // 是否置顶(排序置前;UX-17)
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库) pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库) pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }

View File

@@ -8,6 +8,7 @@
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo}; use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord}; use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus};
// ---------- fixtures ---------- // ---------- fixtures ----------
@@ -20,7 +21,7 @@ fn project(id: &str) -> ProjectRecord {
id: id.to_string(), id: id.to_string(),
name: format!("proj-{id}"), name: format!("proj-{id}"),
description: "desc".to_string(), description: "desc".to_string(),
status: "active".to_string(), status: ProjectStatus::Planning,
idea_id: None, idea_id: None,
path: None, path: None,
stack: None, stack: None,
@@ -35,7 +36,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
project_id: project_id.to_string(), project_id: project_id.to_string(),
title: format!("task-{id}"), title: format!("task-{id}"),
description: "desc".to_string(), description: "desc".to_string(),
status: "todo".to_string(), status: TaskStatus::Todo,
priority: 1, priority: 1,
branch_name: None, branch_name: None,
assignee: None, assignee: None,
@@ -268,7 +269,7 @@ async fn update_field_rejects_tasks_status() {
// 对照:status 未被改写,仍为初始值(task fixture 的初始 status) // 对照:status 未被改写,仍为初始值(task fixture 的初始 status)
let rec = tasks.get_by_id("t1").await.unwrap().unwrap(); let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
assert_ne!(rec.status, "done", "白名单拒绝后 status 不应被改写"); assert_ne!(rec.status.as_str(), "done", "白名单拒绝后 status 不应被改写");
} }
#[tokio::test] #[tokio::test]

View File

@@ -160,6 +160,9 @@ pub enum Augmentation {
/// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。 /// 脱敏后的项目路径(远程 provider 仅 basename / 本地全路径);无目录绑定时为 None。
#[serde(rename = "path", default, skip_serializing_if = "Option::is_none")] #[serde(rename = "path", default, skip_serializing_if = "Option::is_none")]
path: Option<SanitizedPath>, path: Option<SanitizedPath>,
/// Phase 4:项目关联信息(前 N 条任务/灵感/知识)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra: Vec<String>,
}, },
/// @任务 注入体 /// @任务 注入体
Task { Task {
@@ -174,6 +177,9 @@ pub enum Augmentation {
/// 所属项目名(join 展示用);游离任务为 None。 /// 所属项目名(join 展示用);游离任务为 None。
#[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")] #[serde(rename = "project_name", default, skip_serializing_if = "Option::is_none")]
project_name: Option<String>, project_name: Option<String>,
/// Phase 4:任务关联信息(关联任务/所属项目上下文)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra: Vec<String>,
}, },
/// @灵感 注入体 /// @灵感 注入体
Idea { Idea {
@@ -185,6 +191,9 @@ pub enum Augmentation {
status: IdeaStatus, status: IdeaStatus,
#[serde(rename = "description")] #[serde(rename = "description")]
description: String, description: String,
/// Phase 4:灵感关联信息(已立项时所属项目)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra: Vec<String>,
}, },
/// /技能 注入体 /// /技能 注入体
Skill { Skill {

View File

@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::types::NodeId; use crate::types::{ExecutionId, NodeId};
/// 人工审批选择类型(F-260615-01) /// 人工审批选择类型(F-260615-01)
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方 /// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
@@ -24,7 +24,7 @@ pub enum SelectType {
/// 旧调用方仍可只填 decision,HumanNode 下游兼容两者。 /// 旧调用方仍可只填 decision,HumanNode 下游兼容两者。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HumanApprovalResponse { pub struct HumanApprovalResponse {
pub execution_id: String, pub execution_id: ExecutionId,
pub node_id: NodeId, pub node_id: NodeId,
pub decision: String, pub decision: String,
#[serde(default)] #[serde(default)]
@@ -81,7 +81,7 @@ pub enum WorkflowEvent {
/// #[serde(default)] 向后兼容: 老事件/老 DB 反序列化(无 execution_id)填空串不炸。 /// #[serde(default)] 向后兼容: 老事件/老 DB 反序列化(无 execution_id)填空串不炸。
WorkflowCompleted { WorkflowCompleted {
#[serde(default)] #[serde(default)]
execution_id: String, execution_id: ExecutionId,
total_duration_ms: u64, total_duration_ms: u64,
}, },
/// 工作流执行失败 /// 工作流执行失败
@@ -89,13 +89,13 @@ pub enum WorkflowEvent {
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。 /// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。
WorkflowFailed { WorkflowFailed {
#[serde(default)] #[serde(default)]
execution_id: String, execution_id: ExecutionId,
error: String, error: String,
failed_node: NodeId, failed_node: NodeId,
}, },
/// 人工审批请求 /// 人工审批请求
HumanApprovalRequest { HumanApprovalRequest {
execution_id: String, execution_id: ExecutionId,
node_id: NodeId, node_id: NodeId,
title: String, title: String,
description: String, description: String,
@@ -106,7 +106,7 @@ pub enum WorkflowEvent {
}, },
/// 人工审批响应 /// 人工审批响应
HumanApprovalResponse { HumanApprovalResponse {
execution_id: String, execution_id: ExecutionId,
node_id: NodeId, node_id: NodeId,
decision: String, decision: String,
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1) /// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)

View File

@@ -26,6 +26,12 @@ pub type PluginId = String;
pub type ExecutionId = String; pub type ExecutionId = String;
/// 决策 ID /// 决策 ID
pub type DecisionId = String; pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review)
pub type NodeType = String;
/// 任务链接类型(如 depends_on / blocks / relates_to)
pub type LinkType = String;
/// 工具调用类型(如 function)
pub type ToolCallType = String;
// ============================================================ // ============================================================
// 时间工具 // 时间工具

View File

@@ -11,7 +11,7 @@ edition = "2021"
# 开启时:节点收集 inputs 前,对带 condition 的入边以 source output 为 context 求值; # 开启时:节点收集 inputs 前,对带 condition 的入边以 source output 为 context 求值;
# 求值 false 则该前驱 input 不收集,且若该节点所有入边(含 condition)均被过滤, # 求值 false 则该前驱 input 不收集,且若该节点所有入边(含 condition)均被过滤,
# 则跳过该节点执行(保持 Pending),实现条件路由。 # 则跳过该节点执行(保持 Pending),实现条件路由。
default = [] default = ["conditions-eval"]
conditions-eval = [] conditions-eval = []
[dependencies] [dependencies]

View File

@@ -165,9 +165,10 @@ impl Default for Dag {
/// ///
/// 规则: /// 规则:
/// - 两端均为 Object递归合并同 key 时节点级覆盖全局级,新增 key 各自保留)。 /// - 两端均为 Object递归合并同 key 时节点级覆盖全局级,新增 key 各自保留)。
/// - 非两端 Object节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义) /// - 非 Object/Null 两端:节点级直接覆盖全局级。
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null /// - 节点级为 `Null` 时跳过(保留全局配置),对应语义「节点未指定该配置」。
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 keynode_configs 缺失即用全局) /// 若调用方希望「显式置空」,应在构造 Dag 时不写入该 keynode_configs 缺失即用全局)
/// 而非传 JSON null。
/// ///
/// 用途DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)` /// 用途DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`
/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。 /// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。
@@ -187,8 +188,15 @@ pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde
} }
Value::Object(merged) Value::Object(merged)
} }
// 非两端 Object节点级覆盖全局级含 Null、标量、数组 // 节点级为 Null → 保留全局配置(不覆盖
_ => node.clone(), (_, Value::Null) => global.clone(),
// 显式覆盖剩余变体,防止新变体被 _ 吞没
(Value::Number(_), _)
| (Value::String(_), _)
| (Value::Array(_), _)
| (Value::Bool(_), _)
| (Value::Null, _)
| (Value::Object(_), _) => node.clone(),
} }
} }
@@ -226,11 +234,10 @@ mod deep_merge_tests {
} }
#[test] #[test]
fn missing_node_config_treated_as_noop() { fn null_node_config_keeps_global() {
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global此处覆盖语义对照 // 节点级为 Null → 保留全局(不覆盖),区别于「缺失 key 不调 deep_merge」。
// 若强行把缺失视作 Null应覆盖显式空语义与「缺失」不同缺失走 if let Some 分支)。
let global = json!({"a": 1}); let global = json!({"a": 1});
assert_eq!(deep_merge(&global, &json!(null)), json!(null)); assert_eq!(deep_merge(&global, &json!(null)), global);
} }
#[test] #[test]

View File

@@ -30,8 +30,10 @@ impl EventBus {
/// 发送事件 /// 发送事件
pub async fn send(&self, event: WorkflowEvent) { pub async fn send(&self, event: WorkflowEvent) {
// broadcast::send 是同步的,忽略接收者已关闭的错误 // broadcast::send 是同步的接收者已关闭时记录警告
let _ = self.sender.send(event); if let Err(e) = self.sender.send(event) {
tracing::warn!("EventBus 发送事件失败: {}", e);
}
} }
/// 订阅事件 /// 订阅事件

View File

@@ -3,7 +3,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use df_types::events::WorkflowEvent; use df_types::events::WorkflowEvent;
use df_types::types::NodeId; use df_types::types::{ExecutionId, NodeId};
use crate::conditions::ConditionEngine; use crate::conditions::ConditionEngine;
use crate::dag::Dag; use crate::dag::Dag;
@@ -18,7 +18,7 @@ pub struct DagExecutor {
/// 节点状态机 /// 节点状态机
state_machine: StateMachine, state_machine: StateMachine,
/// 工作流执行 ID由调用方传入下沉到每个 NodeContext /// 工作流执行 ID由调用方传入下沉到每个 NodeContext
execution_id: String, execution_id: ExecutionId,
} }
impl DagExecutor { impl DagExecutor {
@@ -26,7 +26,7 @@ impl DagExecutor {
/// ///
/// `execution_id` 为本次工作流执行的唯一标识,会下沉到每个节点的 NodeContext /// `execution_id` 为本次工作流执行的唯一标识,会下沉到每个节点的 NodeContext
/// 用于节点内的事件关联、审计追踪等。 /// 用于节点内的事件关联、审计追踪等。
pub fn new(event_bus: EventBus, execution_id: String) -> Self { pub fn new(event_bus: EventBus, execution_id: ExecutionId) -> Self {
Self { Self {
event_bus, event_bus,
state_machine: StateMachine::new(), state_machine: StateMachine::new(),

View File

@@ -3,7 +3,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use df_types::types::NodeId; use df_types::types::{ExecutionId, NodeId};
use super::eventbus::EventBus; use super::eventbus::EventBus;
use super::state::StateMachine; use super::state::StateMachine;
@@ -18,7 +18,7 @@ pub struct NodeContext {
/// 节点配置参数 /// 节点配置参数
pub config: serde_json::Value, pub config: serde_json::Value,
/// 工作流执行 ID /// 工作流执行 ID
pub execution_id: String, pub execution_id: ExecutionId,
/// 事件总线 /// 事件总线
pub event_bus: EventBus, pub event_bus: EventBus,
/// 节点状态机(用于检查取消状态) /// 节点状态机(用于检查取消状态)

View File

@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex};
use df_types::types::{NodeId, NodeStatus}; use df_types::types::{NodeId, NodeStatus};
/// 节点状态机 /// 节点状态机
///
/// # ⚠️ 并发安全警告
///
/// `state_machine` 方法(`get`、`transition`、`set_running`、`set_completed`、
/// `set_failed`、`set_cancelled`、`snapshot`、`is_cancelled`)内部持锁
/// `self.states.lock()`**不得在 `.await` 期间持锁**。
/// 若在异步上下文中调用,请确保获取结果后立即释放锁(即不要将锁跨越
/// `.await` 点)。当前所有方法均为同步且及时释放,符合此规则。
///
/// 共享语义:内部用 `Arc<Mutex<HashMap>>``clone()` 为浅拷贝Arc 引用计数 +1
/// 所有 clone 共享同一底层 HashMap。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StateMachine { pub struct StateMachine {
/// 节点状态映射Arc 共享clone 浅拷贝同一 HashMap /// 节点状态映射Arc 共享clone 浅拷贝同一 HashMap

View File

@@ -49,6 +49,8 @@
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 | | [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 | | [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 | | [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | — | 📐 设计 | 消息级溯源 Phase 2:切读切换到 ai_messages 视图 |
--- ---
@@ -73,6 +75,14 @@
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) | | [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) |
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 | | [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 | | [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 |
| [三层模型-流程模板与人设体系-2026-06-28.md](./专项设计/三层模型-流程模板与人设体系-2026-06-28.md) | 📐 设计 | 模板→工作流→人设三层架构:流程模板YAML定义、人设AgentPersona数据结构、三层实例化流程 |
| [aichat体验与agent能力系统化重构-2026-06-21.md](./专项设计/aichat体验与agent能力系统化重构-2026-06-21.md) | 📐 设计 | AI Chat 体验 + Agent 能力统一重构:消息模型 / 上下文管理 / 工具路由 |
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | 📐 设计 | 查询接口能力补全:多维度筛选 / 聚合 / 跨表关联 |
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | 📐 设计 | AST 符号解析:代码结构提取 / 符号索引 / 跳转支持 |
| [插件机制-设计-2026-06-24.md](./专项设计/插件机制-设计-2026-06-24.md) | 📐 设计 | 插件系统:动态加载 / 生命周期 / 权限沙箱 |
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
--- ---
@@ -97,6 +107,7 @@
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 | | [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) | | [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 | | [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 |
--- ---

View File

@@ -2,6 +2,7 @@
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想) > 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)AI 执行层依据本文档能力边界) > 关联: [任务推进设计](任务推进构想-2026-06-14.md)AI 执行层依据本文档能力边界)
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计 > 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
--- ---

View File

@@ -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_idNode 端根据 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
└─ 写入 DBworkflow_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 BAINode 接入人设
```
目标: 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 DAI 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 | 启动时加载数百模板影响冷启动 |

View File

@@ -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 可选删除该列

View File

@@ -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 字节替换为 `<60>`。对于中文/日文环境(如 `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 重复)。

View File

@@ -0,0 +1,368 @@
# Todo 归档 2026-06-27
> 从 todo.md / docs/todo.md 迁移的已完成项
> 归档日期: 2026-06-27
---
## 灵感模块功能完善 — P0 数据安全/正确性
- [x] **#1 软删除机制**ideas 表加 `deleted_at` 列 + 软删除(对标 tasks/projects 的 `delete` + `restore` 全套模板)。当前 `delete_idea` 是硬删除,误删不可恢复。
- [x] **#2 白名单收紧**`settings.rs` ideas 白名单移除 `id` / `created_at`,防止主键和创建时间被篡改。(对标 B-260616-16 tasks 白名单修复)
- [x] **#4 priority 值域校验**`create_idea` / `update_idea` 加 priority 校验(`parse::<i32>() ∈ 0..=3`,非法返 Err对标 B-260615-15 tasks priority 修复)
## 灵感模块功能完善 — P1 架构/一致性
- [x] **#5 有损转换修复**`record_to_idea()` 不再硬编码 `status=Draft` / `related_ids=空`,从 IdeaRecord 读取真实值,评估时不丢失上下文。
- [x] **#6 评分关键词拆文件**:将 `scoring.rs` 硬编码关键词拆到独立 const 文件(最小改动)。启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。**不做 DB 表配置化**(过度工程)。
- [x] **#7 promote 补偿改软删除**`promote_idea` 回写失败时的补偿删除从 `purge_with_descendants`(不可恢复)改为 `soft_delete`(软删除,可恢复)。
- [x] **#8 关联双向同步**A 关联 B 时自动在 B 的 `related_ids` 中补入 A解绑时同步移除保持关联关系对称。
- [x] **#9 创建表单补 tags 输入**:捕捉灵感表单增加 tags 输入(逗号分隔或 chip 组件),`confirmCapture` 传 tags JSON 字符串给后端。
## 灵感模块功能完善 — P2 前端/UX
- [x] **#10 创建表单补全 priority/source**:捕捉模态框增加 priority 下拉(低/中/高/紧急)和 source 输入框。
- [x] **#14 雷达图正名/替换**:将 i18n key 和 CSS class 中的 "radar" 改为 "score-bar"(或引入真正的雷达图组件如 ECharts radar
## AI 对话目标丢失
- [x] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goals 内容态与 ConvState 生命周期态正交,不进 enum。
- [x] **G5 附 bug** ✅已排查(不改代码)dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。
- [x] **G1 多目标累积**:`pinned_goal: Option<String>``pinned_goals: Vec<String>`,每次发消息/编辑/强制发送提取目标追加到列表(去重),支持换目标且不丢失历史目标。`MAX_GOALS=5` 防无限膨胀。
## 对抗式评估接入 — 后端 IPC 接入
- [x] **A1 evaluate_idea 命令**:新增 IPC command按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`
- [x] **A3 评估结果持久化**:评估结果写回 `ideas`scores + evaluated_by + evaluated_at避免每次重新评估。
- [x] **A4 类型导出**`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)— 已验证:当前通过 `df_ideas::adversarial::*` 路径直接可用,前端经 `IdeaRecord.ai_analysis` JSON 字符串消费,无需搬运到 `df-types`
## 对抗式评估接入 — 前端结果展示
- [x] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分LLM / 启发式兜底 / 纯启发式)。
- [x] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPCloading 状态 + 错误提示。
- [x] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar
---
## 🔍 2026-06-21 AI Chat 技术债审查
### P1 已即时修
- [x] ✅(主代修·chat.rs:458-461 ai_approve exec_result 包 tokio::time::timeout(60s) 对齐 ai_authorize_dir:658·cargo check EXIT 0) **TD-260621-05a****[P1🔴]** ai_approve 缺 60s 执行超时(用户"静默吞消息"bug F-260620 同型复发)。ai_authorize_dir 有超时 ai_approve 漏。卡死→generating 永真→前端 130s 看门狗吞消息。
- [x] ✅(主代修·useAiConversations.ts:99 switchConversation activeConversationId 设定后加 `state.streaming = state.generatingConvs.has(id)`·vue-tsc EXIT 0) **TD-260621-06a****[P1🔴]** switchConversation 不复位 streaming,真并发切非生成会话残留 stop 按钮→点 stop 发错会话。
### P1 归路线
- [x] TD-260621-01 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·useAiStream `_watchdogTimers` Map<convId,timer> + `_legacyWatchdog` fallback 双路·onStreamTimeout 携 convId 仅清该 conv·2026-06-21) — **watchdog 单计时器全清误杀并发会话**
- [x] TD-260621-02 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·pendingMaxRounds ref<string|null> 挂起 convId + 守卫比对 activeConversationId·DirAuthDialog visibleDirAuths filter conversationId·2026-06-21) — **pendingDirAuth/pendingMaxRounds 全局单 ref 非 per-conv**
- [x] TD-260621-03 [P1·patch_file 落地债 F-260615] ✅(读改写整体移入 _patch_guard 锁内串行化防 lost update + 删 entry().or_insert() 内存泄漏 + drop 写序列后释放·2026-06-21) — **patch_file 读改写在 FILE_LOCKS 外,lost update**
- [x] TD-260621-04 [P1·patch_file 落地债 F-260615] ✅(read_file 三返回点返 file_hash 闭环 + 抽 compute_file_hash helper read_file/patch_file 共用消格式漂移·2026-06-21) — **expected_hash 契约破裂,指纹防并发形同虚设**
- [x] ✅(2026-06-24·ba811ea chat.rs executed→completed 6处:460幂等读/584 ai_approve写/634判定/668 IPC返回/885 authorize_dir日志/889 authorize_dir写 + df-storage V27 迁移 UPDATE 存量 executed→completed + v27 两测试 unify/idempotent·cargo check EXIT 0 + df-storage 78 passed) **TD-260621-05** [P1·归 SMELL-P1-6] — **审批状态字符串双轨**(已统一)。
### P2 精选 — 2026-06-24 核验销账
- [x] ✅已修(commit a7dbd50,finalize:35 `let _ =`→match Err tracing::error,对齐 audit_finalize) — audit_tool_call 吞错
- [x] ✅已修(a7dbd50,conversation/chat finalize_pending_placeholders pub(crate)) — switch+delete retain 占位终态化
- [x] ✅已修(TD-260621-03 06-21,_patch_guard 串行化 + 删 entry().or_insert()) — patch_file FILE_LOCKS 永不清理
- [x] ✅已是正确态(mod.rs:772 #[allow(dead_code)]+预留注释) — PerConvState.created_at
- [x] ✅已是正确态(阶段3a 下沉 enum ApprovalKind::Risk{diff}) — PendingApproval.diff
## 🔍 2026-06-21 查询能力缺口 + run_workflow 缺陷
- [x] BUG-260621-01 [P1🔴·确定修法] — **run_workflow 空 dag 反序列化失败(missing field nodes),阻塞任务工作流推进****2026-06-22 修复**
- [x] F-260621-02 [P2] — **任务/项目/灵感列表查询维度补全(下沉后端+关键词搜索+排序分页)****2026-06-22 实施**
## 🔴 2026-06-19 BUG-260619-06 L0 clear 致冷启动审批丢失(已修复)
- [x] **BUG-260619-06** — L0 clear 致冷启动审批丢失。方案 A 修复:`lib.rs:62` `clear()``retain(|_, a| !a.recovered)`
## 🔴 2026-06-18 Agentic 最大轮次设置不生效
- [x] ✅(workflow wuirgcxoy) **B-260618-23 [P1]****Agentic 最大轮次设置不生效(设 30 仍按 10 截断)**。修复sync 上提到 App.vue 根 onMounted。
## 🔧 2026-06-17 走查·tauri 打包目标收窄 + 状态映射 DRY
- [x] ✅(主代核验·tauri.conf.json:28 `["nsis"]` 已入库) B-260617-11 [P2] — **tauri.conf.json 打包目标收窄**
## 🔧 2026-06-17 走查·DeepSeek reasoning_content 实施审查
- [x] ✅(2026-06-20 核验) B-260617-16 [P3·可选] — **Partial(MidStream 保文)回填半截 reasoning_content 语义待评**
## 🔧 2026-06-17 对话标题不更新
- [x] ✅(2026-06-18) **B-260617-17 [P2]****对话标题不更新(对话很久/刷新后仍"新对话"**
## 💡 2026-06-16 新需求UX 交互优化)
- [x] ✅(F-15 全阶段完成) **F-260616-15** [P1] — **AI Chat 上下文管理增强:会话分段 + 手动压缩 + 智能裁剪**
## P1 — 重要缺陷
- [x] ✅(workflow w999qdu86) **BUG-260618-11****[P0🔴]** `commands.rs:289` ai_approve 幂等路径 `unwrap_or_default` 吞 DB 错误。
## 🔴 架构坏味道全面扫描 — P0 必须修复
- [x] ✅(workflow w5siwnipj) **SMELL-P0-1****[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**
- [x] ✅(workflow w2xkw4ybh) **SMELL-P0-2****[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数**
- [x] ✅(2026-06-20 核验) **SMELL-P0-3****[P0🔴]** `AiChat.vue` **4026 行 God 组件**
- [x] ✅(workflow wowdnw4ba) **SMELL-P0-4****[P0🔴]** `df-execute` crate **零测试**
## 🔴 架构坏味道全面扫描 — P1 应该改进
- [x] ✅(workflow w8774xcev) **SMELL-P1-1****[P1🟡]** 生产代码 206 处 `unwrap()` 排查。
- [x] ✅(workflow wuirgcxoy no-action) **SMELL-P1-2****[P1🟡]** IPC 层 `.map_err(err_str)?` 样板。
- [x] ✅(workflow wuirgcxoy no-action) **SMELL-P1-3****[P1🟡]** Vue views try-catch-finally invoke 模式。
- [x] ✅(workflow w8774xcev) **SMELL-P1-4****[P1🟡]** df-nodes 聚合点依赖 5 crate。
- [x] ✅(workflow weckqp9mv no-action) **SMELL-P1-5****[P1🟡]** IPC 层职责泄漏。
- [x] ✅(workflow weckqp9mv no-action) **SMELL-P1-7****[P1🟡]** bool 参数陷阱。
- [x] ✅(workflow wowdnw4ba) **SMELL-P1-8****[P1🟡]** 架构文档过时 6 篇更新。
- [x] ✅(agent crud-split 实施) **SMELL-P1-9****[P1🟡]** crud.rs 2212 行按表拆分。
## 🔴 架构坏味道全面扫描 — P2 可选优化
- [x] ✅(workflow wowdnw4ba no-action) **SMELL-P2-1** — run_workflow_inner 282 行+8 参数。
- [x] ✅(workflow wowdnw4ba) **SMELL-P2-2** — 前端全局 ErrorBoundary。
- [x] ✅(workflow w8774xcev) **SMELL-P2-3** — HashMap<String,String> → 结构体。
- [x] ✅(workflow w5siwnipj) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap。
- [x] ✅(workflow w5siwnipj) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**
## 🔴 架构坏味道全面扫描 — sweep 派生登记
- [x] ✅(2026-06-22 核验) **ARC-260618-01-e** — adversarial evaluate_with_llm 一致性。
- [x] ✅(2026-06-20) SW-260618-21 [P2] — **formatRelativeZh 重命名 formatRelative**
- [x] ✅(2026-06-20 核验) SW-260618-22 [P2] — **useAiSend resolveLang DRY**
## P2 — 不阻断缺陷/增强
- [x] ✅(2026-06-20 核验) B-260614-05 — **[P2→降级]** 分离窗口detached跨窗口状态。
## 🔧 2026-06-18 6 域并行走查 sweep-fix — 已修 12 处
- [x] ① context.rs `estimate_message` 累加 parts Text/Image.base64 token
- [x] ② anthropic_compat.rs Image 转换 clone→move
- [x] ③ scan.rs `is_pure_badge_line` to_lowercase→to_ascii_lowercase
- [x] ④ scan.rs `is_monorepo` workspaces null 误判
- [x] ⑤ adversarial.rs MockProvider 补 reasoning_content
- [x] ⑥ audit.rs `audit_finalize` 拆 unwrap_or_default 吞错
- [x] ⑦ commands.rs `ai_conversation_delete` 补非活跃对话 pending_approvals retain
- [x] ⑧ audit.rs 抽 PENDING_APPROVAL_PLACEHOLDER 常量
- [x] ⑨ ToolCard.vue isToolFailure/commandOutput 下沉 computed
- [x] ⑩ AiChat.vue 清 4 处虚拟滚动残留死注释
- [x] ⑪ Projects.vue onUnmounted 清 _toastTimer
- [x] ⑫ Knowledge.vue refConvTitle parseContext 两次→一次
## 🔧 2026-06-18 6 域并行走查 sweep-fix — 新 todo 24 项
### P1
- [x] ✅(workflow wexu1isx1) SW-260618-01 [P1] — **executor Ok 路径取消节点事件/状态不一致(TOCTOU 残留)**
- [x] ✅(workflow wexu1isx1 + 主代修) SW-260618-02 [P1] — **审批占位 tool_result 在 stop/clear/create/delete 清 pending_approvals 时未替换终态文本**
### P2
- [x] ✅(评估暂缓) SW-260618-03 [P2] — **Anthropic 图片 url 模式静默 400**
- [x] ✅(workflow wexu1isx1) SW-260618-04 [P2] — **knowledge_events 通用 query 硬编码 ORDER BY created_at 崩溃**
- [x] ✅(评估暂缓) SW-260618-05 [P2] — **pending 占位靠内容字符串匹配**
- [x] ✅(主代修·SW-06) SW-260618-06 [P2] — **ToolCard 双 watch(props.tc.status) 合并**
- [x] ✅(主代修·SW-07) SW-260618-07 [P2] — **AiChat 双 watch(currentText) 合并**
- [x] ✅(主代修·SW-08) SW-260618-08 [P2] — **AuditLog.vue 全硬编码中文未 i18n**
- [x] ✅(主代修·SW-09) SW-260618-09 [P2] — **AiNode/AiSelfReviewNode provider 解析+构建 DRY 重复**
- [x] ✅(主代修·SW-10) SW-260618-10 [P2] — **reqwest Client 构建重复(OpenAI/Anthropic Provider::new)**
### P3
- [x] ✅(评估暂缓) SW-260618-11 [P3] — **OpenAI 图片构造防御缺失**
- [x] ✅(评估确认) SW-260618-12 [P3] — **ai_conversations 白名单 pinned 与 archived 不对称**
- [x] ✅(评估暂缓) SW-260618-13 [P3] — **find_path_conflict 跨层规范化靠文档约定**
- [x] ✅(评估不做) SW-260618-14 [P3] — **keyring failcount sidecar 用 current_dir 跨启动不稳**
- [x] ✅(主代修·SW-16) SW-260618-16 [P3] — **F-05 去重审计 status 固定 completed 不透传 rejected/failed**
- [x] ✅(评估不做) SW-260618-17 [P3] — **ToolCardList 4 Set 跨会话不重置累积**
- [x] ✅(主代修·SW-19) SW-260618-19 [P3] — **combineAndTruncateLines 与 cmdOutput 合并逻辑冗余**
- [x] ✅(主代修·SW-20) SW-260618-20 [P3] — **Ideas 本地 parseTags 与 store 版 DRY 重复**
- [x] ✅(评估暂缓) SW-260618-22 [P3] — **Knowledge getCategoryCount+parseTags 模板重复求值**
## 🔧 2026-06-18 模型能力维度
- [x] ✅(workflow wexu1isx1) **B-260618-03 [P1] 后端路由解耦 cost_tier/intelligence**
- [x] ✅(workflow wexu1isx1) **B-260618-04 [P2] model_probe 去瞎填 + 预设表机制存废**
- [x] ✅(主代修·UX-04) **UX-260618-04 [P2] 前端删 cost/intel 标签**
- [x] ✅(workflow wexu1isx1 + 主代修前端) **B-260618-05 [P3] CostTier::Free 死档 + 枚举清理**
## 🔧 2026-06-18 AI Chat markdown 表格布局破坏
- [x] ✅(workflow w5siwnipj) **B-260618-06 [P1]****markdown 表格 display:block 破坏布局**
## 🔧 2026-06-18 aichat 工具结果渲染核对
- [x] ✅(workflow w2drz3ppo) UX-260618-05 [P0] — **run_workflow 结果丢 execution_id**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-06 [P1] — **patch_file 丢 diff**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-07 [P1] — **delete_file 软删丢 backup_path**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-08 [P1] — **read_file 丢截断提示**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-09 [P1] — **advance_task body 裸 JSON**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-10 [P1] — **list_trash 缺 header 摘要**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-11 [P2] — **list_directory 丢 truncated**
- [x] ✅(workflow w2drz3ppo+主代) UX-260618-12 [P2] — **list_* body 全裸 JSON + 各工具零散字段丢失**
## 🔧 2026-06-18 列表摘要 Markdown 语法字符泄露
- [x] ✅(主代串行) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**
## 🔧 2026-06-18 用户实测·aichat bug 反馈
- [x] ✅(workflow wwchro468) **UX-260618-14 [P1]****advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**
- [x] ✅(agent ux15-batch1 方案A) **UX-260618-15 [P1] 第一批(方案A根治N+1)****流式失败重试每轮独立气泡**
- [x] ✅(主代串行) **UX-260618-16 [P1🔴]****`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**。
- [x] ✅(2026-06-20 核验) **UX-260618-17 [P1🟡]****ProjectDetail.handleApprovalMulti 漏 submitting 复位(防双击破口)**
## 🔧 2026-06-19 文件拆分升级 — P0
- [x] ✅(2026-06-20 核验已拆分) **REFACTOR-260619-01 [P0]****commands.rs(ai,1923 行)拆 5 模块**
- [x] ✅(2026-06-22 核验) **REFACTOR-260619-02** — anthropic_compat.rs 拆模块。
- [x] ✅(2026-06-20 核验已拆分) **REFACTOR-260619-03 [P0]****audit.rs(959 行)拆 5 模块**
## 🔧 2026-06-19 文件拆分升级 — P1
- [x] ✅(2026-06-20 核验已拆分·agentic.rs 已删除) **REFACTOR-260619-05 [P1]****agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**
- [x] ✅(2026-06-20 核验·已拆) **REFACTOR-260619-06 [P1]****ai_node.rs(1107 行)拆 3 模块**
- [x] ✅(2026-06-20 核验已拆分) **REFACTOR-260619-07 [P1]****(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。
- [x] ✅(2026-06-22 核验) **REFACTOR-260619-08** — tool_registry.rs 按功能分组注册函数拆。
- [x] ✅(2026-06-22) **REFACTOR-260619-10** — scan.rs 拆 4 模块。
- [x] ✅(2026-06-20 核验) **(已有 SMELL-P1-9)crud.rs(2212)按表拆**。
## 🔧 2026-06-19 命令行黑窗修复 + GLM 1214 数据调查
- [x] ✅(2026-06-19) **B-260619-01 [P1]****执行命令行弹黑色窗口闪烁**
- [x] ✅(2026-06-22) **B-260619-02** — GLM 1214 tool_result 过大截断。
- [x] ✅(2026-06-20) **CR-260620-04 [P0]****1214/400 messages 非法·配对错根因(compress 绕过 sanitize)+ 停用模型路由穿透**
## 💡 2026-06-19 新需求(任务关联灵感)
- [x] ✅(2026-06-20 核验已落地) **F-260619-01 [P2]****任务关联灵感TaskRecord 新增 idea_id 字段**
## 💡 2026-06-19 新需求MCP Server
- [x] ✅(2026-06-20 核验已落地) **F-260619-02 [P2]****DevFlow MCP Server对外暴露任务/项目/灵感管理能力**
## 💡 2026-06-19 新需求AI 工具文件访问动态权限模型)
- [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A-D 全完成)。
## 💡 灵感模块升级
- [x] ✅ 批1 可信度+体验
- [x] ✅ 批2 评估历史全栈
- [x] ✅ 批3 IdeasPanel 统计看板
- [x] ✅ 批3a 晋升携带
- [x] ✅ 批3b 关联关系 schema
- [x] ✅ 批3c 关联关系前端
- [x] ✅ P1 信号词否定前缀
- [x] ✅ 连带修:`augmentation/resolvers.rs``ResolverRegistry` import
## ⚠️ 灵感升级-遗留缺陷
- [x] ✅(2026-06-22 核验) **related_ids 白名单** — 实际已落地。
## ⚠️ 预存债 — ToolResult 类型缺字段
- [x] ✅(2026-06-22 核验) **TD-260621-07 [P2/预存债]****ToolResult 类型缺 output_mode/files/counts/matches/total_files 字段**
- [x] ✅(workflow wf 批1) **TD-260621-GUARD****[P0·用户实测卡死根因]** generating 状态机前端落地。
## F-260620-01 跨端 AI Chat — Phase3 联调
- [x] ✅(2026-06-22) F-260622-01-阶段1
- [x] ✅(2026-06-22) F-260622-01-阶段2
- [x] ✅(2026-06-22) F-260622-01-阶段3
## AI Chat 跑题/抓不住重点改进
- [x] ✅ P0-P2 全完成
## 单对话内并行多轮推理
- [x] ✅ Phase1 已落
## 🟡 F-09 决策e 前端 newConversation 未跟进
- [x] ✅ 已实施(CR-260620-02 审查 PASS)
## 🟡 MED-1 bind/create/update_project AI 工具路径审批执行后 reload 未兑现
- [x] ✅ 已实施(chat.rs ai_approve)
## ⚪ LOW F-09 stale 注释 + 死 i18n key 清理
- [x] ✅ 已实施
## 🔍 2026-06-22 miniapp 功能走查 — 已修项
- [x] P1-B-260622 [P1🔴·首屏连不上·✅2026-06-23核验已修]
- [x] P1-C-260622 [P1🔴·体验阻断·✅2026-06-23 核验已修]
- [x] P1-E-260622 [P1🟡·多轮场景·✅2026-06-23]
- [x] P2-B-260622 [P2·quickfix·✅2026-06-23核验已修]
- [x] P2-C-260622 [P2·quickfix·✅2026-06-23]
- [x] P2-A-260622 [P2·defer·✅2026-06-23]
- [x] P3-A-260622 [P3·defer·✅2026-06-23]
- [x] P1-F-260622 [P1🟡·✅2026-06-23]
- [x] P1-G-260622 [P1🟡·体验·✅2026-06-23]
- [x] P2-D-260622 [P2·健壮·✅2026-06-23核验已修]
- [x] P2-E-260622 [P2·健壮·✅2026-06-23]
- [x] P3-C-260622 [P3·清理·✅2026-06-23 核验已清]
- [x] P3-D-260622 [P3·体验·✅2026-06-23]
## ✅ 2026-06-23 miniapp 全功能审查加固
- [x] F1 AiConvStateChanged idle/error 终态漏 clearWatchdog → 补 clearWatchdog
- [x] F2 regenerate() 无 generating 守卫 → 加守卫 + toast
- [x] F3 flushCurrentText 启发式回填错位 → 引入 currentAssistantMsgId 按 id 精确回填
- [x] F9 stop() 无终态兜底 + chat 页无停止按钮
- [x] F21 连接状态裸枚举字符串 → 中文文案
- [x] switch 加 default warn / ws.ts 二进制帧 warn / scheduleReconnect 加 MAX_RECONNECT_ATTEMPTS=20
- [x] 删 pages/test 死页
## ✅ 2026-06-23 miniapp 用户需求批
- [x] req1 会话重命名
- [x] req2 断网不丢消息
- [x] req3 审批重连恢复
## ✅ 2026-06-23 miniapp 收尾批
- [x] P1-E MaxRounds 继续/停止
- [x] P1-F 重发 + 复制
- [x] P3-D token 用量
- [x] P2-C 注释陈旧
- [x] P2-A messages 内存上限
## ✅ 2026-06-23 miniapp polish 批
- [x] P2-E 审批失败回填
- [x] P1-G Markdown 渲染
- [x] P3-A textarea 多行
## ✅ 2026-06-24 aichat 可靠性修复
- [x] BUG-260624-01 消息重叠/堆叠根治
- [x] BUG-260624-02 授权弹窗卡死根治
- [x] BUG-260624-03 工具执行心跳误报根治
- [x] BUG-260624-04 搜索工具误授权根治
- [x] BUG-260624-05 压缩后每轮停止根治
- [x] BUG-260625-01 grep 传单文件路径报「目录名称无效」根治
- [x] BUG-260625-02 read_file search 模式显示「0 行」根治
## ✅ 2026-06-25 generating 状态机双轨收口 + 自动压缩路径残留修复
- [x] ✅(2026-06-25) Task#1 自动压缩成功路径残留
- [x] ✅(2026-06-25) Task#2 双轨状态机收口
## 🔍 2026-06-26 灵感模块诊断待办(已完成)
- [x] **IDEA-FIX-01 [P0🔴]** — ideas 表无软删除
- [x] **IDEA-FIX-02 [P0🔴]** — ideas 白名单含 id/created_at
- [x] **IDEA-FIX-03 [P0🔴]** — priority 无值域校验
- [x] **IDEA-FIX-04 [P1🟡]** — record_to_idea 有损转换
- [x] **IDEA-FIX-05 [P1🟡]** — 评分关键词硬编码
- [x] **IDEA-FIX-06 [P1🟡]** — promote 补偿用 purge不可恢复
- [x] **IDEA-FIX-07 [P1🟡]** — 关联单向
- [x] **IDEA-FIX-08 [P1🟡]** — 创建表单无 tags 输入
- [x] **IDEA-FIX-09 [P2🟠]** — 创建表单缺 priority/source
- [x] **IDEA-FIX-11 [P2🟠]** — 假雷达图

View File

@@ -351,3 +351,95 @@
- **决策点**:临时本地构建 vs 有意入库? - **决策点**:临时本地构建 vs 有意入库?
- **选项**:A 临时本地构建(提交前 revert 此行) / B 有意入库(改按平台条件配置而非硬编码单 target) - **选项**:A 临时本地构建(提交前 revert 此行) / B 有意入库(改按平台条件配置而非硬编码单 target)
- **状态**:✅ 已决(2026-06-18) — **决策:A 临时本地构建,要求高速**`["nsis"]` 已是 Windows 单一最快安装包目标。提交前须 revert 为 `"all"`。若进一步提速可用 `["app"]`(裸 .exe 无安装包)或 `tauri build --no-bundle` - **状态**:✅ 已决(2026-06-18) — **决策:A 临时本地构建,要求高速**`["nsis"]` 已是 Windows 单一最快安装包目标。提交前须 revert 为 `"all"`。若进一步提速可用 `["app"]`(裸 .exe 无安装包)或 `tauri build --no-bundle`
### 当前队列已决项迁入2026-06-26 ~ 2026-06-27
> 来源:待决策.md ✅ 2026-06-26 任务图谱推进待决策 + 🟡 各节已决项批量迁入。
#### ✅ DEC-260626-01 父② 知识图谱 Phase 1 多工程合并
- **决策**:**b** — 知识图谱独立 Phase 1(**V29**,V28 已被灵感软删除占用)。多工程代码零行(grep 全空),合并无理由,多工程后续 V30+。
- **关联**:todo 父②
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-02 ⑤.2 #7 promote 补偿删除方式
- **决策**:**b** — 保留 purge。补偿是内部回滚建错的 project(非用户删除),purge 干净不污染回收站。
- **关联**:todo ⑤.2 #7(标"保留 purge 不改")
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-03 ⑤.2 #6 评分关键词配置形式
- **决策**:**c** — 先拆 const 到独立文件(中/英两套,最小改动)。原推荐 a 修正:启发式是过渡(scoring.rs TODO 接 LLM 语义评分),接 LLM 后按需升 a(JSON 文件)。
- **关联**:todo ⑤.2 #6
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-04 ⑤.2 #9/#10 表单补全交互
- **决策**:**a** — tags 逗号分隔输入框(对标 Knowledge.vue 先例)+ priority 下拉 + source 输入。chip 组件留 UX 升级。
- **关联**:todo ⑤.2 #9/#10
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-05 BUG-260620-05 层2 授权政策
- **决策**:**a** — 维持方案①(白名单+弹窗三档)。层1(工程内免授权,F-260619-03 方案①)已解痛点,层2 黑名单制安全风险 + 分发冲突。
- **关联**:todo BUG-260620-05 层2(标"维持①不做")
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-06 父③ ToolCard 跨轮合并 + 审批状态机排期
- **决策**:**b** — 等 G1 实测 + 父② 后再做。审批是高危路径,AiChat.vue 仍在频繁改动,择稳定窗口专项。
- **关联**:todo 父③(排父②后)
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-07 父④ F-09 per-conv 真多会话排期
- **决策**:**a** — 父② Phase 1 后启动。F-09 是 Phase 4 前置,大改不与父② 并行(回归交叉)。
- **关联**:todo 父④(排父②后)
- **状态**:✅ 已决(2026-06-26)
#### ✅ 专题-1 workspace_root 分发适配
- **决策**:**① 先行**(去默认白名单,dev 自用 projects.path 自动授权够),**分发前定②**(去相对锚)。
- **状态**:✅ 已决(2026-06-26,① 立即/② 分发前)
#### ✅ 专题-2 BUG-260623-03 审批 pending 超时兜底
- **决策**:**c 无超时**(不做超时兜底)。用户:等待审批就是等待,一直等待,超时概念多余。审批是用户主动行为,不审批自然挂起等待,无需超时强制 reject/倒计时。
- **状态**:✅ 已决(2026-06-26,不做超时,关闭 BUG-260623-03)
#### ✅ 专题-3 F-09 streaming/currentText per-conv
- **决策**:**暂缓**(随 B 路线多会话并发同批做,即 DEC-07 父④)。
- **状态**:✅ 已决(2026-06-26,归父④)
#### ✅ S-260623-01 「自托管」含义澄清
- **决策**:**自用阶段定案(2026-06-23)** —— 用户决策:小程序当前自用(开发阶段),分发是产品终态但留后续(过渡期靠改 device_id 实现多机)。故「自托管」现阶段 = 已实现的自主执行(autoExecuteMode all 含高危),无另义。分发阶段再做配置层(MINIDEC-01)+ 配对(MINIDEC-02)。
- **关联**:task105 / memory(设置自主执行已实现)
- **状态**:✅ 自用阶段定案(2026-06-23)
#### ✅ B-260618-03 路由解耦 cost_tier/intelligence(用户已全局决策去掉,工程大需专项)
- **决策**:用户已全局决策去掉。自主推进(2026-06-18 workflow wexu1isx1,主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 109 passed + vue-tsc EXIT 0)。title/compress 纯 weight 选模型退化点:用户核对 Settings weight 配置(同 weight 并列 max_by_key 返回最后一个)。
- **关联**:todo B-260618-03/04/05 · UX-260618-04 前端 cost/intel 标签(依赖本项)
- **状态**:✅ 已实施(2026-06-18)
#### ✅ SW-260618-21 死代码预留功能清理批(清理 vs 保留)
- **决策**:**b 保留+标 allow**(预留设计意图明确·清理失去未来扩展点 ROI 低;标 allow 消 warning 即可·零波及)。risk_level 删 + 预留/diff 标 allow·cargo 0 warning。
- **关联**:批次7 transition_status 已删 / CR-22 删 risk_level / CR-23 标 allow 5 处
- **状态**:✅ 已实施 b(2026-06-18)
#### ✅ F-260616-09-B 多会话并发架构 B 阶段实施决策(设计完成 2026-06-19·待拍板)
- **决策**:**✅ b-1 采纳(messages per-conv,技术必然)+ c-1 保持 3 + Settings 配置 + e-1 原路径**。主代自主裁决采纳(2026-06-19)。用户授权「自主推进,能多角度确定的方案不等审批」。启动阶段2 批1。
- **关联**:todo F-260616-09 / 设计文档 / memory aichat-arch-extensibility
- **状态**:✅ 主代自主裁决采纳(2026-06-19)
#### ✅ F-260619-05 任务可关联灵感(产品粒度/方向决策·todo 已登记)
- **决策**:tasks 加 `idea_id REFERENCES ideas(id)`(复用 projects 模式,1对1 起步,单向,后续按需扩展)。**已实施**(2026-06-20 调研确认)。前端任务卡片展示灵感来源(可选增强)待补。
- **关联**:todo.md F-260619-05
- **状态**:✅ 已实施(2026-06-20)
#### ✅ MINIDEC-260623-03 WS 重连续流策略(F10·行为差异·需拍板)
- **决策**:**已实施 a**(2026-06-23)——用户决策「断网不丢消息 + 从远端拉完整」。useAiChat syncOnConnect:ws 'connected' → 发 load_messages 拉完整历史。cargo check 0 + vue-tsc 0 + build DONE。
- **状态**:✅ 已实施(2026-06-23)
#### ✅ MINIDEC-260623-04 审批双源状态分裂统一(F4/F5/F6/F13·渲染源决策)
- **决策**:**已实施 a 单一渲染源**(2026-06-23)——用户决策「审批断网重连恢复卡片状态」。审批卡改从 pendingApprovals 独立面板渲染;remote_bridge 加 sync_pending 路由;useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建;handleEvent 加同 id 去重。cargo 0 + vue-tsc 0 + build DONE。
- **状态**:✅ 已实施(2026-06-23)
#### ✅ MINIDEC-260623-05 会话页管理缺口(删除/重命名·需后端命令)
- **决策**:**重命名已实施 / 删除暂缓**(2026-06-23)——用户决策「小程序对齐桌面端能改会话名」。remote_bridge 加 rename_conversation 路由;useAiChat renameConversation 方法;conversations/index.vue 长按会话编辑。删除会话暂不做(桌面端管理)。cargo 0 + vue-tsc 0 + build DONE。
- **状态**:✅ 重命名已实施 / 删除暂缓(2026-06-23)
#### ✅ MINIDEC-260623-07 regenerate 零调用方 + 备份组件漂移(P3 收尾)
- **决策**:**随 P1-F 选 a**(2026-06-23)—— 🟡 收尾批给 regenerate 加「重发」UI 入口(对齐桌面端),故保留函数不再删;备份组件(MdView/MentionInput)保留预留注释(绕工具解析 bug,未来拆回)。
- **状态**:✅ 随 P1-F 选 a(2026-06-23)

View File

@@ -1060,3 +1060,11 @@
- [P3] **CR-01-G** TaskDetail.vue:218 advance 失败错误走 i18n 分级(en locale 也中文) - [P3] **CR-01-G** TaskDetail.vue:218 advance 失败错误走 i18n 分级(en locale 也中文)
- [P3] **CR-01-H** task_advance_node.rs:297 测试改名(实际未测 CAS 失败) - [P3] **CR-01-H** task_advance_node.rs:297 测试改名(实际未测 CAS 失败)
- [P3] **CR-01-I** i18n `taskDetail.advancing` 冗余 key 处理 - [P3] **CR-01-I** i18n `taskDetail.advancing` 冗余 key 处理
### 当前队列已审项迁入2026-06-27
## 当前队列
### CR-260624-01 AI 路径授权三档化(once/session/always) — ✅ 已审(PASS·单 agent + 6-agent 对抗深审 0 finding)
- **范围**:AllowedDirs 加 `once` 层(state.rs:444)+ ai_authorize_dir decision 改 match 四分支 once/session/always(chat.rs:846)+ 执行后 clear_once(chat.rs:924)+ 前端 DirAuthDialog 加"

File diff suppressed because it is too large Load Diff

View File

@@ -25,71 +25,6 @@
## 当前队列 ## 当前队列
### ✅ 2026-06-26 任务图谱推进待决策(已按推荐方向全部决策)
> 来源:用户 2026-06-26 多角度论证(基于代码实况) + 确认「按推荐方向决策」7 DEC + 3 专题全部拍板。
> **关键修正**:DEC-01 V28 已被灵感软删除(`ideas.deleted_at`,commit 005079f)占用 → 知识图谱用 **V29**;DEC-03 推荐由 a 修正为 **c**(启发式是过渡,接 LLM 评分后按需升 a)。
#### ✅ DEC-260626-01 父② 知识图谱 Phase 1 多工程合并
- **决策**:**b** — 知识图谱独立 Phase 1(**V29**,V28 已被灵感软删除占用)。多工程代码零行(grep 全空),合并无理由,多工程后续 V30+。
- **关联**:todo 父②
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-02 ⑤.2 #7 promote 补偿删除方式
- **决策**:**b** — 保留 purge。补偿是内部回滚建错的 project(非用户删除),purge 干净不污染回收站。
- **关联**:todo ⑤.2 #7(标"保留 purge 不改")
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-03 ⑤.2 #6 评分关键词配置形式
- **决策**:**c** — 先拆 const 到独立文件(中/英两套,最小改动)。原推荐 a 修正:启发式是过渡(scoring.rs TODO 接 LLM 语义评分),接 LLM 后按需升 a(JSON 文件)。
- **关联**:todo ⑤.2 #6
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-04 ⑤.2 #9/#10 表单补全交互
- **决策**:**a** — tags 逗号分隔输入框(对标 Knowledge.vue 先例)+ priority 下拉 + source 输入。chip 组件留 UX 升级。
- **关联**:todo ⑤.2 #9/#10
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-05 BUG-260620-05 层2 授权政策
- **决策**:**a** — 维持方案①(白名单+弹窗三档)。层1(工程内免授权,F-260619-03 方案①)已解痛点,层2 黑名单制安全风险 + 分发冲突。
- **关联**:todo BUG-260620-05 层2(标"维持①不做")
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-06 父③ ToolCard 跨轮合并 + 审批状态机排期
- **决策**:**b** — 等 G1 实测 + 父② 后再做。审批是高危路径,AiChat.vue 仍在频繁改动,择稳定窗口专项。
- **关联**:todo 父③(排父②后)
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-07 父④ F-09 per-conv 真多会话排期
- **决策**:**a** — 父② Phase 1 后启动。F-09 是 Phase 4 前置,大改不与父② 并行(回归交叉)。
- **关联**:todo 父④(排父②后)
- **状态**:✅ 已决(2026-06-26)
#### ✅ 专题-1 workspace_root 分发适配
- **决策**:**① 先行**(去默认白名单,dev 自用 projects.path 自动授权够),**分发前定②**(去相对锚)。
- **状态**:✅ 已决(2026-06-26,① 立即/② 分发前)
#### ✅ 专题-2 BUG-260623-03 审批 pending 超时兜底
- **决策**:**c 无超时**(不做超时兜底)。用户:等待审批就是等待,一直等待,超时概念多余。审批是用户主动行为,不审批自然挂起等待,无需超时强制 reject/倒计时。
- **状态**:✅ 已决(2026-06-26,不做超时,关闭 BUG-260623-03)
#### ✅ 专题-3 F-09 streaming/currentText per-conv
- **决策**:**暂缓**(随 B 路线多会话并发同批做,即 DEC-07 父④)。
- **状态**:✅ 已决(2026-06-26,归父④)
---
#### T-260614-11 条件表达式引擎升级
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
- **选项**:
- a: 手写求值器
- b: 复活 set_skipped
- c: default false + warn(均认可)
- **推荐**:**⏸️ 暂缓**(无消费方,等 BuildNode 落地跑构建→测试→部署真实场景)
- **关联**:todo T-260614-11
- **状态**:⏸️ 暂缓
### 🟡 待用户实测/澄清(需用户操作,主会话无法代办) ### 🟡 待用户实测/澄清(需用户操作,主会话无法代办)
#### S-260614-02 审批可见性实测重评 #### S-260614-02 审批可见性实测重评
@@ -105,22 +40,19 @@
- **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。 - **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。
- **状态**:🟡 待用户实测 - **状态**:🟡 待用户实测
#### S-260623-01 「自托管」含义澄清
- **背景**:用户要求设置支持「自主执行(含高危)+ 自托管」。**自主执行含高危已实现** —— `autoExecuteMode` 三档 `low`/`medium`/`all`(AdvancedSection.vue F-#97, 2026-06-22),`all` = 完全 AI 接管含高危(delete/purge/run_command),选时二次确认。设置 → 高级 → AI 自动执行范围。但「自托管」grep 全项目无匹配,含义未明确。
- **待用户澄清**:「自托管」指什么?
- **选项**:
- a: 自托管 AI 模型 endpoint(用户配自己的模型 API/本地模型,非默认 provider)
- b: 自管执行策略(= autoExecuteMode 已覆盖,无需另做)
- c: 自托管后端服务(用户自部署 df-relay/df-tunnel,非默认 `u-work.1216.top`)
- d: 其他(用户补充)
- **关联**:task105 / memory(设置自主执行已实现)
- **状态**:✅ **自用阶段定案(2026-06-23)** —— 用户决策:小程序当前自用(开发阶段),分发是产品终态但留后续(过渡期靠改 device_id 实现多机)。故「自托管」现阶段 = 已实现的自主执行(autoExecuteMode all 含高危),无另义。分发阶段再做配置层(MINIDEC-01)+ 配对(MINIDEC-02)。
#### ~~B-260617-11 tauri.conf.json 打包目标收窄确认~~
- **状态**:✅ 已决(2026-06-18) — 已迁入归档。决策:A 临时本地构建,要求高速。提交前 revert 为 `"all"`
### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划) ### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划)
#### T-260614-11 条件表达式引擎升级
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
- **选项**:
- a: 手写求值器
- b: 复活 set_skipped
- c: default false + warn(均认可)
- **推荐**:**⏸️ 暂缓**(无消费方,等 BuildNode 落地跑构建→测试→部署真实场景)
- **关联**:todo T-260614-11
- **状态**:⏸️ 暂缓
#### ARC-260615-07 架构重构批(排期/优先级决策) #### ARC-260615-07 架构重构批(排期/优先级决策)
- **背景**:src-tauri IPC 编排层 5711 行成事实业务层,7 项架构债。df-core→df-types 已完成(CR-61),剩 6 项独立大改。 - **背景**:src-tauri IPC 编排层 5711 行成事实业务层,7 项架构债。df-core→df-types 已完成(CR-61),剩 6 项独立大改。
- **决策点**:6 项重构何时做/优先级/是否做(每项 ROI 与风险权衡,无法纯技术论证——做不做是资源/产品取舍) - **决策点**:6 项重构何时做/优先级/是否做(每项 ROI 与风险权衡,无法纯技术论证——做不做是资源/产品取舍)
@@ -155,103 +87,30 @@
- **关联**:todo UX-260617-28 [INFO] - **关联**:todo UX-260617-28 [INFO]
- **状态**:⏸️ 长期(INFO,当前够用) - **状态**:⏸️ 长期(INFO,当前够用)
#### B-260618-03 路由解耦 cost_tier/intelligence(用户已全局决策去掉,工程大需专项)
- **背景**:用户 2026-06-18 决策「API 无判别依据的 cost_tier/intelligence 去掉,不写死不瞎填」。当前 router.rs 用 cost/intel 做硬过滤(步骤4 min_intelligence / 步骤5 max_cost)+ 排序(步骤7 Reverse(cost_tier)),但这两维度 100% 由 model_probe 瞎猜(预设表+启发式),零客观依据。GLM-5.2 被猜成 standard/medium(名不沾词素走兜底),title/compress 按 max_cost 过滤可能选不到合适模型。
- **决策点**:用户已全局决策去掉。本项是落地(自主可推进)。记待决策因:工程大(~30 Edit)+ 主链路由行为变更需 cargo test router 全验证 + title/compress 选模型退化需在场核对。
- **选项**:
- a: 自主推进(用户已决策,专项会话充分资源 + cargo test router 全验证 + 实测 title/compress 选模型)
- b: 等用户在场专项(核对路由行为退化 + 实测)
- **实施清单(勘察完成 2026-06-18)**:
- router.rs:TaskRequirements 删 min_intelligence(:29)/max_cost(:31)字段 + 删步骤4 filter(:62)/步骤5 filter(:63) + 步骤7 max_by_key 改纯 weight 删 Reverse(cost_tier)(:65) + 注释更新(:28/:31/:48-51/:55)
- 9 调用点删 min_intelligence/max_cost 传参:project.rs:535-536/631-632 · compress.rs:62-63 · knowledge_inject.rs:61-62/346-347 · agentic.rs:414-415 · title.rs:88-89 · ai_node.rs:203-204 · adversarial.rs:161-162
- import 清理:调用点 CostTier/IntelligenceTier 不再用则删 import(title.rs/compress.rs 用 CostTier;多处用 IntelligenceTier)
- test:router.rs 多 test 改/删(max_cost_filters_expensive / max_cost_none_allows_any / same_weight_picks_cheaper_cost_tier / intelligence 相关 + TaskRequirements 构造删字段)
- ModelConfig.cost_tier 字段保留(B-04 model_probe 去瞎填处理数据源,不删字段)
- df-ai-core/model.rs 注释更新(:47/:60 提及 router max_cost/min_intelligence)
- **推荐**:**a 自主推进**(用户已全局决策,方向明确),建议专项会话充分资源;title/compress 失去 max_cost 约束后纯 weight 选模型,需核对 weight 配置合理(用户在 Settings 配)
- **关联**:todo B-260618-03/04/05 · UX-260618-04 前端 cost/intel 标签(依赖本项)
- **状态**:✅ **已实施**(2026-06-18 workflow wexu1isx1,主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 109 passed + vue-tsc EXIT 0;SW-02 借用 E0502 主代修)。title/compress 纯 weight 选模型退化点:用户核对 Settings weight 配置(同 weight 并列 max_by_key 返回最后一个)
#### ARC-260618-01 God 文件拆分批(架构重投入·需设计) #### ARC-260618-01 God 文件拆分批(架构重投入·需设计)
- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。 - **背景**:~~2026-06-18 架构坏味道扫描出 3 个 God 文件~~
- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍) - **核验结论(2026-06-28)**:三项拆分均已全部完成:
- **选项**: - **a `tool_registry.rs`** ✅ 1091行单函数已拆为 `register_data_tools` / `register_file_tools` 等多函数(当前文件 3839 行,单函数不再超长)
- a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) — **✅ 已实施(2026-06-18 workflow w2xkw4ybh data 抽出 + 主代 sweep 批4)·build_ai_tool_registry 1091→7 行·抽 register_data_tools(18 持 db 工具)+ register_file_tools(10 文件工具)·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory·断言 len()==28 + tool_names() 集合)·主代核查 cargo check --workspace EXIT 0 / cargo test df-ai 119 passed / devflow 基线 1 passed / vue-tsc EXIT 0** - **b `AiChat.vue`** ✅ 原 4026 行 → **767 行**(已抽 `ToolCard.vue`/`ToolCardList.vue` 等子组件到 `components/ai/`)
- b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) — 🟡 待排期(并发改中缓) - **c `crud.rs`** ✅ 原 2212 行 → 已拆为 `crud/` 目录下 11 个独立 repo 文件(不复存在)
- c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) — 🟡 待排期 - **状态**:✅ **全部完成**,无需进一步拆分
- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做**已实施**,b 待 AiChat 改动沉淀后)
- **关联**:todo SMELL-P0-2/P0-3/P1-9
- **状态**:🟡 待排期决策(a 已实施·b/c 待排期:b AiChat 并发改中缓·c 待专项窗口)
#### SMELL-P1-6 String→newtype 强类型(需设计) #### SMELL-P1-6 String→newtype 强类型(纯重构)
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。 - **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。
- **决策**:做不做 + newtype 边界(仅 Rust 内部 vs 跨 IPC 到前端) - **决策**:✅ b 路线 — 仅 Rust 内部 newtype(IPC 边界仍 String折中波及小)
- **选项**: - **5处改动**:执行状态/任务状态/工具类型等裸 String → newtype
- a: 全栈 newtype(Rust+IPC+前端,类型安全最强,波及大) - **状态**:🟢 已决·待推进
- b: 仅 Rust 内部 newtype(IPC 边界仍 String,折中)
- c: 不做(String 够用,status 已有 enum 部分覆盖)
- **推荐**:**⏸️ 缓做**(全栈波及大 ROI 中等;倾向 b 折中或 c 现状 enum 已部分覆盖。若做需专项设计 newtype 边界)
- **关联**:todo SMELL-P1-6
- **状态**:🟡 待设计决策
#### SW-260618-21 死代码预留功能清理批(清理 vs 保留)
- **背景**:2026-06-18 深层 sweep 核验出 4 类「0 外部消费者但设计预留」符号。清理减负 vs 保留未来功能取舍。注:`PendingApproval.diff` 经 IPC 活跃(useAiEvents:252 `event.diff→tc.diff` + ToolCard:44/754/772 渲染·UX-260618-06 审批 diff)**非死代码不删**;`transition_status` 已删(批次7·0 消费者+TODO 未实现)。
- **核验(独立 grep 2026-06-18)**:
- `SessionState` enum + `session_state()`(mod.rs:172/393):0 外部调用·注释「读状态统一走 session_state() 收敛」设计预留(待重构 SW-02 类终态化复用)
- `AppState.releases`/`node_executions`(state.rs:206/210):`\.releases\b|\.node_executions\b` 0 字段访问·df-storage ReleaseRepo/NodeExecutionRepo 预留(未来 release mgmt/node exec log)
- ~~`PendingApproval.risk_level`(mod.rs:410)~~:**✅ 已删(2026-06-18·前端 types.ts 0 字段坐实真死·零波及·CR-22)**
- **决策点**:清理 0 消费者预留 vs 保留未来功能
- **选项**:
- a: 全清理(删 SessionState/releases/node_executions/risk_level·连带 df-storage repo 定义·减 dead_code warning·但失去预留扩展点)
- b: 全保留+标 `#[allow(dead_code)]` 注释预留意图(消 warning·保留未来·零波及)
- c: 部分(SessionState/releases 近期无计划清·risk_level 核前端类型后定)
- **推荐**:**⏸️ b 保留+标 allow**(预留设计意图明确·清理失去未来扩展点 ROI 低;标 allow 消 warning 即可·零波及)
- **关联**:批次7 transition_status 已删 / CR-22 删 risk_level / CR-23 标 allow 5 处
- **状态**:✅ 已实施 b(2026-06-18·主代自主决策·risk_level 删 + 预留/diff 标 allow·cargo 0 warning)
#### F-260616-09-B 多会话并发架构 B 阶段实施决策(设计完成 2026-06-19·待拍板)
- **背景**:F-09 B 阶段设计文档完成 [F-09-多会话并发架构设计-2026-06-19.md](./02-架构设计/已编号方案/F-09-多会话并发架构设计-2026-06-19.md)。核验发现 **A 路线补漏已全部落地**(commands.rs:1404-1409 + useAiConversations.ts:80-83)→ **阶段1 跳过**。AiSession 12 字段。阶段2(B 主体,批1-8)待启动。
- **决策点**:
- **⚠️ b-1(关键·须拍板)**:messages 是否 per-conv。原决策 b「messages 按 conv reload(不 per-conv)」与决策 e「切换不退出各自跑完」**矛盾**(单例 messages 下旧 loop push 污染新 conv,B-260615-11 退出校验必须保留→与 e 冲突)。设计推荐**修正 b 为 messages per-conv**(e 的技术必然必要条件,侵入面增量极小:ContextManager 挪 HashMap)。
- **⚠️ c-1**:global permits=3 默认值 + Settings 加「并发会话数上限」配置项。
- **e-1(主代已定✅)**:旧 loop save_conversation 保持原路径(save 接 conv_id 零改动)。低风险技术细节,主代裁决采纳。
- **推荐**:**✅ b-1 采纳(messages per-conv,技术必然)+ c-1 保持 3 + Settings 配置 + e-1 原路径**。拍板后启动阶段2 批1(PerConvState 数据结构 + 访问器)。
- **关联**:todo F-260616-09 / 设计文档 / memory aichat-arch-extensibility
- **状态**:✅ **主代自主裁决采纳(2026-06-19)** —— 用户授权「自主推进,能多角度确定的方案不等审批」。b-1 多角度论证充分(messages per-conv 是决策 e 技术必然必要条件,无替代);c-1 合理默认(global=3,Settings 配置后续);e-1 零改动技术细节。启动阶段2 批1。用户醒后可追认/推翻。
#### F-260619-05 任务可关联灵感(产品粒度/方向决策·todo 已登记)
- **背景**:tasks 表无 idea 关联字段;projects 已有 `idea_id REFERENCES ideas(id)` 模式。用户要任务关联灵感。todo.md 已登记(设计点+改动点+验收)。
- **决策点**(产品取舍,需用户拍板):
- **粒度**:一对一(`source_idea_id`,任务来源单灵感)vs 一对多(`related_idea_ids` JSON,借鉴 releases.task_ids)
- **存储**:tasks 加 `idea_id` 列(迁移,复用 projects.idea_id 外键)vs 关联表 `task_idea_links`(多对多,灵活复杂)
- **方向**:单向(任务→灵感)vs 双向(灵感侧反向显示关联任务列表)
- **推荐**:**tasks 加 `idea_id REFERENCES ideas(id)`**(复用 projects 模式,1对1 起步,单向,后续按需扩展)。低侵入(单列迁移 + Repo 白名单 + 工具 idea_id 参数 + 前端展示)。
- **关联**:todo.md F-260619-05(设计点详情)
- **状态**:✅ **已实施**(2026-06-20 调研确认)— tasks.idea_id 1对1 单向已落地:TaskRepo `idea_id` 字段(task_repo.rs:34 row.get / :52 INSERT / :63 UPDATE / :83 SELECT 全含)+ create_task 工具 idea_id 可选参数(tool_registry.rs:637「可选传 idea_id 关联灵感(1对1 单向)」)。复用 projects.idea_id 模式,粒度 1对1/单向/单列,符合推荐方向。前端任务卡片展示灵感来源(可选增强)待补。
#### 消息级溯源 P2 切读策略(技术决策·大改需知情) #### 消息级溯源 P2 切读策略(技术决策·大改需知情)
- **背景**:消息级溯源 P0(地基 ai_messages 表 ✅ CR-10)+ P1(溯源字段 ✅ CR-12)完成。P2 切读是拆表实际启用(读写路径从 `ai_conversations.messages` JSON 切到 `ai_messages` 表)。 - **背景**:消息级溯源 P0(地基 ai_messages 表 ✅ CR-10)+ P1(溯源字段 ✅ CR-12)完成。P2 切读是拆表实际启用(读写路径从 `ai_conversations.messages` JSON 切到 `ai_messages` 表)。
- **决策点**(技术策略,影响读写路径改造): - **决策**:✅ **b 一次性切读**。读写路径直接切换,保留原 `ai_conversations.messages` 列数据不删(数据安全,可回退)。
- **a 三阶段渐进**(设计推荐):Phase1 脏标记 → Phase2 双写(旧列+新表)→ Phase3 切读删旧列。安全(双写期可对比回退),有性能开销+复杂。 - **状态**:🟢 已决·待推进
- **b 一次性切读**:直接切+删旧列。简单,但切读前无回退(风险)。
- **推荐**:**a 三阶段渐进**(低风险,设计推荐)。但大改(ContextManager restore/clear/compress/replace 读写路径 + save_conversation 双写 + V22 删旧列重建表),需充分测试。
- **关联**:消息级溯源 P0(CR-10 ✅)/ P1(CR-12 ✅)/ 消息拆分存储设计文档
- **状态**:🟡 待用户拍板(P2 是否现在做 + 策略 a/b)。大改建议用户知情后启动。
#### BUG-260623-03 审批 pending 无超时兜底(aichat 卡死·架构)
- **背景**:实测会话 4ae73423 末尾 advance_task 触发审批 `__PENDING__` 后,`agentic/mod.rs:1464` loop return,恢复唯一依赖用户主动 ai_approve。全库无审批超时定时器(cache.rs:31 占位标记无时间戳)。用户离开/忘记 → generating=true 永久挂起,会话卡死,用户须手动重发(且该会话 token 已大,重发易触上限)。属 aichat 中断B,非 generating 状态机卡死(generating 已收敛 GeneratingGuard)。
- **决策点**:审批 pending 超时兜底怎么实现?
- **选项**:
- a: 后端 per-conv 定时器(到期自动 reject + emit 超时,触及 AiSession 单例 + 定时器机制,最彻底)
- b: 前端倒计时提示(用户可见到期,后端不变,最小改动)
- c: 无超时(现状,靠用户手动)
- **推荐**:**b 前端倒计时**(最小改动不触 AiSession;用户可见主动处理)。a 后端定时器最彻底但触及单例大改,归 [[devflow-async-approval-concept]]
- **关联**:memory devflow-async-approval-concept / docs/05-代码审查/aichat-会话实测分析-2026-06-22.md(CR-260622-01 修正)
- **状态**:🟡 待决策
### df-miniapp 全功能审查(2026-06-23 workflow wll7qabgr + 主代补审) ### df-miniapp 全功能审查(2026-06-23 workflow wll7qabgr + 主代补审)
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程)。已**自主实施**:F1 看门狗 clearWatchdog 兜底 / F2 regenerate 守卫 / F3 flushCurrentText id 精确回填 / F9 stop 终态兜底 + 停止按钮(原无停止入口) / F21 连接状态中文文案 + 手动重连 / switch default 分支 / 二进制帧 warn / scheduleReconnect maxAttempts / 删 test 死页。以下为需决策项。 > 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程)。以下为仍待决策/暂缓项。
#### MINIDEC-260623-01 relay 配置层范围(F15·产品方向·分发卡点) #### MINIDEC-260623-01 relay 配置层范围(F15·产品方向·分发卡点)
- **背景**:config.ts relayHost/deviceId/token 全硬编码(setConfig 零调用方,storage 未接,无设置页)。当前填测试服 wss + 本机 device_id(联调期)。分发后用户无法改连自托管中继 → 连不上。 - **背景**:config.ts relayHost/deviceId/token 全硬编码(setConfig 零调用方,storage 未接,无设置页)。当前填测试服 wss + 本机 device_id(联调期)。分发后用户无法改连自托管中继 → 连不上。
@@ -276,35 +135,6 @@
- **用户已定未来愿景(2026-06-23)**:分发阶段做完整配对授权,三步:① 设备端弹二维码 ② 小程序扫码识别并授权,双方来回点击确认 ③ 授权管理(设定授权的权限范围等)。本轮不做,留专项。 - **用户已定未来愿景(2026-06-23)**:分发阶段做完整配对授权,三步:① 设备端弹二维码 ② 小程序扫码识别并授权,双方来回点击确认 ③ 授权管理(设定授权的权限范围等)。本轮不做,留专项。
- **状态**:⏸️ **暂缓(2026-06-23)** —— 当前自用靠手抄 device_id;分发阶段按上述愿景做完整 QR 配对 + 权限范围管理(跨端 df-miniapp/df-relay/桌面端协同,专项立项)。 - **状态**:⏸️ **暂缓(2026-06-23)** —— 当前自用靠手抄 device_id;分发阶段按上述愿景做完整 QR 配对 + 权限范围管理(跨端 df-miniapp/df-relay/桌面端协同,专项立项)。
#### MINIDEC-260623-03 WS 重连续流策略(F10·行为差异·需拍板)
- **背景**:watchdog 在 WS 重连后不重启。重连期间 device 续推 AiTextDelta 会丢(断连窗口)或半截文本错位。当前断连 onStatus 已 reset generating + clearWatchdog(连接断肯定停),但重连后续流未处理。
- **决策点**:重连后续流策略?
- **选项**:
- a: onStatus 'connected' 主动发 load_messages 重新同步整个会话(最稳,但重置视图)
- b: 收到 AiTextDelta 时若 !generating 隐式恢复 + 补 assistant 占位(最平滑,有幽灵续流风险)
- c: 不处理(接受断连窗口该轮丢失)
- **推荐**:**c 现状**(MVP 断连窗口丢失可接受,a/b 复杂度高)。重度依赖移动端弱网再升级 a。
- **状态**:✅ **已实施 a**(2026-06-23)—— 用户决策「断网不丢消息 + 从远端拉完整」。useAiChat syncOnConnect:ws 'connected' → 发 load_messages 拉完整历史(断网期间 missed 消息恢复 + 兼修冷启动空白 P1-C)。cargo check 0 + vue-tsc 0 + build DONE。
#### MINIDEC-260623-04 审批双源状态分裂统一(F4/F5/F6/F13·渲染源决策)
- **背景**:审批状态双写——pendingApprovals 数组 + messages[].toolCalls.status。AiApprovalRequired 双写为源头,AiError/switchConversation 只清 pendingApprovals 一侧(messages 内 tc 留陈旧 pending_approval)→ 可能渲染陈旧审批按钮,点 approve 命中已失效 tool_call。chat 页按 m.toolCalls 内联渲染审批按钮,pendingApprovals 仅顶部徽标计数。
- **决策点**:统一单一渲染源?
- **选项**:
- a: 以 pendingApprovals 为准(messages tc 仅显示 status 不渲染按钮)—— 改 chat 页渲染逻辑(行为变更)
- b: switchConversation/AiError 清 pendingApprovals 时同步遍历 messages 把 pending_approval 标 rejected —— 改 useAiChat 状态清理(纯后端态,UI 不变)
- **推荐**:**b 同步清理**(最小行为变更,治陈旧按钮根因)。a 渲染源统一更彻底但 UI 改动大。
- **关联**:BUG-260623-03(审批超时,后端侧)
- **状态**:✅ **已实施 a 单一渲染源**(2026-06-23)—— 用户决策「审批断网重连恢复卡片状态」。审批卡改从 pendingApprovals 独立面板渲染(与 messages 解耦,避重连 load_messages 替换 messages 的竞态);remote_bridge 加 sync_pending 路由(读 AiSession.pending_approvals 按 conv 过滤重发 AiApprovalRequired/AiDirAuthRequired);useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建;handleEvent 加同 id 去重防竞态重复。内联工具卡保留状态徽标/参数/结果(按钮移面板)。cargo 0 + vue-tsc 0 + build DONE。
#### MINIDEC-260623-05 会话页管理缺口(删除/重命名·需后端命令)
- **背景**:conversations/index.vue 仅列表/切换/新建/下拉刷新。无删除/重命名(需后端 delete_conversation/rename_conversation 命令 + remote_bridge 路由,当前无)。
- **决策点**:miniapp 是否需要会话管理?
- **选项**:
- a: 本轮加(后端命令 + bridge 路由 + 会话页长按菜单)
- b: 暂不做(用户回桌面端管理,MVP 仅查看/切换)
- **推荐**:**⏸️ b 暂不做**(miniapp 定位轻量操作终端,会话管理桌面端足够)
- **状态**:✅ **重命名已实施 / 删除暂缓**(2026-06-23)—— 用户决策「小程序对齐桌面端能改会话名」。remote_bridge 加 rename_conversation 路由(调 ai_conversation_rename + 推 AiConversationList 刷新);useAiChat renameConversation 方法(乐观本地更新);conversations/index.vue 长按会话 uni.showModal 编辑。删除会话暂不做(桌面端管理)。cargo 0 + vue-tsc 0 + build DONE。
#### MINIDEC-260623-06 心跳/历史替换协议(F8/F20·跨模块·低优先) #### MINIDEC-260623-06 心跳/历史替换协议(F8/F20·跨模块·低优先)
- **背景**:(F8)心跳活性检测依赖「任意入站消息」,低活跃场景每 ~60s 误判半开死连接触发无谓重连(relay 无 pong);(F20)AiMessageHistory 整体替换会丢弃正在进行的乐观气泡(device 主动推历史路径难区分 load 响应)。 - **背景**:(F8)心跳活性检测依赖「任意入站消息」,低活跃场景每 ~60s 误判半开死连接触发无谓重连(relay 无 pong);(F20)AiMessageHistory 整体替换会丢弃正在进行的乐观气泡(device 主动推历史路径难区分 load 响应)。
- **决策点**:是否本轮改 relay 协议? - **决策点**:是否本轮改 relay 协议?
@@ -315,15 +145,6 @@
- **推荐**:**⏸️ c 暂缓**(跨模块改动 ROI 低) - **推荐**:**⏸️ c 暂缓**(跨模块改动 ROI 低)
- **状态**:⏸️ 暂缓 - **状态**:⏸️ 暂缓
#### MINIDEC-260623-07 regenerate 零调用方 + 备份组件漂移(P3 收尾)
- **背景**:(1) useAiChat.regenerate() 零调用方(全死,已加 generating 守卫防御);(2) MdView.vue/MentionInput.vue 因绕工具组件解析 bug 被 chat 页内联,源文件保留备用但已与内联实现漂移(MdView 无 mdCache、MentionInput 是 MVP 占位)——「拆回」时会引入旧实现。
- **决策点**:regenerate 加 UI(重发按钮)or 删?备份组件同步/删/保留?
- **选项**:
- a: regenerate 加重发按钮 + 备份组件同步内联实现(或删)
- b: regenerate 删减负 + 备份组件保留标注释(预留工具 bug 修复后拆回)
- **推荐**:**b**(regenerate 删减负,备份保留预留意图)。或 a 视移动端重发需求。
- **状态**:✅ **随 P1-F 选 a**(2026-06-23)—— 🟡 收尾批给 regenerate 加「重发」UI 入口(对齐桌面端),故保留函数不再删;备份组件(MdView/MentionInput)保留预留注释(绕工具解析 bug,未来拆回)。
--- ---
## workspace_root 分发适配(编译期 CARGO_MANIFEST_DIR 写死,跨机器/跨平台失效) ## workspace_root 分发适配(编译期 CARGO_MANIFEST_DIR 写死,跨机器/跨平台失效)
@@ -362,18 +183,18 @@
**用户政策指令**:"默认直接访问,明确知道无权限才申请"。 **用户政策指令**:"默认直接访问,明确知道无权限才申请"。
**背景**:F-260619-03 Phase C 去固定根(b22e9ae)后,`reload_allowed_dirs` 在 KV allowed_dirs 或 projects.bind_directory 非空时**丢失 workspace_root**,致工程内路径(如 docs/)误弹窗。用户反馈"本来就有访问权限,为什么还来申请"。详见 todo BUG-260620-05。 **实现现状(2026-06-28 核验)**:✅ 全部已落地——
- **层1 工程内默认免授权**:`reload_allowed_dirs` 无条件保留 workspace_root(`state.rs:839`)。
- **层2 workspace_root 外**:白名单制(不翻转黑名单),三种授权途径:
- **persistent**:Settings 页 `AllowedDirsPanel.vue` 添加(永久持久化)
- **session**:授权弹窗「未来都允许」(会话级)
- **once**:授权弹窗「仅本次」(单次)
- **黑名单兜底**:系统敏感目录(System32 / .ssh / .aws 等)硬拒,即便误入白名单也拒。
- **未授权非黑名单**:弹窗询问用户(PathAuthDecision::NeedsAuthorization)。
**决策点(语义范围澄清)**: **决策(2026-06-28 用户确认)**:workspace_root 外通过授权弹窗访问或 Settings 界面配置白名单 —— 即当前白名单制,不翻转黑名单。
- **层1 工程内默认免授权**(dev 自用足够):`reload_allowed_dirs` 无条件保留 workspace_root(state.rs:618 去 `all_dirs.is_empty()` 包裹)。工程内路径默认放行,workspace_root 外非白名单仍弹窗。1 行修复,确定性 bug(注释承诺失配)。
- **层2 全局默认放行**(语义翻转):`is_authorized` 改黑名单制——非黑名单路径默认放行,仅明确敏感(系统目录/凭据)拒。等于实质去掉白名单授权机制。安全风险大(任何非黑名单路径 AI 可访问),涉 P0 去固定根决策方向推翻。
**与 P0 去固定根冲突**:P0(CR-260620-01 ⑤)决策"去掉代码硬编码固定放行,用户从白名单删工程根后工程根也需授权"。层1 恢复工程内免授权=部分推翻 P0。但 P0 "用户删 root" 语义当前未落地(set/get filter root,root 不可删),层1 不破坏现有能力。 **状态**:✅ 已决·已实施
**与分发适配衔接**:层1 workspace_root = CARGO_MANIFEST_DIR(编译期写死),开发机自用有效;分发后失效则依赖 projects.path 自动授权(reload 已读 project_dirs)。分发适配见上节"workspace_root 分发适配"方案 b。
**倾向**:层1(满足 dev 自用 + 不破坏安全 + 修注释承诺失配的确定性 bug)。层2 若需全局放行另立专项。
**状态**:🟡 待用户拍板(层1 确定性 bug 可直改 1 行;层2 语义翻转需明确安全边界)。
--- ---
@@ -402,4 +223,91 @@
- [2026-06.md](./07-项目管理/待决策归档/2026-06.md) — 2026-06 已决策/已实施/已排期/已解决历史(③类产品取舍 12 项 / ④类设计方向 6 项 / 重投入排期 8 项 / ④类续 5 项 / C类已解决 2 项) - [2026-06.md](./07-项目管理/待决策归档/2026-06.md) — 2026-06 已决策/已实施/已排期/已解决历史(③类产品取舍 12 项 / ④类设计方向 6 项 / 重投入排期 8 项 / ④类续 5 项 / C类已解决 2 项)
> 新月份拍板项累积时,新建 `YYYY-MM.md` 承载。 ### 2026-06-27 讨论已决
#### DEC-260627-01 F-09 B 路线(多会话真并发)
- **决策**:✅ 需要实施
- **记录**:用户确认需要推进多会话并发架构
- **状态**:✅ 已决
#### DEC-260627-02 Conditions 条件引擎
- **决策**:✅ 需要接入使用(开启 feature flag + 可视化)
- **记录**:用户确认需要启用条件引擎并接入工作流执行器
- **核验(2026-06-28)**:✅ 全部已完成——
- 引擎代码完备(`df-workflow/conditions.rs`)
- feature flag `conditions-eval`(默认开,cfg! 门控)
- 执行器集成(`executor.rs` 按入边 condition 过滤)
- 前端 UI(`WorkflowDagDisplay.vue` 可编辑边条件、保存、显示)
- i18n 中英文补齐(workflowCondPlaceholder 等)
- **状态**:✅ 已决·已实施
#### DEC-260627-03 @项目展开摘要(⑥.4 Phase4 前端)
- **决策**:✅ C 方案展开摘要
- **记录**:@[项目] 发送前可展开查看 enrichment 内容
- **状态**:✅ 已决
#### DEC-260627-04 历史消息渲染
- **决策**:✅ C 暂缓
- **记录**:长对话渲染卡顿暂不处理
- **状态**:✅ 已决
### 2026-06-27 讨论待定
#### 对话透明化 L1(目标钉扎可见)
- **背景**:G1 目标钉扎已落地,但用户完全看不见自己设了哪些目标
- **选项**:
- A: 不做
- B: 对话顶部显示目标列表
- C: 显示 + 可删除过时目标 + 持久化
- **决策**:✅ C 方案。对话顶部 🎯 显示目标列表(✅/🔄/⏳ 状态),可清除/编辑,持久化到 ai_conversations 表
- **配套**:模型选择器折叠缩小腾空间
- **状态**:✅ 已决
#### 灵感来源采集
灵感来源采集
- **背景**:灵感捕捉时没有自动记录来源
- **选项**:
- A: 对话自动采集
- B: 手动录入增强
- C: 暂缓
- **决策**:✅ C 暂缓。与知识库抽取共用一次 AI 调用,但灵感需用户确认后才创建(非自动写入)。需前端通知机制,当前不做
- **状态**:✅ 已决 — 暂缓
---
## 已决归档
### 2026-06-28 核验已决
#### DEC-260628-01 安全 8 项
- **核验**:全数已修(代码核验 2026-06-28)
- **详情**:① relay.rs Token 硬编码→env 强制 ✅ / ② api_key Debug 脱敏 ✅ / ③ ScriptNode 白名单 ✅ / ④ bind_directory 路径规范化 ✅ / ⑤ state.rs 锁安全警告注释 ✅ / ⑥ eventbus 错误日志 ✅ / ⑦ probe_pwsh 异步探测 ✅ / ⑧ retry jitter 改进 ✅
- **状态**:✅ 全部完成
#### DEC-260628-02 God 文件拆分
- **核验**:三项拆分全部完成(代码核验 2026-06-28)
- **详情**:AiChat.vue 4026→767行 / tool_registry 单函数已拆 / crud.rs 拆为11文件
- **扩展核验**:ToolCard.vue 拆分也已完成(1527→373行,useToolApproval/useToolCardHeader/useToolCardRender 全部独立)
- **状态**:✅ 全部完成
#### DEC-260628-03 审批 pending 超时兜底
- **决策**:✅ 后端 15min 超时自动取消,超时时长在 Setting 可配置
- **记录**:pending 超 15min→自动 cancel(status=cancelled)+LLM 收到回执
- **状态**:✅ 已实施(2026-06-28):后端 `agentic/approval_timeout.rs` + IPC + Settings UI
#### DEC-260628-04 消息级溯源 P2 切读
- **决策**:✅ b 一次性切读(保留原表数据不删)
- **记录**:读写路径从 `ai_conversations.messages` 切到 `ai_messages` 表,原列保留
- **核验(2026-06-28)**:✅ 全部已完成——
- 写路径:`save_conversation` 已全量重写 ai_messages
- 读路径:`ai_conversation_switch`/`ai_conversation_export`/`remote_bridge::route_load_messages` 优先读 ai_messages,表空时 fallback 旧 JSON(老库兼容)
- 最后一处补全:`knowledge_inject::extract_knowledge_from_conversation` 也切到 ai_messages
- **状态**:✅ 已决·已实施
#### DEC-260628-05 SMELL-P1-6 String→newtype
- **决策**:✅ b 路线(仅 Rust 内部 newtype)
- **记录**:5 处裸 String 改强类型,纯重构
- **评估**:5 处分散在 df-storage(df-types 已有 TaskStatus enum 覆盖)/df-ai/df-ai-core,IPC 边界仍 String 波及中等。属纯重构(无行为变化),优先级低于功能项,后续技术债窗口一并处理。
- **状态**:🟢 已决·优先级低(重构窗口处理)
按月归档(随时间增长追加月份文件,防主文件膨胀):

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@ serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
chrono.workspace = true chrono.workspace = true
# augmentation::MentionResolver async trait(Input Augmentation 层核心设计2) # augmentation::MentionResolver async trait(Input Augmentation 层核心设计2)
async-trait = { workspace = true } async-trait = { workspace = true }

View File

@@ -0,0 +1,105 @@
//! 审批超时取消
//!
//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。
//!
//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用,
//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。
//!
//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟),
//! 0 表示禁用超时。
use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Emitter};
use crate::state::AppState;
use super::super::{
audit::audit_finalize, conversation::save_conversation, AiChatEvent,
};
/// 清理指定会话中超时的待审批。
///
/// 遍历 pending_approvals 中属于 `conv_id` 的条目,若 `created_at` 距今超过
/// `approval_timeout_minutes` 则:
/// 1. 从 pending_approvals 移除
/// 2. 把占位 tool_result 内容替换为"审批超时已自动取消"
/// 3. emit AiApprovalResult(rejected=超时)
/// 4. audit_finalize 写入审计表(status=rejected)
///
/// 设 0 时整体跳过(禁用超时)。无 pending 或均未超时则空操作。
/// 失败(DB 错误等)不抛错,仅日志告警,避免影响主流程。
pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_id: &str) {
// 读超时配置(0 = 禁用)
let timeout_minutes = state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst);
if timeout_minutes == 0 {
return;
}
let timeout_dur = Duration::from_secs(timeout_minutes * 60);
let now = SystemTime::now();
// 单次锁取出所有超时的审批(锁内不做 await,避免锁跨 await)
let expired: Vec<crate::commands::ai::PendingApproval> = {
let session = state.ai_session.lock().await;
session
.pending_approvals
.iter()
.filter(|(_, a)| {
// 只处理本会话的审批(按 conversation_id 过滤)
a.conversation_id.as_deref() == Some(conv_id)
&& a.created_at
.map(|t| now.duration_since(t).map(|d| d >= timeout_dur).unwrap_or(false))
.unwrap_or(false) // created_at=None(异常)不超时
})
.map(|(_, a)| a.clone())
.collect()
};
if expired.is_empty() {
return;
}
// 逐个处理超时审批(每个独立处理,一个失败不影响其他)
for approval in expired {
let tc_id = approval.tool_call_id.clone();
tracing::info!(
conv_id = %conv_id,
tool_call_id = %tc_id,
tool_name = %approval.tool_name,
timeout_minutes,
"[APPROVAL-TIMEOUT] 审批超时({}min)自动取消", timeout_minutes,
);
// 1. 从内存 pending_approvals 移除
{
let mut session = state.ai_session.lock().await;
session.pending_approvals.remove(&tc_id);
// 2. 替换占位 tool_result 为超时取消提示(让 LLM 看到回执不再盲猜)
session
.conv(conv_id)
.messages
.replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
}
// 3. emit AiApprovalResult(让前端清除 pending UI)
let ev = AiChatEvent::AiApprovalResult {
id: tc_id.clone(),
approved: false,
conversation_id: Some(conv_id.to_string()),
};
let _ = app.emit("ai-chat-event", ev.clone());
let _ = state.ai_event_bus.publish_event(ev);
// 4. audit_finalize 记入审计表
audit_finalize(
state,
&tc_id,
"rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
)
.await;
}
// 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
}

View File

@@ -27,10 +27,10 @@
//! # 与目标钉扎(G1)的衔接(2026-06-26) //! # 与目标钉扎(G1)的衔接(2026-06-26)
//! //!
//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边); //! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边);
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`), //! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goals`),
//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成 //! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成
//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。 //! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`, //! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goals`,
//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。 //! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@@ -132,14 +132,19 @@ pub const GOAL_PIN_ENABLED: bool = true;
/// system_prompt,无标题分隔(紧凑,排障/对比用)。 /// system_prompt,无标题分隔(紧凑,排障/对比用)。
pub const GOAL_INJECT_BANNER: bool = true; pub const GOAL_INJECT_BANNER: bool = true;
/// G1 截断长度:目标文本截断上限(默认 500 字符)。 /// G1 截断长度:每条目标文本截断上限(默认 500 字符)。
/// ///
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。 /// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版, /// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。 /// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
pub const GOAL_MAX_CHARS: usize = 500; pub const GOAL_MAX_CHARS: usize = 500;
/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。 /// G1 多目标上限:最多累积目标数(默认 5)。
///
/// 防无限膨胀:每次发消息提取的目标追加到 pinned_goals vec,超上限时淘汰最早目标。
pub const MAX_GOALS: usize = 5;
/// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。
/// ///
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的 /// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当 /// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
@@ -240,6 +245,9 @@ pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
mod guard; mod guard;
use guard::GeneratingGuard; use guard::GeneratingGuard;
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
mod approval_timeout;
// ============================================================ // ============================================================
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。 // 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
@@ -768,51 +776,74 @@ pub(crate) async fn run_agentic_loop(
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传 // BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None; let mut last_reasoning_content: Option<String> = None;
// G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。 // G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
// //
// 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request // 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request
// 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非 // 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非
// messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。 // messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。
// //
// 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 → // 单次 lock 读 pinned_goals clone(复用 stop_flag 取用模式,同一 lock 块);非空 → 逐条截断到
// 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过, // GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+编号列表。GOAL_PIN_ENABLED=false → 整块跳过,
// pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt) // pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
// //
// 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入 // 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
// 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。
let mut system_prompt = system_prompt; let mut system_prompt = system_prompt;
if GOAL_PIN_ENABLED { if GOAL_PIN_ENABLED {
let goal_opt: Option<String> = { let goals: Vec<String> = {
let session = session_arc.lock().await; let session = session_arc.lock().await;
session session
.conv_read(&conv_id) .conv_read(&conv_id)
.and_then(|c| c.pinned_goal.clone()) .map(|c| c.pinned_goals.clone())
.unwrap_or_default()
}; };
if let Some(goal) = goal_opt { if !goals.is_empty() {
let goal_text = goal.trim(); let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
if !goal_text.is_empty() { let g = g.trim();
let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect(); let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS { let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
format!("{}", goal_text) format!("{}", truncated)
} else { } else {
goal_text truncated
}; };
format!("{}. {}", i + 1, truncated)
}).collect();
let goals_text = goal_lines.join("\n");
system_prompt = if GOAL_INJECT_BANNER { system_prompt = if GOAL_INJECT_BANNER {
format!( format!(
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}", "{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}",
system_prompt, goal_text system_prompt, goals_text
) )
} else { } else {
format!("{}\n\n{}", system_prompt, goal_text) format!("{}\n\n{}", system_prompt, goals_text)
}; };
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
chars = goal_text.chars().count(), count = goals.len(),
"[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt" first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
goals.len()
); );
} }
} }
}
// EnvSnapshot 环境感知:入口把当前平台的真实环境(OS/shell/工具版本)拼进 system_prompt 尾部。
//
// 治「LLM 跨平台命令幻觉」:LLM 训练数据 Unix 多,易生成 macOS/Linux 语法命令(PowerShell 5
// 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码),把真实环境塞 prompt 即可锚定
// 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样
// 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
let pinned_goals_snapshot: Vec<String> = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.map(|c| c.pinned_goals.clone())
.unwrap_or_default()
};
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
@@ -845,7 +876,7 @@ pub(crate) async fn run_agentic_loop(
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await; guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) // generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
// L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 // L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens, total_tokens: usage.total_tokens,
@@ -853,6 +884,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
return; return;
} }
@@ -879,16 +911,11 @@ pub(crate) async fn run_agentic_loop(
return; return;
} }
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在 // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goals 非空
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。 // 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
// 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push // 一次 lock 同读 pinned_goals(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
// user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。 // user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。
let goal_active = TOPIC_MARKER_GOAL_AWARE let goal_active = TOPIC_MARKER_GOAL_AWARE && !conv.pinned_goals.is_empty();
&& conv
.pinned_goal
.as_deref()
.map(|g| !g.trim().is_empty())
.unwrap_or(false);
let marker = conv.messages.take_topic_marker(); let marker = conv.messages.take_topic_marker();
if goal_active && marker.is_some() { if goal_active && marker.is_some() {
tracing::info!( tracing::info!(
@@ -1428,6 +1455,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
incomplete: Some(true), incomplete: Some(true),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
// L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。 // L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
@@ -1436,6 +1464,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
return; return;
} }
@@ -1496,7 +1525,7 @@ pub(crate) async fn run_agentic_loop(
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await; guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) // generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
// L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。 // L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens, total_tokens: usage.total_tokens,
@@ -1504,6 +1533,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
return; return;
} }
@@ -1664,16 +1694,21 @@ pub(crate) async fn run_agentic_loop(
{ {
stall_warned = true; stall_warned = true;
let goal_text = if STALL_BREAKER_GOAL_REMIND { let goal_text = if STALL_BREAKER_GOAL_REMIND {
let g = session_arc let goals = session_arc
.lock() .lock()
.await .await
.conv_read(&conv_id) .conv_read(&conv_id)
.and_then(|c| c.pinned_goal.clone()) .map(|c| c.pinned_goals.clone())
.unwrap_or_default(); .unwrap_or_default();
if g.trim().is_empty() { if goals.is_empty() {
String::new() String::new()
} else { } else {
format!("(当前目标: {})", g.trim()) let goal_summary = goals.iter()
.map(|g| g.trim())
.filter(|g| !g.is_empty())
.collect::<Vec<_>>()
.join("; ");
format!("(当前目标: {})", goal_summary)
} }
} else { } else {
String::new() String::new()
@@ -1804,7 +1839,7 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await; guard.reset().await;
// generating 复位后再 emit Completed落库/标题/提炼已在后台,前端立即感知完成 // generating 复位后再 emit Completed落库/标题/提炼已在后台,前端立即感知完成
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() });
// L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内, // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内,
// 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 // 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted { let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
@@ -1813,6 +1848,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
incomplete: None, incomplete: None,
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
pinned_goals: pinned_goals_snapshot.clone(),
}); });
} }
@@ -1896,6 +1932,12 @@ pub(crate) async fn try_continue_agent_loop(
conv_id: &str, conv_id: &str,
start_iteration: usize, start_iteration: usize,
) { ) {
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、 // BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。 // spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子 // 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
@@ -1930,12 +1972,14 @@ pub(crate) async fn try_continue_agent_loop(
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false); let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
let agent_language = conv.and_then(|c| c.agent_language.clone()); let agent_language = conv.and_then(|c| c.agent_language.clone());
let model_override = conv.and_then(|c| c.model_override.clone()); let model_override = conv.and_then(|c| c.model_override.clone());
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
ContinueSnapshot { ContinueSnapshot {
is_generating, is_generating,
has_pending, has_pending,
pending_conv_id, pending_conv_id,
agent_language, agent_language,
model_override, model_override,
pinned_goals_snapshot,
} }
}; };
let should_continue = snap.is_generating && !snap.has_pending; let should_continue = snap.is_generating && !snap.has_pending;
@@ -1962,6 +2006,7 @@ pub(crate) async fn try_continue_agent_loop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(emit_conv_id), conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
@@ -2053,6 +2098,7 @@ pub(crate) async fn try_continue_agent_loop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(conv_id_owned.clone()), conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
@@ -2088,4 +2134,70 @@ struct ContinueSnapshot {
pending_conv_id: Option<String>, pending_conv_id: Option<String>,
agent_language: Option<String>, agent_language: Option<String>,
model_override: Option<String>, model_override: Option<String>,
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
pinned_goals_snapshot: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
// ============================================================
// G2 is_empty_tool_result 单测
// ============================================================
#[test]
fn test_empty_tool_result_empty_string() {
assert!(is_empty_tool_result(""));
}
#[test]
fn test_empty_tool_result_whitespace() {
assert!(is_empty_tool_result(" "));
}
#[test]
fn test_empty_tool_result_total_zero() {
assert!(is_empty_tool_result(r#"{"total":0}"#));
}
#[test]
fn test_empty_tool_result_entries_empty() {
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
}
#[test]
fn test_empty_tool_result_matches_empty() {
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
}
#[test]
fn test_empty_tool_result_results_empty() {
assert!(is_empty_tool_result(r#"{"results":[]}"#));
}
#[test]
fn test_empty_tool_result_files_empty() {
assert!(is_empty_tool_result(r#"{"files":[]}"#));
}
#[test]
fn test_empty_tool_result_chinese_no_match() {
assert!(is_empty_tool_result("未找到相关文件"));
}
#[test]
fn test_empty_tool_result_english_no_match() {
assert!(is_empty_tool_result("No matches found"));
}
#[test]
fn test_non_empty_tool_result() {
assert!(!is_empty_tool_result(r#"{"total":5}"#));
}
#[test]
fn test_non_empty_tool_result_with_content() {
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
}
} }

View File

@@ -142,6 +142,7 @@ mod idea_source_test_helpers {
use df_storage::crud::IdeaRepo; use df_storage::crud::IdeaRepo;
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::IdeaRecord; use df_storage::models::IdeaRecord;
use df_types::types::IdeaStatus;
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。 /// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
/// ///
@@ -155,7 +156,7 @@ mod idea_source_test_helpers {
id: id.to_string(), id: id.to_string(),
title: format!("fixture-{}", id), title: format!("fixture-{}", id),
description: String::new(), description: String::new(),
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: 1, priority: 1,
score: None, score: None,
tags: None, tags: None,

View File

@@ -450,6 +450,7 @@ pub(crate) async fn process_tool_calls(
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
kind: ApprovalKind::Path(req), kind: ApprovalKind::Path(req),
retry_count, retry_count,
created_at: Some(std::time::SystemTime::now()),
}, },
); );
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
@@ -506,11 +507,19 @@ pub(crate) async fn process_tool_calls(
// low(默认):Low→auto, Med/High→审批(等价现状) // low(默认):Low→auto, Med/High→审批(等价现状)
// medium:Low/Med→auto, High→审批 // medium:Low/Med→auto, High→审批
// all:全 auto(完全接管,无审批) // all:全 auto(完全接管,无审批)
let should_auto = match risk_level { let mut should_auto = match risk_level {
RiskLevel::Low => true, RiskLevel::Low => true,
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all", RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
RiskLevel::High => auto_exec_mode == "all", RiskLevel::High => auto_exec_mode == "all",
}; };
// C-260627: patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流
if !should_auto && draft.name == "patch_file" {
if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) {
if text.lines().count() <= 5 {
should_auto = true;
}
}
}
if should_auto { if should_auto {
low_risk.push((draft, args, risk_level)); low_risk.push((draft, args, risk_level));
} else { } else {
@@ -623,6 +632,7 @@ pub(crate) async fn process_tool_calls(
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。 // 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
kind: ApprovalKind::Risk { diff: approval_diff.clone() }, kind: ApprovalKind::Risk { diff: approval_diff.clone() },
retry_count, retry_count,
created_at: Some(std::time::SystemTime::now()),
}); });
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan) // 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));

View File

@@ -79,6 +79,9 @@ pub async fn restore_pending_approvals(state: &AppState) {
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。 // 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。 // 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
retry_count: 0, retry_count: 0,
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
// 超时取消计数仍从此刻起,避免恢复后立即被误判超时。
created_at: Some(std::time::SystemTime::now()),
}, },
); );
} }
@@ -133,6 +136,7 @@ mod tests_f09_batch8_restore {
kind: ApprovalKind::Risk { diff: None }, kind: ApprovalKind::Risk { diff: None },
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。 // 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
retry_count: 0, retry_count: 0,
created_at: None,
}, },
); );
} }

View File

@@ -66,6 +66,7 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
status, status,
description, description,
path, path,
extra,
.. ..
} => { } => {
out.push_str(""); out.push_str("");
@@ -85,6 +86,10 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
out.push_str(description); out.push_str(description);
out.push('\n'); out.push('\n');
} }
for line in extra {
out.push_str(line);
out.push('\n');
}
out.push('\n'); out.push('\n');
} }
Augmentation::Task { Augmentation::Task {
@@ -92,6 +97,7 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
status, status,
description, description,
project_name, project_name,
extra,
.. ..
} => { } => {
out.push_str(""); out.push_str("");
@@ -111,12 +117,17 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
out.push_str(description); out.push_str(description);
out.push('\n'); out.push('\n');
} }
for line in extra {
out.push_str(line);
out.push('\n');
}
out.push('\n'); out.push('\n');
} }
Augmentation::Idea { Augmentation::Idea {
title, title,
status, status,
description, description,
extra,
.. ..
} => { } => {
out.push_str(""); out.push_str("");
@@ -131,6 +142,10 @@ fn render_one(aug: &Augmentation, out: &mut String, label: &str) {
out.push_str(description); out.push_str(description);
out.push('\n'); out.push('\n');
} }
for line in extra {
out.push_str(line);
out.push('\n');
}
out.push('\n'); out.push('\n');
} }
Augmentation::Skill { Augmentation::Skill {

View File

@@ -17,6 +17,7 @@ use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_types::augmentation::{Augmentation, MentionRef, ResolveError, SanitizedPath}; use df_types::augmentation::{Augmentation, MentionRef, ResolveError, SanitizedPath};
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus}; use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus};
use df_storage::crud::{IdeaQuery, TaskQuery};
use crate::commands::ai::augmentation::registry::ResolverRegistry; use crate::commands::ai::augmentation::registry::ResolverRegistry;
use crate::commands::ai::augmentation::sanitize::{sanitize_for, ProviderLocality}; use crate::commands::ai::augmentation::sanitize::{sanitize_for, ProviderLocality};
@@ -87,7 +88,7 @@ impl MentionResolver for ProjectResolver {
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
// status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入) // status: String → ProjectStatus(未知字符串按 Planning 兜底,不阻断注入)
let status = ProjectStatus::from_db_str(&record.status).unwrap_or(ProjectStatus::Planning); let status = ProjectStatus::from_db_str(record.status.as_str()).unwrap_or(ProjectStatus::Planning);
// path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype // path 脱敏:None(未绑定)保持 None;Some 则按 locality 脱敏包 newtype
let path = record let path = record
.path .path
@@ -100,6 +101,43 @@ impl MentionResolver for ProjectResolver {
status, status,
description: record.description, description: record.description,
path, path,
extra: {
let mut lines: Vec<String> = Vec::new();
// Phase 4:查询该项目的前 5 条未完成任务
let task_repo = TaskRepo::new(&self.db);
if let Ok(tasks) = task_repo.list_by_query(&TaskQuery {
project_id: Some(id.clone()),
status: Some("in_progress".into()),
limit: Some(5),
..Default::default()
}).await {
if !tasks.is_empty() {
let mut task_lines: Vec<String> = tasks.iter().map(|t| {
format!(" - {} ({})", t.title, t.status.as_str())
}).collect();
task_lines.insert(0, format!("进行中任务({}):", tasks.len()));
lines.push(task_lines.join("\n"));
}
}
// 待评估灵感
let idea_repo = IdeaRepo::new(&self.db);
if let Ok(ideas) = idea_repo.list_by_query(&IdeaQuery {
limit: Some(3),
..Default::default()
}).await {
let related: Vec<_> = ideas.iter()
.filter(|i| i.status.as_str() == "pending_review")
.take(3)
.map(|i| format!(" - {} ({})", i.title, i.status.as_str()))
.collect();
if !related.is_empty() {
let mut idea_lines = vec![format!("待评估灵感({}):", related.len())];
idea_lines.extend(related);
lines.push(idea_lines.join("\n"));
}
}
lines
},
}) })
} }
} }
@@ -151,7 +189,7 @@ impl MentionResolver for TaskResolver {
kind: "task".to_string(), kind: "task".to_string(),
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
let status = TaskStatus::from_db_str(&task.status).unwrap_or(TaskStatus::Todo); let status = TaskStatus::from_db_str(task.status.as_str()).unwrap_or(TaskStatus::Todo);
// join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误) // join project_name:project_id 取 ProjectRecord.name;失败/无对应 None(非错误)
let project_name = { let project_name = {
let project_repo = ProjectRepo::new(&self.db); let project_repo = ProjectRepo::new(&self.db);
@@ -166,6 +204,7 @@ impl MentionResolver for TaskResolver {
status, status,
description: task.description, description: task.description,
project_name, project_name,
extra: vec![],
}) })
} }
} }
@@ -214,12 +253,13 @@ impl MentionResolver for IdeaResolver {
kind: "idea".to_string(), kind: "idea".to_string(),
ref_id: id.clone(), ref_id: id.clone(),
})?; })?;
let status = IdeaStatus::from_db_str(&record.status).unwrap_or(IdeaStatus::Draft); let status = IdeaStatus::from_db_str(record.status.as_str()).unwrap_or(IdeaStatus::Draft);
Ok(Augmentation::Idea { Ok(Augmentation::Idea {
id: record.id, id: record.id,
title: record.title, title: record.title,
status, status,
description: record.description, description: record.description,
extra: vec![],
}) })
} }
} }

View File

@@ -39,6 +39,8 @@ const DEFINITION_KINDS: &[&str] = &[
"interface_declaration", "interface_declaration",
"enum_declaration", "enum_declaration",
"method_definition", "method_definition",
// TS/JS const/let/var 声明(如 const fn = () => {})
"variable_declarator",
// Go // Go
"method_declaration", // Go method(receiver func) "method_declaration", // Go method(receiver func)
"type_declaration", // Go type Xxx struct/interface/func "type_declaration", // Go type Xxx struct/interface/func

View File

@@ -24,7 +24,7 @@ use crate::state::AppState;
use crate::commands::{err_str, now_millis}; use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED}; use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS};
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。 // 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
use super::super::agentic::conv_state::ConvState; use super::super::agentic::conv_state::ConvState;
use super::super::audit::{audit_finalize, emit_data_changed}; use super::super::audit::{audit_finalize, emit_data_changed};
@@ -72,10 +72,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
} }
} }
/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。 /// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。
/// ///
/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active), /// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标,
/// 提取后 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。 /// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。
/// ///
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次): /// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs /// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
@@ -84,7 +84,7 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
/// 2. trim 空白。 /// 2. trim 空白。
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。 /// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
/// ///
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。 /// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。 /// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
pub(crate) fn extract_pinned_goal(text: &str) -> String { pub(crate) fn extract_pinned_goal(text: &str) -> String {
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑, // strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
@@ -463,12 +463,15 @@ pub async fn ai_chat_send(
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。 // G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新 // GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加
if GOAL_PIN_ENABLED { if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content); let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() { if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
conv.pinned_goal = Some(goal); conv.pinned_goals.push(goal);
if conv.pinned_goals.len() > MAX_GOALS {
conv.pinned_goals.remove(0);
}
} }
} }
(target, user_msg_id) (target, user_msg_id)
@@ -526,7 +529,6 @@ pub async fn ai_approve(
tool_call_id: String, tool_call_id: String,
approved: bool, approved: bool,
) -> Result<String, String> { ) -> Result<String, String> {
authz_debug(&format!("[ai_approve] 入口 tool_call_id={} approved={}", tool_call_id, approved));
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let approval = match session.pending_approvals.remove(&tool_call_id) { let approval = match session.pending_approvals.remove(&tool_call_id) {
@@ -658,7 +660,12 @@ pub async fn ai_approve(
state.ai_tools.execute(&approval.tool_name, args.clone()), state.ai_tools.execute(&approval.tool_name, args.clone()),
).await { ).await {
Ok(r) => r, Ok(r) => r,
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)), // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name,
if approval.tool_name == "run_command" && cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
} else { "" })),
} }
}; };
// 工具失败不 return Err把错误包成 tool_result落库 + emit completed + 续循环全走通。 // 工具失败不 return Err把错误包成 tool_result落库 + emit completed + 续循环全走通。
@@ -753,13 +760,7 @@ pub async fn ai_approve(
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。 /// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。 /// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
fn authz_debug(msg: &str) {
use std::io::Write;
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = writeln!(f, "{}", msg);
}
}
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。 /// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
/// ///
@@ -849,7 +850,6 @@ pub async fn ai_authorize_dir(
decision = %decision, decision = %decision,
"[AI-AUTHZ] ai_authorize_dir 入口" "[AI-AUTHZ] ai_authorize_dir 入口"
); );
authz_debug(&format!("[ai_authorize_dir] 入口 tool_call_id={} decision={}", tool_call_id, decision));
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。 // 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
let approval = { let approval = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
@@ -1362,8 +1362,11 @@ pub async fn ai_chat_edit(
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致) // G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
if GOAL_PIN_ENABLED { if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&new_message); let goal = extract_pinned_goal(&new_message);
if !goal.is_empty() { if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
conv.pinned_goal = Some(goal); conv.pinned_goals.push(goal);
if conv.pinned_goals.len() > MAX_GOALS {
conv.pinned_goals.remove(0);
}
} }
} }
} }
@@ -1461,7 +1464,7 @@ pub async fn ai_chat_force_send(
// F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板), // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他 // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。 // 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
let (old_conv_id, conv_id, _user_message_id) = { let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 目标 conv:入参优先 → active → 懒创建。 // 目标 conv:入参优先 → active → 懒创建。
let target = conversation_id.clone().filter(|s| !s.is_empty()) let target = conversation_id.clone().filter(|s| !s.is_empty())
@@ -1531,16 +1534,18 @@ pub async fn ai_chat_force_send(
} }
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。 // G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 → // force_send 是用户新指令,审批续跑会复用已累积目标。
// pinned_goal 已更新,续跑用新目标)。
if GOAL_PIN_ENABLED { if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content); let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() { if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
conv.pinned_goal = Some(goal); conv.pinned_goals.push(goal);
if conv.pinned_goals.len() > MAX_GOALS {
conv.pinned_goals.remove(0);
} }
} }
(was_gen.then_some(target.clone()), target, user_msg_id) }
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
}; };
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit) // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
@@ -1551,6 +1556,7 @@ pub async fn ai_chat_force_send(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
@@ -1649,10 +1655,12 @@ pub async fn ai_chat_stop(
), ),
} }
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
let pinned_goals = conv.pinned_goals.clone();
drop(session); drop(session);
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
conversation_id: Some(target), conversation_id: Some(target),
pinned_goals,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
@@ -1701,6 +1709,7 @@ pub async fn ai_chat_stop(
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)" "[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
), ),
} }
let pinned_goals = conv.pinned_goals.clone();
drop(session); // 释放锁后再 emit避免持锁调 runtime emit drop(session); // 释放锁后再 emit避免持锁调 runtime emit
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, total_tokens: 0,
@@ -1708,6 +1717,7 @@ pub async fn ai_chat_stop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: conv_id, conversation_id: conv_id,
pinned_goals,
}; };
let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
@@ -1772,6 +1782,7 @@ pub async fn ai_stop_loop(
state: State<'_, AppState>, state: State<'_, AppState>,
conversation_id: String, conversation_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let pinned_goals: Vec<String>;
{ {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
@@ -1794,6 +1805,7 @@ pub async fn ai_stop_loop(
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)" "[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
), ),
} }
pinned_goals = conv.pinned_goals.clone();
} }
// 暂停态进入前已 save_conversation此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) // 暂停态进入前已 save_conversation此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
@@ -1802,9 +1814,74 @@ pub async fn ai_stop_loop(
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
pinned_goals,
}; };
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev); let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok("ok".to_string()) Ok("ok".to_string())
} }
#[cfg(test)]
mod tests {
use super::*;
// ============================================================
// G1 extract_pinned_goal 单测
// ============================================================
#[test]
fn test_extract_plain_goal() {
let goal = extract_pinned_goal("实现代码审查功能");
assert_eq!(goal, "实现代码审查功能");
}
#[test]
fn test_extract_strip_mention_suffix() {
let goal = extract_pinned_goal("分析灵感模块不足 [项目: DevFlow]");
assert_eq!(goal, "分析灵感模块不足");
}
#[test]
fn test_extract_strip_mention_prefix() {
let goal = extract_pinned_goal("[任务: T-123] 修复登录页白屏");
assert_eq!(goal, "修复登录页白屏");
}
#[test]
fn test_extract_empty_input() {
let goal = extract_pinned_goal("");
assert!(goal.is_empty());
}
#[test]
fn test_extract_only_mention() {
let goal = extract_pinned_goal("[项目: DevFlow]");
assert!(goal.is_empty());
}
#[test]
fn test_extract_english_kind() {
let goal = extract_pinned_goal("Fix login bug [task: T-789]");
assert_eq!(goal, "Fix login bug");
}
#[test]
fn test_extract_fullwidth_colon() {
let goal = extract_pinned_goal("重构模块 [项目DevFlow]");
assert_eq!(goal, "重构模块");
}
#[test]
fn test_extract_truncate_long() {
let long = "a".repeat(GOAL_MAX_CHARS + 100);
let goal = extract_pinned_goal(&long);
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
}
#[test]
fn test_extract_mention_unclosed() {
let goal = extract_pinned_goal("测试 [项目: DevFlow");
assert_eq!(goal, "测试 [项目: DevFlow");
}
}

View File

@@ -221,12 +221,17 @@ pub async fn ai_conversation_list(
let models: Vec<String> = r.models.as_deref() let models: Vec<String> = r.models.as_deref()
.and_then(|s| serde_json::from_str(s).ok()) .and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default(); .unwrap_or_default();
let pinned_goals: Vec<String> = r.pinned_goals
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
serde_json::json!({ serde_json::json!({
"id": r.id, "id": r.id,
"title": r.title, "title": r.title,
"provider_id": r.provider_id, "provider_id": r.provider_id,
"model": r.model, "model": r.model,
"models": models, "models": models,
"pinned_goals": pinned_goals,
"archived": r.archived, "archived": r.archived,
"pinned": r.pinned, "pinned": r.pinned,
"prompt_tokens": r.prompt_tokens, "prompt_tokens": r.prompt_tokens,
@@ -311,6 +316,12 @@ pub async fn ai_conversation_switch(
conv.agent_language = None; conv.agent_language = None;
conv.iteration_used = 0; conv.iteration_used = 0;
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
let pinned_goals: Vec<String> = record.pinned_goals
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
conv.pinned_goals = pinned_goals;
} }
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空, // 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的) // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
@@ -321,6 +332,29 @@ pub async fn ai_conversation_switch(
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换"); super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。 // 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
if let Some(pending_json) = &record.pending_approvals {
if pending_json != "{}" && !pending_json.is_empty() {
if let Ok(restored) = serde_json::from_str::<
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
>(pending_json) {
let restored_count = restored.len();
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
session.pending_approvals.extend(restored);
tracing::info!(
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)",
conversation_id, restored_count
);
} else {
tracing::warn!(
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
conversation_id, &pending_json.chars().take(200).collect::<String>()
);
}
}
}
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB) // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
drop(session); drop(session);
@@ -439,6 +473,30 @@ pub async fn ai_conversation_set_pinned(
Ok(()) Ok(())
} }
/// 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)
///
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
#[tauri::command]
pub async fn ai_update_conversation_goals(
state: State<'_, AppState>,
conversation_id: String,
goals: Vec<String>,
) -> Result<(), String> {
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
// 写 DB
state.ai_conversations
.update_field(&conversation_id, "pinned_goals", &goals_json)
.await
.map_err(err_str)?;
// 同步内存 per_conv(若已加载)
let mut session = state.ai_session.lock().await;
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
conv.pinned_goals = goals;
}
Ok(())
}
/// 导出对话为指定格式(UX-18:对话导出) /// 导出对话为指定格式(UX-18:对话导出)
/// ///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)

View File

@@ -164,7 +164,7 @@ pub(crate) async fn save_conversation(
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。 // loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端 // conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。 // 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
let (persist_msgs, provider_id, created_at) = { let (persist_msgs, provider_id, created_at, pinned_goals) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let mut msgs = session.conv(conv_id).messages.all_messages_clone(); let mut msgs = session.conv(conv_id).messages.all_messages_clone();
for m in &mut msgs { for m in &mut msgs {
@@ -185,9 +185,22 @@ pub(crate) async fn save_conversation(
msgs, msgs,
session.active_provider_id.clone(), session.active_provider_id.clone(),
session.active_conv_created_at.clone(), session.active_conv_created_at.clone(),
session.conv(conv_id).pinned_goals.clone(),
) )
}; };
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
// 序列化为 JSON 对象(tool_call_id → PendingApproval),落库供重启/切回时恢复。
let pending_approvals_json = {
let session = session_arc.lock().await;
let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session.pending_approvals
.iter()
.filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id))
.collect();
serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
};
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id) // F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。 // 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。 // created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
@@ -224,6 +237,10 @@ pub(crate) async fn save_conversation(
if !list.iter().any(|x| x == m) { list.push(m.to_string()); } if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string())); rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
} }
// G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
rec.pending_approvals = Some(pending_approvals_json.clone());
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await { if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -250,6 +267,8 @@ pub(crate) async fn save_conversation(
pinned: false, pinned: false,
prompt_tokens: usage.map(|u| u.prompt_tokens as i64), prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
completion_tokens: usage.map(|u| u.completion_tokens as i64), completion_tokens: usage.map(|u| u.completion_tokens as i64),
pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())),
pending_approvals: Some(pending_approvals_json.clone()),
created_at: conv_created.clone(), created_at: conv_created.clone(),
updated_at: now, updated_at: now,
}; };

View File

@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::crud::{AiConversationRepo, KnowledgeRepo}; use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{AiProviderRecord, KnowledgeRecord}; use df_storage::models::{AiProviderRecord, KnowledgeRecord};
@@ -566,12 +566,21 @@ async fn extract_knowledge_from_conversation(
.filter(|t| !t.trim().is_empty()) .filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string()); .unwrap_or_else(|| "未命名对话".to_string());
let messages: Vec<ChatMessage> = match serde_json::from_str(&conv.messages) { // 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
Ok(v) => v, let msg_repo = AiMessageRepo::new(db);
Err(e) => { let records = msg_repo.list_by_conversation(conv_id).await.unwrap_or_default();
tracing::warn!(error = %e, "[ai] 对话消息 JSON 解析失败,降级空消息(知识提炼跳过)"); let messages: Vec<ChatMessage> = if !records.is_empty() {
Vec::new() records.iter().map(crate::commands::ai::commands::record_to_message).collect()
} else {
// 老库未迁移或坏数据:回退旧 messages JSON 列(与 ai_conversation_switch 同一兼容路径)
let has_legacy = conv.messages != "[]" && !conv.messages.is_empty();
if has_legacy {
tracing::warn!(
conv_id,
"[KNOWLEDGE-EXTRACT] ai_messages 表为空,回退旧 messages JSON 列(老库兼容)"
);
} }
serde_json::from_str(&conv.messages).unwrap_or_default()
}; };
// 过滤 user/assistant,取最后 6 条 // 过滤 user/assistant,取最后 6 条
let recent: Vec<&ChatMessage> = messages let recent: Vec<&ChatMessage> = messages

View File

@@ -138,6 +138,8 @@ pub enum AiChatEvent {
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>, incomplete: Option<bool>,
conversation_id: Option<String>, conversation_id: Option<String>,
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
pinned_goals: Vec<String>,
}, },
/// 错误 /// 错误
/// ///
@@ -437,24 +439,6 @@ fn normalize_dir_key(dir: &str) -> String {
} }
} }
/// workspace_root 字符串(作 run_command working_dir 缺省时的 trust key 回退)。
///
/// 与 tool_registry.rs workspace_root() 同源CARGO_MANIFEST_DIR 上两级canonicalize 后
/// 保证与 run_command handler 默认 working_dir = workspace_root().to_string() 生成的 key 一致。
fn workspace_root_str() -> String {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
root.canonicalize()
.map(|c| c.to_string_lossy().to_string())
.unwrap_or_else(|_| root.to_string_lossy().to_string())
}
// 引入 PathBuf 供 workspace_root_str / dir_of_path_normalized 使用
use std::path::PathBuf;
/// AI 会话内状态Mutex 保护) /// AI 会话内状态Mutex 保护)
/// ///
/// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv) /// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv)
@@ -776,17 +760,17 @@ pub struct PerConvState {
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。 /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
pub conv_state: ConvState, pub conv_state: ConvState,
/// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。 /// G1 目标钉扎真相源:用户消息提取的目标列表(内容态字段,与生命周期态正交)。
/// ///
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失): /// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。 /// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然 /// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。 /// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
/// ///
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。 /// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标);
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前) /// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。 /// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。
pub pinned_goal: Option<String>, pub pinned_goals: Vec<String>,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>, pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
@@ -833,12 +817,12 @@ impl PerConvState {
/// - session_trust: HashSet::new() /// - session_trust: HashSet::new()
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义) /// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志) /// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
/// - pinned_goal: None(G1 目标钉扎,新会话未提取目标) /// - pinned_goals: Vec::new()(G1 目标钉扎,新会话未提取目标)
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
messages: ContextManager::new(ContextConfig::default()), messages: ContextManager::new(ContextConfig::default()),
conv_state: ConvState::Idle, conv_state: ConvState::Idle,
pinned_goal: None, pinned_goals: Vec::new(),
stop_flag: Arc::new(AtomicBool::new(false)), stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()), notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0, iteration_used: 0,
@@ -863,7 +847,7 @@ impl Default for PerConvState {
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 + /// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir /// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
/// 各只消费自己 kind,入口校验防误调)。 /// 各只消费自己 kind,入口校验防误调)。
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingApproval { pub struct PendingApproval {
pub tool_call_id: String, pub tool_call_id: String,
pub tool_name: String, pub tool_name: String,
@@ -871,6 +855,10 @@ pub struct PendingApproval {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑 /// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool, pub recovered: bool,
/// 审批创建时间(用于超时取消判定)。运行期内存态,不序列化(重启恢复的积压审批按恢复时间计)。
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
#[serde(default)]
pub created_at: Option<std::time::SystemTime>,
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。 /// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。 /// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。
pub kind: ApprovalKind, pub kind: ApprovalKind,
@@ -896,7 +884,7 @@ pub struct PendingApproval {
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。 /// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览; /// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。 /// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ApprovalKind { pub enum ApprovalKind {
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。 /// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
Path(PathAuthRequest), Path(PathAuthRequest),
@@ -907,7 +895,7 @@ pub enum ApprovalKind {
} }
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。 /// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathAuthRequest { pub struct PathAuthRequest {
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。 /// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
/// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to /// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to

View File

@@ -162,7 +162,10 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_project_ids.iter().any(|id| id == &p.id) { if excluded_project_ids.iter().any(|id| id == &p.id) {
continue; continue;
} }
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description)); prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description));
if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir));
}
} }
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用 // 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
prompt.push_str(&projects_listed_note(lang, 20)); prompt.push_str(&projects_listed_note(lang, 20));
@@ -178,7 +181,7 @@ pub(crate) async fn build_system_prompt_with_excluded(
if excluded_task_ids.iter().any(|id| id == &tk.id) { if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue; continue;
} }
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description)); prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description));
} }
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang)); prompt.push_str(&tasks_listed_note(lang));

View File

@@ -76,6 +76,12 @@ pub(crate) async fn ensure_conversation_title(
return; return;
} }
// 任务3: 合并相邻同 role 消息(防 Anthropic 协议 1214 错误)。
// session.messages 历史可能存在连续同 role(如 user 失败重提、助手多轮中断后续接、tool_result 被过滤后
// user 连续),Anthropic API 严格校验「messages 必须 user/assistant 交替」,连续同 role 直接拒 1214。
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
let summary_msgs = merge_consecutive_roles(summary_msgs);
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。 // B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住 // 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台 // (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
@@ -208,3 +214,36 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
if chars.next().is_some() { format!("{}...", t) } else { t } if chars.next().is_some() { format!("{}...", t) } else { t }
}) })
} }
/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。
/// role 比较: System/User/Assistant/Tool 中,User 和 Assistant 交替校验最严,
/// 不同 role 不合并(保留边界),仅合并连续同 role。
fn merge_consecutive_roles(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
if msgs.is_empty() {
return msgs;
}
let mut out: Vec<ChatMessage> = Vec::with_capacity(msgs.len());
for m in msgs {
if let Some(last) = out.last_mut() {
// role 比较(MessageRole 未派生 PartialEq,用 matches!按变体逐个比)。
let same_role = match (&last.role, &m.role) {
(MessageRole::System, MessageRole::System)
| (MessageRole::User, MessageRole::User)
| (MessageRole::Assistant, MessageRole::Assistant)
| (MessageRole::Tool, MessageRole::Tool) => true,
_ => false,
};
if same_role {
// 连续同 role: 拼接 content(中间补换行,避免语义粘连),保留首条元数据(id/timestamp)。
// 不拼接 reasoning_content(标题摘要场景该字段无用,且不同 turn reasoning 串一起无意义)。
if !m.content.is_empty() {
last.content.push('\n');
last.content.push_str(&m.content);
}
continue;
}
}
out.push(m);
}
out
}

View File

@@ -11,7 +11,7 @@ use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord}; use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord};
use df_types::types::new_id; use df_types::types::{new_id, IdeaStatus, ProjectStatus, TaskStatus};
use crate::commands::now_millis; use crate::commands::now_millis;
use crate::state::AllowedDirs; use crate::state::AllowedDirs;
@@ -573,7 +573,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let repo = df_storage::crud::ProjectRepo::new(&db); let repo = df_storage::crud::ProjectRepo::new(&db);
let record = ProjectRecord { let record = ProjectRecord {
id: new_id(), name: name.to_string(), description: description.to_string(), id: new_id(), name: name.to_string(), description: description.to_string(),
status: "planning".to_string(), idea_id: None, status: ProjectStatus::Planning, idea_id: None,
path: None, stack: None, path: None, stack: None,
created_at: now_millis(), updated_at: now_millis(), created_at: now_millis(), updated_at: now_millis(),
}; };
@@ -683,7 +683,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
}; };
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
if let Some(status) = args.get("status").and_then(|v| v.as_str()) { if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
tasks.retain(|t| t.status == status); tasks.retain(|t| t.status.as_str() == status);
} }
let total = tasks.len(); let total = tasks.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize; let offset = args["offset"].as_u64().unwrap_or(0) as usize;
@@ -767,7 +767,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(), id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32, status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
@@ -1032,13 +1032,13 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
"backlog" => "todo".to_string(), "backlog" => "todo".to_string(),
"active" => { "active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) { if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() current.status.as_str().to_string()
} else { } else {
"in_progress".to_string() "in_progress".to_string()
} }
} }
"todo" => "todo".to_string(), "todo" => "todo".to_string(),
"decision" => current.status.clone(), "decision" => current.status.as_str().to_string(),
_ => unreachable!("queue 白名单已收口"), _ => unreachable!("queue 白名单已收口"),
}; };
@@ -1047,7 +1047,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
repo.update_field(id, "queue", &new_queue).await?; repo.update_field(id, "queue", &new_queue).await?;
} }
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
if current.status != new_status { if current.status.as_str() != new_status {
repo.set_status_for_aggregation(id, &new_status).await?; repo.set_status_for_aggregation(id, &new_status).await?;
} }
@@ -1354,7 +1354,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
id: new_id(), title: title.to_string(), id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32, status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()), score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()), source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None, promoted_to: None, ai_analysis: None, scores: None,
@@ -1549,17 +1549,18 @@ fn register_file_tools(
})}, })},
); );
registry.register( registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)", "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), df_ai::ai_tools::object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
RiskLevel::Low, RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone(); let allowed_dirs = allowed_dirs.clone();
Box::pin(async move { Box::pin(async move {
let p = args["path"].as_str().unwrap_or("");
if p.is_empty() {
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
}
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed( let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false); let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
@@ -1693,7 +1694,7 @@ fn register_file_tools(
}) })
}; };
registry.register( registry.register(
"patch_file", "局部更新文件内容三模式互斥。模式1 old_text精确匹配原文替换含空格/缩进CAS 语义。模式2 replace_lines按行号区间 {start,end}1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_textexpected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容", "patch_file", "局部更新文件内容三模式互斥。模式1 old_text精确匹配原文替换含空格/缩进CAS 语义。模式2 replace_lines按行号区间 {start,end}1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_textexpected_hash 可选通用。小改动(≤5 行)自动放行不阻塞,大改动需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
patch_file_schema, patch_file_schema,
RiskLevel::Medium, RiskLevel::Medium,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
@@ -2128,7 +2129,7 @@ fn register_file_tools(
let grep_schema = { let grep_schema = {
let mut props = serde_json::Map::new(); let mut props = serde_json::Map::new();
props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" })); props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" }));
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。必填" })); props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" }));
props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" })); props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" }));
props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] })); props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] }));
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" })); props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
@@ -2138,21 +2139,22 @@ fn register_file_tools(
serde_json::json!({ serde_json::json!({
"type": "object", "type": "object",
"properties": props, "properties": props,
"required": ["pattern", "path"], "required": ["pattern"],
}) })
}; };
registry.register( registry.register(
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,文件或目录均可——传单文件仅搜该文件,锚 workspace + path_auth 授权)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)", "grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
grep_schema, grep_schema,
RiskLevel::Low, RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone(); let allowed_dirs = allowed_dirs.clone();
Box::pin(async move { Box::pin(async move {
let p = args["path"].as_str().unwrap_or("");
if p.is_empty() {
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
}
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed( let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let pattern = args["pattern"].as_str() let pattern = args["pattern"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; .ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
@@ -2273,16 +2275,17 @@ fn register_file_tools(
// ── 文件搜索 (Low risk) ── // ── 文件搜索 (Low risk) ──
registry.register( registry.register(
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]), df_ai::ai_tools::object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
RiskLevel::Low, RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone(); let allowed_dirs = allowed_dirs.clone();
Box::pin(async move { Box::pin(async move {
let p = args["path"].as_str().unwrap_or("");
if p.is_empty() {
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
}
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed( let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false); let recursive = args["recursive"].as_bool().unwrap_or(false);
@@ -2313,8 +2316,13 @@ fn register_file_tools(
]), ]),
RiskLevel::High, RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move { Box::new(|args: serde_json::Value| Box::pin(async move {
// BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致
// JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str()
// 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸
// 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。
let command = args["command"].as_str() let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
.to_string();
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
@@ -2329,7 +2337,7 @@ fn register_file_tools(
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest { let request = ShellRequest {
command: command.to_string(), command: command.clone(),
working_dir: Some(working_dir.clone()), working_dir: Some(working_dir.clone()),
env: HashMap::new(), env: HashMap::new(),
timeout_secs: Some(timeout_secs), timeout_secs: Some(timeout_secs),
@@ -2341,15 +2349,24 @@ fn register_file_tools(
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
//
// 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`),
// LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、
// 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。
let shell_hint = if cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\"\"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)"
} else {
""
};
let result = execute(request).await.map_err(|e| { let result = execute(request).await.map_err(|e| {
let msg = e.to_string(); let msg = e.to_string();
if msg.contains("命令执行超时") { if msg.contains("命令执行超时") {
anyhow::anyhow!( anyhow::anyhow!(
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。", "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}",
timeout_secs timeout_secs, shell_hint
) )
} else { } else {
e anyhow::anyhow!("{}{}", msg, shell_hint)
} }
})?; })?;
@@ -2963,7 +2980,7 @@ mod tests {
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
@@ -3210,7 +3227,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db); let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let args = serde_json::json!({ "path": canon_file, "limit": 15 });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -3237,7 +3254,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db); let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -3719,7 +3736,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
(db, registry) (db, registry)
} }

View File

@@ -6,7 +6,7 @@ use serde::Deserialize;
use tauri::State; use tauri::State;
use df_ai::provider::LlmProvider; use df_ai::provider::LlmProvider;
use df_types::types::{new_id, Priority}; use df_types::types::{new_id, IdeaStatus, Priority, ProjectStatus};
use df_ideas::capture::Idea; use df_ideas::capture::Idea;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery}; use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord}; use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
@@ -87,7 +87,7 @@ pub async fn create_idea(
id: new_id(), id: new_id(),
title: input.title, title: input.title,
description: input.description, description: input.description,
status: "draft".to_string(), status: IdeaStatus::Draft,
priority: input.priority, priority: input.priority,
score: None, score: None,
tags: input.tags, tags: input.tags,
@@ -190,7 +190,7 @@ pub async fn promote_idea(
id: project_id.clone(), id: project_id.clone(),
name: project.name, name: project.name,
description: project.description, description: project.description,
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id: Some(id.clone()), idea_id: Some(id.clone()),
path: None, path: None,
stack: None, stack: None,
@@ -208,7 +208,7 @@ pub async fn promote_idea(
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 // 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)。 // 删除而非真事务(改动最小,工程投入产出比最高)。
let updated = IdeaRecord { let updated = IdeaRecord {
status: "promoted".to_string(), status: IdeaStatus::Promoted,
promoted_to: Some(project_id.clone()), promoted_to: Some(project_id.clone()),
updated_at: now, updated_at: now,
..record ..record
@@ -338,7 +338,7 @@ pub async fn evaluate_idea(
scores: Some(scores_json.clone()), scores: Some(scores_json.clone()),
ai_analysis: Some(ai_analysis.clone()), ai_analysis: Some(ai_analysis.clone()),
score: Some(score_value as f64), score: Some(score_value as f64),
status: "pending_review".to_string(), status: IdeaStatus::PendingReview,
updated_at: now_millis(), updated_at: now_millis(),
..record ..record
}; };
@@ -460,7 +460,7 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性) // IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
status: status_from_str(&record.status), status: status_from_str(record.status.as_str()),
priority: priority_from_i32(record.priority), priority: priority_from_i32(record.priority),
scores: None, scores: None,
tags, tags,

View File

@@ -12,7 +12,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest};
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, select_model_id, Modality, TaskRequirements,
}; };
use df_types::types::new_id; use df_types::types::{new_id, ProjectStatus};
use df_project::scan::{ use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description, collect_sample, detect_stack, discover_projects, extract_description,
normalize_path, DiscoveredProject, normalize_path, DiscoveredProject,
@@ -128,17 +128,23 @@ async fn create_with_binding(
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈 // 绑定目录:校验存在 + 防重复 + 自动探测技术栈
let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) { let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
Some(p) => { Some(p) => {
if !Path::new(p).is_dir() { // 拒绝原始路径含 `..`(防穿越),存入前调用 normalize_path 规范化
return Err(format!("目录不存在: {p}")); if p.contains("..") {
return Err(format!("路径不得包含 '..': {}", p));
} }
if let Some(conflict) = find_binding_conflict(state, p, None).await? { if !Path::new(p).is_dir() {
// 目录不存在则自动创建(消除建项目→建目录死锁)
std::fs::create_dir_all(p).map_err(|e| format!("目录创建失败: {}", e))?;
}
let normalized = normalize_path(p);
if let Some(conflict) = find_binding_conflict(state, &normalized, None).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name)); return Err(format!("目录已被项目「{}」绑定", conflict.name));
} }
// stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime) // stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime)
let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(), Some(s) => s.to_string(),
None => { None => {
let root = std::path::PathBuf::from(p); let root = std::path::PathBuf::from(&normalized);
let detected = tokio::task::spawn_blocking(move || detect_stack(&root)) let detected = tokio::task::spawn_blocking(move || detect_stack(&root))
.await .await
.map_err(err_str)? .map_err(err_str)?
@@ -146,7 +152,7 @@ async fn create_with_binding(
serde_json::to_string(&detected).map_err(err_str)? serde_json::to_string(&detected).map_err(err_str)?
} }
}; };
(Some(p.to_string()), Some(stack_json)) (Some(normalized), Some(stack_json))
} }
None => (None, None), None => (None, None),
}; };
@@ -156,7 +162,7 @@ async fn create_with_binding(
id: new_id(), id: new_id(),
name, name,
description, description,
status: "planning".to_string(), status: ProjectStatus::Planning,
idea_id, idea_id,
path, path,
stack, stack,
@@ -221,7 +227,6 @@ pub async fn import_project(
return Err("导入路径不能为空".to_string()); return Err("导入路径不能为空".to_string());
} }
if !Path::new(&path).is_dir() { if !Path::new(&path).is_dir() {
return Err(format!("目录不存在: {path}"));
} }
// 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。 // 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
@@ -371,7 +376,6 @@ pub async fn relocate_project_path(
new_path: String, new_path: String,
) -> Result<ProjectRecord, String> { ) -> Result<ProjectRecord, String> {
if !Path::new(&new_path).is_dir() { if !Path::new(&new_path).is_dir() {
return Err(format!("目录不存在: {new_path}"));
} }
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? { if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name)); return Err(format!("目录已被项目「{}」绑定", conflict.name));
@@ -435,7 +439,6 @@ pub async fn scan_directory_for_projects(
) -> Result<Vec<ScannedProjectItem>, String> { ) -> Result<Vec<ScannedProjectItem>, String> {
let root = Path::new(&root_path); let root = Path::new(&root_path);
if !root.is_dir() { if !root.is_dir() {
return Err(format!("目录不存在: {root_path}"));
} }
// 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime) // 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
@@ -670,7 +673,6 @@ pub async fn scan_project_with_ai(
) -> Result<AiScanResult, String> { ) -> Result<AiScanResult, String> {
let root = Path::new(&path); let root = Path::new(&path);
if !root.is_dir() { if !root.is_dir() {
return Err(format!("目录不存在: {path}"));
} }
// 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime) // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime)

View File

@@ -53,3 +53,41 @@ pub async fn settings_delete(
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> { pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string()) Ok(state.data_dir.to_string_lossy().to_string())
} }
/// 读取审批超时分钟数(默认 15 分钟,0=禁用超时)
///
/// 前端 Settings 页启动时拉取,展示当前配置。
#[tauri::command]
pub async fn ai_get_approval_timeout(state: State<'_, AppState>) -> Result<u64, String> {
Ok(state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst))
}
/// 设置审批超时分钟数并持久化(默认 15 分钟,0=禁用超时)
///
/// 前端 Settings 页保存时调用。值范围 0-1440(0=禁用,最大 24 小时)。
/// 注意:此命令与前端 AdvancedSection.vue 的 df-approval-timeout KV 持久化路径不同,
/// 前端走 appSettings.set 走 KV,这里作为后端热改入口。两者最终读写同一个 KV。
#[tauri::command]
pub async fn ai_set_approval_timeout(
state: State<'_, AppState>,
minutes: u64,
) -> Result<u64, String> {
// 范围校验(0-1440 分钟,即 0-24 小时)
if minutes > 1440 {
return Err("审批超时分钟数超出范围(最大 1440 = 24 小时)".to_string());
}
// 持久化到 Settings KV(与前端同 key,值为毫秒)
let ms = minutes * 60_000;
state
.settings
.set(
crate::state::APPROVAL_TIMEOUT_KEY,
&ms.to_string(),
)
.await
.map_err(err_str)?;
// 更新内存
state.approval_timeout_minutes.store(minutes, std::sync::atomic::Ordering::SeqCst);
tracing::info!("[APPROVAL-TIMEOUT] 已更新: {} 分钟 ({} ms)", minutes, ms);
Ok(minutes)
}

View File

@@ -284,7 +284,7 @@ pub async fn create_task(
project_id: input.project_id, project_id: input.project_id,
title: input.title, title: input.title,
description: input.description, description: input.description,
status: "todo".to_string(), status: TaskStatus::Todo,
priority: input.priority, priority: input.priority,
branch_name: input.branch_name, branch_name: input.branch_name,
assignee: input.assignee, assignee: input.assignee,
@@ -314,7 +314,7 @@ pub async fn create_task(
Some("task"), Some("task"),
Some(&record.id), Some(&record.id),
None, None,
Some(&record.status), Some(record.status.as_str()),
) )
.await; .await;
Ok(record) Ok(record)
@@ -466,8 +466,8 @@ pub async fn advance_task(
"task_advanced", "task_advanced",
Some("task"), Some("task"),
Some(&updated.id), Some(&updated.id),
from_state.as_deref(), from_state.as_ref().map(TaskStatus::as_str),
Some(&updated.status), Some(updated.status.as_str()),
) )
.await; .await;
@@ -505,7 +505,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
.get_by_id(parent_id) .get_by_id(parent_id)
.await .await
.map_err(err_str)? .map_err(err_str)?
.map(|t| t.status) .map(|t| t.status.as_str().to_string())
.ok_or_else(|| format!("父任务 {parent_id} 不存在")); .ok_or_else(|| format!("父任务 {parent_id} 不存在"));
} }
@@ -541,7 +541,7 @@ async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -
.await .await
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?; .ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
if current.status == new_status { if current.status.as_str() == new_status {
return Ok(new_status); return Ok(new_status);
} }
state state
@@ -688,13 +688,13 @@ pub async fn move_task_queue(
"backlog" => "todo".to_string(), "backlog" => "todo".to_string(),
"active" => { "active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) { if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() // 已在执行中三态,保留 current.status.as_str().to_string() // 已在执行中三态,保留
} else { } else {
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态) "in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
} }
} }
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态) "todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态) "decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
_ => unreachable!("validate_queue 已收口"), _ => unreachable!("validate_queue 已收口"),
}; };
@@ -708,7 +708,7 @@ pub async fn move_task_queue(
.map_err(err_str)?; .map_err(err_str)?;
} }
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径) // 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
if current.status != new_status { if current.status.as_str() != new_status {
state state
.tasks .tasks
.set_status_for_aggregation(&id, &new_status) .set_status_for_aggregation(&id, &new_status)

View File

@@ -8,7 +8,7 @@ use tauri::{AppHandle, Emitter, State};
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
use df_types::events::{WorkflowEvent, HumanApprovalResponse, SelectType}; use df_types::events::{WorkflowEvent, HumanApprovalResponse, SelectType};
use df_types::types::{new_id, NodeStatus}; use df_types::types::{new_id, ExecutionId, NodeStatus};
use df_nodes::task_advance_node::advance_task_atomic; use df_nodes::task_advance_node::advance_task_atomic;
// F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。 // F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。
// regression_target 已收敛到 df-nodes::task_state_machine(状态机业务契约,单一可信源), // regression_target 已收敛到 df-nodes::task_state_machine(状态机业务契约,单一可信源),
@@ -29,7 +29,7 @@ use super::{err_str, now_millis};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct WorkflowEventPayload { struct WorkflowEventPayload {
/// 工作流执行记录 ID /// 工作流执行记录 ID
execution_id: String, execution_id: ExecutionId,
/// 原始工作流事件serde tag = "type" /// 原始工作流事件serde tag = "type"
event: WorkflowEvent, event: WorkflowEvent,
} }
@@ -385,7 +385,7 @@ pub async fn get_workflow_execution(
pub async fn approve_human_approval( pub async fn approve_human_approval(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
execution_id: String, execution_id: ExecutionId,
node_id: String, node_id: String,
decision: String, decision: String,
// F-260615-01: 多选结果数组,缺省空数组(单选调用方不传) // F-260615-01: 多选结果数组,缺省空数组(单选调用方不传)
@@ -466,7 +466,7 @@ pub async fn approve_human_approval(
#[tauri::command] #[tauri::command]
pub async fn cancel_workflow_node( pub async fn cancel_workflow_node(
state: State<'_, AppState>, state: State<'_, AppState>,
execution_id: String, execution_id: ExecutionId,
node_id: String, node_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
// 经 execution_id 取执行器状态机(与 NodeContext.node_status 共享同一 HashMap // 经 execution_id 取执行器状态机(与 NodeContext.node_status 共享同一 HashMap

View File

@@ -11,6 +11,27 @@ use df_tunnel::TunnelClient;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
let log_path = std::env::temp_dir().join("devflow-trace.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.expect("创建日志文件失败");
let (non_blocking, _guard) = tracing_appender::non_blocking(log_file);
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
)
.with_writer(non_blocking)
.with_ansi(false)
.init();
tracing::info!(
path = %log_path.display(),
"[startup] 日志已初始化"
);
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -36,11 +57,33 @@ pub fn run() {
app.manage(app_state); app.manage(app_state);
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态 // B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new(
std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(3600))
.unwrap_or_else(std::time::Instant::now),
));
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
app.listen("ai-client-ready", move |_event| { app.listen("ai-client-ready", move |_event| {
let session_arc = session_for_handshake.clone(); let session_arc = session_for_handshake.clone();
let app_h = app_handle.clone(); let app_h = app_handle.clone();
let last_at = last_handshake_at.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。
{
let mut last = last_at.lock().await;
let elapsed = last.elapsed();
if elapsed < std::time::Duration::from_secs(3) {
tracing::info!(
"[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)",
elapsed.as_millis()
);
return;
}
*last = std::time::Instant::now();
}
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。 // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
@@ -68,6 +111,13 @@ pub fn run() {
} }
} }
} }
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
.iter()
.filter_map(|cid| {
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
})
.collect();
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending), // 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。 // 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
@@ -86,6 +136,7 @@ pub fn run() {
completion_tokens: 0, completion_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
}, },
); );
} }
@@ -318,6 +369,7 @@ pub fn run() {
commands::ai::ai_conversation_rename, commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive, commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned, commands::ai::ai_conversation_set_pinned,
commands::ai::ai_update_conversation_goals,
commands::ai::ai_conversation_export, commands::ai::ai_conversation_export,
commands::ai::ai_list_skills, commands::ai::ai_list_skills,
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效) // 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
@@ -354,6 +406,9 @@ pub fn run() {
commands::settings::settings_get_all, commands::settings::settings_get_all,
commands::settings::settings_delete, commands::settings::settings_delete,
commands::settings::get_data_dir, commands::settings::get_data_dir,
// 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改)
commands::settings::ai_get_approval_timeout,
commands::settings::ai_set_approval_timeout,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -37,10 +37,18 @@ pub enum ExtractTrigger {
ManualOnly, ManualOnly,
} }
/// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc<Mutex> 启动 default 覆盖
/// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。
/// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc<Mutex> 启动 default 覆盖 /// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc<Mutex> 启动 default 覆盖
/// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。 /// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。
pub const KNOWLEDGE_CONFIG_KEY: &str = "df-knowledge-config"; pub const KNOWLEDGE_CONFIG_KEY: &str = "df-knowledge-config";
/// 审批超时配置持久化 KV key(前端 AdvancedSection.vue 也在用同一个 key,保持单一真相源)。
/// 前端值单位为毫秒(0/300000/900000/1800000/3600000),后端同步使用此 key 读取并转换。
/// 前端在运行期负责会话内的审批超时(定时器+ai_approve(false)),后端在启动恢复阶段
/// 清理重启前遗留的 pending(避免重启后无人调用 try_continue 致审批永不超时)。
pub const APPROVAL_TIMEOUT_KEY: &str = "df-approval-timeout";
/// 知识库行为配置(内存真相源 + Settings KV 持久化,前后端通过 IPC 读写) /// 知识库行为配置(内存真相源 + Settings KV 持久化,前后端通过 IPC 读写)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeConfig { pub struct KnowledgeConfig {
@@ -410,6 +418,11 @@ pub struct AppState {
/// 任何 token流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay + /// 任何 token流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
/// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3 /// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3
pub agent_max_retries: Arc<AtomicUsize>, pub agent_max_retries: Arc<AtomicUsize>,
// ── 审批超时配置 ──
/// 审批超时分钟数(前端 Settings 配置 → AppState 字段 → try_continue 入口读取)。
/// 待审批超过此时长后自动取消,避免用户离开后会话永久卡死。
/// 默认 15 分钟0 表示禁用超时(不推荐,会导致卡死无自愈)。
pub approval_timeout_minutes: Arc<AtomicU64>,
// ── 工作流执行状态 ── // ── 工作流执行状态 ──
/// 工作流执行 → 节点状态机注册表 /// 工作流执行 → 节点状态机注册表
/// ///
@@ -436,8 +449,8 @@ pub struct AppState {
/// ///
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载, /// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时: /// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
/// - workspace_root 始终视为已授权(向后兼容,默认根) /// - 任一 persistent 或 session 目录 starts_with 命中即放行
/// - 任一 persistent 目录 starts_with 命中即放行 /// - 无任何授权时全部拒绝(引导用户绑定项目或授权目录)
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。 /// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空, /// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与 /// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
@@ -463,9 +476,9 @@ impl AllowedDirs {
pub const SETTINGS_KEY: &'static str = "allowed_dirs"; pub const SETTINGS_KEY: &'static str = "allowed_dirs";
/// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。 /// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。
/// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单;用户首次访问任意目录 /// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单
/// 走弹窗三档授权(once/session/always),对齐产品定位(不预设源码目录)。仅作 init /// 当前仅测试使用,生产环境由 reload_allowed_dirs 覆盖。
/// 占位(reload_allowed_dirs 从 KV 覆盖)+ 测试构造基线。 #[cfg_attr(not(test), allow(dead_code))]
pub fn default_with_root() -> Self { pub fn default_with_root() -> Self {
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() } Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
} }
@@ -574,7 +587,7 @@ pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。 /// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
/// ///
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策: /// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied` /// - 路径规范化(去 .. / 锚定首个持久授权目录)后,若命中黑名单 → `Denied`
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized` /// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust) /// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
/// ///
@@ -668,14 +681,6 @@ fn workspace_root_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
} }
/// 应用数据目录(运行期确定,跨平台)。
/// 不依赖编译期常量,打包分发后仍有效。
/// 用于 DevFlow 自身数据存储(.trash / logs 等),非用户项目目录。
/// 取 AppState.data_dir,不持 state 时回退 workspace_root_path()(开发期兼容)。
pub fn data_dir_path(state: &AppState) -> &Path {
&state.data_dir
}
impl AppState { impl AppState {
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表 /// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> { pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
@@ -726,6 +731,7 @@ impl AppState {
agent_max_retries: Arc::new(AtomicUsize::new( agent_max_retries: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)), )),
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
allowed_dirs: allowed_dirs.clone(), allowed_dirs: allowed_dirs.clone(),
@@ -750,14 +756,17 @@ impl AppState {
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。 // P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
state.reload_knowledge_config().await; state.reload_knowledge_config().await;
// 从 Settings KV 恢复审批超时配置(默认 15 分钟,0=禁用超时)。
state.reload_approval_timeout().await;
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。 // 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
let old_trash = workspace_root_path().join(".trash"); let old_trash = workspace_root_path().join(".trash");
let new_trash = data_dir.join(".trash"); let new_trash = data_dir.join(".trash");
if old_trash.exists() && !new_trash.exists() { if old_trash.exists() && !new_trash.exists() {
if let Err(e) = std::fs::rename(&old_trash, &new_trash) { if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
tracing::warn!( // 跨盘 rename 失败(E:→C:),静默跳过,新目录自动创建
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)", tracing::debug!(
"迁移 .trash 跨盘失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
old_trash, new_trash, e, old_trash, new_trash, e,
); );
} }
@@ -806,6 +815,32 @@ impl AppState {
} }
} }
/// 从 Settings KV 恢复审批超时分钟数(默认 15 分钟,0=禁用)。
///
/// KV key `df-approval-timeout` 与前端 AdvancedSection.vue 共用,值为毫秒数(0/300000/900000/...)。
/// 启动时读取用户配置,失败或首次启动无配置时保持默认 15 分钟。
pub async fn reload_approval_timeout(&self) {
match self.settings.get(APPROVAL_TIMEOUT_KEY).await {
Ok(Some(json)) => {
// 兼容两种格式:JSON 数字或字符串(纯数字字串)
let ms: u64 = if let Ok(v) = serde_json::from_str::<u64>(&json) {
v
} else if let Ok(s) = serde_json::from_str::<String>(&json) {
s.parse().unwrap_or(900_000)
} else {
tracing::warn!("[APPROVAL-TIMEOUT] KV 格式非法,保持 default: {}", json);
900_000
};
// ms → minutes(向上取整,0 保留为 0 表示禁用)
let mins = if ms == 0 { 0 } else { (ms + 59_999) / 60_000 };
self.approval_timeout_minutes.store(mins, Ordering::SeqCst);
tracing::info!("[APPROVAL-TIMEOUT] 启动恢复: {} 分钟 (KV ms={})", mins, ms);
}
Ok(None) => {} // 首次启动保持默认 15
Err(e) => tracing::warn!("[APPROVAL-TIMEOUT] 读 KV 失败,保持 default: {}", e),
}
}
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。 /// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
/// ///
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持 /// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
@@ -843,8 +878,9 @@ impl AppState {
all_dirs.sort(); all_dirs.sort();
all_dirs.dedup(); all_dirs.dedup();
let mut set = HashSet::new(); let mut set = HashSet::new();
// 方案①:KV + 项目绑定均空时不再硬塞 workspace_root(分发后该路径无效)。 // 始终插入 workspace_root(工程内路径默认免授权,对齐用户政策 + 注释承诺)。
// 用户首次访问任意目录走弹窗三档授权,授权后落 KV 持久 // BUG-260620-05 修:去掉 all_dirs.is_empty() 条件,无条件插入,不再依赖 KV/project_dirs 是否存在
set.insert(workspace_root_path());
for d in all_dirs { for d in all_dirs {
let d = d.trim(); let d = d.trim();
if d.is_empty() { if d.is_empty() {

View File

@@ -301,11 +301,19 @@ export const aiApi = {
}, },
/** /**
* 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。 /** 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。 * 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
* @param format 'markdown' | 'json' | 'txt' * @param format 'markdown' | 'json' | 'txt'
*/ */
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> { exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
return invoke('ai_conversation_export', { conversationId, format }) return invoke('ai_conversation_export', { conversationId, format })
}, },
}
/**
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
*/
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
return invoke('ai_update_conversation_goals', { conversationId, goals })
},
}

View File

@@ -372,7 +372,7 @@ export type AiChatEvent = ({
} | { } | {
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断), // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。 // 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[]
} | { } | {
type: 'AiError'; error: string; error_type?: AiErrorType type: 'AiError'; error: string; error_type?: AiErrorType
} | { } | {
@@ -572,6 +572,7 @@ export interface AiConversationSummary {
title: string | null title: string | null
provider_id: string | null provider_id: string | null
model: string | null model: string | null
pinned_goals?: string[]
archived: boolean archived: boolean
pinned?: boolean pinned?: boolean
prompt_tokens?: number | null prompt_tokens?: number | null

View File

@@ -237,6 +237,7 @@ function scrollToFirstPending(): void {
const { confirmState, confirmDialog, answerConfirm } = useConfirm() const { confirmState, confirmDialog, answerConfirm } = useConfirm()
async function confirmDeleteConversation(id: string) { async function confirmDeleteConversation(id: string) {
const conv = store.state.conversations.find(c => c.id === id) const conv = store.state.conversations.find(c => c.id === id)
if (!conv) console.warn('confirmDeleteConversation: conv not found, id=', id)
const title = conv?.title || t('aiChat.newConversation') const title = conv?.title || t('aiChat.newConversation')
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。 // UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
const isActive = id === store.state.activeConversationId const isActive = id === store.state.activeConversationId

View File

@@ -40,6 +40,10 @@
</div> </div>
</div> </div>
<div v-if="tc.reason" class="ai-tool-approval-reason"> {{ tc.reason }}</div> <div v-if="tc.reason" class="ai-tool-approval-reason"> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) -->
<div v-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
已等待 {{ formatWaitTime(waitSecs) }}
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)旧文件不存在tc.diff 为空,回退显上方 args content --> <!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)旧文件不存在tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff"> <div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre> <pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
@@ -50,21 +54,21 @@
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path"> <div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
<button class="ai-tool-btn ai-tool-btn--approve" <button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving" :disabled="approving"
@click="onAuthorize('once')"> @click="onAuthorizeOnce">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }} {{ $t('aiChat.dirAuthOnce') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--always" <button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving" :disabled="approving"
@click="onAuthorize('always')"> @click="onAuthorizeAlways">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }} {{ $t('aiChat.dirAuthAlways') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--reject" <button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving" :disabled="approving"
@click="onAuthorize('deny')"> @click="onDeny">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }} {{ $t('aiChat.dirAuthDeny') }}
@@ -73,14 +77,14 @@
<div v-else class="ai-tool-actions"> <div v-else class="ai-tool-actions">
<button class="ai-tool-btn ai-tool-btn--approve" <button class="ai-tool-btn ai-tool-btn--approve"
:disabled="approving" :disabled="approving"
@click="onApprove(true)"> @click="onApprove">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }} {{ $t('aiTool.approve') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--reject" <button class="ai-tool-btn ai-tool-btn--reject"
:disabled="approving" :disabled="approving"
@click="onApprove(false)"> @click="onReject">
<span v-if="approving" class="ai-tool-btn-spinner" /> <span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }} {{ $t('aiTool.reject') }}
@@ -120,10 +124,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue' import { computed, toRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import { import {
shouldKeepOpen, shouldKeepOpen,
parseResult, parseResult,
@@ -133,6 +135,7 @@ import {
} from '@/composables/ai/useToolCard' } from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender' import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader' import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { useToolApproval } from '@/composables/ai/useToolApproval'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import ToolResultBody from './ToolResultBody.vue' import ToolResultBody from './ToolResultBody.vue'
@@ -161,98 +164,24 @@ const emit = defineEmits<{
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }] approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
}>() }>()
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。 // 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc) const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
/** // 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
* 审批按钮 loading 态(B-260616-08)。点击后置 true 禁用两按钮防重复点击 + spinner; const {
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。兜底计时到点复位 + toast 提示 approving,
* (对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。 approveToast,
*/ waitSecs,
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS waitLevel,
const approving = ref(false) formatWaitTime,
let approvingTimer: ReturnType<typeof setTimeout> | null = null onApprove,
onReject,
// ── 审批超时兜底 toast(本卡自管,分离窗口运行时 App 根 toast 不在 DOM) ── onAuthorizeOnce,
const approveToast = reactive({ visible: false, msg: '' }) onAuthorizeAlways,
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null onDeny,
function showApproveTimeoutToast(): void { confirmState,
approveToast.msg = t('aiTool.approveLoadingTimeout') answerConfirm,
approveToast.visible = true } = useToolApproval(toRef(props, 'tc'), emit)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
])
/** High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete')
}
return t('aiTool.confirmHighGeneric')
}
async function onApprove(approved: boolean) {
if (approving.value) return // 防重入
// AE-2025-05:High 风险 + 批准 → 先二次确认;拒绝不再二次确认(无害,加确认反增摩擦)
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
if (!ok) return
}
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: props.tc.id, approved })
}
/**
* path_auth 审批链阶段3b:path 类(路径授权挂起)三选项处理。
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
* 与 onApprove 同款 loading + 超时兜底,但 emit 带 decision(approved=false 仅占位,
* 实际语义由 decision 决定;调用方 store.approveToolCall 按 decision 走 authorizeDir)。
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,once/always/deny 三选项
* 本身就是用户明示决策,二次确认反增摩擦)。
*/
async function onAuthorize(decision: 'once' | 'always' | 'deny') {
if (approving.value) return // 防重入
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
// approved=false 仅占位:store.approveToolCall 据 decision 走 authorizeDir,approved 入参被忽略。
emit('approve', { id: props.tc.id, approved: false, decision })
}
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。原 cmdOutputExpanded/
// httpBodyExpanded 初始化逻辑已随结果区迁入 ToolResultBody,此处仅留 approving 复位。
watch(() => props.tc.status, (s) => {
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
}
}, { immediate: true })
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
})
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */ /** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
const parsed = computed(() => parseResult(props.tc.result)) const parsed = computed(() => parseResult(props.tc.result))
@@ -380,6 +309,22 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
} }
/* BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
.ai-tool-wait-time {
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
font-family: var(--df-font-mono);
color: var(--df-text-dim, #888);
border-radius: var(--df-radius-sm, 4px);
}
.ai-tool-wait-time.is-long {
color: var(--df-warning, #d97706);
animation: pulseGlow 1.5s ease-in-out infinite;
}
.ai-tool-wait-time.is-urgent {
color: var(--df-danger, #e5484d);
animation: pulseGlow 1s ease-in-out infinite;
}
/* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级) */ /* AE-2025-03: write_file 审批 diff 预览区(红删绿增行级) */
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; } .ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
.ai-tool-diff-pre { .ai-tool-diff-pre {

View File

@@ -19,6 +19,7 @@
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span> <span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span> <span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<span class="ai-tool-group-count">{{ group.calls.length }}</span> <span class="ai-tool-group-count">{{ group.calls.length }}</span>
<span class="ai-tool-group-files">{{ groupFileSummary(group) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }"></span> <span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }"></span>
<!-- 全部展开/收起(U-260618: topbar 下沉到首个可折叠分组标题行尾巴, run command 同行) --> <!-- 全部展开/收起(U-260618: topbar 下沉到首个可折叠分组标题行尾巴, run command 同行) -->
<div <div
@@ -216,6 +217,26 @@ function groupDisplayName(group: ToolCallGroup): string {
return group.name.replace(/_/g, ' ') return group.name.replace(/_/g, ' ')
} }
/** 从工具调用参数中提取路径摘要(取前 3 个文件名)。
* 供分组标题行展示,让用户一眼知道影响了哪些文件。*/
function groupFileSummary(group: ToolCallGroup): string {
const paths: string[] = []
for (const tc of group.calls) {
const args = tc.args as Record<string, unknown> | undefined
const p = args?.path
if (typeof p === 'string' && p.trim()) {
// 取最后一段文件名(或最后两级目录)
const parts = p.trim().split(/[/\\]/)
const short = parts.length >= 2
? parts.slice(-2).join('/')
: parts[parts.length - 1]
if (!paths.includes(short)) paths.push(short)
if (paths.length >= 3) break
}
}
return paths.length ? ' · ' + paths.join(' · ') : ''
}
/** /**
* 单卡是否展开: * 单卡是否展开:
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开 * - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开

View File

@@ -120,6 +120,26 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button> </button>
</div> </div>
<!-- .4 Phase 4: @项目展开摘要(输入区 enrichment 预览) -->
<div v-if="hasEnrichment" class="ai-enrichment-bar">
<button
class="ai-enrichment-toggle"
:class="{ 'ai-enrichment-toggle--expanded': enrichmentExpanded }"
@click="enrichmentExpanded = !enrichmentExpanded"
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
>
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path d="M2 3l3 4 3-4" />
</svg>
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
</button>
<button class="ai-enrichment-dismiss" @click="dismissEnrichment" title="取消关联">×</button>
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
</div>
</div>
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div> <div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
</div> </div>
</template> </template>
@@ -382,6 +402,112 @@ const mentionStart = ref(-1)
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。 // 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([]) const pendingMentionSpans = ref<MentionSpan[]>([])
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
// 在输入区下方显式 badge + 可展开的上下文参考面板。
interface ProjectEnrichment {
id: string
name: string
status: string
path: string | null
taskCount: number
ideaCount: number
inProgressTasks: { title: string; status: string }[]
pendingIdeas: { title: string; status: string }[]
}
const enrichmentExpanded = ref(false)
/** @项目 mentions 经 stores 反查后的 enrichment 摘要列表 */
const projectEnrichments = computed<ProjectEnrichment[]>(() => {
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
if (projectSpans.length === 0) return []
const projects = (projectsStore.projects || []) as ProjectRecord[]
const tasks = (projectsStore.tasks || []) as TaskRecord[]
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
const results: ProjectEnrichment[] = []
for (const span of projectSpans) {
const project = projects.find(p => p.id === span.refId)
if (!project) continue
const relTasks = tasks.filter(t => t.project_id === project.id)
// ideas linking to this project via promoted_to (project name matching)
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
const pending = relIdeas.filter(i => i.status === 'pending_review').slice(0, 20)
results.push({
id: project.id,
name: project.name,
status: project.status as string,
path: project.path,
taskCount: relTasks.length,
ideaCount: relIdeas.length,
inProgressTasks: running.map(t => ({ title: t.title, status: t.status as string })),
pendingIdeas: pending.map(i => ({ title: i.title, status: i.status as string })),
})
}
return results
})
/** 是否有 enrichment 可展示 */
const hasEnrichment = computed(() => projectEnrichments.value.length > 0)
/** 概要 badge 文本(汇总所有 @项目 entrichment) */
const enrichmentBadgeText = computed(() => {
const list = projectEnrichments.value
if (!list.length) return ''
const totalTasks = list.reduce((s, p) => s + p.taskCount, 0)
const totalIdeas = list.reduce((s, p) => s + p.ideaCount, 0)
const names = list.map(p => p.name).join('、')
return t('aiChat.enrichmentBadge', { project: names, tasks: totalTasks, ideas: totalIdeas })
})
/** 展开后的 enrichment 正文(mimic 后端 build_augmentation_segment 格式) */
const enrichmentDetailText = computed(() => {
const list = projectEnrichments.value
if (!list.length) return ''
const lines: string[] = []
for (const p of list) {
lines.push(`【项目】${p.name}(状态: ${p.status}`)
if (p.path) lines.push(`目录: ${p.path}`)
if (p.inProgressTasks.length > 0) {
lines.push(`进行中任务(${p.taskCount}):`)
for (const t of p.inProgressTasks) {
lines.push(` - ${t.title} (${t.status})`)
}
}
if (p.pendingIdeas.length > 0) {
lines.push(`待评估灵感(${p.ideaCount}):`)
for (const i of p.pendingIdeas) {
lines.push(` - ${i.title} (${i.status})`)
}
}
lines.push('')
}
return lines.join('\n')
})
/** 取消 @项目 关联:清除 mentionSpan + 移除输入框中的 [@项目: xxx] 标记 */
function dismissEnrichment() {
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
if (projectSpans.length === 0) return
// 从 inputText 中移除所有 [@项目: xxx] 标记
let text = inputText.value
for (const span of projectSpans) {
const label = text.slice(span.start, span.start + span.length)
// 替换标记 + 前后可能的空格为单个空格
const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`)
text = text.replace(pattern, ' ').trim()
}
inputText.value = text
// 清除 project 类型的 mentionSpan
pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project')
enrichmentExpanded.value = false
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const mentionGroupLabel = computed(() => ({ const mentionGroupLabel = computed(() => ({
project: t('aiChat.mentionGroupProject'), project: t('aiChat.mentionGroupProject'),
task: t('aiChat.mentionGroupTask'), task: t('aiChat.mentionGroupTask'),
@@ -972,4 +1098,86 @@ defineExpose({
margin-top: 4px; margin-top: 4px;
opacity: 0.6; opacity: 0.6;
} }
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
.ai-enrichment-bar {
display: flex;
align-items: center;
margin-top: 6px;
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
overflow: hidden;
}
.ai-enrichment-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 8px;
border: none;
background: color-mix(in srgb, var(--df-accent) 10%, transparent);
color: var(--df-text);
cursor: pointer;
font-size: 11px;
text-align: left;
transition: background 0.15s;
}
.ai-enrichment-toggle:hover {
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
}
.ai-enrichment-toggle--expanded {
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
}
.ai-enrichment-chevron {
flex-shrink: 0;
color: var(--df-text-dim);
transition: transform 0.15s;
}
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
transform: rotate(180deg);
}
.ai-enrichment-dismiss {
flex-shrink: 0;
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
line-height: 1;
transition: color 0.15s;
}
.ai-enrichment-dismiss:hover {
color: var(--df-danger);
}
.ai-enrichment-badge {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
font-weight: 500;
}
.ai-enrichment-panel {
padding: 6px 8px;
background: var(--df-bg);
max-height: 200px;
overflow-y: auto;
}
.ai-enrichment-label {
font-size: 10px;
color: var(--df-text-dim);
margin-bottom: 4px;
font-weight: 600;
}
.ai-enrichment-body {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-all;
font-family: var(--df-font-mono, ui-monospace, monospace);
}
</style> </style>

View File

@@ -20,34 +20,26 @@
{{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }} {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div> </div>
</div> </div>
<!-- F-01 阶段6: 模型选择器(自动/指定) <!-- 提供商状态 + 模型选择器 -->
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话 <div class="ai-header-center">
provider model_configs(空池):下拉空 + 强制自动(toggle 禁用) <div
仅展示 active provider enabled model_configs --> class="ai-provider-bar"
<div class="ai-model-picker" :title="modelPickerHint"> :class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
<div class="ai-model-mode"> v-if="store.state.providers.length > 0"
<button :title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
type="button" @click="cycleProvider"
class="ai-model-mode-btn" >
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }" <span class="provider-dot"></span>
:disabled="!enabledModels.length" <span class="provider-name">{{ activeProviderName }}</span>
@click="setAutoMode"
>{{ $t('aiChat.autoMode') }}</button>
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
:disabled="!enabledModels.length"
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
@click="setSpecifyMode"
>{{ $t('aiChat.specifyMode') }}</button>
</div> </div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select <select
v-if="enabledModels.length" v-if="enabledModels.length"
class="ai-model-select" class="ai-model-select ai-model-select--compact"
:value="selectedModelId" :value="selectedModelId"
:disabled="!isSpecifyMode"
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
@change="onModelSelect($event)" @change="onModelSelect($event)"
> >
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id"> <option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
@@ -56,6 +48,7 @@
</select> </select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span> <span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
</div> </div>
</div>
<div class="ai-header-actions"> <div class="ai-header-actions">
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')"> <button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
@@ -114,20 +107,89 @@
</div> </div>
</div> </div>
<!-- 提供商状态 --> <!-- 底部工具区:标题 + 目标 + 历史消息 + 上下文摘要 -->
<div <div class="ai-bottom-tools" ref="goalsRef">
class="ai-provider-bar" <span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }" <div class="ai-bottom-tools-right">
v-if="store.state.providers.length > 0" <!-- 目标计数 -->
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''" <div v-if="goals.length" class="ai-goals-inline">
@click="cycleProvider" <span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" title="查看目标">
> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span class="provider-dot"></span> {{ goals.length }}
<span class="provider-name">{{ activeProviderName }}</span> </span>
<div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
<span class="ai-goal-text">{{ g }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
</div> </div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div> </div>
</div>
<!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" @click="historyExpanded = !historyExpanded" title="查看历史消息">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ historyMsgs.length }}
</span>
<div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
</div>
</div>
</div>
<!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" @click="summaryExpanded = !summaryExpanded" title="查看上下文摘要">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ summaryMsgs.length }}
</span>
<div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') || '上下文摘要' }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
</div>
</div>
<!-- L3 完整上下文 -->
<div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline">
<span class="ai-context-inline-badge ai-tool-badge" @click="contextExpanded = !contextExpanded" title="查看 AI 上下文">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</span>
<div v-if="contextExpanded" class="ai-context-inline-list">
<!-- 系统提示 -->
<div class="ai-context-section">
<div class="ai-context-section-title">系统提示</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">目标({{ contextInfo.goals.length }})</span>
<span class="ai-context-value">{{ contextInfo.goals.join('; ') || '无' }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">环境</span>
<span class="ai-context-value">{{ contextInfo.osInfo }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">压缩摘要</span>
<span class="ai-context-value">{{ contextInfo.summaryCount }} </span>
</div>
</div>
</div>
<!-- 增强上下文 -->
<div class="ai-context-section">
<div class="ai-context-section-title">增强上下文</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">@项目</span>
<span class="ai-context-value">{{ contextInfo.projectNames || '无' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model ,2s 后淡出) --> <!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model ,2s 后淡出) -->
<Transition name="ai-provider-switch"> <Transition name="ai-provider-switch">
<div v-if="providerBarVisible" class="ai-provider-switch-bar"> <div v-if="providerBarVisible" class="ai-provider-switch-bar">
@@ -141,10 +203,11 @@
// 第三批抽离: 顶部工具栏 TopBar(从 AiChat.vue 行 12-145 template + 行 741-902 script 迁移) // 第三批抽离: 顶部工具栏 TopBar(从 AiChat.vue 行 12-145 template + 行 741-902 script 迁移)
// 零行为变更。provider/model 选择逻辑直接读 store(单例共享),动作按钮 emit 回父, // 零行为变更。provider/model 选择逻辑直接读 store(单例共享),动作按钮 emit 回父,
// 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。 // 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import type { ModelConfig } from '../../api/types' import { aiApi } from '../../api/ai'
import type { AiMessage, ModelConfig } from '../../api/types'
defineProps<{ defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */ /** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
@@ -194,9 +257,9 @@ const activeProviderName = computed(() => {
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured') return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
}) })
// ── F-01 阶段6: 顶部模型选择器(自动/指定) ── // ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。 // active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空 + 强制自动模式 // 空池(provider 未拉取/无 enabled 模型)时下拉为空。
const activeProviderRecord = computed(() => const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider) store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default) || store.state.providers.find(p => p.is_default)
@@ -206,51 +269,188 @@ const activeProviderRecord = computed(() =>
const enabledModels = computed(() => { const enabledModels = computed(() => {
const p = activeProviderRecord.value const p = activeProviderRecord.value
if (!p?.model_configs) return [] if (!p?.model_configs) return []
// weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs。
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
return p.model_configs return p.model_configs
.filter(m => m.enabled) .filter(m => m.enabled)
.slice() .slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)) .sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
}) })
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
const selectedModelId = computed(() => store.modelOverride.value || '') const selectedModelId = computed(() => store.modelOverride.value || '')
/** 当前选中模型的 model_id(指定模式);自动模式为空 */
const modelPickerHint = computed(() => {
if (!enabledModels.value.length) return t('aiChat.noModels')
return isSpecifyMode.value
? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint')
})
/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
function modelOptionLabel(m: ModelConfig): string { function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id return m.label || m.model_id
} }
function setAutoMode() {
store.modelOverride.value = null
}
function setSpecifyMode() {
if (!enabledModels.value.length) return
// 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留)
const cur = store.modelOverride.value
const inPool = cur && enabledModels.value.some(m => m.model_id === cur)
if (!inPool) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
}
function onModelSelect(e: Event) { function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null store.modelOverride.value = v || null
} }
// UX-09 + 2026-06-18: 切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、 // 切换会话默认选中权重最高 model
// 不随机/不摇摆)。从 AiChat.vue 迁移(原与 editingMsgId/cancelEdit 同 watch,拆分后此 watch
// 仅管 modelOverride 默认值;编辑态取消留在 AiChat)。零行为变更。
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null store.modelOverride.value = enabledModels.value[0]?.model_id || null
}) })
// ── 当前对话标题 ──
const currentConvTitle = computed(() => {
const id = store.state.activeConversationId
if (!id) return ''
const conv = store.state.conversations.find(c => c.id === id)
return conv?.title || ''
})
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
const goals = ref<string[]>([])
const goalsExpanded = ref(false)
// ── 📋 历史消息(当前会话 user 消息列表) ──
interface HistoryItem { id: string; text: string; time: string; num: number }
/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */
function fmtTime(ts: number): string {
const d = new Date(ts)
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
// 同一天 → 只显示时间
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
return hm
}
// 昨天
const yest = new Date(now)
yest.setDate(yest.getDate() - 1)
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
return `昨天 ${hm}`
}
// 更早
return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}`
}
const MAX_HISTORY = 20
const MAX_CHARS = 80
const historyMsgs = computed<HistoryItem[]>(() => {
const allUserMsgs = store.state.messages
.filter((m): m is AiMessage & { timestamp: number } => m.role === 'user' && !!m.content && !!m.timestamp)
.map((m, idx) => ({
id: m.id,
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
time: fmtTime(m.timestamp),
num: idx + 1,
}))
return allUserMsgs.slice(-MAX_HISTORY).reverse()
})
const historyExpanded = ref(false)
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
function scrollToMessage(msgId: string) {
historyExpanded.value = false
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位
const el = document.querySelector(`[data-msg-id="${msgId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
const goalsRef = ref<HTMLElement | null>(null)
function onBottomToolsClickOutside(e: MouseEvent) {
if (!goalsRef.value) return
if (goalsExpanded.value && !goalsRef.value.contains(e.target as Node)) {
goalsExpanded.value = false
}
if (historyExpanded.value && !goalsRef.value.contains(e.target as Node)) {
historyExpanded.value = false
}
if (summaryExpanded.value && !goalsRef.value.contains(e.target as Node)) {
summaryExpanded.value = false
}
if (contextExpanded.value && !goalsRef.value.contains(e.target as Node)) {
contextExpanded.value = false
}
}
// ── 📄 上下文摘要(压缩/归档的系统消息) ──
interface SummaryItem { id: string; text: string }
const summaryMsgs = computed<SummaryItem[]>(() => {
return store.state.messages
.filter(m => m.role === 'system' && !!m.content)
.map(m => ({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
}))
})
const summaryExpanded = ref(false)
// ── L3 完整上下文 ──
interface ContextInfo {
goals: string[]
summaryCount: number
osInfo: string
projectNames: string
}
const contextExpanded = ref(false)
const contextInfo = computed<ContextInfo>(() => {
const convId = store.state.activeConversationId
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
// 从所有 user 消息中收集 mentionSpans(kind === 'project'),去重
const projectLabels = new Set<string>()
for (const m of store.state.messages) {
if (m.role === 'user' && m.mentionSpans?.length) {
for (const sp of m.mentionSpans) {
if (sp.kind === 'project' && sp.label) {
projectLabels.add(sp.label)
}
}
}
}
return {
goals: conv?.pinned_goals ?? [],
summaryCount: summaryMsgs.value.length,
osInfo: 'Windows 11 / PowerShell',
projectNames: Array.from(projectLabels).join(', ') || '无',
}
})
/** 按 Escape 关闭所有下拉 */
function onBottomToolsKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
goalsExpanded.value = false
historyExpanded.value = false
summaryExpanded.value = false
contextExpanded.value = false
}
}
onMounted(() => {
document.addEventListener('click', onBottomToolsClickOutside)
document.addEventListener('keydown', onBottomToolsKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onBottomToolsClickOutside)
document.removeEventListener('keydown', onBottomToolsKeydown)
})
/** 监听 activeConversationId 变化,加载目标 */
watch(() => store.state.activeConversationId, (convId) => {
loadGoals(convId)
}, { immediate: true })
/** 消息变化后也刷新目标 */
watch(() => store.state.messages.length, () => {
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
})
function loadGoals(convId: string | null) {
if (!convId) { goals.value = []; return }
const conv = store.state.conversations.find(c => c.id === convId)
goals.value = conv?.pinned_goals ?? []
}
/** 移除第 i 个目标(移除最后一个自动收起) */
async function removeGoal(i: number) {
const convId = store.state.activeConversationId
if (!convId) return
const next = [...goals.value]
next.splice(i, 1)
goals.value = next
if (next.length === 0) goalsExpanded.value = false
await aiApi.updateConversationGoals(convId, next)
}
// 循环切换 Provider(provider bar 点击) // 循环切换 Provider(provider bar 点击)
function cycleProvider() { function cycleProvider() {
const ps = store.state.providers const ps = store.state.providers
@@ -312,6 +512,10 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--df-text); color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
} }
/* AE-2025-02:审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */ /* AE-2025-02:审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */
.ai-approval-badge { .ai-approval-badge {
@@ -331,45 +535,12 @@ onBeforeUnmount(() => {
opacity: 0.85; opacity: 0.85;
} }
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */ /* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker { .ai-model-picker {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px;
margin-left: auto;
margin-right: 4px;
padding-left: 8px;
min-width: 0; min-width: 0;
} }
.ai-model-mode {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.ai-model-mode-btn {
border: none;
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.ai-model-mode-btn:hover:not(:disabled) {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-model-mode-btn--active {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-model-mode-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ai-model-select { .ai-model-select {
min-width: 0; min-width: 0;
max-width: 180px; max-width: 180px;
@@ -383,6 +554,9 @@ onBeforeUnmount(() => {
outline: none; outline: none;
cursor: pointer; cursor: pointer;
} }
.ai-model-select--compact {
max-width: 130px;
}
.ai-model-select:disabled { .ai-model-select:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
@@ -431,20 +605,40 @@ onBeforeUnmount(() => {
.ai-btn-spinner { .ai-btn-spinner {
animation: ai-btn-spin 0.9s linear infinite; animation: ai-btn-spin 0.9s linear infinite;
} }
/* 底部工具区(标题左 + 工具右) */
/* ═══ Provider Bar ═══ */ .ai-bottom-tools {
.ai-provider-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px;
padding: 6px 14px; padding: 0 14px 4px;
border-bottom: 0.5px solid var(--df-border); }
.ai-bottom-title {
font-size: 11px;
color: var(--df-text-secondary);
min-width: 0;
margin-right: auto;
}
.ai-bottom-tools-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
/* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar {
display: inline-flex;
gap: 4px;
font-size: 11px;
color: var(--df-text-secondary);
white-space: nowrap;
} }
.ai-provider-bar--switchable { .ai-provider-bar--switchable {
cursor: pointer; cursor: pointer;
} }
.ai-provider-bar--switchable:hover { .ai-provider-bar--switchable:hover {
background: var(--df-bg-card); color: var(--df-text-primary);
} }
.provider-dot { .provider-dot {
width: 5px; width: 5px;
@@ -460,6 +654,235 @@ onBeforeUnmount(() => {
.ai-provider-empty { .ai-provider-empty {
background: var(--df-danger-bg); background: var(--df-danger-bg);
} }
/* 历史消息(底部) */
.ai-history-inline {
position: relative;
display: inline-flex;
align-items: center;
}
/* 底部工具通用徽标 */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 目标计数(底部) */
.ai-goals-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-history-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 240px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-history-inline-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
color: var(--df-text-secondary);
transition: background 0.1s;
}
.ai-history-inline-item:hover {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-history-index {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
min-width: 24px;
text-align: right;
font-family: var(--df-font-mono);
}
.ai-history-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.ai-history-time {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
/* ═══ 上下文摘要(底部) ═══ */
.ai-summary-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-summary-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
max-height: 360px;
overflow-y: auto;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 6px 0;
margin-top: 2px;
}
.ai-summary-inline-item {
padding: 6px 10px;
cursor: pointer;
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
}
.ai-summary-inline-item:last-child { border-bottom: none; }
.ai-summary-inline-item:hover {
background: var(--df-bg-card);
}
.ai-summary-label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 2px;
}
.ai-summary-preview {
margin: 0;
font-size: 11px;
color: var(--df-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ═══ L3 完整上下文(底部) ═══ */
.ai-context-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-context-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 2px;
}
.ai-context-section {
padding: 6px 10px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-context-section:last-child { border-bottom: none; }
.ai-context-section-title {
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 4px;
}
.ai-context-section-body {
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-context-row {
display: flex;
gap: 6px;
font-size: 11px;
}
.ai-context-label {
flex-shrink: 0;
color: var(--df-text-dim);
min-width: 56px;
}
.ai-context-value {
color: var(--df-text-secondary);
word-break: break-all;
}
/* header 中间区:provider + 模型选择器并排 */
.ai-header-center {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
min-width: 0;
}
.ai-goals-inline-list {
position: absolute;
left: auto; right: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg, #fff);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-goal-inline-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.ai-goal-inline-item:hover {
background: var(--df-bg-card);
}
.ai-goal-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-goal-remove:hover {
color: var(--df-danger, #e5484d);
}
.provider-hint { .provider-hint {
font-size: 11px; font-size: 11px;
color: var(--df-danger); color: var(--df-danger);

View File

@@ -27,6 +27,17 @@
<option value="debug">Debug</option> <option value="debug">Debug</option>
</select> </select>
</SettingRow> </SettingRow>
<!-- 审批超时:用户未处理审批弹窗时的自动拒绝阅值;0 表示不限时(对齐旧默认) -->
<SettingRow ref="rowApprovalTimeout" :label="$t('settings.labelApprovalTimeout')" :desc="$t('settings.descApprovalTimeout')">
<select v-model="settings.approvalTimeout" class="setting-select" @change="onApprovalTimeoutChange">
<option :value="0">{{ $t('settings.approvalTimeoutUnlimited') }}</option>
<option :value="300000">{{ $t('settings.approvalTimeout5m') }}</option>
<option :value="900000">{{ $t('settings.approvalTimeout15m') }}</option>
<option :value="1800000">{{ $t('settings.approvalTimeout30m') }}</option>
<option :value="3600000">{{ $t('settings.approvalTimeout60m') }}</option>
</select>
</SettingRow>
</div> </div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死删除不适合确认接管) --> <!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死删除不适合确认接管) -->
@@ -88,10 +99,13 @@ const settings = reactive({
// clampMode 兜底脏值。 // clampMode 兜底脏值。
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')), autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
logLevel: 'info', logLevel: 'info',
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
}) })
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute') const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel') const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
const rowApprovalTimeout = useTemplateRef<InstanceType<typeof SettingRow>>('rowApprovalTimeout')
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all // 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
// 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。 // 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。
@@ -130,6 +144,20 @@ watch(
function markSaved() { function markSaved() {
rowLogLevel.value?.markSaved() rowLogLevel.value?.markSaved()
} }
// 审批超时合法选项白名单(对齐 <select> option);脏值(外部改库为非法数字)回退默认 15min,
// 防 <select v-model> 无匹配 option 致控件空白。允许的取值:0/5/15/30/60 分钟对应毫秒数。
const VALID_APPROVAL_TIMEOUTS = [0, 300_000, 900_000, 1_800_000, 3_600_000]
function clampApprovalTimeout(raw: number): number {
return VALID_APPROVAL_TIMEOUTS.includes(raw) ? raw : 900_000
}
// 审批超时下拉变更:落 KV(df-approval-timeout,ms)+ 显「已保存」反馈。
// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。
function onApprovalTimeoutChange() {
appSettings.set('df-approval-timeout', settings.approvalTimeout)
rowApprovalTimeout.value?.markSaved()
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -41,6 +41,38 @@
</div> </div>
</div> </div>
</section> </section>
<!-- 命令执行安全:ScriptNode /黑名单(前端仅 UI + 持久化,后端当前从环境变量读) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelScriptSafety') }}</h2>
</div>
<div class="script-safety">
<p class="script-safety-desc">{{ $t('settings.descScriptSafety') }}</p>
<SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')">
<input
v-model="scriptWhitelist"
class="setting-select script-safety-input"
:placeholder="$t('settings.placeholderScriptWhitelist')"
@blur="onScriptWhitelistCommit"
@keyup.enter="onScriptWhitelistCommit"
/>
</SettingRow>
<SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')">
<input
v-model="scriptBlacklist"
class="setting-select script-safety-input"
:placeholder="$t('settings.placeholderScriptBlacklist')"
@blur="onScriptBlacklistCommit"
@keyup.enter="onScriptBlacklistCommit"
/>
</SettingRow>
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
</div>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -48,8 +80,11 @@ import { ref, onMounted } from 'vue'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
const { t } = useI18n() const { t } = useI18n()
const appSettings = useAppSettingsStore()
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈 // F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>() const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>()
@@ -138,6 +173,27 @@ async function reload() {
await load() await load()
} }
// ============================================================
// ScriptNode 命令执行安全 — 白/黑名单 UI
// ------------------------------------------------------------
// 前端仅做 UI + 持久化到 appSettings(df-script-whitelist / df-script-blacklist)。
// 后端 ScriptNode 当前从环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST 读取
// (同一逗号分隔语义),前后端打通留后续:前端写入需经 IPC/启动期注入环境变量。
// 输入语义:逗号分隔命令名(如 `git,npm,cargo`),空串=该项不限制。
// ============================================================
const scriptWhitelist = ref(appSettings.get<string>('df-script-whitelist', ''))
const scriptBlacklist = ref(appSettings.get<string>('df-script-blacklist', ''))
function onScriptWhitelistCommit() {
appSettings.set('df-script-whitelist', scriptWhitelist.value.trim())
emit('toast', t('settings.savedHint'), 'info')
}
function onScriptBlacklistCommit() {
appSettings.set('df-script-blacklist', scriptBlacklist.value.trim())
emit('toast', t('settings.savedHint'), 'info')
}
onMounted(load) onMounted(load)
</script> </script>
@@ -165,4 +221,17 @@ onMounted(load)
.add-row { display: flex; gap: 8px; margin-top: 4px; } .add-row { display: flex; gap: 8px; margin-top: 4px; }
.dir-input { flex: 1; min-width: 0; } .dir-input { flex: 1; min-width: 0; }
.actions { display: flex; gap: 8px; margin-top: 8px; } .actions { display: flex; gap: 8px; margin-top: 8px; }
/* ===== ScriptNode 命令执行安全面板 ===== */
.script-safety { display: flex; flex-direction: column; gap: 4px; }
.script-safety-desc {
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
}
.script-safety-input {
min-width: 280px;
font-family: var(--df-font-mono, monospace);
}
.script-safety-note {
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
}
</style> </style>

View File

@@ -80,10 +80,13 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
// ===== 安全 ===== // ===== 安全 =====
{ category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' }, { category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' },
{ category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' },
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
// ===== 高级 ===== // ===== 高级 =====
// F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名 // F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名
{ category: 'advanced', key: 'autoExecuteMode', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' }, { category: 'advanced', key: 'autoExecuteMode', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
{ category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' }, { category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' },
{ category: 'advanced', key: 'approvalTimeout', labelKey: 'settings.labelApprovalTimeout', descKey: 'settings.descApprovalTimeout' },
{ category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' }, { category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' },
] ]

View File

@@ -0,0 +1,291 @@
<template>
<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>
<div class="wf-dag-layer-nodes">
<div
v-for="node in layer"
:key="node.id"
class="wf-dag-node"
:class="nodeClass(node.id, props.nodeStatuses)"
>
<span class="wf-dag-node-status-dot"></span>
<span class="wf-dag-node-name">{{ node.id }}</span>
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
<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>
<!-- 边条件编辑列表 -->
<details class="wf-dag-edges-detail">
<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>
<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>
</div>
<div v-else class="wf-dag wf-dag--empty">
{{ $t('taskDetail.workflowDagEmpty') || '暂无工作流数据' }}
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
interface DagNode {
id: string
node_type: string
config?: Record<string, unknown>
}
interface EdgeDef {
source: string
target: string
condition?: string | null
}
export type NodeStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
const props = withDefaults(defineProps<{
dagJson: string
nodeStatuses?: Record<string, NodeStatus>
}>(), {
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 }
})
const nodeTypeLabel: Record<string, string> = {
'AiNode': 'AI',
'HumanNode': '人工审批',
'ScriptNode': '脚本',
'AiSelfReviewNode': 'AI 自审',
}
function statusLabel(s: NodeStatus): string {
return { pending: '⏳', running: '🔄', completed: '✅', failed: '❌', skipped: '⏭️' }[s] || ''
}
function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<string, boolean> {
const s = statuses[id]
return {
'wf-dag-node--running': s === 'running',
'wf-dag-node--completed': s === 'completed',
'wf-dag-node--failed': s === 'failed',
'wf-dag-node--skipped': s === 'skipped',
}
}
/** 拓扑分层 */
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) {
inDegree[n.id] = 0
edgeMap[n.id] = []
}
for (const e of d.edges) {
if (!edgeMap[e.source]) edgeMap[e.source] = []
edgeMap[e.source].push(e.target)
inDegree[e.target] = (inDegree[e.target] || 0) + 1
}
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) {
const layer: DagNode[] = []
const next: string[] = []
for (const id of queue) {
if (visited.has(id)) continue
visited.add(id)
const node = d.nodes.find(n => n.id === id)
if (node) layer.push(node)
for (const target of (edgeMap[id] || [])) {
inDegree[target]--
if (inDegree[target] === 0) next.push(target)
}
}
if (layer.length > 0) result.push(layer)
queue = next
}
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>
.wf-dag {
margin-top: 8px; padding: 8px 12px;
background: var(--df-bg-card, #fafafa);
border-radius: var(--df-radius-sm, 4px); font-size: 12px;
}
.wf-dag--empty { color: var(--df-text-dim, #888); }
.wf-dag-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
.wf-dag-layers { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.wf-dag-layer { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.wf-dag-layer-label { font-size: 10px; color: var(--df-text-dim, #888); align-self: flex-start; }
.wf-dag-layer-nodes {
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
}
.wf-dag-layer-arrow { font-size: 16px; color: var(--df-text-dim, #888); line-height: 1; }
.wf-dag-node {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px;
background: var(--df-bg, #fff);
border: 1px solid var(--df-border, #ddd);
border-radius: var(--df-radius-sm, 4px);
transition: border-color 0.3s, background 0.3s;
}
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
.wf-dag-node-type {
font-size: 10px; color: var(--df-text-dim, #888);
background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 3px;
}
.wf-dag-node-status { font-size: 11px; }
.wf-dag-node-status-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--df-text-dim, #ccc);
}
.wf-dag-node--running {
border-color: var(--df-info, #4a90e2);
background: rgba(74,144,226,0.05);
}
.wf-dag-node--running .wf-dag-node-status-dot {
background: var(--df-info, #4a90e2);
animation: pulse 1s ease-in-out infinite;
}
.wf-dag-node--completed { border-color: var(--df-success, #3ddba0); }
.wf-dag-node--completed .wf-dag-node-status-dot { background: var(--df-success, #3ddba0); }
.wf-dag-node--failed { border-color: var(--df-danger, #e5484d); background: rgba(229,72,77,0.05); }
.wf-dag-node--failed .wf-dag-node-status-dot { background: var(--df-danger, #e5484d); }
.wf-dag-node--skipped { opacity: 0.5; }
@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); 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-input:focus { border-color: var(--df-accent); }
</style>

View File

@@ -70,8 +70,26 @@ export function findToolCall(id: string): AiToolCallInfo | undefined {
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ── // ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝 /// 审批超时阈值来源的 appSettings key(运行期可调,默认 15min)。
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 /// 值语义:正整数=毫秒超时;0=不限时(跳过计时器);缺省=900000(15min)。
const APPROVAL_TIMEOUT_KEY = 'df-approval-timeout'
const APPROVAL_TIMEOUT_DEFAULT_MS = 900_000 // 15 分钟
/**
* 读取当前审批超时阈值(ms):从 appSettings 取 `df-approval-timeout`,
* 缺省 900000(15min),返回 0 表示用户配置为「不限时」(调用方应跳过计时器)。
*
* 每次 startApprovalTimer 调用时取最新值 —— 用户在 Settings 页调整后,下一笔审批即生效
* (已在跑的计时器沿用旧值,符合「不影响已发起审批」的预期)。
*/
function getApprovalTimeoutMs(): number {
const raw = appSettings.get<number>(APPROVAL_TIMEOUT_KEY, APPROVAL_TIMEOUT_DEFAULT_MS)
// 防御:负数 / NaN 视为默认值;0 保留(语义=不限时)
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
return APPROVAL_TIMEOUT_DEFAULT_MS
}
return raw
}
/** toolCallId → 审批超时计时器 */ /** toolCallId → 审批超时计时器 */
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>() const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
@@ -91,6 +109,9 @@ export function startApprovalTimer(
kind: 'risk' | 'path' = 'risk', kind: 'risk' | 'path' = 'risk',
): void { ): void {
if (_approvalTimers.has(toolCallId)) return if (_approvalTimers.has(toolCallId)) return
// 0 = 用户配置为「不限时」,跳过计时器(对齐旧 Infinity 行为)
const timeoutMs = getApprovalTimeoutMs()
if (timeoutMs === 0) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
_approvalTimers.delete(toolCallId) _approvalTimers.delete(toolCallId)
// 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死 // 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死
@@ -107,7 +128,7 @@ export function startApprovalTimer(
isError: true, isError: true,
timestamp: Date.now(), timestamp: Date.now(),
}) })
}, APPROVAL_TIMEOUT_MS) }, timeoutMs)
_approvalTimers.set(toolCallId, timer) _approvalTimers.set(toolCallId, timer)
} }

View File

@@ -10,9 +10,9 @@ import { state } from '@/stores/ai'
import { notifyConversationChanged } from './useAiEvents' import { notifyConversationChanged } from './useAiEvents'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState } from './aiShared' import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiMessage, AiToolCallInfo } from '@/api/types' import type { AiConversationDetail, AiMessage, AiToolCallInfo } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -92,7 +92,22 @@ let _latestSwitchId = 0
export async function switchConversation(id: string) { export async function switchConversation(id: string) {
const mySwitchId = ++_latestSwitchId const mySwitchId = ++_latestSwitchId
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图 // 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
const detail = await aiApi.switchConversation(id) let detail: AiConversationDetail
try {
detail = await aiApi.switchConversation(id)
} catch {
// 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代
console.warn('[AI] switchConversation 失败,创建新对话:', id)
const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return
void appSettings.set('df-ai-active-conv', created.id)
// 用新对话 id 重走后续逻辑
detail = { id: created.id, title: null, messages: '[]' }
state.messages = []
notifyConversationChanged()
void loadConversations()
return
}
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return
state.activeConversationId = id state.activeConversationId = id
@@ -226,6 +241,9 @@ export async function switchConversation(id: string) {
dir: tc.dir, dir: tc.dir,
reason: tc.reason, reason: tc.reason,
}) })
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝,
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
startApprovalTimer(tc.id, tc.name, kind)
} }
} }
} }

View File

@@ -578,8 +578,14 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
role: 'assistant', role: 'assistant',
content: t('ai.responseIncomplete'), content: t('ai.responseIncomplete'),
timestamp: Date.now(), timestamp: Date.now(),
} as AiMessage) })
} }
// 对话透明化 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 累加对话总量 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) { if (isShowTokenUsage()) {
state.lastTokenUsage = { state.lastTokenUsage = {

View File

@@ -0,0 +1,212 @@
/**
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
*
* 职责:审批按钮 loading 态、审批挂起时长显示(BUG-260624-02)、
* 审批超时兜底 toast(UX-260617-14)、approve/reject/path_auth 三选项处理、
* status 变化自动复位 + pending 计时。
*
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
*/
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import type { AiToolCallInfo } from '@/api/types'
/**
* 审批按钮 loading 超时兜底(B-260616-08)。
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
* 兜底计时到点复位 + toast 提示(对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
*/
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
])
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── B-260616-08: 审批 loading 态 ──
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── BUG-260624-02: 审批挂起时长显示 ──
const pendingSince = ref<number | null>(null)
const waitSecs = ref(0)
let waitTimer: ReturnType<typeof setInterval> | null = null
function startPendingTimer() {
if (pendingSince.value !== null) return
pendingSince.value = Date.now()
waitSecs.value = 0
if (waitTimer) clearInterval(waitTimer)
waitTimer = setInterval(() => {
if (pendingSince.value !== null) {
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
}
}, 1000)
}
function stopPendingTimer() {
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
pendingSince.value = null
waitSecs.value = 0
}
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
// ── UX-260617-14: 审批超时兜底 toast ──
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
function showApproveTimeoutToast(): void {
approveToast.msg = t('aiTool.approveLoadingTimeout')
approveToast.visible = true
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/** AE-2025-05:High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete')
}
return t('aiTool.confirmHighGeneric')
}
/**
* 批准按钮处理(risk 类专用)。
* AE-2025-05:High 风险工具 → 先二次确认(useConfirm 弹层)。
* loading + 超时兜底复用 B-260616-08 机制。
*/
async function onApprove() {
if (approving.value) return // 防重入
// AE-2025-05:High 风险 → 先二次确认
if (HIGH_RISK_TOOLS.has(tc.value.name)) {
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
if (!ok) return
}
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: true })
}
/**
* 拒绝按钮处理(risk 类专用)。
* loading + 超时兜底复用 B-260616-08 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。
*/
async function onReject() {
if (approving.value) return // 防重入
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false })
}
/**
* path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。
* 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。
*/
async function onAuthorizeOnce() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'once' })
}
/**
* path_auth 审批链阶段3b:path 类「写持久白名单」处理。
* 同 onAuthorizeOnce,decision='always'。
*/
async function onAuthorizeAlways() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'always' })
}
/**
* path_auth 审批链阶段3b:path 类「拒绝」处理。
* 同 onAuthorizeOnce,decision='deny'。
*/
async function onDeny() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'deny' })
}
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。
watch(() => tc.value.status, (s) => {
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
stopPendingTimer()
} else {
startPendingTimer()
}
}, { immediate: true })
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
stopPendingTimer()
})
return {
approving,
approveToast,
waitSecs,
waitLevel,
formatWaitTime,
onApprove,
onReject,
onAuthorizeOnce,
onAuthorizeAlways,
onDeny,
// 供模板 ConfirmDialog 绑定
confirmState,
answerConfirm,
}
}

View File

@@ -170,6 +170,12 @@ export default {
// Same-name skill conflict (scan_skills returns conflicts; frontend warns, injection takes first by skills>commands>plugins priority) // Same-name skill conflict (scan_skills returns conflicts; frontend warns, injection takes first by skills>commands>plugins priority)
skillConflict: 'Skill "{name}" has multiple definitions; using skills priority', skillConflict: 'Skill "{name}" has multiple definitions; using skills priority',
// ── ⑥.4 Phase 4: @project expansion summary (input area preview) ──
enrichmentBadge: '📎 Referenced "{project}" ({tasks} tasks, {ideas} ideas)',
enrichmentExpand: 'Show context preview',
enrichmentCollapse: 'Hide context preview',
enrichmentLabel: 'Context preview',
// ── F-15 phase 2: manual context management (clear / compress) ── // ── F-15 phase 2: manual context management (clear / compress) ──
// Buttons (Header action area: trash + compress icon) // Buttons (Header action area: trash + compress icon)

View File

@@ -139,11 +139,5 @@ export default {
confirmDelete: 'Delete idea "{title}"? This cannot be undone.', confirmDelete: 'Delete idea "{title}"? This cannot be undone.',
promoteFailed: 'Promotion failed', promoteFailed: 'Promotion failed',
evalFailed: 'Evaluation failed', evalFailed: 'Evaluation failed',
// Error fallbacks (user-visible via state.error)
err: {
loadFailed: 'Failed to load ideas',
createFailed: 'Failed to create idea',
deleteFailed: 'Failed to delete idea',
},
}, },
} }

View File

@@ -125,6 +125,15 @@ export default {
labelLogLevel: 'Log level', labelLogLevel: 'Log level',
descLogLevel: 'Verbosity of workflow logs', descLogLevel: 'Verbosity of workflow logs',
// ===== Approval timeout =====
labelApprovalTimeout: 'Approval timeout',
descApprovalTimeout: 'Auto-reject threshold when user does not respond to an approval prompt (only affects approvals started after the change)',
approvalTimeoutUnlimited: 'No limit',
approvalTimeout5m: '5 minutes',
approvalTimeout15m: '15 minutes (default)',
approvalTimeout30m: '30 minutes',
approvalTimeout60m: '60 minutes',
labelShowTokenUsage: 'Show token usage', labelShowTokenUsage: 'Show token usage',
descShowTokenUsage: 'Show token consumption of each reply in conversations', descShowTokenUsage: 'Show token consumption of each reply in conversations',
@@ -159,6 +168,17 @@ export default {
allowedDirsAddFail: 'Failed to add authorized directory', allowedDirsAddFail: 'Failed to add authorized directory',
allowedDirsRemoveFail: 'Failed to remove authorized directory', allowedDirsRemoveFail: 'Failed to remove authorized directory',
// ===== ScriptNode command execution safety =====
panelScriptSafety: '🛡️ Command Execution Safety',
descScriptSafety: 'Configure ScriptNode command whitelist/blacklist. Command name is the first token (e.g. `rm -rf /` -> `rm`), compared trimmed and lowercase. Blacklist takes precedence over whitelist.',
labelScriptWhitelist: 'Command whitelist',
descScriptWhitelist: 'Comma-separated command names (e.g. git,npm,cargo); empty = unrestricted. When non-empty, only listed commands are allowed.',
labelScriptBlacklist: 'Command blacklist',
descScriptBlacklist: 'Comma-separated command names (e.g. rm,format,shutdown); any match rejects execution.',
placeholderScriptWhitelist: 'Leave empty for no restriction, or: git,npm,cargo',
placeholderScriptBlacklist: 'Leave empty for no blacklist, or: rm,format',
noteScriptSafety: 'Note: backend ScriptNode currently reads DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST env vars (same comma-separated semantics). This UI only persists on the frontend; wiring to backend is planned for a later release.',
// ===== Knowledge base panel ===== // ===== Knowledge base panel =====
panelKnowledge: '📚 Knowledge base', panelKnowledge: '📚 Knowledge base',
labelAutoExtract: 'Auto-distill knowledge', labelAutoExtract: 'Auto-distill knowledge',

View File

@@ -55,5 +55,12 @@ export default {
workflowStepsProgress: '{done} / {total} steps done', workflowStepsProgress: '{done} / {total} steps done',
workflowCompletedHint: 'Workflow completed, task advanced', workflowCompletedHint: 'Workflow completed, task advanced',
workflowFailedHint: 'Workflow failed, task rolled back', workflowFailedHint: 'Workflow failed, task rolled back',
// Workflow DAG display (WorkflowDagDisplay.vue)
workflowDagTitle: 'Workflow Structure',
workflowLayer: 'Layer',
workflowDagEmpty: 'No workflow data',
workflowEdgeDetail: 'Edge Condition Editor',
workflowUnconditional: 'Unconditional',
workflowCondPlaceholder: 'Enter condition expression (e.g. outputs.done == true)',
}, },
} }

View File

@@ -171,6 +171,12 @@ export default {
// 同名技能冲突(scan_skills 返回 conflicts,前端提示,注入按 skills>commands>plugins 优先级取首份) // 同名技能冲突(scan_skills 返回 conflicts,前端提示,注入按 skills>commands>plugins 优先级取首份)
skillConflict: '技能 {name} 存在多份定义,已采用 skills 优先级', skillConflict: '技能 {name} 存在多份定义,已采用 skills 优先级',
// ── ⑥.4 Phase 4: @项目展开摘要(输入区预览 ──
enrichmentBadge: '📎 已关联「{project}」({tasks} 项任务, {ideas} 项灵感)',
enrichmentExpand: '展开上下文参考',
enrichmentCollapse: '收起上下文参考',
enrichmentLabel: '上下文参考',
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ── // ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// 按钮(Header 操作区:垃圾桶 + 压缩图标) // 按钮(Header 操作区:垃圾桶 + 压缩图标)

View File

@@ -139,11 +139,5 @@ export default {
confirmDelete: '确定删除灵感「{title}」?此操作不可撤销。', confirmDelete: '确定删除灵感「{title}」?此操作不可撤销。',
promoteFailed: '立项失败', promoteFailed: '立项失败',
evalFailed: '评估失败', evalFailed: '评估失败',
// 错误回退(state.error 用户可见)
err: {
loadFailed: '加载灵感失败',
createFailed: '创建灵感失败',
deleteFailed: '删除灵感失败',
},
}, },
} }

View File

@@ -125,6 +125,15 @@ export default {
labelLogLevel: '日志级别', labelLogLevel: '日志级别',
descLogLevel: '工作流日志详细程度', descLogLevel: '工作流日志详细程度',
// ===== 审批超时 =====
labelApprovalTimeout: '审批超时',
descApprovalTimeout: '用户未处理审批弹窗时的自动拒绝阅值(只影响下一笔发起的审批)',
approvalTimeoutUnlimited: '不限时',
approvalTimeout5m: '5 分钟',
approvalTimeout15m: '15 分钟(默认)',
approvalTimeout30m: '30 分钟',
approvalTimeout60m: '60 分钟',
labelShowTokenUsage: '显示 Token 用量', labelShowTokenUsage: '显示 Token 用量',
descShowTokenUsage: '在对话中展示每次回复的 token 消耗', descShowTokenUsage: '在对话中展示每次回复的 token 消耗',
@@ -159,6 +168,17 @@ export default {
allowedDirsAddFail: '添加授权目录失败', allowedDirsAddFail: '添加授权目录失败',
allowedDirsRemoveFail: '删除授权目录失败', allowedDirsRemoveFail: '删除授权目录失败',
// ===== ScriptNode 命令执行安全 =====
panelScriptSafety: '🛡️ 命令执行安全',
descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。',
labelScriptWhitelist: '命令白名单',
descScriptWhitelist: '逗号分隔命令名(如 git,npm,cargo);留空表示不限制。非空时仅允许名单内命令执行。',
labelScriptBlacklist: '命令黑名单',
descScriptBlacklist: '逗号分隔命令名(如 rm,format,shutdown);命中任一项即拒绝执行。',
placeholderScriptWhitelist: '留空不限制,或输入:git,npm,cargo',
placeholderScriptBlacklist: '留空表示无黑名单,或输入:rm,format',
noteScriptSafety: '注:后端 ScriptNode 当前从环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST 读取,与同语义逗号分隔。此处配置仅前端持久化,与后端打通待后续版本。',
// ===== 知识库面板 ===== // ===== 知识库面板 =====
panelKnowledge: '📚 知识库', panelKnowledge: '📚 知识库',
labelAutoExtract: '自动提炼知识', labelAutoExtract: '自动提炼知识',

View File

@@ -55,5 +55,12 @@ export default {
workflowStepsProgress: '已完成 {done} / {total} 步', workflowStepsProgress: '已完成 {done} / {total} 步',
workflowCompletedHint: '工作流完成,任务已推进', workflowCompletedHint: '工作流完成,任务已推进',
workflowFailedHint: '工作流失败,任务已退回', workflowFailedHint: '工作流失败,任务已退回',
// 工作流 DAG 显示(WorkflowDagDisplay.vue)
workflowDagTitle: '工作流结构',
workflowLayer: '层',
workflowDagEmpty: '暂无工作流数据',
workflowEdgeDetail: '边条件编辑',
workflowUnconditional: '无条件',
workflowCondPlaceholder: '输入条件表达式(如 outputs.done == true)',
}, },
} }

View File

@@ -131,6 +131,7 @@ function createStore() {
deleteIdea: ideasStore.deleteIdea, deleteIdea: ideasStore.deleteIdea,
evaluateIdea: ideasStore.evaluateIdea, evaluateIdea: ideasStore.evaluateIdea,
promoteIdea: ideasStore.promoteIdea, promoteIdea: ideasStore.promoteIdea,
relateIdeas: ideasStore.relateIdeas,
// workflow actions // workflow actions
runWorkflow: workflowStore.runWorkflow, runWorkflow: workflowStore.runWorkflow,
loadWorkflowExecutions: workflowStore.loadWorkflowExecutions, loadWorkflowExecutions: workflowStore.loadWorkflowExecutions,

View File

@@ -86,9 +86,11 @@
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }} {{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span> </span>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span> <span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-else-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span> <span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div> </div>
</div> </div>
<!-- 工作流 DAG 结构 -->
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
<div class="info-item"> <div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span> <span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value"> <span class="value">
@@ -168,6 +170,8 @@ import {
} from '../constants/project' } from '../constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types' import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue' import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import type { NodeStatus } from '@/components/workflow/WorkflowDagDisplay.vue'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -198,6 +202,17 @@ let _wfResultTimer: ReturnType<typeof setTimeout> | null = null
const wfProgressHint = computed(() => wfResult.value !== null) const wfProgressHint = computed(() => wfResult.value !== null)
const wfCompletedHint = computed(() => wfResult.value === 'completed') const wfCompletedHint = computed(() => wfResult.value === 'completed')
const wfFailedHint = computed(() => wfResult.value === 'failed') const wfFailedHint = computed(() => wfResult.value === 'failed')
// 工作流 DAG 结构展示
const wfDagJson = ref('')
const wfNodeStatuses = ref<Record<string, NodeStatus>>({})
async function refreshWorkflowDag(execId: string) {
wfNodeStatuses.value = {}
try {
const record = await workflowApi.getExecution(execId)
if (record?.dag_json) wfDagJson.value = record.dag_json
} catch { /* 静默 */ }
}
const wfDoneTotal = computed(() => wfTotalNodes.value) const wfDoneTotal = computed(() => wfTotalNodes.value)
const taskId = computed(() => route.params.id as string) const taskId = computed(() => route.params.id as string)
@@ -337,6 +352,7 @@ async function handleWorkflowAdvance(target: string) {
) )
wfExecId.value = execId wfExecId.value = execId
errorMsg.value = '' errorMsg.value = ''
await refreshWorkflowDag(execId)
} catch (e: any) { } catch (e: any) {
wfAdvancing.value = false wfAdvancing.value = false
wfExecId.value = null wfExecId.value = null
@@ -354,12 +370,16 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度) // 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
const node = String(evt.node_id ?? '') const node = String(evt.node_id ?? '')
wfRunningNode.value = node wfRunningNode.value = node
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'running' }
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1 if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1) else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
break break
} }
case 'node_completed': { case 'node_completed': {
wfDoneCount.value += 1 wfDoneCount.value += 1
if (wfRunningNode.value) {
wfNodeStatuses.value = { ...wfNodeStatuses.value, [wfRunningNode.value]: 'completed' }
}
wfRunningNode.value = '' wfRunningNode.value = ''
break break
} }
@@ -377,6 +397,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
} }
case 'workflow_failed': case 'workflow_failed':
case 'node_failed': { case 'node_failed': {
if (evt?.node_id) {
const node = String(evt.node_id)
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' }
}
if (evt?.type === 'workflow_failed') { if (evt?.type === 'workflow_failed') {
wfAdvancing.value = false wfAdvancing.value = false
wfRunningNode.value = '' wfRunningNode.value = ''

66
todo.md
View File

@@ -5,39 +5,20 @@
> 诊断发现的 P0-P2 问题合并为单次任务,逐项推进。 > 诊断发现的 P0-P2 问题合并为单次任务,逐项推进。
> 排除项:#3 status 无状态机(暂保留状态可跳跃迁移,不处理)。 > 排除项:#3 status 无状态机(暂保留状态可跳跃迁移,不处理)。
### P0 — 数据安全 / 正确性
- [x] **#1 软删除机制**ideas 表加 `deleted_at` 列 + 软删除(对标 tasks/projects 的 `delete` + `restore` 全套模板)。当前 `delete_idea` 是硬删除,误删不可恢复。
- [x] **#2 白名单收紧**`settings.rs` ideas 白名单移除 `id` / `created_at`,防止主键和创建时间被篡改。(对标 B-260616-16 tasks 白名单修复)
- [x] **#4 priority 值域校验**`create_idea` / `update_idea` 加 priority 校验(`parse::<i32>() ∈ 0..=3`,非法返 Err对标 B-260615-15 tasks priority 修复)
### P1 — 架构 / 一致性
- [x] **#5 有损转换修复**`record_to_idea()` 不再硬编码 `status=Draft` / `related_ids=空`,从 IdeaRecord 读取真实值,评估时不丢失上下文。
- [x] **#6 评分关键词拆文件**:将 `scoring.rs` 硬编码关键词拆到独立 const 文件(最小改动)。启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。**不做 DB 表配置化**(过度工程)。
- [x] **#7 promote 补偿改软删除**`promote_idea` 回写失败时的补偿删除从 `purge_with_descendants`(不可恢复)改为 `soft_delete`(软删除,可恢复)。
- [x] **#8 关联双向同步**A 关联 B 时自动在 B 的 `related_ids` 中补入 A解绑时同步移除保持关联关系对称。
- [x] **#9 创建表单补 tags 输入**:捕捉灵感表单增加 tags 输入(逗号分隔或 chip 组件),`confirmCapture` 传 tags JSON 字符串给后端。
### P2 — 前端 / UX ### P2 — 前端 / UX
- [x] **#10 创建表单补全 priority/source**:捕捉模态框增加 priority 下拉(低/中/高/紧急)和 source 输入框 - [x] **#12 前端 filter 改后端查询** ✅ 团队决策已落地:`hot`/`pending` 保留前端 filter(keyword/order_by 已下沉后端)
- [~] **#12 前端 filter 改后端查询**`hot`score≥80`pending`(多状态 OR仍保留前端 filter团队决策扩多值属过度设计keyword/order_by 已下沉后端。
- [x] **#14 雷达图正名/替换**:将 i18n key 和 CSS class 中的 "radar" 改为 "score-bar"(或引入真正的雷达图组件如 ECharts radar
## AI 对话目标丢失(诊断待办) ## AI 对话目标丢失(诊断待办)
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md` > 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
> 实测会话 `54c8b5bd`480 万 token原始目标消息被压缩出局LLM 尾部盲搜死循环。 > 实测会话 `54c8b5bd`480 万 token原始目标消息被压缩出局LLM 尾部盲搜死循环。
- [~] **G1 目标钉扎** ✅代码落地(编译过)`PerConvState.pinned_goal` + chat.rs 处提取 + `run_agentic_loop` 入口拼 system_prompt。核心治 R1。**待实测** - [x] **G1 目标钉扎** 代码落地(`GOAL_PIN_ENABLED=true`)pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec<String>` + chat.rs 处提取(追加去重,支持累积多目标)
- [~] **G2 探索熔断** ✅代码落地(编译过)`STALL_BREAKER_*` 四开关 + stall_count + `is_empty_tool_result`(对齐 serde_json 紧凑格式)。警示 `insert_at(0,system)` **经核验安全**(convert_request:101/117 把 system 抽顶层合并,无连续 System 1214 风险;G1 评审该论据不成立)。**待实测** - [x] **G2 探索熔断** 代码落地(`STALL_BREAKER_ENABLED=true``STALL_BREAKER_THRESHOLD=3`)stall_count + is_empty_tool_result 完整实现
- [~] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum - [x] **G4 话题标记修正** ✅ 代码已落地(`TOPIC_MARKER_GOAL_AWARE=true`)pinned_goals 非空时 take 后丢弃跳过 insert
- [~] **G4 话题标记修正** ✅代码落地(编译过)`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goal 存在时 take 后丢弃跳过 insert。**待实测**。
- [x] **G5 附 bug** ✅已排查(不改代码)dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。
- [ ] **G1 目标刷新策略**(openQuestion):首版 None 才写,同对话改目标保留旧目标。待观察是否需 drift 刷新。
> 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。 > 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`MAX_GOALS=5`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`STALL_BREAKER_GOAL_REMIND`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。
> 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。 > 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。
## 对抗式评估接入(引擎已实现 → IPC + 前端打通) ## 对抗式评估接入(引擎已实现 → IPC + 前端打通)
@@ -47,13 +28,34 @@
### 后端 — IPC 接入 ### 后端 — IPC 接入
- [ ] **A1 evaluate_idea 命令**:新增 IPC command按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval` - [x] **A2 批量评估命令** ⏸️ **暂不做**:单 idea 评估 `evaluate_idea` IPC 已接入(AdversarialEngine),批量评估当前无前端消费方,属过度设计
- [ ] **A2 批量评估命令**`evaluate_ideas_batch`,遍历多个 idea 调同一 engine 实例(复用 provider 连接)。
- [ ] **A3 评估结果持久化**:评估结果写回 `ideas`scores + evaluated_by + evaluated_at避免每次重新评估。
- [ ] **A4 类型导出**`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)。
### 前端 — 结果展示 ## 最新对话分析-2026-06-28死锁与审批卡死
> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
> 核心表现7 次工具连续失败后 L1 断路器熔断1 条 pending 审批导致全链路死锁。
### P0 — 审批 pending 死锁
- [x] **审批超时自动取消** ✅ 已实现(15min 默认,可配置):
- 后端 `agentic/approval_timeout.rs::cleanup_expired_approvals``try_continue_agent_loop` 入口检测超时,超时审批自动取消 + emit AiApprovalResult + audit_finalize 记入审计。
- `PendingApproval.created_at` 记录创建时刻(SystemTime,重启恢复的按重启时刻计)。
- `AppState.approval_timeout_minutes` + `reload_approval_timeout()` 从 Settings KV `df-approval-timeout` 启动恢复(与前端 AdvancedSection.vue 同 key,值为毫秒)。
- IPC `ai_get_approval_timeout` / `ai_set_approval_timeout`(0=禁用,范围 0-1440 分钟)。
- 前端已有 Settings UI(0/5/15/30/60 分钟 select),前端定时器负责运行期会话内超时,后端负责重启恢复场景兼底。
- [x] **`create_project` 自动创建目录** ✅ 已实现(`project.rs:137`):有 path 且目录不存在时自动 `create_dir_all`,死锁链已消除(非参数控制,直接自动创建)。
### P1 — Windows 平台兼容
- [x] **`run_command` 失败追加 shell 适配提示** ✅ 已实现(`tool_registry.rs:2351-2358`)Windows 下 tool result 尾部追加 PowerShell 适配提示(路径引用、`&&``;`)。
- [x] **PowerShell 反斜杠自动转义** ✅ 已修复(`tool_registry.rs:2306-2328` 注释说明根因)serde_json 已正确还原反斜杠不做额外转义PowerShell 正确接受裸反斜杠路径。
### P2 — 标题 LLM 失败
- [x] **标题摘要合并连续同 role** ✅ 已实现(`title.rs:79-83`)`merge_consecutive_roles` 发 LLM 前合并相邻 user/assistant已覆盖 Anthropic/GLM 1214。
### P3 — 其他
- [x] **migration V33** ✅ 已实现(`migrations.rs` migrate_v33)`workflow_executions``updated_at` 列 + `ai_conversations``pending_approvals`(审批重启恢复)。
- [x] **L0-handshake 防抖** ✅ 已实现(`lib.rs:73-82`)3s 内重复 `ai-client-ready` 跳过。
- [ ] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分LLM / 启发式兜底 / 纯启发式)。
- [ ] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPCloading 状态 + 错误提示。
- [ ] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar