Compare commits

34 Commits
Author SHA1 Message Date
lxy 4840f7ebc6 文档: df-relay 标记 deprecated 已提取为 u-relay 独立仓库 2026-08-23 14:15:13 +08:00
lxy 9753a6f902 优化: git状态色与设置控件token化收敛+看板销账清尾 2026-08-15 18:58:24 +08:00
lxy 13ecd7eba4 优化: 审批超时倒计时(60s变红)+分离窗口监听核验 2026-08-15 18:46:13 +08:00
lxy 871416637a 优化: 前端审批队列核验销账+清理无引用CSS变量 2026-08-15 18:46:13 +08:00
lxy 5ac815b9ca 优化: 清理无意义注释(走查编号前缀/过时历史标注/死代码注释) 2026-08-15 18:05:43 +08:00
lxy 5a4c821629 优化: drainQueue并发互斥+deep watch指纹+前端打磨 2026-08-12 01:00:56 +08:00
lxy 4ad78abad6 优化: 清理编号注释 2026-08-12 00:47:45 +08:00
lxy 7a1e80eac2 优化: workflow记录补project_id+前端审批/交互打磨 2026-08-12 00:40:40 +08:00
lxy d31a27512c 修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复 2026-08-12 00:29:06 +08:00
lxy 9407f821b9 优化: 图像模型兜底防御+任务UI(状态多选+跳详情)+token popover遮挡 2026-08-10 13:46:26 +08:00
lxy 256af4e736 优化: 工具调用标识类型安全加固 2026-08-10 08:04:30 +08:00
lxy 5a1b7a871a 重构: AI执行循环拆分降低复杂度 2026-08-10 08:04:30 +08:00
lxy 6141a67b0e 优化: 审批弹窗全局化与体验打磨 2026-08-10 08:04:30 +08:00
lxy dedb9a8862 修复: 工作流推进链路与执行入口优化 2026-08-10 08:04:29 +08:00
lxy 2473dcfe68 优化: 文件预览大纲提取 + 预览/图标/滚动条打磨 2026-08-10 02:23:53 +08:00
lxy 40224d603a 文档: 待办看板更新 + manifest appid 忽略规则 2026-08-10 00:00:56 +08:00
lxy cf00345c35 优化: 项目文件树/依赖图组件 + 灵感对抗校验 + 小程序脚本 + 模块管理 2026-08-10 00:00:31 +08:00
lxy 6bafdcd5a5 修复: token 汇总正确性(会话级双计/is_estimated跨端/流式usage传递) + is_estimated SELECT 缺失修复 2026-08-09 23:59:55 +08:00
lxy 11f4978ec1 优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账 2026-08-09 21:35:59 +08:00
lxy fbd8fae44b 修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账 2026-08-09 21:35:58 +08:00
lxy 8bc5380ecb 文档: 全系统走查登记(阶段②: 任务/仪表盘/灵感知识59项 + Settings/审计/布局/后端命令95项 + df-mcp/工具crate 35项) + API Key 401 已解决销账 2026-08-09 21:35:46 +08:00
lxy 7eb2e25ed5 优化: aichat效率批(save无变化捷径/断路器尾窗/system缓存指纹/会话列表刷新收敛) + 新增 LLM prompt caching(cache_control开关默认关/system稳定段易变段分离治缓存命中率) + 销账 2026-08-09 21:35:13 +08:00
lxy 4df91ed155 修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文git乱码/子目录错误行内/零工程空态/切tab保活) + 依赖图批量(节点渲染register/环高亮/防环防重/点击选中) + 销账 2026-08-09 21:35:13 +08:00
lxy 4a9f77872e 文档: 项目详情页走查(60项) + aichat效率走查(74项,含LLM缓存命中率核心发现)登记 2026-08-09 21:35:03 +08:00
lxy 5a92e6056c 优化: AI治理与性能(tool_failure_stats失败率统计命令 + git status仓库级缓存治cmd闪烁N²) + 销账 2026-08-09 21:35:02 +08:00
lxy e099eff4cb 优化: MCP 收尾(update原子CAS治TOCTOU/advance_task数据变更映射/list_trash分页) + miniapp渲染收尾(mention chip可视化/代码块语言标签) + 销账 2026-08-09 21:35:02 +08:00
lxy 37040616bd 优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key 2026-08-09 21:34:53 +08:00
lxy cad76ffdf2 修复: @任务联想源隔离 store.tasks 污染 + DirAuthDialog 拒绝二次确认 + 看板销账(AC弱模型机制 3/5 已落地核对) 2026-08-09 21:34:52 +08:00
lxy 180d8e339f 修复: TaskDetail 操作按钮缺陷(推进竞态/菜单状态机过滤/工作流残留重置/子任务校验/描述编辑) + 看板销账(aichat切换缺陷 14/16 已修核对) 2026-08-09 21:34:52 +08:00
lxy e7228231d1 优化: miniapp渲染(图片/checkbox/代码高亮) + MCP规则共享下沉 + 文档重写 + 节点风格对齐 2026-08-08 21:39:30 +08:00
lxy 9eb2995a74 新增: 工具工作流补全(diff_files工具 + 技能清单注入 + 工作流进度共享 + human端到端测试 + 知识库MCP工具) 2026-08-08 21:24:53 +08:00
lxy 6ba6daf188 优化: aichat 打磨收尾(切换信任保留 + save并发串行 + 后台授权守卫 + 分离窗口失效 + 气泡截图复制) 2026-08-08 21:08:25 +08:00
lxy 65c0f6b2b6 优化: aichat 体验收尾(useAiEvents拆分4子域 + AIC-FIX P1队列/mutex/缓存 + i18n残留抽取) 2026-08-08 20:38:33 +08:00
lxy 4092a8d5bb 优化: 前端体验批次(Knowledge持久化+流式缓存失效 + dfnodes节点注册 + delta 50ms合批 + MCP schema必填校验+bind_directory对齐) 2026-08-08 20:19:19 +08:00
227 changed files with 13649 additions and 3714 deletions
+4
View File
@@ -89,3 +89,7 @@ workflows/
devflow-dev.db devflow-dev.db
devflow-dev.db-shm devflow-dev.db-shm
devflow-dev.db-wal devflow-dev.db-wal
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
apps/df-miniapp/src/manifest.json
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
+190 -12
View File
@@ -157,7 +157,7 @@ devflow/
├── crates/ ├── crates/
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation │ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
│ ├── df-execute/ # 执行运行时 (Shell) │ ├── df-execute/ # 执行运行时 (Shell)
│ ├── df-storage/ # 存储层 (SQLite) │ ├── df-storage/ # 存储层 (SQLite)
@@ -330,23 +330,25 @@ pub struct Decision {
| 节点 | 功能 | 状态 | | 节点 | 功能 | 状态 |
|------|------|------| |------|------|------|
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 | | AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 | | GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 | | HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 | | NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 | | SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 | | DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 含 ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。 > `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。
## 六、数据模型 ## 六、数据模型
### SQLite 表结构 ### SQLite 表结构
> 真相源:`crates/df-storage/src/migrations.rs`V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。 > 真相源:`crates/df-storage/src/migrations.rs`V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
**13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
```sql ```sql
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores) -- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
@@ -362,6 +364,8 @@ CREATE TABLE ideas (
promoted_to TEXT, -- 晋升后的 project_id (V2) promoted_to TEXT, -- 晋升后的 project_id (V2)
ai_analysis TEXT, -- AI 分析结果 JSON (V2) ai_analysis TEXT, -- AI 分析结果 JSON (V2)
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2) scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
deleted_at TEXT, -- 软删回收站 (V28)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -380,18 +384,27 @@ CREATE TABLE projects (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch) -- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch / V20 补 idea_id / V14 补 deleted_at /
-- V15 补 review_rounds / V17 补 output_json / V29 补 queue+parent_id+content_json / V41 补 module_id)
CREATE TABLE tasks ( CREATE TABLE tasks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id), project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
priority INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium
branch_name TEXT, branch_name TEXT,
assignee TEXT, assignee TEXT,
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2) workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
base_branch TEXT, -- 基础分支 (V2) base_branch TEXT, -- 基础分支 (V2)
idea_id TEXT REFERENCES ideas(id), -- 关联灵感 (V20)
review_rounds INTEGER NOT NULL DEFAULT 0, -- review 退回累计轮数 (V15)
output_json TEXT, -- AiNode 自审闭环产出 (V17)
queue TEXT NOT NULL DEFAULT 'todo', -- 管理池 backlog/todo/decision/active/done (V29)
parent_id TEXT REFERENCES tasks(id), -- 父任务(限 1 级嵌套) (V29)
content_json TEXT, -- 结构化需求规格 JSON (V29)
module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL, -- 关联工程 (V41)
deleted_at TEXT, -- 软删回收站 (V14)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -432,7 +445,8 @@ CREATE TABLE workflow_executions (
project_id TEXT, -- 关联项目 ID (V2) project_id TEXT, -- 关联项目 ID (V2)
task_id TEXT, -- 关联任务 ID (V2) task_id TEXT, -- 关联任务 ID (V2)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
completed_at TEXT completed_at TEXT,
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
); );
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照 -- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
@@ -449,7 +463,7 @@ CREATE TABLE node_executions (
completed_at TEXT completed_at TEXT
); );
-- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象 -- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight)
CREATE TABLE ai_providers ( CREATE TABLE ai_providers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -460,29 +474,38 @@ CREATE TABLE ai_providers (
models TEXT, -- JSON array of model names models TEXT, -- JSON array of model names
is_default INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0,
config TEXT, -- JSON extra config config TEXT, -- JSON extra config
model_configs TEXT, -- 模型能力配置 JSON (V18)
enabled INTEGER NOT NULL DEFAULT 1, -- 负载均衡池开关 (V19)
weight INTEGER NOT NULL DEFAULT 50, -- 负载均衡权重 (V19)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models) -- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models /
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
CREATE TABLE ai_conversations ( CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT, title TEXT,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages)
provider_id TEXT, provider_id TEXT,
model TEXT, model TEXT,
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6) models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4) archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5) prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5) completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
pinned_goals TEXT DEFAULT '[]', -- 目标钉扎持久化 (V32)
pending_approvals TEXT DEFAULT '{}', -- 挂起审批快照,重启恢复 (V33)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕 -- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id)
-- Agent 工具调用的审批/执行留痕
CREATE TABLE ai_tool_executions ( CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
conversation_id TEXT, conversation_id TEXT,
message_id TEXT, -- 关联消息 (V21)
tool_call_id TEXT NOT NULL, tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL, tool_name TEXT NOT NULL,
arguments TEXT NOT NULL, arguments TEXT NOT NULL,
@@ -491,7 +514,8 @@ CREATE TABLE ai_tool_executions (
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
requested_at TEXT NOT NULL, requested_at TEXT NOT NULL,
executed_at TEXT, executed_at TEXT,
decided_by TEXT -- human/auto decided_by TEXT, -- human/auto
subtask_id TEXT -- 归属子任务 (V36)
); );
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层 -- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
@@ -510,6 +534,7 @@ CREATE TABLE knowledges (
source_ref TEXT, -- 来源实体引用(如 conv:{id}) source_ref TEXT, -- 来源实体引用(如 conv:{id})
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10) reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8) embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -531,6 +556,159 @@ CREATE TABLE app_settings (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 消息 (V9 新库建表 / V21 老库补建 / V36 补 subtask_id / V38 补 token 列 / V39 补 cache/reason 列)
-- V21 起 ChatMessage 逐条拆行存储,支撑消息级 token / 分页 / 溯源
CREATE TABLE ai_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
parts TEXT,
tool_call_id TEXT,
tool_calls TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'active',
reasoning_content TEXT,
timestamp INTEGER,
subtask_id TEXT, -- 归属子任务 (V36)
created_at TEXT NOT NULL,
prompt_tokens INTEGER, -- 消息级输入 token (V38)
completion_tokens INTEGER, -- 消息级输出 token (V38)
prompt_cache_hit_tokens INTEGER, -- 缓存命中 token (V39)
prompt_cache_miss_tokens INTEGER, -- 缓存未命中 token (V39)
reasoning_tokens INTEGER, -- 思考 token (V39)
UNIQUE(conversation_id, seq)
);
-- 灵感评估历史 (V22 建表 / V25 补唯一约束) — 追加型审计,version 单调递增
CREATE TABLE idea_evaluations (
id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL,
version INTEGER NOT NULL,
ai_analysis TEXT,
scores TEXT,
score REAL,
evaluated_by TEXT,
evaluated_at TEXT NOT NULL,
UNIQUE(idea_id, version) -- 评估版本并发重复兜底 (V25)
);
-- 任务横向关联 (V29 建表) — 任务间 depends_on/blocks/relates_to,循环检测走应用层
CREATE TABLE task_links (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES tasks(id),
target_id TEXT NOT NULL REFERENCES tasks(id),
link_type TEXT NOT NULL,
remark TEXT,
created_at TEXT NOT NULL
);
-- 项目事件流 (V30 建表) — 跨实体统一事件,追加型审计(只增不改)
CREATE TABLE project_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
from_state TEXT,
to_state TEXT,
context_json TEXT,
source TEXT, -- ai/human/system
conversation_id TEXT,
created_at TEXT NOT NULL
);
-- 项目基础设施配置 (V31 建表) — 记录"项目用了什么基础设施",不存敏感凭证
CREATE TABLE project_services (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
endpoint TEXT,
config_json TEXT,
environment TEXT NOT NULL DEFAULT 'development',
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程 (V34 建表 / V40 补 description + status) — 项目下多工程(Monorepo/微服务/前后端分离)
CREATE TABLE 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,
description TEXT, -- 工程职责描述 (V40)
status TEXT, -- active/archived (V40)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程依赖 (V35 建表) — 工程间依赖边,支撑依赖图
CREATE TABLE 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', -- library/api/mq/shared/custom
label TEXT,
created_at TEXT NOT NULL
);
-- 多 Agent 并行执行 (V36 建表) — ai_plans/ai_subtasks/ai_conflicts 三表 + ai_messages/ai_tool_executions 补 subtask_id
CREATE TABLE ai_plans (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
user_message_id TEXT,
status TEXT NOT NULL DEFAULT 'planning',
subtask_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_subtasks (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
persona_id TEXT,
intent TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
layer INTEGER NOT NULL DEFAULT 0,
deps TEXT,
branch TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_conflicts (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
file_path TEXT NOT NULL DEFAULT '',
conflict_type TEXT NOT NULL DEFAULT 'file',
subtask_a TEXT,
subtask_b TEXT,
diff_a TEXT,
diff_b TEXT,
resolution TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
);
-- 对话版本化快照 (V37 建表)
CREATE TABLE conversation_checkpoints (
id TEXT PRIMARY KEY,
conv_id TEXT NOT NULL,
snapshot TEXT NOT NULL,
token_total INTEGER NOT NULL,
label TEXT,
created_at TEXT NOT NULL
);
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
Generated
+1
View File
@@ -953,6 +953,7 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"df-nodes", "df-nodes",
"df-project",
"df-storage", "df-storage",
"df-types", "df-types",
"futures", "futures",
+28 -25
View File
@@ -12,6 +12,7 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -21,6 +22,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
@@ -4715,7 +4717,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4729,7 +4730,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4743,7 +4743,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4757,7 +4756,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4771,7 +4769,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4785,7 +4782,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4799,7 +4795,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4813,7 +4808,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4827,7 +4821,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4841,7 +4834,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4855,7 +4847,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4869,7 +4860,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4883,7 +4873,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4897,7 +4886,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4911,7 +4899,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4925,7 +4912,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4939,7 +4925,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4953,7 +4938,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4967,7 +4951,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4981,7 +4964,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4995,7 +4977,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5009,7 +4990,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5023,7 +5003,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5037,7 +5016,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5051,7 +5029,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5080,6 +5057,16 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
@@ -8052,6 +8039,15 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -13010,6 +13006,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
+3 -1
View File
@@ -7,7 +7,7 @@
"dev:h5": "uni", "dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build", "build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin", "build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -15,6 +15,7 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -24,6 +25,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
+69
View File
@@ -0,0 +1,69 @@
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
//!
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
//!
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
//! 同时保留 marked 完整功能(运行时本就降级)。
//!
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
const fs = require('fs')
const path = require('path')
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
function fixLookbehind() {
if (!fs.existsSync(VENDOR)) {
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
return
}
let src = fs.readFileSync(VENDOR, 'utf8')
const before = src
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
src = src.replace(
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
'var Te=false,'
)
if (src === before) {
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
}
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
// 替换后行为不变,同时消除微信工具的静态触发器。
src = src.replace(/\(\?<!`\)/g, '(?:x)')
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(')
src = src.replace(/\\k<[a-z]>/g, '\\1')
if (src !== before) {
fs.writeFileSync(VENDOR, src)
console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)')
} else {
console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)')
}
}
// 清理手动补的 @swc/helpers(不再需要,避免污染)
function cleanupSwcHelpers() {
const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers')
if (fs.existsSync(swcDir)) {
fs.rmSync(swcDir, { recursive: true, force: true })
console.log('[fix-mp-build] 已清理手动补的 @swc/helpers')
}
}
fixLookbehind()
cleanupSwcHelpers()
@@ -812,6 +812,7 @@ export function handleEvent(event: AiChatEvent): void {
cache_hit: event.prompt_cache_hit_tokens, cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens, cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens, reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
} }
// 不完整标记(网络中断保文) // 不完整标记(网络中断保文)
if (event.incomplete) { if (event.incomplete) {
-53
View File
@@ -1,53 +0,0 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}
+72 -2
View File
@@ -350,6 +350,50 @@ function shouldRenderMsg(m: ChatMessage): boolean {
return true return true
} }
// ────────────────────────────────────────────────────────────────
// MR-P2-2 用户消息 mention chip:解析 `[项目:名]`/`[任务:名]`/`[灵感:名]` 标记分段渲染。
//
// 对齐桌面 MessageItem.segmentUserContent:命中已知实体的标记渲染为 chip(浅底圆角徽标),
// 未命中保持字面(防误伤正常方括号文本)。
// 标记格式与 @ 选中插入一致(onEntitySelect):冒号后是实体展示名 —— project 用 name,
// task/idea 用 title。miniapp 消息内容仅存原始文本(AiUserMessage 只回 message,无 span 元数据),
// 故「已解析成功」以实体名是否命中当前 entities 列表判定(与插入时同字段)。
// ────────────────────────────────────────────────────────────────
type UserSegment =
| { type: 'text'; text: string }
| { type: 'chip'; kind: 'project' | 'task' | 'idea'; label: string }
/** 实体名是否命中已知实体(项目 name / 任务·灵感 title,与 @ 选中插入同字段) */
function isKnownEntity(kind: 'project' | 'task' | 'idea', name: string): boolean {
const e = entities.value
if (kind === 'project') return e.projects.some((p) => p.name === name)
if (kind === 'task') return e.tasks.some((t) => t.title === name)
return e.ideas.some((i) => i.title === name)
}
/** 用户消息内容 → 分段(文本段 + chip 段),纯文本返回单文本段(零回归) */
function segmentUserContent(content: string): UserSegment[] {
const segs: UserSegment[] = []
const re = /\[(项目|任务|灵感):([^\]]+)\]/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(content)) !== null) {
if (m.index > last) segs.push({ type: 'text', text: content.slice(last, m.index) })
const kind = m[1] === '项目' ? 'project' : m[1] === '任务' ? 'task' : 'idea'
const name = m[2].trim()
// 仅命中已知实体渲染 chip;未命中(正常方括号文本/无效引用)保持字面
segs.push(isKnownEntity(kind, name) ? { type: 'chip', kind, label: name } : { type: 'text', text: m[0] })
last = m.index + m[0].length
}
if (last < content.length) segs.push({ type: 'text', text: content.slice(last) })
return segs.length ? segs : [{ type: 'text', text: content }]
}
/** 用户消息内容是否含 mention 标记段(决定是否走分段渲染;纯文本走原单一 text 零回归) */
function hasMentionMarker(content: string): boolean {
return /\[(项目|任务|灵感):[^\]]+\]/.test(content)
}
/** /**
* 输入变化时检测联想触发条件。 * 输入变化时检测联想触发条件。
* *
@@ -771,8 +815,17 @@ function tokenInOf(t: TokenUsage): number {
<view v-if="m.role === 'user'"> <view v-if="m.role === 'user'">
<!-- 图片预览(消息带图时) --> <!-- 图片预览(消息带图时) -->
<image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" /> <image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
<!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) --> <!-- MR-P2-2 文本: mention 标记时分段渲染 chip(命中已知实体的 `[项目:名]`/`[任务:名]`/`[灵感:名]`
<text v-if="m.content" user-select>{{ m.content }}</text> 渲染为 chip,未命中保持字面);纯文本走原单一 `<text>` 渲染零回归 -->
<template v-if="m.content && hasMentionMarker(m.content)">
<text user-select>
<template v-for="(seg, idx) in segmentUserContent(m.content)" :key="m.id + '-seg-' + idx">
<text v-if="seg.type === 'text'">{{ seg.text }}</text>
<text v-else class="user-chip" :class="'user-chip--' + seg.kind">{{ seg.label }}</text>
</template>
</text>
</template>
<text v-else-if="m.content" user-select>{{ m.content }}</text>
</view> </view>
<template v-else> <template v-else>
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) --> <!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
@@ -1054,6 +1107,23 @@ function tokenInOf(t: TokenUsage): number {
.msg.user text { .msg.user text {
color: #ffffff; color: #ffffff;
} }
/* MR-P2-2 用户消息 mention chip(对齐桌面 ai-msg-chip:浅底圆角徽标)。
用户气泡底 = #4a9eff,chip 用半透明白底 + 深蓝字保证对比度(对齐桌面 rgba(255,255,255,.32));
按 kind 区分颜色变体(project/task/idea),与 @ mention 浮层分组色呼应。
mp-weixin <text> 组件天然 inline,嵌套于外层 <text> 内联排布;背景/圆角/padding 真机可渲染。
选择器用 .msg.user text.user-chip(≥ .msg.user text 的 (0,2,1))盖过白字,保证深蓝字生效。 */
.msg.user text.user-chip {
padding: 1px 6px;
margin: 0 1px;
border-radius: 4px;
font-size: 13px;
line-height: 1.4;
color: #14407a;
background-color: rgba(255, 255, 255, 0.32);
}
.msg.user text.user-chip--project { background-color: rgba(255, 255, 255, 0.38); }
.msg.user text.user-chip--task { background-color: rgba(255, 255, 255, 0.3); }
.msg.user text.user-chip--idea { background-color: rgba(255, 255, 255, 0.24); }
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */ /* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
.msg-user-img { .msg-user-img {
display: block; display: block;
+4
View File
@@ -53,6 +53,8 @@ export type AiChatEvent =
prompt_cache_hit_tokens: number prompt_cache_hit_tokens: number
prompt_cache_miss_tokens: number prompt_cache_miss_tokens: number
reasoning_tokens: number reasoning_tokens: number
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
is_estimated: boolean
incomplete?: boolean | null incomplete?: boolean | null
conversation_id?: string | null conversation_id?: string | null
} }
@@ -170,6 +172,8 @@ export interface TokenUsage {
cache_hit?: number cache_hit?: number
cache_miss?: number cache_miss?: number
reasoning?: number reasoning?: number
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
is_estimated?: boolean
} }
// ============================================================ // ============================================================
+150 -5
View File
@@ -1,5 +1,5 @@
/** /**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G) * marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。
* *
* 为什么后处理而非 marked 自定义 renderer: * 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。 * - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
@@ -11,8 +11,88 @@
* 跳转需第三方 mp-html(本次不做,记后续)。 * 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠 * - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。 * white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
* - <img> 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示
* (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。
* - <input> 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。
*/ */
// 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。
// 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import markdown from 'highlight.js/lib/languages/markdown'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import bash from 'highlight.js/lib/languages/bash'
import sql from 'highlight.js/lib/languages/sql'
import yaml from 'highlight.js/lib/languages/yaml'
import diff from 'highlight.js/lib/languages/diff'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('css', css)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('rust', rust)
hljs.registerLanguage('go', go)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('diff', diff)
// 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用)
const LANG_ALIASES: Record<string, string> = {
js: 'javascript', ts: 'typescript', py: 'python',
sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown',
html: 'xml', vue: 'xml', svg: 'xml',
}
/** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */
const TOKEN_STYLE: Record<string, string> = {
'hljs-keyword': 'color:#ff7b72',
'hljs-doctag': 'color:#ff7b72',
'hljs-meta': 'color:#ff7b72',
'hljs-type': 'color:#ff7b72',
'hljs-template-tag': 'color:#ff7b72',
'hljs-template-variable': 'color:#ff7b72',
'hljs-title': 'color:#d2a8ff',
'hljs-attr': 'color:#79c0ff',
'hljs-attribute': 'color:#79c0ff',
'hljs-literal': 'color:#79c0ff',
'hljs-number': 'color:#79c0ff',
'hljs-operator': 'color:#79c0ff',
'hljs-variable': 'color:#79c0ff',
'hljs-selector-attr': 'color:#79c0ff',
'hljs-selector-class': 'color:#79c0ff',
'hljs-selector-id': 'color:#79c0ff',
'hljs-regexp': 'color:#a5d6ff',
'hljs-string': 'color:#a5d6ff',
'hljs-built_in': 'color:#ffa657',
'hljs-symbol': 'color:#ffa657',
'hljs-comment': 'color:#8b949e',
'hljs-code': 'color:#8b949e',
'hljs-formula': 'color:#8b949e',
'hljs-name': 'color:#7ee787',
'hljs-quote': 'color:#7ee787',
'hljs-selector-tag': 'color:#7ee787',
'hljs-selector-pseudo': 'color:#7ee787',
'hljs-section': 'color:#1f6feb;font-weight:bold',
'hljs-bullet': 'color:#f2cc60',
'hljs-addition': 'color:#aff5b4;background-color:#033a16',
'hljs-deletion': 'color:#ffdcd7;background-color:#67060c',
'hljs-emphasis': 'font-style:italic',
'hljs-strong': 'font-weight:bold',
}
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */ /** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string { function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g') const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
@@ -28,22 +108,84 @@ function styleTag(html: string, tag: string, style: string): string {
}) })
} }
/** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */
function unescapeCode(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */
function inlineHljs(html: string): string {
return html.replace(/<span class="([^"]+)">/g, (_m, cls: string) => {
const style = cls
.split(/\s+/)
.map((c) => TOKEN_STYLE[c])
.filter(Boolean)
.join(';')
return style ? `<span style="${style}">` : '<span>'
})
}
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
function codeLangBadge(lang: string): string {
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
}
/**
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
*/
function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : ''
const lang = LANG_ALIASES[langRaw] ?? langRaw
const badge = langRaw ? codeLangBadge(langRaw) : ''
if (!lang || !hljs.getLanguage(lang)) {
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
let value: string
try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} catch {
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
})
}
/** /**
* 对 marked.parse 产出的 HTML 注入 inline style。 * 对 marked.parse 产出的 HTML 注入 inline style。
* *
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code * 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读) * 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读);
* 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。
* *
* 正文颜色(P1-1 真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text` * 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th * (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。 * 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。 * strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
*/ */
export function styleMarkdown(html: string): string { export function styleMarkdown(html: string): string {
let out = html let out = html
// 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选)
out = out.replace(/<input[^>]*type="checkbox"[^>]*>/gi, (m) => {
const checked = /checked/i.test(m)
const color = checked ? '#6bd99a' : '#666666'
return `<span style="color:${color};margin-right:4px">${checked ? '☑' : '☐'}</span>`
})
// 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式)
out = highlightCodeBlocks(out)
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本) // 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的 // word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
// word-break,须 inline 注入;对齐 mdRenderer 文件头"rich-text 只认 inline style")。 // word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all' const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) { for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
out = styleTag(out, tag, BODY_STYLE) out = styleTag(out, tag, BODY_STYLE)
@@ -54,7 +196,7 @@ export function styleMarkdown(html: string): string {
'pre', 'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0', 'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
) )
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块) // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
out = styleTag( out = styleTag(
out, out,
'code', 'code',
@@ -62,6 +204,9 @@ export function styleMarkdown(html: string): string {
) )
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开 // 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all') out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
// 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。
// 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、 // 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向 // 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡; // 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
+9 -6
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -320,6 +320,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
@@ -377,6 +378,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -437,6 +439,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
+4
View File
@@ -138,6 +138,10 @@ pub struct ChatMessage {
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。 /// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>, pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_estimated: Option<bool>,
} }
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
+88 -6
View File
@@ -29,10 +29,38 @@ use crate::retry::{
// 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers // 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers
// 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。 // 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。
use crate::anthropic_helpers::{ use crate::anthropic_helpers::{
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, SystemBlock,
ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS, ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS,
}; };
// ============================================================
// Anthropic prompt caching 开关(进程级全局,受 AppState settings 同步)
// ============================================================
/// Anthropic prompt caching 总开关(默认关)。
///
/// 该分支同时服务 Claude 官方 / GLM 订阅端点 (open.bigmodel.cn/api/anthropic) / 任意 Messages
/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。
///
/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}`
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存)。
/// 开关关:system 保持纯字符串(现状行为,零风险兼容网关)。
///
/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点
/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。
static ANTHROPIC_CACHE_ENABLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// 设置 anthropic prompt caching 开关(上层读取 AppState settings 后同步)。
pub fn set_anthropic_cache_enabled(enabled: bool) {
ANTHROPIC_CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 anthropic prompt caching 开关(`convert_request` 组装 system 形态用)。
pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
// ============================================================ // ============================================================
// Provider 实现 // Provider 实现
// ============================================================ // ============================================================
@@ -52,7 +80,7 @@ impl AnthropicCompatProvider {
/// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer /// - `api_key`: API 密钥(Anthropic 用 x-api-key 头,非 Bearer
/// - `default_model`: 默认模型名称 /// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。 // reqwest Client 构建抽 crate::build_provider_client(与 OpenAI 共用 DRY)。
let client = crate::build_provider_client(); let client = crate::build_provider_client();
Self { Self {
client, client,
@@ -92,8 +120,10 @@ impl AnthropicCompatProvider {
req.model req.model
}; };
// 抽离 system 消息 // 抽离 system 消息
let system: Option<String> = { // 开关开 → blocks 数组 + cache_control(Anthropic prompt caching,让 system 稳定段在
// 支持缓存的端点上命中);开关关 → 纯字符串(兼容非官方网关,零风险)。
let system: Option<SystemBlock> = {
let sys: Vec<String> = req let sys: Vec<String> = req
.messages .messages
.iter() .iter()
@@ -102,8 +132,14 @@ impl AnthropicCompatProvider {
.collect(); .collect();
if sys.is_empty() { if sys.is_empty() {
None None
} else if anthropic_cache_enabled() {
Some(SystemBlock::Cached(vec![serde_json::json!({
"type": "text",
"text": sys.join("\n\n"),
"cache_control": { "type": "ephemeral" },
})]))
} else { } else {
Some(sys.join("\n\n")) Some(SystemBlock::Plain(sys.join("\n\n")))
} }
}; };
@@ -854,7 +890,7 @@ mod tests {
assert!(!c.finished); assert!(!c.finished);
} }
/// error 事件 → error=Some + finished=falseR-P1-1避免残缺响应被当正常完成入库, /// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库,
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致) /// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test] #[test]
fn anthropic_error_event_yields_error_not_finished() { fn anthropic_error_event_yields_error_not_finished() {
@@ -1126,4 +1162,50 @@ mod tests {
"补占位后 precheck 应通过" "补占位后 precheck 应通过"
); );
} }
// ---------- prompt caching:开关驱动 system 形态 ----------
/// 开关控制 system 形态:开 → blocks 数组含 cache_control:ephemeral;关 → 纯字符串。
/// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。)
#[test]
fn anthropic_cache_switch_controls_system_shape() {
set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let mk = || CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::system("a"),
ChatMessage::system("b"),
ChatMessage::user("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
// 开:数组形态 + cache_control(Anthropic prompt caching)
set_anthropic_cache_enabled(true);
let body = provider.convert_request(mk());
match body.system.expect("开关开时应有 system") {
SystemBlock::Cached(blocks) => {
assert_eq!(blocks.len(), 1, "开关开应包成单 blocks 数组");
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "a\n\nb", "多条 system 应以 \\n\\n join 进单个 text 块");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
SystemBlock::Plain(s) => panic!("开关开应为数组形态,实际 Plain: {}", s),
}
// 关:纯字符串(兼容非官方网关,零行为变更)
set_anthropic_cache_enabled(false);
let body = provider.convert_request(mk());
match body.system {
Some(SystemBlock::Plain(s)) => assert_eq!(s, "a\n\nb", "开关关多条 system 应以 \\n\\n join 纯字符串"),
other => panic!("开关关应为纯字符串,实际: {:?}", other),
}
}
} }
+22 -6
View File
@@ -18,6 +18,20 @@ use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCal
// Anthropic API 请求/响应结构体 // Anthropic API 请求/响应结构体
// ============================================================ // ============================================================
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
/// 代理——部分网关会拒收 cache_control 字段)。
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum SystemBlock {
/// 纯字符串形态(兼容网关,开关关)
Plain(String),
/// blocks 数组形态(含 cache_control,开关开)
Cached(Vec<serde_json::Value>),
}
/// Anthropic 请求体 /// Anthropic 请求体
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest { pub(crate) struct AnthropicRequest {
@@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest {
pub messages: Vec<serde_json::Value>, pub messages: Vec<serde_json::Value>,
pub max_tokens: u32, pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>, pub system: Option<SystemBlock>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub stream: bool, pub stream: bool,
@@ -47,12 +61,12 @@ pub(crate) struct AnthropicToolDef {
/// Anthropic 同步响应 /// Anthropic 同步响应
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct AnthropicResponse { pub(crate) struct AnthropicResponse {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning // Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
id: String, id: String,
pub model: String, pub model: String,
pub content: Vec<AnthropicContentBlock>, pub content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning // Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
stop_reason: Option<String>, stop_reason: Option<String>,
pub usage: AnthropicUsage, pub usage: AnthropicUsage,
@@ -151,7 +165,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
reasoning_tokens: 0, reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
@@ -159,9 +173,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let acc = usage_accum let acc = usage_accum
.get_or_insert(TokenUsage::default()); .get_or_insert(TokenUsage::default());
acc.completion_tokens = out as u32; acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens; acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } // 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
+12 -1
View File
@@ -275,6 +275,17 @@ impl ContextManager {
self.messages.iter().map(|t| t.message.clone()).collect() self.messages.iter().map(|t| t.message.clone()).collect()
} }
/// 只读最近 N 条消息(尾部切片 clone)。BE2 断路器/探索熔断检查等只关心
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
let skip = self.messages.len().saturating_sub(n);
self.messages[skip..]
.iter()
.map(|t| t.message.clone())
.collect()
}
/// 从 Vec 恢复(兼容从 DB 加载) /// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载", // clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
@@ -315,7 +326,7 @@ impl ContextManager {
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result /// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result
/// ///
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results /// 用于「重新生成」删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。 /// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
/// ///
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早 /// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
+1 -1
View File
@@ -34,7 +34,7 @@ use crate::provider::{ChatMessage, MessageRole, ToolCall};
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> { pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet; use std::collections::HashSet;
// step 0UX-09):过滤 truncated 软删消息,不进 LLM 上下文。 // step 0过滤 truncated 软删消息,不进 LLM 上下文。
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除, // 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。 // 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
let messages: Vec<ChatMessage> = messages let messages: Vec<ChatMessage> = messages
+5 -5
View File
@@ -1135,7 +1135,7 @@ mod tests {
#[test] #[test]
fn extract_key_info_single_huge_line_no_newline_compressed() { fn extract_key_info_single_huge_line_no_newline_compressed() {
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。 // 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let content = "x".repeat(50_000); let content = "x".repeat(50_000);
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "run_command");
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len()); assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
@@ -1149,7 +1149,7 @@ mod tests {
fn extract_key_info_json_huge_string_field_truncated() { fn extract_key_info_json_huge_string_field_truncated() {
// JSON 对象中大字符串字段(单行少行)逃逸压缩。 // JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。 // 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let large = "z".repeat(10_000); let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large); let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "run_command");
@@ -1171,7 +1171,7 @@ mod tests {
#[test] #[test]
fn extract_key_info_eleven_lines_triggers_compression() { fn extract_key_info_eleven_lines_triggers_compression() {
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记 // 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect(); let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n"); let content = lines.join("\n");
let result = extract_key_info(&content, "run_command"); let result = extract_key_info(&content, "run_command");
@@ -1185,7 +1185,7 @@ mod tests {
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含) // 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含) // 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
// 错误行在中间区间 → 标注 [行 N] 插入 // 错误行在中间区间 → 标注 [行 N] 插入
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。 // 注:read_file 已豁免压缩,此处用 run_command 验证通用压缩路径。
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect(); let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail) // idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
lines[2] = "error in head zone".to_string(); lines[2] = "error in head zone".to_string();
@@ -1347,7 +1347,7 @@ mod tests {
assert_eq!(extract_pending_tc_id(""), None); assert_eq!(extract_pending_tc_id(""), None);
} }
// ── read_file 豁免压缩(BUG-260801: AI 定向读代码不应被折叠) ── // ── read_file 豁免压缩AI 定向读代码不应被折叠) ──
// //
// 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为 // 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为
// "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行, // "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行,
+1 -1
View File
@@ -56,7 +56,7 @@ use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。 // df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core; pub use df_ai_core;
/// SW-260618-10: 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY)。 /// 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY)。
/// ///
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的 /// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层 /// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
+1 -1
View File
@@ -239,7 +239,7 @@ mod tests {
// SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions), // SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions),
// /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。 // /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。
assert!(is_non_chat_model("sensenova-u1-fast")); assert!(is_non_chat_model("sensenova-u1-fast"));
assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关 assert!(is_non_chat_model("SenseNova-ast")); // 大小写无关
assert!(is_non_chat_model("sensenova-u1-pro")); assert!(is_non_chat_model("sensenova-u1-pro"));
// 通用图像生成命名也剔 // 通用图像生成命名也剔
assert!(is_non_chat_model("infographic-v1")); assert!(is_non_chat_model("infographic-v1"));
+1
View File
@@ -196,6 +196,7 @@ mod tests {
fn should_use_namespace_always_large_tool() { fn should_use_namespace_always_large_tool() {
assert!(should_use_namespace("short", "read_file")); assert!(should_use_namespace("short", "read_file"));
assert!(should_use_namespace("short", "list_directory")); assert!(should_use_namespace("short", "list_directory"));
assert!(should_use_namespace("short", "diff_files"));
} }
#[test] #[test]
+1 -1
View File
@@ -47,7 +47,7 @@ impl OpenAICompatProvider {
/// - `api_key`: API 密钥 /// - `api_key`: API 密钥
/// - `default_model`: 默认模型名称 /// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。 // reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。 // connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。
let client = crate::build_provider_client(); let client = crate::build_provider_client();
Self { Self {
+7 -5
View File
@@ -67,7 +67,7 @@ pub(crate) struct OpenAiResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiChoice { pub(crate) struct OpenAiChoice {
pub message: OpenAiMessageResp, pub message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning // OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
pub finish_reason: Option<String>, pub finish_reason: Option<String>,
} }
@@ -84,7 +84,7 @@ pub(crate) struct OpenAiMessageResp {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct OpenAiToolCallResp { pub(crate) struct OpenAiToolCallResp {
pub id: String, pub id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning // 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)] #[allow(dead_code)]
#[serde(rename = "type")] #[serde(rename = "type")]
pub call_type: String, pub call_type: String,
@@ -256,17 +256,19 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
delta: delta_text, delta: delta_text,
finished, finished,
tool_calls, tool_calls,
usage: None, // 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积) // choices 为空 = usage-only chunk,不输出文本(usage 已累积)usage 一并带出
StreamChunk { StreamChunk {
delta: String::new(), delta: String::new(),
finished: false, finished: false,
tool_calls: None, tool_calls: None,
usage: None, usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
} }
+3 -3
View File
@@ -40,7 +40,7 @@ const JITTER_RATIO: f64 = 1.0;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
/// ///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。 /// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
#[derive(Debug)] #[derive(Debug)]
pub enum AttemptOutcome<T> { pub enum AttemptOutcome<T> {
/// 成功,携带结果。 /// 成功,携带结果。
@@ -60,7 +60,7 @@ pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。 /// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
/// ///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。 /// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool { pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status) status == 429 || (500..600).contains(&status)
} }
@@ -91,7 +91,7 @@ pub fn backoff_delay(attempt: u32) -> Duration {
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder) /// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder)
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。 /// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
/// ///
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数 /// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用 /// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
/// backoff_delay + is_status_retryable)。 /// backoff_delay + is_status_retryable)。
pub(crate) async fn retry_with_backoff<T, F, Fut>( pub(crate) async fn retry_with_backoff<T, F, Fut>(
+16
View File
@@ -131,6 +131,22 @@ pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<S
ModelRouter::select(req, pool).map(|m| m.model_id.clone()) ModelRouter::select(req, pool).map(|m| m.model_id.clone())
} }
/// 判断 model_id 是否为图像生成模型(文本任务不应使用)。
/// 用于 select 返回 None 兜底 default_model 时的防御:避免标题/压缩等文本任务
/// 误用图像模型(如 sensenova-u1-fast/dall-e)致 LLM 404 刷屏(治 default_model 配错为图像模型)。
/// 判断:pool 有该模型 → 校验模态是否含 Text(最可靠);pool 无 → 名字启发式(已知图像模型模式)。
pub fn is_likely_image_model(model_id: &str, pool: &[ModelConfig]) -> bool {
if let Some(m) = pool.iter().find(|m| m.model_id == model_id) {
return !m.modalities.contains(&Modality::Text);
}
let lower = model_id.to_lowercase();
const IMAGE_PATTERNS: &[&str] = &[
"dall-e", "gpt-image", "imagen", "flux", "stable-diffusion",
"sensenova-u1", "u1-fast", "midjourney", "sd-", "sdxl",
];
IMAGE_PATTERNS.iter().any(|p| lower.contains(p))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -1
View File
@@ -258,7 +258,7 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
} }
}; };
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。 // kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程, // 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。 // 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。 // 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
+87 -25
View File
@@ -13,7 +13,7 @@
//! //!
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至 //! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口, //! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径( //! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径(
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。 //! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
use std::sync::Arc; use std::sync::Arc;
@@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation, AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
}; };
use crate::adversarial_helpers::{ use crate::adversarial_helpers::{
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval, SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt,
priority_label, enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level,
}; };
/// 对抗评估引擎 /// 对抗评估引擎
@@ -97,8 +97,9 @@ impl AdversarialEngine {
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险, /// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。 /// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
/// ///
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与 /// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`]
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。 /// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时,
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> { async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea); let prompt = build_adversarial_prompt(idea);
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。 // 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
@@ -134,7 +135,9 @@ impl AdversarialEngine {
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1] // 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1]
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。 // 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
parse_llm_eval(&resp.text, &idea.id) // ARC-260618-01-e: 解析后做自洽性校验(final_score 与 recommendation /
// analyst.final_assessment 矛盾时按 final_score 修正,见 enforce_score_consistency)。
Ok(enforce_score_consistency(parse_llm_eval(&resp.text, &idea.id)?))
} }
/// 启发式评估(基于评分与内容信号,稳定有区分度) /// 启发式评估(基于评分与内容信号,稳定有区分度)
@@ -145,7 +148,7 @@ impl AdversarialEngine {
let positive = self.generate_positive_argument(idea, &scores)?; let positive = self.generate_positive_argument(idea, &scores)?;
let negative = self.generate_negative_argument(idea, &scores)?; let negative = self.generate_negative_argument(idea, &scores)?;
let analyst = self.analyst_analysis(idea, &scores)?; let analyst = self.analyst_analysis(idea, &scores)?;
let recommendation = self.recommendation_for(&analyst.final_assessment); let recommendation = recommendation_for_level(&analyst.final_assessment);
Ok(AdversarialEval { Ok(AdversarialEval {
idea_id: idea.id.clone(), idea_id: idea.id.clone(),
@@ -240,13 +243,8 @@ impl AdversarialEngine {
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成 /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> { fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
let final_assessment = match scores.overall { // 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移
x if x >= 7.5 => AssessmentLevel::StrongGo, let final_assessment = assessment_for_score(scores.overall);
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let mut strengths = Vec::new(); let mut strengths = Vec::new();
if scores.impact >= 6.0 { if scores.impact >= 6.0 {
@@ -305,22 +303,12 @@ impl AdversarialEngine {
final_assessment, final_assessment,
}) })
} }
/// 评估等级 → 最终建议
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::adversarial_helpers::recommendation_for_score;
use crate::capture::Idea; use crate::capture::Idea;
use crate::scoring::ScoringEngine; use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority}; use df_types::types::{IdeaStatus, Priority};
@@ -594,4 +582,78 @@ mod tests {
assert!((eval.final_score - 7.0).abs() < 1e-9); assert!((eval.final_score - 7.0).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::Soon); assert_eq!(eval.recommendation, Recommendation::Soon);
} }
/// 自洽性校验(ARC-260618-01-e 已实施): LLM 高分配 Monitor/Defer → 按 final_score 修正
/// 为 ImmediateAction/StrongGo,仅字段修正不整体降级启发式。
#[tokio::test]
async fn a13_llm_inconsistent_high_score_corrected() {
let idea = make_idea("矛盾测试", "高分却给 Monitor", Priority::High, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.8},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.3},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Defer"},
"final_score": 8.5,
"recommendation": "Monitor"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a13] 高分配 Monitor/Defer → 修正");
println!(" final_score={:.2} (高分)", eval.final_score);
println!(" recommendation={:?} (期望 ImmediateAction)", eval.recommendation);
println!(" final_assessment={:?} (期望 StrongGo)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅字段矛盾不整体降级");
assert!((eval.final_score - 8.5).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
}
/// 自洽性校验: LLM 低分配 ImmediateAction/StrongGo → 按 final_score 修正为 Monitor/Defer。
#[tokio::test]
async fn a14_llm_inconsistent_low_score_corrected() {
let idea = make_idea("低分矛盾", "低分却给 ImmediateAction", Priority::Medium, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.9},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.1},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"StrongGo"},
"final_score": 2.0,
"recommendation": "ImmediateAction"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a14] 低分配 ImmediateAction/StrongGo → 修正");
println!(" final_score={:.2} (低分)", eval.final_score);
println!(" recommendation={:?} (期望 Monitor)", eval.recommendation);
println!(" final_assessment={:?} (期望 Defer)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm);
assert!((eval.final_score - 2.0).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::Monitor);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::Defer);
}
/// 自洽性校验分档单测: 与启发式 analyst_analysis 同一分档(阈值边界逐一覆盖)。
#[test]
fn consistency_banding_matches_heuristic() {
assert_eq!(assessment_for_score(9.0), AssessmentLevel::StrongGo);
assert_eq!(assessment_for_score(7.5), AssessmentLevel::StrongGo); // 上界含
assert_eq!(assessment_for_score(7.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(6.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(5.0), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.5), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(3.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(2.9), AssessmentLevel::Defer);
assert_eq!(assessment_for_score(0.0), AssessmentLevel::Defer);
assert_eq!(recommendation_for_score(9.0), Recommendation::ImmediateAction);
assert_eq!(recommendation_for_score(6.5), Recommendation::Soon);
assert_eq!(recommendation_for_score(4.8), Recommendation::WithResources);
assert_eq!(recommendation_for_score(3.5), Recommendation::ResearchMore);
assert_eq!(recommendation_for_score(1.0), Recommendation::Monitor);
}
} }
+68 -4
View File
@@ -1,8 +1,9 @@
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。 //! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
//! //!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造JSON 解析等无副作用 //! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造JSON 解析与自洽性校验
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与 //! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。 //! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在
//! `adversarial.rs`,二者经 [`use`] 互相引用。
//! //!
//! 抽离边界:类型定义(非 impl+ 常量 + 顶层 free function。impl 主体不动,外部 //! 抽离边界:类型定义(非 impl+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等) //! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
@@ -295,7 +296,7 @@ pub(crate) fn extract_json(text: &str) -> String {
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde, // (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。) // 必然失败降级启发式,即便正则本可救回。)
} }
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象CR-40-2 // 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象。
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。 // (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。 // 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new(); static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
@@ -336,6 +337,69 @@ pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
} }
} }
// ============================================================
// 自洽性校验(ARC-260618-01-e 已实施)— 综合评分分档 / 修正矛盾字段
// ============================================================
/// 评估等级 → 最终建议(与启发式 recommendation 映射同一套,收敛于此单点实现)。
pub(crate) fn recommendation_for_level(level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
/// 综合评分 → 评估等级(分档与启发式 analyst_analysis 一致;自洽性校验复用,单源防两处漂移)。
pub(crate) fn assessment_for_score(score: f64) -> AssessmentLevel {
if score >= 7.5 {
AssessmentLevel::StrongGo
} else if score >= 6.0 {
AssessmentLevel::Recommended
} else if score >= 4.5 {
AssessmentLevel::Conditional
} else if score >= 3.0 {
AssessmentLevel::Revised
} else {
AssessmentLevel::Defer
}
}
/// 综合评分 → 最终建议(先分档再映射)。
pub(crate) fn recommendation_for_score(score: f64) -> Recommendation {
recommendation_for_level(&assessment_for_score(score))
}
/// 自洽性校验: LLM 解析结果可能 final_score 与 recommendation / analyst.final_assessment
/// 互相矛盾(如高分配 Monitor、低分配 ImmediateAction,parse 只校验枚举合法性不校验语义。
/// 以 final_score 为准重新判定这两个结构化字段消除矛盾;LLM 生成的论点/证据/summary 等
/// 自由文本原样保留。不改 LLM 调用/输出结构,只在解析后修正字段值(ARC-260618-01-e)。
pub(crate) fn enforce_score_consistency(mut eval: AdversarialEval) -> AdversarialEval {
let expected_rec = recommendation_for_score(eval.final_score);
let expected_level = assessment_for_score(eval.final_score);
if eval.recommendation != expected_rec {
tracing::warn!(
score = eval.final_score,
from = ?eval.recommendation,
to = ?expected_rec,
"LLM 评估自洽性修正: recommendation 与 final_score 矛盾,按 final_score 重新判定"
);
eval.recommendation = expected_rec;
}
if eval.analyst.final_assessment != expected_level {
tracing::warn!(
score = eval.final_score,
from = ?eval.analyst.final_assessment,
to = ?expected_level,
"LLM 评估自洽性修正: final_assessment 与 final_score 矛盾,按 final_score 重新判定"
);
eval.analyst.final_assessment = expected_level;
}
eval
}
pub(crate) fn priority_label(p: &Priority) -> &'static str { pub(crate) fn priority_label(p: &Priority) -> &'static str {
match p { match p {
Priority::Critical => "紧急", Priority::Critical => "紧急",
-1
View File
@@ -1,6 +1,5 @@
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。 //! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。 //! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
use serde::Serialize; use serde::Serialize;
+3
View File
@@ -11,6 +11,9 @@ df-types = { path = "../df-types" }
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层 # 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。 # advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
df-nodes = { path = "../df-nodes" } df-nodes = { path = "../df-nodes" }
# df-project: bind_directory 复用技术栈探测(detect_stack)与路径规范化(normalize_path),
# 对齐 GUI tool_registry.rs::bind_dir_to_project,避免在本 crate 重复实现镜像。
df-project = { path = "../df-project" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
+134 -1
View File
@@ -152,13 +152,35 @@ where
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await; let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name { if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) {
fire_write_hook(config, &name); fire_write_hook(config, &name);
} }
} }
}
Ok(()) Ok(())
} }
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
///
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
///
/// stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() {
return false;
}
match &resp.result {
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
None => false,
}
}
/// 方法分发 → 构造 Response。 /// 方法分发 → 构造 Response。
/// ///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
@@ -215,7 +237,19 @@ pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, meth
let r = CallToolResult::error(msg); let r = CallToolResult::error(msg);
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null)); return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
} }
// Low / Medium:执行 // Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
let missing: Vec<String> = required_names(&spec.tool.input_schema)
.into_iter()
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
.collect();
if !missing.is_empty() {
let r = CallToolResult::error(format!(
"缺少必填参数: {}",
missing.join(", ")
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
let result = (spec.handler)(ctx, arguments).await; let result = (spec.handler)(ctx, arguments).await;
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null)) Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
} }
@@ -235,6 +269,30 @@ fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High !(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
} }
/// 从工具 inputSchema 提取必填参数名列表。
///
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
fn required_names(schema: &Value) -> Vec<String> {
match schema.get("required") {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
Some(Value::Bool(true)) => {
let mut names: Vec<String> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
_ => Vec::new(),
}
}
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发, /// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发,
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。 /// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
fn fire_write_hook(config: &ServerConfig, name: &str) { fn fire_write_hook(config: &ServerConfig, name: &str) {
@@ -415,6 +473,54 @@ mod tests {
assert_eq!(list_v["projects"][0]["name"], "McpProj"); assert_eq!(list_v["projects"][0]["name"], "McpProj");
} }
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
#[tokio::test]
async fn tools_call_missing_required_arg_returns_error() {
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
let input =
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["result"]["isError"], true);
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
}
#[test]
fn required_names_from_array() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {} },
"required": ["id", "name"]
});
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
}
#[test]
fn required_names_from_bool_true_means_all_properties() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {}, "desc": {} },
"required": true
});
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
assert_eq!(
required_names(&schema),
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
);
}
#[test]
fn required_names_false_or_missing_is_empty() {
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
assert_eq!(
required_names(&json!({ "type": "object", "required": false })),
Vec::<String>::new()
);
}
#[tokio::test] #[tokio::test]
async fn visible_predicate() { async fn visible_predicate() {
assert!(visible(false, RiskLevel::Low)); assert!(visible(false, RiskLevel::Low));
@@ -542,4 +648,31 @@ mod tests {
*calls.lock().unwrap() *calls.lock().unwrap()
); );
} }
#[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() {
// MC-5业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"业务失败(isError=true)不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
} }
+4 -2
View File
@@ -162,8 +162,10 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await; let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI) // ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
if resp.error.is_none() { // (桌面端据此刷新 GUI)。MC-5/业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) { if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name); cb(&name);
} }
+517 -100
View File
@@ -15,9 +15,9 @@
// name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。 // name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use df_storage::crud::{IdeaQuery, IdeaRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo}; use df_storage::crud::{IdeaQuery, IdeaRepo, KnowledgeRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; use df_storage::models::{IdeaRecord, KnowledgeRecord, ProjectRecord, TaskRecord};
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, 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};
@@ -104,27 +104,43 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
// ─── 项目 ─── // ─── 项目 ───
spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_projects), spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_projects),
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project), spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name", "description"]), Medium, create_project), spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name"]), Medium, create_project),
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_project), spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_project),
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project), spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory), spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ─── // ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks), spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title", "description"]), Medium, create_task), spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 idea_id 关联灵感、queue 管理池(默认 todo,新建仅 backlog/todo/decision)、parent_id 父任务 ID(限 1 级嵌套,父任务自身不能是子任务)、content_json 结构化需求规格(须合法 JSON))", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 2=medium,值域 0..=3)"), "idea_id": opt_str_field("关联灵感 ID(可空)"), "queue": opt_str_field("管理池(可空,默认 todo,新建仅 backlog/todo/decision)"), "parent_id": opt_str_field("父任务 ID(可空,限 1 级嵌套)"), "content_json": opt_str_field("结构化需求规格 JSON(可空,须合法 JSON)")}), &["project_id", "title"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task), spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task), spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled/deferred)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task), spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ─── // ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas), spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea), spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 1)")}), &["title"]), Medium, create_idea),
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea), spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea),
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea), spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea), spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, score_idea), spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, score_idea),
// ─── 工作流(High) ─── // ─── 工作流(High) ───
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow), spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
// ─── 知识库 ───
spec("search_knowledge", "检索知识库:按关键词 LIKE 匹配 title/content(可选 kind/limit);传 query_embedding 数组则走向量检索(余弦相似度 top-N)", object_schema(json!({
"query": str_field("检索关键词"),
"kind": opt_str_field("知识类型过滤(可空):review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization"),
"limit": int_field("返回上限(可空,默认 5,上限 10)"),
"query_embedding": json!({ "type": "array", "items": { "type": "number" }, "description": "查询向量(可空,传则走向量检索)" })
}), &["query"]), Low, search_knowledge),
spec("insert_knowledge", "新增知识条目(Medium 风险,默认允许+审计日志;状态恒为 candidate,审核发布后才参与检索)", object_schema(json!({
"kind": str_field("知识类型:review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization"),
"title": str_field("标题"),
"content": str_field("内容"),
"tags": opt_str_field("标签 JSON 数组字符串(可空)"),
"source_project": opt_str_field("来源项目(可空)"),
"source_ref": opt_str_field("来源引用(可空,如 conv:{id})"),
"confidence": opt_str_field("置信度(可空:high/medium/low)")
}), &["kind", "title", "content"]), Medium, insert_knowledge),
// ─── 回收站 ─── // ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash), spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务;分页 offset/limit,默认 limit=50 上限 100,projects/tasks 各自独立分页)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project), spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
] ]
}) })
@@ -195,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
.to_owned() .to_owned()
} }
/// 取可选整数参数(默认值)
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
args.get(key)
.and_then(|v| v.as_i64())
.map(|i| i as i32)
.unwrap_or(default)
}
/// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。 /// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。 /// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
fn pagination(args: &Value) -> (u32, u32) { fn pagination(args: &Value) -> (u32, u32) {
@@ -239,7 +247,7 @@ fn check_expected_updated_at(args: &Value, db_updated_at: &str) -> Result<(), Ca
} }
// ============================================================ // ============================================================
// 跨实体校验(防 B-260801-01:跨实体误操作) // 跨实体校验(防 跨实体误操作)
// ============================================================ // ============================================================
// //
// 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。 // 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。
@@ -344,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create,
// 防空白项目名进库——required 只保证传参,不保证非空白)。
let name = name.trim().to_string();
if name.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"项目名不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "planning"); let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name); medium_audit("create_project", &name);
@@ -390,7 +406,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
let existing = match repo.get_by_id(&id).await { let existing = match repo.get_by_id(&id).await {
Ok(Some(p)) => p, Ok(Some(p)) => p,
Ok(None) => { Ok(None) => {
// 跨实体校验(B-260801-01):id 可能属于 task/idea,给精确错误防误操作 // 跨实体校验id 可能属于 task/idea,给精确错误防误操作
if let Some(msg) = cross_entity_err(&db, &id, "project").await { if let Some(msg) = cross_entity_err(&db, &id, "project").await {
return CallToolResult::error(msg); return CallToolResult::error(msg);
} }
@@ -402,16 +418,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
return r; return r;
} }
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据 // 部分更新:name/description 缺省回退 existing,避免空默认清空数据
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone()); let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned()); // status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
let status = match ProjectStatus::from_db_str(&status) { // 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
Some(s) => s, // 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
None => return CallToolResult::error( let status = existing.status;
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let now = now_millis(); let now = now_millis();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
@@ -424,12 +437,15 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 与 check_expected_updated_at(客户端显式 expected_updated_at 的提前拦截)不同,
// 这里封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated })) json_ok(json!({ "id": id, "project": updated }))
} }
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -455,26 +471,62 @@ 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);
// 目录必须真实存在(对齐 GUI bind_dir_to_project:先 is_dir 校验再往下走)
if !std::path::Path::new(&path).is_dir() {
return CallToolResult::error(format!("目录不存在: {path}"));
}
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名, // 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。 // 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == ".."); let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
if has_traversal { if has_traversal {
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path)); return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
} }
let norm = normalize_path(&path); // canonicalize 解析真实绝对路径:目录已存在,符号链接在此被解析,
// 路径冲突检测 // 防经 symlink 逃逸到预期外目录(对齐 GUI normalize_path 的 canonicalize 优先)。
let canon = match std::path::Path::new(&path).canonicalize() {
Ok(c) => c,
Err(e) => return CallToolResult::error(format!("路径解析失败: {path}: {e}")),
};
// 路径冲突检测(规范化比较,防路径写法差异绕过,复用 Repo 统一实现)
let norm = df_project::scan::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() {
return CallToolResult::error(format!( return CallToolResult::error(format!(
"路径已被项目「{}」({})绑定,请先解绑", "路径已被项目「{}」({})绑定,请先解绑",
conflict.name, conflict.id conflict.name, conflict.id
)); ));
} }
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它) // 技术栈探测:detect_stack 内含多次同步 fs IO,spawn_blocking 防阻塞 tokio runtime
// (与 GUI bind_dir_to_project 同法;用 canonicalize 后的真实目录探测)
let stack_dir = canon;
let stack = match tokio::task::spawn_blocking(move || {
df_project::scan::detect_stack(&stack_dir)
})
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => return CallToolResult::error(format!("技术栈探测失败: {e}")),
Err(e) => return CallToolResult::error(format!("技术栈探测任务失败: {e}")),
};
let stack_json = match serde_json::to_string(&stack) {
Ok(s) => s,
Err(e) => return CallToolResult::error(format!("技术栈序列化失败: {e}")),
};
// 写回 path(规范化后的真实路径)与 stack 字段,其余字段保留
match repo.update_field(&id, "path", &norm).await { match repo.update_field(&id, "path", &norm).await {
Ok(true) => {} Ok(true) => {}
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e), Err(e) => return err_str(e),
} }
// MC-6两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
tracing::warn!(
tool = "bind_directory",
project_id = %id,
error = %e,
"stack 写回失败(降级不中断,path 已绑定)"
);
}
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated })) json_ok(json!({ "id": id, "project": updated }))
}) })
@@ -526,32 +578,53 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。
let title = title.trim().to_string();
if title.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"任务标题不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 0);
medium_audit("create_task", &format!("{project_id}/{title}")); medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move { Box::pin(async move {
// parent_id 可选(arg_str_or 给 "" 哨兵,空串视为 None)。非空时校验 1 级嵌套铁律:
// 父任务存在 + 父任务自身无 parent_id(防孙任务),违反返回明确错误(与 IPC create_task 同规则)。
let parent_id_raw = arg_str_or(&args, "parent_id", "");
let parent_id = if parent_id_raw.trim().is_empty() {
None
} else {
let pid = parent_id_raw.trim();
let repo = TaskRepo::new(&db); let repo = TaskRepo::new(&db);
match repo.get_by_id(pid).await {
Ok(Some(parent)) => { // 共享校验(与 GUI/IPC 同源,一处修改全局生效):queue / parent_id / content_json / priority 值域
if parent.parent_id.is_some() { let queue = match df_storage::crud::normalize_queue_for_create(
return CallToolResult::error(format!( args.get("queue").and_then(|v| v.as_str()),
"父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}", ) {
parent.parent_id Ok(q) => q,
)); Err(e) => return CallToolResult::error(e),
}
Some(pid.to_string())
}
Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")),
Err(e) => return err_str(e),
}
}; };
let parent_id = match df_storage::crud::validate_parent_id(
&repo,
args.get("parent_id").and_then(|v| v.as_str()),
)
.await
{
Ok(pid) => pid,
Err(e) => return CallToolResult::error(e),
};
let content_json = match df_storage::crud::validate_content_json(
args.get("content_json").and_then(|v| v.as_str()),
) {
Ok(c) => c,
Err(e) => return CallToolResult::error(e),
};
let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()),
) {
Ok(p) => p,
Err(e) => return CallToolResult::error(e),
};
// 可选关联灵感(空串/缺省视为不关联,与 GUI create_task 同语义)
let idea_id = args
.get("idea_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from);
let now = now_millis(); let now = now_millis();
let rec = TaskRecord { let rec = TaskRecord {
id: new_id(), id: new_id(),
@@ -566,15 +639,14 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id,
module_id: None, module_id: None,
queue: "todo".to_string(), queue,
parent_id, parent_id,
content_json: None, content_json,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
let repo = TaskRepo::new(&db);
match repo.insert(rec).await { match repo.insert(rec).await {
Ok(id) => { Ok(id) => {
let created = repo.get_by_id(&id).await.ok().flatten(); let created = repo.get_by_id(&id).await.ok().flatten();
@@ -597,7 +669,7 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let existing = match repo.get_by_id(&id).await { let existing = match repo.get_by_id(&id).await {
Ok(Some(t)) => t, Ok(Some(t)) => t,
Ok(None) => { Ok(None) => {
// 跨实体校验(B-260801-01):id 可能属于 project/idea,给精确错误防误操作 // 跨实体校验id 可能属于 project/idea,给精确错误防误操作
if let Some(msg) = cross_entity_err(&db, &id, "task").await { if let Some(msg) = cross_entity_err(&db, &id, "task").await {
return CallToolResult::error(msg); return CallToolResult::error(msg);
} }
@@ -636,12 +708,14 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": updated })) json_ok(json!({ "id": id, "task": updated }))
} }
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -721,7 +795,14 @@ fn create_idea(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 priority = arg_int_or(&args, "priority", 0); // priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源,
// 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。
let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()),
) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(CallToolResult::error(e))),
};
medium_audit("create_idea", &title); medium_audit("create_idea", &title);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
@@ -764,7 +845,7 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let existing = match repo.get_by_id(&id).await { let existing = match repo.get_by_id(&id).await {
Ok(Some(i)) => i, Ok(Some(i)) => i,
Ok(None) => { Ok(None) => {
// 跨实体校验(B-260801-01):id 可能属于 project/task,给精确错误防误操作 // 跨实体校验id 可能属于 project/task,给精确错误防误操作
if let Some(msg) = cross_entity_err(&db, &id, "idea").await { if let Some(msg) = cross_entity_err(&db, &id, "idea").await {
return CallToolResult::error(msg); return CallToolResult::error(msg);
} }
@@ -796,12 +877,14 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": updated })) json_ok(json!({ "id": id, "idea": updated }))
} }
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -870,15 +953,29 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")), Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
}); });
rec.updated_at = now; rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
return err_str(e); // 旧 update_full 忽略 Ok(false) 静默覆盖;改后并发端(GUI)已改则 affected==0,
// 报版本冲突,不再互相覆盖。
match repo.update_full_cas(&rec, &idea.updated_at).await {
Ok(true) => json_ok(json!({ "id": id, "idea": rec, "scores": scores })),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
} }
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
}) })
} }
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。 /// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai /// 确定性纯函数。
///
/// **独立口径声明(MC-6/MCP-5 决策)**:本实现与 `df_ideas::scoring::ScoringEngine` 是两套
/// 并行算法,维度对齐(均为 0-10 的 feasibility/impact/urgency/overall)但权重/信号词/边界不同。
/// 保留独立实现而非复用 ScoringEngine 的取舍:
/// - df-mcp 是轻量独立 server,当前不依赖 df-ideas;而 df-ideas 传递依赖 df-ai(reqwest/
/// eventsource-stream 等重 HTTP 依赖),为消除 ~20 行评分函数把整棵重依赖树拉进 MCP 进程
/// 投入产出比不划算(编译/二进制/攻击面)。
/// - 复用还需在 df-mcp 内复制 IdeaRecord→df_ideas::Idea 转换(record_to_idea 同款),再次引入
/// 双实现;且会改变 MCP 侧既有评分输出值(行为变更)。
/// 故保持独立口径,在此显式声明;若未来 df-ideas 轻量化或 MCP 确需与 GUI 评分完全一致,再收敛。
fn heuristic_scores(title: &str, description: &str) -> Value { fn heuristic_scores(title: &str, description: &str) -> Value {
let desc_len = description.chars().count(); let desc_len = description.chars().count();
// feasibility:描述越详细越可行(评估前已有思考) // feasibility:描述越详细越可行(评估前已有思考)
@@ -915,8 +1012,9 @@ fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult>
// handler 实现 — 回收站 // handler 实现 — 回收站
// ============================================================ // ============================================================
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { fn list_trash(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone(); let db = ctx.db.clone();
let (offset, limit) = pagination(&args);
Box::pin(async move { Box::pin(async move {
let projects = match ProjectRepo::new(&db).list_deleted().await { let projects = match ProjectRepo::new(&db).list_deleted().await {
Ok(v) => v, Ok(v) => v,
@@ -926,11 +1024,34 @@ fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(e) => return err_str(e), Err(e) => return err_str(e),
}; };
// 分页:与 list_projects/tasks/ideas 同契约(offset/limit/has_more)。
// projects 与 tasks 各自独立分页(同页语义:各自第 N 页),has_more 取两者并集。
// 回收站数据量小(仅软删项),内存分页足够,避免给共享 list_deleted 加 SQL 分页侵入。
// 探页:多取一条探测是否有下一页(对齐 list_* 的 limit+1 探页语义)。
let project_probe: Vec<_> = projects
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let project_has_more = project_probe.len() > limit as usize;
let project_page: Vec<_> = project_probe.into_iter().take(limit as usize).collect();
let task_probe: Vec<_> = tasks
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let task_has_more = task_probe.len() > limit as usize;
let task_page: Vec<_> = task_probe.into_iter().take(limit as usize).collect();
json_ok(json!({ json_ok(json!({
"projects": projects, "projects": project_page,
"tasks": tasks, "tasks": task_page,
"project_count": projects.len(), "project_count": project_page.len(),
"task_count": tasks.len() "task_count": task_page.len(),
"offset": offset,
"limit": limit,
"has_more": project_has_more || task_has_more,
"project_has_more": project_has_more,
"task_has_more": task_has_more
})) }))
}) })
} }
@@ -952,36 +1073,123 @@ fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
} }
// ============================================================ // ============================================================
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project) // handler 实现 — 知识库(复用 df-storage KnowledgeRepo)
// ============================================================ // ============================================================
//
// 分层存储(Tier 2/3:热/温/冷知识分级)需单独设计,本批只接 Tier 1 扁平
// KnowledgeRepo(单表 + 向量列),不引入存储分层。
fn normalize_path(p: &str) -> String { /// 7 种合法知识类型(对齐 migrations.rs V7 建表注释)。
// 先尝试 canonicalize(解析符号链接 + 绝对化 + .. 折叠,仅已存在的路径有效) const KNOWLEDGE_KINDS: &[&str] = &[
if let Ok(abs) = std::path::Path::new(p).canonicalize() { "review_rule", "prompt_template", "pitfall", "architecture_pattern",
return abs.to_string_lossy().replace('\\', "/").to_lowercase(); "diagnosis", "deployment_note", "workflow_optimization",
} ];
// fallback:路径尚未创建,手动做以下处理:
// ① 统一分隔符 /// 检索知识(Low 只读):默认关键词 LIKE(title/content),可选 kind/limit;
// ② 逐段折叠 ..(防 foo/../bar → foo/bar) /// 传 query_embedding(数组)则走向量检索(余弦 top-N),返回带相似度。
// ③ 去尾斜杠 /// 向量由调用方生成(MCP 无 AI provider 上下文),与 GUI 的 hybrid_search 共用
// ④ 小写化 /// KnowledgeRepo::search_vector,结果一致只做列裁剪。
let normalized = p.replace('\\', "/"); fn search_knowledge(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let mut segments: Vec<&str> = Vec::new(); let db = ctx.db.clone();
for seg in normalized.split('/') { let query = match arg_str(&args, "query") {
match seg { Ok(v) => v,
"." | "" => continue, // 当前目录 / 空段(连续斜杠) Err(r) => return Box::pin(std::future::ready(r)),
".." if segments.is_empty() => segments.push(".."), // 根级 .. 保留(相对路径语义)
".." => { segments.pop(); } // 上级 → 弹出上一段
_ => segments.push(seg),
}
}
let result = if segments.is_empty() {
String::new()
} else {
segments.join("/")
}; };
// 去尾斜杠 let kind = args.get("kind").and_then(|v| v.as_str()).map(|s| s.to_owned());
result.trim_end_matches('/').to_lowercase() let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.map(|i| i.max(1) as usize)
.unwrap_or(5)
.min(10);
let query_vec: Option<Vec<f32>> = args
.get("query_embedding")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|n| n.as_f64()).map(|f| f as f32).collect());
Box::pin(async move {
let repo = KnowledgeRepo::new(&db);
match query_vec {
Some(vec) => {
if vec.is_empty() {
return CallToolResult::error("query_embedding 不能为空数组");
}
match repo.search_vector(&vec, limit).await {
Ok(hits) => {
let hits: Vec<Value> = hits
.into_iter()
.map(|(rec, score)| json!({
"score": (score * 1000.0).round() / 1000.0,
"knowledge": rec
}))
.collect();
json_ok(json!({ "count": hits.len(), "hits": hits }))
}
Err(e) => err_str(e),
}
}
None => match repo.search(&query, kind.as_deref(), limit).await {
Ok(list) => json_ok(json!({ "count": list.len(), "knowledge": list })),
Err(e) => err_str(e),
},
}
})
}
/// 新增知识条目(Medium 写库):状态恒为 candidate(审核发布后才参与检索,
/// 对齐 GUI knowledge_create 语义),不自动生成嵌入(嵌入由发布链路处理)。
fn insert_knowledge(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let kind = match arg_str(&args, "kind") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let title = match arg_str(&args, "title") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let content = match arg_str(&args, "content") {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
let tags = args.get("tags").and_then(|v| v.as_str()).map(|s| s.to_owned());
let source_project = args.get("source_project").and_then(|v| v.as_str()).map(|s| s.to_owned());
let source_ref = args.get("source_ref").and_then(|v| v.as_str()).map(|s| s.to_owned());
let confidence = args.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_owned());
medium_audit("insert_knowledge", &title);
Box::pin(async move {
if !KNOWLEDGE_KINDS.contains(&kind.as_str()) {
return CallToolResult::error(format!(
"非法知识类型: {kind}, 有效值: {}",
KNOWLEDGE_KINDS.join("/")
));
}
if title.trim().is_empty() || content.trim().is_empty() {
return CallToolResult::error("title/content 不能为空");
}
let now = now_millis();
let rec = KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
source_project,
source_ref,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
};
let repo = KnowledgeRepo::new(&db);
match repo.insert(rec.clone()).await {
Ok(id) => json_ok(json!({ "id": id, "knowledge": rec })),
Err(e) => err_str(e),
}
})
} }
// ============================================================ // ============================================================
@@ -1083,6 +1291,30 @@ mod tests {
repo.insert(rec).await.unwrap() repo.insert(rec).await.unwrap()
} }
/// 插入一条知识,返回 id(search_knowledge 测试用;status=published 才进检索)
async fn seed_knowledge(ctx: &Ctx, title: &str, content: &str, kind: &str) -> String {
let repo = KnowledgeRepo::new(&ctx.db);
let now = now_millis();
let rec = KnowledgeRecord {
id: new_id(),
kind: kind.to_owned(),
title: title.to_owned(),
content: content.to_owned(),
tags: None,
status: "published".to_string(),
confidence: Some("high".to_owned()),
reuse_count: 0,
verified: true,
source_project: None,
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
};
repo.insert(rec).await.unwrap()
}
/// 读当前 DB 中的 idea.scores(原始字符串) /// 读当前 DB 中的 idea.scores(原始字符串)
async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> { async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> {
IdeaRepo::new(&ctx.db) IdeaRepo::new(&ctx.db)
@@ -1219,7 +1451,7 @@ mod tests {
} }
} }
// ── 跨实体校验(B-260801-01):update_* 检测 id 属于其他实体时报精确错误 ── // ── 跨实体校验update_* 检测 id 属于其他实体时报精确错误 ──
// //
// 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时, // 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时,
// 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。 // 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。
@@ -1514,4 +1746,189 @@ mod tests {
assert_eq!(v["projects"].as_array().unwrap().len(), 1); assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["has_more"], false); assert_eq!(v["has_more"], false);
} }
// ── bind_directory(对齐 GUI:is_dir 校验 + canonicalize + stack 探测)──
#[tokio::test]
async fn bind_directory_rejects_non_existent_dir() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let fake = std::env::temp_dir().join(format!("df_mcp_no_such_{}", uuid::Uuid::new_v4()));
let r = bind_directory(&ctx, json!({ "id": pid, "path": fake.to_string_lossy() })).await;
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("目录不存在"), "实际: {}", text_of(&r));
}
#[tokio::test]
async fn bind_directory_rejects_traversal_path() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let root = std::env::temp_dir().join(format!("df_mcp_trav_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(root.join("sub")).unwrap();
// 真实存在但含 `..` 段的路径:is_dir 通过后应由遍历检测拒绝
let path = root.join("sub").join("..").join("sub");
let r = bind_directory(&ctx, json!({ "id": pid, "path": path.to_string_lossy() })).await;
std::fs::remove_dir_all(&root).ok();
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("'..'"), "实际: {}", text_of(&r));
}
#[tokio::test]
async fn bind_directory_binds_real_dir_and_writes_stack() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let dir = std::env::temp_dir().join(format!("df_mcp_bind_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
// 放一个 Cargo.toml 让 detect_stack 识别为 rust,验证 stack 探测生效
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let r = bind_directory(&ctx, json!({ "id": pid, "path": dir.to_string_lossy() })).await;
std::fs::remove_dir_all(&dir).ok();
assert!(r.is_error.is_none(), "绑定失败: {:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["id"], pid);
let stack = v["project"]["stack"].as_str().unwrap_or_default();
assert!(stack.contains("rust"), "应探测到 rust 技术栈,实际 stack: {stack}");
}
// ── 知识库工具(search_knowledge Low / insert_knowledge Medium)────────────
/// search_knowledge:关键词命中已发布知识,返回记录列表(不写库,只读契约)。
#[tokio::test]
async fn search_knowledge_keyword_returns_hits() {
let ctx = test_ctx().await;
seed_knowledge(&ctx, "Rust 异步模型", "tokio 运行时与并发", "pitfall").await;
let r = search_knowledge(&ctx, json!({ "query": "tokio" })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["count"], 1);
assert_eq!(v["knowledge"][0]["title"], "Rust 异步模型");
}
/// search_knowledge:缺 query 必填参数 → 报错。
#[tokio::test]
async fn search_knowledge_missing_query_errors() {
let ctx = test_ctx().await;
let r = search_knowledge(&ctx, json!({})).await;
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("缺少必填参数"));
}
/// search_knowledge:传入 query_embedding → 向量检索,返回带 score 的命中。
#[tokio::test]
async fn search_knowledge_vector_with_embedding() {
let ctx = test_ctx().await;
let id = seed_knowledge(&ctx, "Rust 异步", "tokio 并发模型", "pitfall").await;
// 写一条向量(f32 数组,维度 3),search_vector 才能命中
KnowledgeRepo::new(&ctx.db).set_embedding(&id, &[0.1, 0.2, 0.3]).await.unwrap();
let r = search_knowledge(&ctx, json!({
"query": "ignored", // 有 embedding 时 query 仅作占位,检索走向量
"query_embedding": [0.1, 0.2, 0.3]
})).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["count"], 1);
assert!(v["hits"][0]["score"].is_number());
assert_eq!(v["hits"][0]["knowledge"]["id"], id);
}
/// insert_knowledge:创建成功,状态恒 candidate(待审核),DB 可读回。
#[tokio::test]
async fn insert_knowledge_creates_candidate() {
let ctx = test_ctx().await;
let r = insert_knowledge(&ctx, json!({
"kind": "pitfall",
"title": "MCP 超时",
"content": "审批响应须带 execution_id 匹配",
"tags": "[\"mcp\",\"workflow\"]"
})).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
let v = json_of(&r);
let id = v["id"].as_str().unwrap().to_string();
assert_eq!(v["knowledge"]["status"], "candidate");
assert_eq!(v["knowledge"]["reuse_count"], 0);
let persisted = KnowledgeRepo::new(&ctx.db).get_by_id(&id).await.unwrap();
assert!(persisted.is_some(), "DB 应能读回新建知识");
assert_eq!(persisted.unwrap().status, "candidate");
}
/// insert_knowledge:非法 kind / 空 title → 报错(不落库)。
#[tokio::test]
async fn insert_knowledge_invalid_input_errors() {
let ctx = test_ctx().await;
let bad_kind = insert_knowledge(&ctx, json!({ "kind": "nope", "title": "t", "content": "c" })).await;
assert_eq!(bad_kind.is_error, Some(true));
assert!(text_of(&bad_kind).contains("非法知识类型"));
let empty_title = insert_knowledge(&ctx, json!({ "kind": "pitfall", "title": " ", "content": "c" })).await;
assert_eq!(empty_title.is_error, Some(true));
assert!(text_of(&empty_title).contains("不能为空"));
}
/// 风险契约:search_knowledge=Low(只读,read-only 放行),insert_knowledge=Medium(写库,read-only 拒)。
#[test]
fn knowledge_tools_risk_contract() {
let search = find("search_knowledge").expect("search_knowledge 必须注册");
let insert = find("insert_knowledge").expect("insert_knowledge 必须注册");
assert_eq!(search.risk, RiskLevel::Low, "search_knowledge 必须 Low(只读契约)");
assert_eq!(insert.risk, RiskLevel::Medium, "insert_knowledge 必须 Medium(写库 → read-only 拒)");
assert!(visible_for_test(true, "search_knowledge"));
assert!(!visible_for_test(true, "insert_knowledge"));
assert!(visible_for_test(false, "search_knowledge"));
assert!(visible_for_test(false, "insert_knowledge"));
}
// ── list_trash 分页(offset/limit/has_more,projects/tasks 各自独立分页)──
#[tokio::test]
async fn list_trash_pagination_and_has_more() {
let ctx = test_ctx().await;
// 3 项目软删进回收站 + 1 任务软删(宿主项目不删,仅任务进回收站)
let mut pids = Vec::new();
for i in 0..3 {
pids.push(seed_project(&ctx, &format!("回收项目{i}")).await);
}
for p in &pids {
ProjectRepo::new(&ctx.db).soft_delete(p).await.unwrap();
}
let tid = seed_task(&ctx, &pids[0], "回收任务").await;
TaskRepo::new(&ctx.db).soft_delete(&tid).await.unwrap();
// 首页 limit=2 → 项目 2 条(仍有下一页),任务 1 条(无下一页)
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 0 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 2);
assert_eq!(v["tasks"].as_array().unwrap().len(), 1);
assert_eq!(v["project_count"], 2);
assert_eq!(v["task_count"], 1);
assert_eq!(v["project_has_more"], true, "3 项目取 2 还有下一页");
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], true);
assert_eq!(v["limit"], 2);
assert_eq!(v["offset"], 0);
// 第二页 offset=2 → 项目 1 条,两边都无下一页
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 2 })).await;
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["project_has_more"], false);
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], false);
}
/// limit 超上限钳到 100(对齐其他 list 工具的钳制)。
#[tokio::test]
async fn list_trash_caps_limit_to_100() {
let ctx = test_ctx().await;
let r = list_trash(&ctx, json!({ "limit": 999 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
assert_eq!(json_of(&r)["limit"], 100, "limit 超上限应钳到 100");
}
} }
+2 -2
View File
@@ -124,7 +124,7 @@ impl Node for AiNode {
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" }, "base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" } "api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" }
}, },
// SW-260802-01: schema 与 handler 行为对齐 — config 层 required=[] 正确,但 prompt 运行时必填。 // schema 与 handler 行为对齐 — config 层 required=[] 正确,但 prompt 运行时必填。
// prompt: execute → parse_params(ai_node_helpers.rs:189-200) 取 inputs["prompt"] > config.prompt, // prompt: execute → parse_params(ai_node_helpers.rs:189-200) 取 inputs["prompt"] > config.prompt,
// 两者皆无则 Err("缺少必填参数: prompt")。即 prompt 真必填,但可由上游节点注入, // 两者皆无则 Err("缺少必填参数: prompt")。即 prompt 真必填,但可由上游节点注入,
// JSON Schema 只校验 config 属性无法表达"二选一",故 required 留空 + description 标注兜底来源, // JSON Schema 只校验 config 属性无法表达"二选一",故 required 留空 + description 标注兜底来源,
@@ -427,7 +427,7 @@ mod tests {
// ai_self_review_node.rs(与被测代码同位,纯搬运)。 // ai_self_review_node.rs(与被测代码同位,纯搬运)。
// ============================================================ // ============================================================
// SW-260802-01: schema required 与 handler 行为对齐测试 // schema required 与 handler 行为对齐测试
// ============================================================ // ============================================================
// //
// 真实 bug(误判修正):原注释称"prompt/provider_id 均留空走兜底",实则 prompt 运行时必填 —— // 真实 bug(误判修正):原注释称"prompt/provider_id 均留空走兜底",实则 prompt 运行时必填 ——
+74 -7
View File
@@ -13,7 +13,7 @@ use df_ai::provider::LlmProvider;
// AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由 // AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。 // (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
use df_ai::router::{select_model_id, TaskRequirements}; use df_ai::router::{select_model_id, TaskRequirements};
use df_storage::crud::AiProviderRepo; use df_storage::crud::{AiProviderRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use df_storage::secret::{ensure_resolved_key, resolve_provider_secret}; use df_storage::secret::{ensure_resolved_key, resolve_provider_secret};
@@ -64,7 +64,7 @@ pub(crate) struct ResolvedProvider {
/// 无任何 provider → 友好错误「未配置 AI Provider」。 /// 无任何 provider → 友好错误「未配置 AI Provider」。
/// ///
/// modelconfig["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。 /// modelconfig["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并 /// provider 三件套 DRY —— resolve_provider + parse_params 合并
/// (AiNode/AiSelfReviewNode execute 逐字重复)。 /// (AiNode/AiSelfReviewNode execute 逐字重复)。
pub(crate) async fn resolve_and_parse( pub(crate) async fn resolve_and_parse(
db: &Arc<Database>, db: &Arc<Database>,
@@ -72,10 +72,69 @@ pub(crate) async fn resolve_and_parse(
inputs: &HashMap<String, NodeOutput>, inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> { ) -> anyhow::Result<AiNodeParams> {
let provider_cfg = resolve_provider(db, config).await?; let provider_cfg = resolve_provider(db, config).await?;
parse_params(config, inputs, provider_cfg) // parse_params 缺 prompt 报错时兜底 —— 工作流推进链模板 AiNode config 无 prompt
// (in_progress/testing/done 三链首节点 AiNode 仅带 provider_id, prompt 留空),
// 原行为直接 Err 致整链必失败。兜底:从 config.task_id 读 TaskRecord 生成基于
// title/description 的执行 prompt;无 task_id 用通用默认。不改 LLM 调用结构,
// 仅 parse_params 失败时补 prompt 重解析(provider_cfg 已 resolve 不重复)。
// 实现:parse_params 按值吃 provider_cfg,失败需重建 → 先 clone 一份供兜底重解析用,
// 避免 ResolvedProvider(api_key 等)无法从 Err 取回(原签名为 by-value 消费)。
match parse_params(config, inputs, provider_cfg.clone()) {
Ok(p) => Ok(p),
Err(e) => {
let prompt = resolve_fallback_prompt(db, config)
.await
.unwrap_or_else(|| default_fallback_prompt());
// 把兜底 prompt 注入 config 后重解析(prompt 字段补齐后 parse_params 不再缺 prompt)。
// 用 mutable config copy 避免改原 config 借用(config: &serde_json::Value 不可变借)。
let mut cfg = config.clone();
if let Some(obj) = cfg.as_object_mut() {
obj.insert("prompt".to_string(), serde_json::Value::String(prompt));
} else {
// config 非 object(理论不会,防御):构造 {prompt: ...} 替代。
cfg = serde_json::json!({ "prompt": prompt });
}
// tracing 兜底可见性(用户/排查:看到 AiNode 用了默认 prompt 而非报错)。
tracing::warn!(
error = %e,
"AiNode 缺 prompt 兜底: 从 task_id/通用默认生成执行 prompt"
);
parse_params(&cfg, inputs, provider_cfg)
}
}
} }
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复) /// P0-B 兜底 prompt 构建:从 config.task_id 读 TaskRecord.title/description 生成执行 prompt
///
/// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。
/// description 空只取 title;非空拼「标题(描述)」。truncate 防 token 滥用。
async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value) -> Option<String> {
let task_id = config.get("task_id").and_then(|v| v.as_str())?;
if task_id.is_empty() {
return None;
}
let repo = TaskRepo::new(db);
let task = repo.get_by_id(task_id).await.ok()??;
let title = task.title.trim();
let desc = task.description.trim();
let body = if desc.is_empty() {
title.to_string()
} else {
format!("{}({})", title, truncate_for_summary(desc))
};
Some(format!(
"根据任务「{}」完成开发,产出可执行代码/方案并自检。要求:逐步推理、\
",
body
))
}
/// P0-B 通用兜底 prompt:无 task_id 时用(独立 AiNode 调用 / config 无 task 关联)。
fn default_fallback_prompt() -> String {
"请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string()
}
/// build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> { pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
df_ai::build_provider( df_ai::build_provider(
&p.provider.protocol, &p.provider.protocol,
@@ -342,15 +401,23 @@ fn normalize_verdict_in_place(v: &mut serde_json::Value) {
/// 原地 clamp dimensions.*.score 到 [0,10]。只处理 number 类型,跳过非 number(留原值, /// 原地 clamp dimensions.*.score 到 [0,10]。只处理 number 类型,跳过非 number(留原值,
/// serde 反序列化由调用方按 schema 容错)。 /// serde 反序列化由调用方按 schema 容错)。
///
/// 类型保真:合法范围内的整数(如 9)保持整数原样,不转 f64(9 → 9.0,污染下游展示与
/// 断言;测试 parse_review_json_valid_passes_through 锁定 9 应为整数)。仅越界值改写
/// (99/-1 → clamp 到 10/0)。
fn clamp_dimension_scores_in_place(v: &mut serde_json::Value) { fn clamp_dimension_scores_in_place(v: &mut serde_json::Value) {
let Some(obj) = v.as_object_mut() else { return }; let Some(obj) = v.as_object_mut() else { return };
let Some(dims) = obj.get_mut("dimensions").and_then(|d| d.as_object_mut()) else { return }; let Some(dims) = obj.get_mut("dimensions").and_then(|d| d.as_object_mut()) else { return };
for (_, dim) in dims.iter_mut() { for (_, dim) in dims.iter_mut() {
let Some(dim_obj) = dim.as_object_mut() else { continue }; let Some(dim_obj) = dim.as_object_mut() else { continue };
if let Some(score) = dim_obj.get_mut("score").and_then(|s| s.as_f64()) { let Some(score) = dim_obj.get("score") else { continue };
let clamped = score.clamp(0.0, 10.0); let Some(f) = score.as_f64() else { continue };
dim_obj.insert("score".into(), serde_json::json!(clamped)); // 合法范围 [0,10] 内:保留原值(整数仍是整数,浮点仍是浮点),仅越界才改写 clamp 值。
if (0.0..=10.0).contains(&f) {
continue;
} }
let clamped = f.clamp(0.0, 10.0);
dim_obj.insert("score".into(), serde_json::json!(clamped));
} }
} }
+10 -8
View File
@@ -62,7 +62,7 @@ impl AiSelfReviewNode {
let output = truncate_for_review_input(output_text); let output = truncate_for_review_input(output_text);
format!( format!(
"\ "\
<task_requirements> <task_output> ,, \ `<task_requirements>` `<task_output>` ,, \
(//),, \ (//),, \
@@ -75,7 +75,7 @@ impl AiSelfReviewNode {
</task_output> </task_output>
## ##
1. : <task_requirements> 1. : `<task_requirements>`
2. : 2. :
3. :// 3. ://
4. : 4. :
@@ -225,7 +225,7 @@ impl Node for AiSelfReviewNode {
// //
// 设计选型(三方案对比,见 commit/设计文档): // 设计选型(三方案对比,见 commit/设计文档):
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调, // A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。 // 零 executor 核心改动,零依赖暂缓的条件引擎。最简。
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前 // B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件 // 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
// 引擎 Phase1+2 才能用 → 拆波。 // 引擎 Phase1+2 才能用 → 拆波。
@@ -264,7 +264,7 @@ impl Node for AiSelfReviewNode {
"max_tokens": { "type": "integer" }, "max_tokens": { "type": "integer" },
"gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" } "gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
}, },
// SW-260802-01: schema 与 handler 行为对齐 — required 仅列 handler 真正强制必填的字段。 // schema 与 handler 行为对齐 — required 仅列 handler 真正强制必填的字段。
// task_id: execute 第 100-104 行缺 task_id 直接 Err("缺少必填参数: task_id"),真必填 → 保留。 // task_id: execute 第 100-104 行缺 task_id 直接 Err("缺少必填参数: task_id"),真必填 → 保留。
// provider_id: execute 调 resolve_and_parse → resolve_provider(ai_node_helpers.rs:100-108), // provider_id: execute 调 resolve_and_parse → resolve_provider(ai_node_helpers.rs:100-108),
// 空串走路径 2(老明文)/路径 3(默认 provider)兜底,运行时非必填 → 移出 required。 // 空串走路径 2(老明文)/路径 3(默认 provider)兜底,运行时非必填 → 移出 required。
@@ -481,9 +481,9 @@ mod tests {
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(10.0), "score 99 应 clamp 到 10"); assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(10.0), "score 99 应 clamp 到 10");
// -5 → 0(下界) // -5 → 0(下界)
assert_eq!(v["dimensions"]["completeness"]["score"], json!(0.0), "score -5 应 clamp 到 0"); assert_eq!(v["dimensions"]["completeness"]["score"], json!(0.0), "score -5 应 clamp 到 0");
// 区间内值不变 // 区间内值不变(类型保真:整数保持整数,不转 f64)
assert_eq!(v["dimensions"]["correctness"]["score"], json!(7.5), "score 7.5 区间内不变"); assert_eq!(v["dimensions"]["correctness"]["score"], json!(7.5), "score 7.5 区间内不变");
assert_eq!(v["dimensions"]["boundary"]["score"], json!(10.0), "score 10 边界值不变"); assert_eq!(v["dimensions"]["boundary"]["score"], json!(10), "score 10 边界值不变且保持整数");
} }
/// P2-加固3:LLM 前置解释文字 + JSON,正则兜底提取首个 { 到末 } 解析成功。 /// P2-加固3:LLM 前置解释文字 + JSON,正则兜底提取首个 { 到末 } 解析成功。
@@ -647,8 +647,10 @@ mod tests {
"超长输入应被截断并标记" "超长输入应被截断并标记"
); );
// 截断后单个标签内字符数应受控(开闭标签之间 <= 2000 + 截断标记) // 截断后单个标签内字符数应受控(开闭标签之间 <= 2000 + 截断标记)
// 注意:模板说明文字也引用 `task_requirements`(行内反引号形式),find 首现会误命中
// 行内引用而非真实标签。真实标签独占一行(`\n<task_requirements>\n`),用换行锚定定位。
for tag in ["task_requirements", "task_output"] { for tag in ["task_requirements", "task_output"] {
let open = format!("<{tag}>"); let open = format!("\n<{tag}>\n");
let close = format!("</{tag}>"); let close = format!("</{tag}>");
let start = p.find(&open).unwrap() + open.len(); let start = p.find(&open).unwrap() + open.len();
let end = p.find(&close).unwrap(); let end = p.find(&close).unwrap();
@@ -701,7 +703,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// SW-260802-01: schema required 与 handler 行为对齐测试 // schema required 与 handler 行为对齐测试
// ============================================================ // ============================================================
// //
// 真实 bug:schema `required=["task_id","provider_id"]` 与 handler 不一致 —— // 真实 bug:schema `required=["task_id","provider_id"]` 与 handler 不一致 ——
+7 -7
View File
@@ -56,7 +56,7 @@ impl Node for HumanNode {
let mut rx = ctx.event_bus.subscribe(); let mut rx = ctx.event_bus.subscribe();
// 2. 发送人工审批请求到事件总线 // 2. 发送人工审批请求到事件总线
// send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(B-03b-R6) // send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(b-R6)
let _ = ctx.event_bus let _ = ctx.event_bus
.send(WorkflowEvent::HumanApprovalRequest { .send(WorkflowEvent::HumanApprovalRequest {
execution_id: ctx.execution_id.clone(), execution_id: ctx.execution_id.clone(),
@@ -292,7 +292,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn request_is_emitted_to_bus() { async fn request_is_emitted_to_bus() {
// B-03b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线 // b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线
// (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败) // (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败)
let bus = EventBus::new(); let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放) let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放)
@@ -375,7 +375,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn invalid_decision_ignored_then_timeout() { async fn invalid_decision_ignored_then_timeout() {
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点), // options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。 // warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
let bus = EventBus::new(); let bus = EventBus::new();
let ctx = make_ctx( let ctx = make_ctx(
@@ -421,7 +421,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn empty_decision_ignored_then_timeout_even_with_empty_options() { async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。 // 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
let bus = EventBus::new(); let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 })); let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
let bus_clone = bus.clone(); let bus_clone = bus.clone();
@@ -441,7 +441,7 @@ mod tests {
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。 // 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。 // 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
// ===== B-03b-R8: executor 级端到端集成测试 ===== // ===== b-R8: executor 级端到端集成测试 =====
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。 // 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、 // 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。 // outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
@@ -470,7 +470,7 @@ mod tests {
} }
} }
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。 /// b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。 /// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。 /// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
#[tokio::test] #[tokio::test]
@@ -532,7 +532,7 @@ mod tests {
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed); assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
} }
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC) /// b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1) /// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。 /// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
#[tokio::test] #[tokio::test]
+9 -17
View File
@@ -6,7 +6,7 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 脚本节点 /// 脚本节点
pub struct ScriptNode; pub struct ScriptNode;
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ── // ── 运行时白/黑名单(通过 set_script_safety_config 注入,优先于 env var;env var 作回退) ──
use std::sync::OnceLock; use std::sync::OnceLock;
/// 运行时白名单配置(前端设置页写入,优先于环境变量) /// 运行时白名单配置(前端设置页写入,优先于环境变量)
@@ -56,9 +56,7 @@ impl Node for ScriptNode {
shell_type: Default::default(), shell_type: Default::default(),
}; };
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。 // 命令执行安全:白/黑名单校验(运行时配置优先,回退环境变量/默认黑名单)。
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
// - 黑名单匹配时:直接拒绝执行
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。 // 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase(); let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
if let Some(denied) = check_command_policy(&cmd_name) { if let Some(denied) = check_command_policy(&cmd_name) {
@@ -70,7 +68,7 @@ impl Node for ScriptNode {
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command); anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
} }
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行 // 危险关键词告警:匹配到仅告警不阻止执行(与硬拒绝的白/黑名单正交)
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"]; let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
for &kw in &dangerous_keywords { for &kw in &dangerous_keywords {
if command.contains(kw) { if command.contains(kw) {
@@ -143,36 +141,30 @@ const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs",
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。 /// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
/// ///
/// 优先级(高→低): /// 优先级(高→低):运行时配置(前端设置页写入) > 环境变量 > 默认黑名单兜底。
/// 1. 运行时配置(set_script_safety_config,前端设置页写入) /// 黑名单优先于白名单(命中黑名单总是拒绝,即便同时在白名单);命令名比较前 trim + 小写。
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
///
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
/// ///
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。 /// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
fn check_command_policy(cmd_name: &str) -> Option<&'static str> { fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高) // 运行时配置优先(前端设置页写入,优先级最高)
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() { if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) { if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令在黑名单中(运行时配置)"); return Some("命令在黑名单中(运行时配置)");
} }
} }
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查 // 运行时白名单非空时,命令须在名单内(命中即放行,跳过环境变量)
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() { if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) { if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令不在白名单中(运行时配置)"); return Some("命令不在白名单中(运行时配置)");
} }
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
if !list.is_empty() { if !list.is_empty() {
return None; return None;
} }
} }
// 步骤3:回退环境变量(兼容旧部署) // 回退环境变量(兼容旧部署);黑名单缺省用默认黑名单兜底
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok(); let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
let blacklist: Vec<String> = match env_blacklist { let blacklist: Vec<String> = match env_blacklist {
Some(raw) => raw Some(raw) => raw
@@ -186,7 +178,7 @@ fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
return Some("命令在黑名单中"); return Some("命令在黑名单中");
} }
// 白名单:非空时命令名须在名单中才放行 // 环境变量白名单非空时,命令名须在名单
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") { if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
let whitelist: Vec<String> = whitelist_raw let whitelist: Vec<String> = whitelist_raw
.split(',') .split(',')
+16 -7
View File
@@ -1,14 +1,14 @@
//! SubflowNode — 嵌套子工作流节点 //! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行
//! //!
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。 //! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。
use async_trait::async_trait; use async_trait::async_trait;
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// SubflowNode 配置 /// 子工作流配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct SubflowConfig { struct SubflowConfig {
/// 子工作流 DAG 定义(内联 JSON) /// 子工作流 DAG 定义(内联 JSON)
@@ -21,13 +21,13 @@ struct SubflowConfig {
/// 最大递归深度(安全阀) /// 最大递归深度(安全阀)
const MAX_DEPTH: u32 = 10; const MAX_DEPTH: u32 = 10;
/// SubflowNode — 嵌套子工作流 /// 子工作流节点
pub struct SubflowNode; pub struct SubflowNode;
#[async_trait] #[async_trait]
impl Node for SubflowNode { impl Node for SubflowNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult { async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("SubflowNode 执行: {}", ctx.node_id); tracing::info!("SubflowNode 执行: node_id={}", ctx.node_id);
let config: SubflowConfig = serde_json::from_value(ctx.config.clone()) let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?; .map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
@@ -84,11 +84,20 @@ impl Node for SubflowNode {
}, },
"required": ["dag"] "required": ["dag"]
}), }),
output: serde_json::json!({}), output: serde_json::json!({
"type": "object",
"properties": {
"subflow": { "type": "boolean" },
"node_count": { "type": "integer" },
"edge_count": { "type": "integer" },
"max_depth": { "type": "integer" },
"dag": { "type": "object" }
}
}),
} }
} }
fn node_type(&self) -> &'static str { fn node_type(&self) -> &str {
"subflow" "subflow"
} }
} }
+132 -45
View File
@@ -27,15 +27,15 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A
/// 入参: /// 入参:
/// - `repo`:df-storage TaskRepo /// - `repo`:df-storage TaskRepo
/// - `id`:任务 ID /// - `id`:任务 ID
/// - `target_status`:目标状态(7 态之一,snake_case) /// - `target_status`:目标状态(8 态之一,snake_case)
/// ///
/// 流程: /// 流程:
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示) /// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链) /// 2. target_status 合法性:非 8 态 → Validation 错误(防脏数据直入推进链)
/// 3. 状态机(三类拒绝,错误区分供前端分辨): /// 3. 状态机(三类拒绝,错误区分供前端分辨):
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作) /// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文) /// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
/// - 两类拒绝均附 legal_targets(from) 合法目标列表,供 LLM 下次选对目标态(AC-5) /// - 两类拒绝均附 legal_targets(from) 合法目标列表,供 LLM 下次选对目标态
/// - can_transition 闸门矩阵判否即此分支 /// - can_transition 闸门矩阵判否即此分支
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true /// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功) /// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
@@ -46,7 +46,7 @@ pub async fn advance_task_atomic(
id: &str, id: &str,
target_status: &str, target_status: &str,
) -> df_types::error::Result<TaskRecord> { ) -> df_types::error::Result<TaskRecord> {
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。 // 1. target 合法性(8 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
if !is_valid_state(target_status) { if !is_valid_state(target_status) {
return Err(df_types::error::Error::Validation(format!( return Err(df_types::error::Error::Validation(format!(
"非法 target_status {:?},合法值: {}", "非法 target_status {:?},合法值: {}",
@@ -66,24 +66,36 @@ pub async fn advance_task_atomic(
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」 // - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
// - 两类错误均附加 legal_targets(from) 合法目标列表(当前态→可去态), // - 两类错误均附加 legal_targets(from) 合法目标列表(当前态→可去态),
// 让 LLM 下次直接选对目标态,降低状态机拒绝的往返次数(AC-5 机制降失败)。 // 让 LLM 下次直接选对目标态,降低状态机拒绝的往返次数(AC-5 机制降失败)。
// - 非法转换额外附最短路径(如"deferred→todo→in_progress→blocked"),比
// 纯合法目标列表更直观(SC-260811-P1-1 状态机最短路径推导)。
let from = current.status.as_str(); let from = current.status.as_str();
let legal_hint = |from: &str| -> String { let legal_hint = |from: &str, to_hint: Option<&str>| -> String {
let legal = crate::task_state_machine::legal_targets(from); let legal = crate::task_state_machine::legal_targets(from);
if legal.is_empty() { let base = if legal.is_empty() {
format!("{from} 是终态, 无合法后继") format!("{from} 是终态, 无合法后继")
} else { } else {
format!("{from} 的合法目标: {}", legal.join("/")) format!("{from} 的合法目标: {}", legal.join("/"))
};
// 当调用方明确知道目标态(非法转换分支),附加最短路径。
if let Some(target) = to_hint {
let path = crate::task_state_machine::shortest_path(from, target);
if path.len() > 2 {
// 路径比 2 长说明需要多跳,附路径提示。
return format!("{}, 合法路径: {}", base, crate::task_state_machine::format_path(&path));
} }
}
base
}; };
if from == target_status { if from == target_status {
return Err(df_types::error::Error::Validation(format!( return Err(df_types::error::Error::Validation(format!(
"相同状态 {from:?},无需推进,{}", "相同状态 {from:?},无需推进,{}",
legal_hint(from) legal_hint(from, None)
))); )));
} }
if !can_transition(from, target_status) { if !can_transition(from, target_status) {
let hint = legal_hint(from, Some(target_status));
return Err(df_types::error::Error::InvalidState { return Err(df_types::error::Error::InvalidState {
current: format!("{from}→{target_status}(非法状态转换), {}", legal_hint(from)), current: format!("{from}→{target_status}(非法状态转换), {hint}"),
expected: target_status.to_string(), expected: target_status.to_string(),
}); });
} }
@@ -102,24 +114,56 @@ pub async fn advance_task_atomic(
} }
// ============================================================ // ============================================================
// 父任务聚合 — 父任务 status 重算 + 推进联动(知识图谱 Phase 1 V29,设计 §2.1) // 父任务聚合 — 父任务 status 重算 + 推进联动(容器模型,设计 §2.1)
// ============================================================ // ============================================================
/// 父任务 status 重算(容器模型,不走状态机)。 /// 父状态聚合纯函数(全状态集完备划分,与 recompute_parent_status 解耦以便测试)。
/// ///
/// 聚合规则(设计 §2.1 父聚合规则,优先级从高到低): /// `counts`:子任务 status→数量;`total`:子任务总数(counts 各值之和,调用方已算好)。
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点) ///
/// 2. 任一子 in_progress → 父 in_progress(执行中) /// 划分口径(不再手数固定几个桶,而是按语义类归并全部 8 态,避免加态漏改):
/// 3. 全子 done/cancelled → 父 done(全部完成/取消) /// - 活跃态 active = {in_progress, in_review, testing} —— 有子任务正在推进
/// 4. 全子 todo → 父 todo(尚未开始) /// - 阻塞态 = blocked
/// 5. 其他混合态(如 todo+done)→ 父 in_progress(进行中,有进展未全完) /// - 收尾态 settled = {done, cancelled} —— 已了结(完成/取消)
/// - 静置态 = {todo, deferred} —— 尚未开始/暂缓
///
/// 判定优先级(高→低,首个命中即定):
/// 1. 任一 blocked → blocked(阻塞优先,不被其他态掩盖卡点)
/// 2. 任一 active → in_progress(有子在推进)
/// 3. 全部 settled → done(全部了结)
/// 4. 全部 todo → todo(整体尚未开始)
/// 5. 全部 deferred → deferred(整体暂缓,不再误报 in_progress)
/// 6. 其余静置/收尾混合(todo+deferred / todo+done / deferred+done 等,无 active/blocked)
/// → in_progress(有进展或部分了结,整体视为进行中)
fn aggregate_parent_status(map: &std::collections::HashMap<String, i64>, total: i64) -> String {
let c = |k: &str| map.get(k).copied().unwrap_or(0);
let blocked = c("blocked");
let active = c("in_progress") + c("in_review") + c("testing");
let settled = c("done") + c("cancelled");
let todo = c("todo");
let deferred = c("deferred");
if blocked > 0 {
"blocked".to_string()
} else if active > 0 {
"in_progress".to_string()
} else if settled == total {
"done".to_string()
} else if todo == total {
"todo".to_string()
} else if deferred == total {
"deferred".to_string()
} else {
"in_progress".to_string()
}
}
/// 父任务 status 重算(容器模型,不走状态机)。聚合规则见 [`aggregate_parent_status`]。
/// ///
/// 无子任务(悬空)→ 不重算,返回当前 status。 /// 无子任务(悬空)→ 不重算,返回当前 status。
/// 数据源 `repo.count_children_by_status`(一次 GROUP BY 查询,数据量小无压力); /// 数据源 `repo.count_children_by_status`(一次 GROUP BY 查询,数据量小无压力);
/// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径)。 /// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径,
/// 状态相同则不写(避免无谓 updated_at 抖动)。 /// 状态相同则不写,避免无谓 updated_at 抖动)。返回重算后的最新 status。
///
/// 返回:重算后的父任务最新 status。
pub async fn recompute_parent_status( pub async fn recompute_parent_status(
repo: &TaskRepo, repo: &TaskRepo,
parent_id: &str, parent_id: &str,
@@ -139,27 +183,7 @@ pub async fn recompute_parent_status(
// 转 HashMap<status, count> 便于按规则判定 // 转 HashMap<status, count> 便于按规则判定
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect(); let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
let total: i64 = map.values().sum(); let total: i64 = map.values().sum();
let blocked = map.get("blocked").copied().unwrap_or(0); let new_status = aggregate_parent_status(&map, total);
let in_progress = map.get("in_progress").copied().unwrap_or(0);
let todo = map.get("todo").copied().unwrap_or(0);
let done = map.get("done").copied().unwrap_or(0);
let cancelled = map.get("cancelled").copied().unwrap_or(0);
// 聚合规则判定(优先级从高到低,首个命中即定)
let new_status = if blocked > 0 {
"blocked".to_string()
} else if in_progress > 0 {
"in_progress".to_string()
} else if (done + cancelled) == total {
// 全 done/cancelled → done(终端态聚合为 done)
"done".to_string()
} else if todo == total {
// 全 todo → todo(尚未开始)
"todo".to_string()
} else {
// 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中)
"in_progress".to_string()
};
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动) // 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
let current = repo let current = repo
@@ -256,7 +280,7 @@ impl Node for TaskAdvanceNode {
"task_id": { "type": "string" }, "task_id": { "type": "string" },
"target_status": { "target_status": {
"type": "string", "type": "string",
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"] "enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled", "deferred"]
} }
}, },
"required": ["task_id", "target_status"] "required": ["task_id", "target_status"]
@@ -288,6 +312,69 @@ mod tests {
use df_storage::models::{ProjectRecord, TaskRecord}; use df_storage::models::{ProjectRecord, TaskRecord};
use df_types::types::{ProjectStatus, TaskStatus}; use df_types::types::{ProjectStatus, TaskStatus};
// ---------- aggregate_parent_status 纯函数(全状态集覆盖) ----------
/// 由 (status, count) 列表构造 map + total,喂给 aggregate_parent_status。
fn agg(pairs: &[(&str, i64)]) -> String {
let map: std::collections::HashMap<String, i64> =
pairs.iter().map(|(s, n)| (s.to_string(), *n)).collect();
let total: i64 = pairs.iter().map(|(_, n)| *n).sum();
aggregate_parent_status(&map, total)
}
#[test]
fn agg_blocked_wins_over_all() {
// 阻塞优先,不被任何其他态掩盖
assert_eq!(agg(&[("blocked", 1), ("in_progress", 3), ("done", 2)]), "blocked");
assert_eq!(agg(&[("blocked", 1), ("deferred", 5)]), "blocked");
}
#[test]
fn agg_any_active_to_in_progress() {
// 活跃三态任一在 → in_progress
assert_eq!(agg(&[("in_progress", 1), ("todo", 2)]), "in_progress");
assert_eq!(agg(&[("in_review", 1), ("done", 2)]), "in_progress");
assert_eq!(agg(&[("testing", 1), ("deferred", 2)]), "in_progress");
}
#[test]
fn agg_all_settled_to_done() {
assert_eq!(agg(&[("done", 3)]), "done");
assert_eq!(agg(&[("cancelled", 2)]), "done");
assert_eq!(agg(&[("done", 2), ("cancelled", 1)]), "done");
}
#[test]
fn agg_all_todo_to_todo() {
assert_eq!(agg(&[("todo", 4)]), "todo");
}
#[test]
fn agg_all_deferred_to_deferred() {
// P0-1 核心回归:全暂缓不再误报 in_progress
assert_eq!(agg(&[("deferred", 3)]), "deferred");
}
#[test]
fn agg_static_mixed_to_in_progress() {
// P0-1 回归:todo+deferred / deferred+done 等静置收尾混合 → in_progress
assert_eq!(agg(&[("todo", 1), ("deferred", 2)]), "in_progress");
assert_eq!(agg(&[("deferred", 1), ("done", 2)]), "in_progress");
assert_eq!(agg(&[("todo", 1), ("done", 1)]), "in_progress");
}
#[test]
fn agg_result_always_valid_state() {
// 性质:任意状态组合的聚合结果必是合法状态值(防未来加态漏改落非法串)
use crate::task_state_machine::{is_valid_state, ALL_STATES};
for a in ALL_STATES {
for b in ALL_STATES {
let out = agg(&[(a, 1), (b, 2)]);
assert!(is_valid_state(&out), "agg({a},{b}) = {out:?} 非合法状态值");
}
}
}
fn rec(id: &str, status: TaskStatus) -> TaskRecord { fn rec(id: &str, status: TaskStatus) -> TaskRecord {
TaskRecord { TaskRecord {
id: id.to_string(), id: id.to_string(),
@@ -381,7 +468,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn illegal_skip_rejected() { async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。 // 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", TaskStatus::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();
@@ -397,7 +484,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn terminal_done_no_successor() { async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。 // 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
repo.insert(rec("t1", TaskStatus::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();
@@ -411,7 +498,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn same_status_rejected() { async fn same_status_rejected() {
// CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。 // 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
// 同态属空操作,归 Validation「相同状态,无需推进」; // 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。 // 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await; let repo = setup().await;
+162 -24
View File
@@ -1,11 +1,11 @@
//! 任务推进状态机 — 7 态合法转换定义。 //! 任务推进状态机 — 8 态合法转换定义。
//! //!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落 //! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层 //! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成)。 //! (原子写 SQL 在 task_advance_node.rs 完成)。
//! //!
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01): //! 8 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled //! todo / in_progress / in_review / testing / done / blocked / cancelled / deferred
//! //!
//! 闸门链主路径: todo → in_progress → in_review → testing → done //! 闸门链主路径: todo → in_progress → in_review → testing → done
//! //!
@@ -19,7 +19,7 @@
//! blocked, cancelled //! blocked, cancelled
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务) //! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
//! - blocked → in_progress(解除阻塞继续), cancelled //! - blocked → in_progress(解除阻塞继续), cancelled
//! - cancelled → 终态 //! - cancelled → todo(取消后恢复,非终态;仅 done 为终态)
// ============================================================ // ============================================================
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源) // 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
@@ -43,8 +43,10 @@ pub const TESTING: &str = TaskStatus::Testing.as_str();
pub const DONE: &str = TaskStatus::Done.as_str(); pub const DONE: &str = TaskStatus::Done.as_str();
/// 已阻塞 /// 已阻塞
pub const BLOCKED: &str = TaskStatus::Blocked.as_str(); pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
/// 已取消(终态) /// 已取消(可恢复为待开始)
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str(); pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
/// 已暂缓
pub const DEFERRED: &str = TaskStatus::Deferred.as_str();
/// 全部合法状态值(供输入校验与错误提示复用) /// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[ pub const ALL_STATES: &[&str] = &[
@@ -55,6 +57,7 @@ pub const ALL_STATES: &[&str] = &[
DONE, DONE,
BLOCKED, BLOCKED,
CANCELLED, CANCELLED,
DEFERRED,
]; ];
/// 字符串是否为合法状态值 /// 字符串是否为合法状态值
@@ -64,7 +67,7 @@ pub fn is_valid_state(s: &str) -> bool {
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。 /// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
/// ///
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false /// done 为唯一终态,无任何合法后继(cancelled 可恢复为 todo,非终态);非法或未知状态入参一律返回 false
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。 /// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
/// ///
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1) /// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
@@ -101,7 +104,21 @@ pub fn can_transition(from: &str, to: &str) -> bool {
// blocked → in_progress(解除阻塞继续), cancelled // blocked → in_progress(解除阻塞继续), cancelled
(BLOCKED, IN_PROGRESS), (BLOCKED, IN_PROGRESS),
(BLOCKED, CANCELLED), (BLOCKED, CANCELLED),
// cancelled → 终态,无后继 // cancelled → todo(取消后恢复)
(CANCELLED, TODO),
// deferred → todo(恢复), cancelled(取消)
(DEFERRED, TODO),
(DEFERRED, CANCELLED),
// todo → deferred(延后)
(TODO, DEFERRED),
// in_progress → deferred
(IN_PROGRESS, DEFERRED),
// in_review → deferred
(IN_REVIEW, DEFERRED),
// testing → deferred
(TESTING, DEFERRED),
// blocked → deferred
(BLOCKED, DEFERRED),
]; ];
for (f, t) in allowed { for (f, t) in allowed {
m.insert((*f, *t), true); m.insert((*f, *t), true);
@@ -117,7 +134,7 @@ pub fn can_transition(from: &str, to: &str) -> bool {
/// 供 advance_task 错误提示复用:状态机拒绝非法转换时,把「当前状态 + 合法目标」附进 /// 供 advance_task 错误提示复用:状态机拒绝非法转换时,把「当前状态 + 合法目标」附进
/// 错误信息,让 LLM 下次直接选对目标态,避免靠猜反复触发状态机拒绝。 /// 错误信息,让 LLM 下次直接选对目标态,避免靠猜反复触发状态机拒绝。
/// 遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐(矩阵改动自动同步)。 /// 遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐(矩阵改动自动同步)。
/// 终态(done/cancelled)无合法后继 → 返回空列表,调用方据此提示「终态无后继」。 /// done 为唯一终态无合法后继 → 返回空列表,调用方据此提示「终态无后继」。
pub fn legal_targets(from: &str) -> Vec<&'static str> { pub fn legal_targets(from: &str) -> Vec<&'static str> {
ALL_STATES ALL_STATES
.iter() .iter()
@@ -126,6 +143,60 @@ pub fn legal_targets(from: &str) -> Vec<&'static str> {
.collect() .collect()
} }
/// 基于 BFS 计算从 `from` 到 `to` 的最短合法转换路径(含首尾)。
///
/// 当 `can_transition(from, to)` 为 false 时,调用方可将此路径附进错误提示,
/// 让 LLM 或用户知道「一步跳不到,但可以这样走」。
///
/// 返回路径列表,第一项为 `from`, 最后一项为 `to`。
/// 如果 `from == to`(同态)或不存在可达路径,返回空切片。
/// 注意:done 是唯一终态,从非终态到 done 只有主路径一条(跳闸门仍不可达);
/// 从 done 到任何态均不可达,返回空。
pub fn shortest_path(from: &str, to: &str) -> Vec<&'static str> {
use std::collections::VecDeque;
if from == to {
return vec![]; // 同态,无路径
}
if !is_valid_state(from) || !is_valid_state(to) {
return vec![]; // 未知态
}
// BFS:队列存(当前节点,路径);visited 跳过已探节点。
let mut visited = std::collections::HashSet::<&'static str>::new();
let mut queue: VecDeque<Vec<&'static str>> = VecDeque::new();
// from 已知是合法状态值,unwrap 安全
let from_static = ALL_STATES.iter().find(|s| **s == from).copied().unwrap();
visited.insert(from_static);
queue.push_back(vec![from_static]);
while let Some(path) = queue.pop_front() {
let current = path.last().copied().unwrap();
for candidate in ALL_STATES {
if !can_transition(current, candidate) {
continue;
}
if *candidate == to {
let mut full = path.clone();
full.push(candidate);
return full;
}
if visited.insert(candidate) {
let mut next = path.clone();
next.push(candidate);
queue.push_back(next);
}
}
}
vec![] // 不可达
}
/// 格式化为人类可读路径字符串,如 "deferred → todo → in_progress → blocked"。
pub fn format_path(path: &[&'static str]) -> String {
path.join("")
}
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。 /// 判定一次转换是否为「退回」(review_rounds 应 +1)。
/// ///
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、 /// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
@@ -217,11 +288,9 @@ mod tests {
#[test] #[test]
fn terminal_states_have_no_successors() { fn terminal_states_have_no_successors() {
// done / cancelled 是终态,任何后继都拒绝 // done 是唯一终态,任何后继都拒绝(cancelled 已支持取消后恢复,非终态)
for term in [DONE, CANCELLED] {
for to in ALL_STATES { for to in ALL_STATES {
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}"); assert!(!can_transition(DONE, to), "终态 {DONE} 不应有后继 → {to}");
}
} }
} }
@@ -248,14 +317,15 @@ mod tests {
#[test] #[test]
fn legal_targets_matches_transition_matrix() { fn legal_targets_matches_transition_matrix() {
// 逐态锁定合法目标列表(顺序 = ALL_STATES 遍历序),与 can_transition 矩阵一一对应 // 逐态锁定合法目标列表(顺序 = ALL_STATES 遍历序),与 can_transition 矩阵一一对应
assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED]); assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED]); assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED]); assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED]); assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED]); assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED, DEFERRED]);
// 终态无合法后继 assert_eq!(legal_targets(CANCELLED), vec![TODO]);
assert_eq!(legal_targets(DEFERRED), vec![TODO, CANCELLED]);
// done 是唯一终态,无合法后继
assert!(legal_targets(DONE).is_empty()); assert!(legal_targets(DONE).is_empty());
assert!(legal_targets(CANCELLED).is_empty());
} }
#[test] #[test]
@@ -331,7 +401,7 @@ mod tests {
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。 // 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"), // 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
// 该字符串过不了 is_valid_state,本测试立即失败定位。 // 该字符串过不了 is_valid_state,本测试立即失败定位。
// 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。 // 覆盖全部 8 态入参,不依赖上例已锁定的具体期望值。
for target in ALL_STATES { for target in ALL_STATES {
match regression_target(target) { match regression_target(target) {
Some(ret) => assert!( Some(ret) => assert!(
@@ -346,7 +416,7 @@ mod tests {
// ---------- is_valid_state ---------- // ---------- is_valid_state ----------
#[test] #[test]
fn is_valid_state_accepts_7_known() { fn is_valid_state_accepts_all_known() {
for s in ALL_STATES { for s in ALL_STATES {
assert!(is_valid_state(s)); assert!(is_valid_state(s));
} }
@@ -362,13 +432,13 @@ mod tests {
} }
#[test] #[test]
fn all_states_has_seven_entries() { fn all_states_has_eight_entries() {
assert_eq!(ALL_STATES.len(), 7); assert_eq!(ALL_STATES.len(), 8);
} }
// ---------- 双源一致性校验 ---------- // ---------- 双源一致性校验 ----------
// //
// task_state_machine 维护一份独立的 7 态字符串常量集(本模块 ALL_STATES / TODO / ...), // task_state_machine 维护一份独立的 8 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位, // 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值, // 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。 // 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
@@ -407,5 +477,73 @@ mod tests {
assert_eq!(DONE, TaskStatus::Done.as_str()); assert_eq!(DONE, TaskStatus::Done.as_str());
assert_eq!(BLOCKED, TaskStatus::Blocked.as_str()); assert_eq!(BLOCKED, TaskStatus::Blocked.as_str());
assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str()); assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str());
assert_eq!(DEFERRED, TaskStatus::Deferred.as_str());
}
// ---------- shortest_path(最短路径推导) ----------
#[test]
fn shortest_path_deferred_to_blocked() {
// deferred→todo→in_progress→blocked:最短 3 跳
let path = shortest_path(DEFERRED, BLOCKED);
assert_eq!(path, vec![DEFERRED, TODO, IN_PROGRESS, BLOCKED]);
}
#[test]
fn shortest_path_direct_transition() {
// 一步可达:todo→in_progress
let path = shortest_path(TODO, IN_PROGRESS);
assert_eq!(path, vec![TODO, IN_PROGRESS]);
}
#[test]
fn shortest_path_same_state_returns_empty() {
// 同态返回空
assert!(shortest_path(TODO, TODO).is_empty());
assert!(shortest_path(IN_PROGRESS, IN_PROGRESS).is_empty());
}
#[test]
fn shortest_path_from_terminal_returns_empty() {
// done 是终态,无任何后继
assert!(shortest_path(DONE, TODO).is_empty());
assert!(shortest_path(DONE, IN_PROGRESS).is_empty());
}
#[test]
fn shortest_path_unknown_state_returns_empty() {
assert!(shortest_path("merged", TODO).is_empty());
assert!(shortest_path(TODO, "merged").is_empty());
assert!(shortest_path("", "").is_empty());
}
#[test]
fn shortest_path_multi_hop_consistency() {
// 多跳路径:第一条边必须是 legal_targets(from) 的子集
let path = shortest_path(DEFERRED, BLOCKED);
assert_eq!(path.len(), 4, "deferred→blocked 最短是 4 节点,实际: {:?}", path);
// 首条边 deferred→todo 必须合法
assert!(can_transition(path[0], path[1]));
// 中间每条边都合法
for i in 0..path.len() - 1 {
assert!(
can_transition(path[i], path[i + 1]),
"路径边 {i}: {}→{} 不合法",
path[i],
path[i + 1]
);
}
// 最后一段必须是 blocked
assert_eq!(path.last().copied().unwrap(), BLOCKED);
}
#[test]
fn format_path_joins_with_arrow() {
assert_eq!(
format_path(&[DEFERRED, TODO, IN_PROGRESS, BLOCKED]),
"deferred → todo → in_progress → blocked"
);
assert_eq!(format_path(&[TODO, IN_PROGRESS]), "todo → in_progress");
assert_eq!(format_path(&[]), "");
} }
} }
+45 -3
View File
@@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String {
format!("{}…(已截断)", truncated) format!("{}…(已截断)", truncated)
} }
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级), /// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。 /// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径 /// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)
pub fn normalize_path(p: &str) -> String { pub fn normalize_for_compare(p: &str) -> String {
match Path::new(p).canonicalize() { match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p Err(_) => p
@@ -74,6 +74,22 @@ pub fn normalize_path(p: &str) -> String {
} }
} }
/// 规范化路径用于存库:canonicalize 解析绝对规范路径(失败降级为 trim + 正斜杠),
/// **保留大小写**(大小写敏感系统如 Linux/容器文件系统路径解析需原样大小写,
/// 小写化会导致路径解析失败)。比较/查重请用 [`normalize_for_compare`]。
pub fn canonicalize_for_store(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/"),
Err(_) => p.trim_end_matches(['\\', '/']).replace('\\', "/"),
}
}
/// 兼容别名:小写比较版(等价 [`normalize_for_compare`])。
/// 旧调用方沿用;新代码按用途选 `normalize_for_compare`(比较)/ `canonicalize_for_store`(存库)。
pub fn normalize_path(p: &str) -> String {
normalize_for_compare(p)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -111,4 +127,30 @@ mod tests {
assert!(!n.contains('\\'), "反斜杠未归一: {n}"); assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}"); assert_eq!(n, n.to_lowercase(), "未小写: {n}");
} }
#[test]
fn canonicalize_for_store_preserves_case() {
// 存库版保留大小写(降级分支:不存在的路径走 trim + replace,大小写原样保留)
let n = canonicalize_for_store(r"C:\Foo\Bar\");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
assert!(
n.contains("Foo") && n.contains("Bar"),
"存库版不应小写化(大小写敏感系统路径解析依赖): {n}"
);
}
#[test]
fn compare_lowercases_but_store_keeps_case() {
// compare 版与 store 版语义分离:比较小写、存库保留大小写
let p = r"C:\Foo\Bar";
let comp = normalize_for_compare(p);
let store = canonicalize_for_store(p);
assert_eq!(comp, comp.to_lowercase(), "比较版应小写: {comp}");
assert_ne!(comp, store, "小写化仅限比较版,存库版应保留大小写: {store}");
assert!(!store.contains('\\'), "存库版应正斜杠: {store}");
}
} }
+9
View File
@@ -0,0 +1,9 @@
# df-relay — 已迁移(deprecated
> **本 crate 已于 2026-08-23 提取为独立产品 [`u-relay`](../u-relay)**`E:/wk-lab/u-relay`git filter-repo 保留完整提交历史,脱敏改名 df_\*→u_\*)。
- **新功能一律在 u-relay 仓库开发**,本 crate 不再接受功能演进(防双份漂移)
- 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / 小程序 ws.ts)迁移 u-relay 后整体下线(设计 Q7)
- u-relay 协议为 df-relay 超集:hello / device_id / Event / Command 帧原样兼容,迁移只需切 host
设计文档:`C:/Users/23780/docs/u-box-design.md` §3.3 + `u-relay-extract-plan.md`
+10 -3
View File
@@ -4,15 +4,22 @@
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权) //! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由) //! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
//! //!
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。 //! 纯转发中继,无业务逻辑。token 校验对齐 `DF_RELAY_TOKEN`(relay.rs,必须设置)。
//! //!
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。 //! 联调启动:`DF_RELAY_TOKEN=<token> cargo run -p df-relay`(未设 token 时启动即失败,exit 1)。
use df_relay::{DefaultRelayServer, RelayServer}; use df_relay::{DefaultRelayServer, RelayError, RelayServer};
#[tokio::main] #[tokio::main]
async fn main() -> df_relay::Result<()> { async fn main() -> df_relay::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。
// relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。
if std::env::var("DF_RELAY_TOKEN").is_err() {
return Err(RelayError::Start(
"必须设置环境变量 DF_RELAY_TOKEN(未设置时拒绝启动)".into(),
));
}
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。 // 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)"); tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
+112 -6
View File
@@ -10,6 +10,14 @@
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。 //! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。 //! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
//! //!
//! ## 鉴权模型(MC-4 现状与风险)
//! MVP 单共享 token(`DF_RELAY_TOKEN`),无 per-device 绑定:**任何持 token 客户端可声明任意
//! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token
//! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固:
//! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。
//! - token 比较用常量时间比较,防时序侧信道。
//! 完整 per-device 鉴权留设计文档 Phase3。
//!
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。 //! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -32,12 +40,39 @@ 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};
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。 /// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
/// 不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。 /// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
fn expected_token() -> String { fn expected_token() -> Option<String> {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| { std::env::var("DF_RELAY_TOKEN").ok()
panic!("必须设置环境变量 DF_RELAY_TOKEN") }
})
/// 入站文本帧大小上限(1 MiB)。
/// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。
const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
/// 常量时间字符串比较(RLY-4,防时序侧信道)。
///
/// 没有引入 subtle 依赖;用等长逐字节异或累加 + 尾随等量循环掩蔽长度差,
/// 比较时长不随首字节差异提前返回。长度本身不保密(可被包长度观测),故长度不等直接返回。
fn constant_time_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
// 用最大长度循环,短者补 0 参与异或,时长恒等于较长者,不泄露首字节命中点
let len = a.len().max(b.len());
let mut diff: u8 = 0;
for i in 0..len {
let av = a.get(i).copied().unwrap_or(0);
let bv = b.get(i).copied().unwrap_or(0);
diff |= av ^ bv;
}
diff == 0
}
/// MC-4 最小加固:校验 device_id 格式。
/// 拒绝空串与超长(防内存/日志注入),仅做基础防御;per-device token 绑定留 Phase3。
fn validate_device_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
} }
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)
@@ -222,7 +257,34 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
let _ = socket_tx.close().await; let _ = socket_tx.close().await;
return; return;
} }
if hello.token != expected_token() { // MC-4 最小加固:device_id 格式校验(空/超长/含控制字符一律拒)。
// 单共享 token 下 device_id 无强绑定,但格式校验可挡最基础的注入/异常输入。
if !validate_device_id(&hello.device_id) {
tracing::warn!(
device_id = %hello.device_id,
"握手失败:device_id 格式非法"
);
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"invalid_device_id"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
// token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic
let Some(expected) = expected_token() else {
tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手");
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"server_misconfigured"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
};
// 常量时间比较防时序侧信道
if !constant_time_eq(&hello.token, &expected) {
tracing::warn!( tracing::warn!(
device_id = %hello.device_id, device_id = %hello.device_id,
"握手失败:token 校验不通过" "握手失败:token 校验不通过"
@@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
maybe_msg = socket_rx.next() => { maybe_msg = socket_rx.next() => {
match maybe_msg { match maybe_msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
// 入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽
if text.len() > MAX_FRAME_BYTES {
tracing::warn!(
conn_id = conn_id.0,
len = text.len(),
"入站帧超限,断开连接"
);
break;
}
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await { if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略"); tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
} }
@@ -331,6 +402,13 @@ async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Re
} }
_ => return Err(RelayError::Client("握手首帧类型非法".into())), _ => return Err(RelayError::Client("握手首帧类型非法".into())),
}; };
// Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意)
if text.len() > MAX_FRAME_BYTES {
return Err(RelayError::Client(format!(
"Hello 帧超限({} B > {MAX_FRAME_BYTES} B)",
text.len()
)));
}
let hello: Hello = let hello: Hello =
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?; serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
Ok(hello) Ok(hello)
@@ -440,3 +518,31 @@ fn now_ms() -> i64 {
.map(|d| d.as_millis() as i64) .map(|d| d.as_millis() as i64)
.unwrap_or(0) .unwrap_or(0)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_equality_and_inequality() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "ab"));
assert!(!constant_time_eq("", "a"));
assert!(constant_time_eq("", ""));
}
#[test]
fn validate_device_id_basic() {
assert!(validate_device_id("dev-1"));
assert!(validate_device_id("a".repeat(128).as_str()), "上限 128 应放行");
assert!(!validate_device_id(""), "空串拒绝");
assert!(!validate_device_id("a".repeat(129).as_str()), "超长拒绝");
assert!(!validate_device_id("dev\u{0001}"), "控制字符拒绝");
}
#[test]
fn max_frame_is_one_mib() {
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
}
}
+207 -7
View File
@@ -98,7 +98,9 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
// message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底: // message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。 // 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
message_id: row.get("message_id").unwrap_or(None), message_id: row.get("message_id").unwrap_or(None),
tool_call_id: row.get("tool_call_id")?, // row.get::<String> 后 From<String> → ToolCallId(newtype 不直接 impl rusqlite::FromSql,
// 避免 df-types 反向依赖 rusqlite;Deref<Target=str> 不自动给 ToSql)
tool_call_id: row.get::<_, String>("tool_call_id")?.into(),
tool_name: row.get("tool_name")?, tool_name: row.get("tool_name")?,
arguments: row.get("arguments")?, arguments: row.get("arguments")?,
result: row.get("result")?, result: row.get("result")?,
@@ -221,7 +223,7 @@ impl_repo!(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
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)",
params![ params![
rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by
], ],
@@ -231,7 +233,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12", "UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
params![ params![
rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by, rec.id rec.requested_at, rec.executed_at, rec.decided_by, rec.id
], ],
@@ -243,6 +245,56 @@ impl_repo!(
// 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。 // 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。
// 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。 // 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。
impl AiToolExecutionRepo { impl AiToolExecutionRepo {
/// 批量插入审计记录(单事务多行 INSERT,砍 N 次串行 INSERT 尾巴)。
///
/// 对比 [`insert`](`impl_repo!` 生成,每次 spawn_blocking + 单行 execute):
/// 本方法单次 `spawn_blocking` + 单事务,`prepare` 一次 INSERT stmt 循环 bind N 行,
/// 一次 `COMMIT`(原子性:全插或全不插,审计留痕可追溯)。空 `records` 直接返回(无操作)。
///
/// **用途**:audit/mod.rs `process_tool_calls` 低风险工具 join_all 并行执行后的回填循环
/// (每工具一条审计),把 N 次串行 INSERT 合并为一次事务批量(治 aichat 效率 AC-EFF-T1-1)。
///
/// 安全:全部值走参数绑定(同 `insert` 宏体),无 SQL 拼接注入面;单连接 Mutex 持锁整段,
/// 与单行 insert 的锁粒度相同(一次持锁换 N 次持锁)。
pub async fn insert_batch(&self, records: Vec<AiToolExecutionRecord>) -> Result<()> {
if records.is_empty() {
return Ok(());
}
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard.execute_batch("BEGIN").map_err(storage_err)?;
let result = (|| -> std::result::Result<(), rusqlite::Error> {
let mut stmt = guard.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?;
for rec in &records {
stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by
])?;
}
Ok(())
})();
match result {
Ok(()) => {
guard.execute_batch("COMMIT").map_err(storage_err)?;
}
Err(e) => {
// 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层)
let _ = guard.execute_batch("ROLLBACK");
return Err(storage_err(e));
}
}
Ok(())
})
.await
.map_err(storage_err)??;
Ok(())
}
/// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。 /// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。
pub async fn find_by_tool_call_id( pub async fn find_by_tool_call_id(
&self, &self,
@@ -267,6 +319,38 @@ impl AiToolExecutionRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 按 (conversation_id, tool_name, arguments) 查最新一条审计记录(重试防护用)。
///
/// 语义:同一会话内同一工具同一参数的重试判重。区别于 `find_by_tool_call_id`
/// (裸 id 匹配,弱模型 provider 的 tool_call_id 每轮重排,裸 id 判重会误杀跨轮合法调用,
/// 实证 2026-08-11)。
pub async fn find_by_conv_tool_args(
&self,
conversation_id: &str,
tool_name: &str,
arguments: &str,
) -> Result<Option<AiToolExecutionRecord>> {
let conn = self.conn.clone();
let cid = conversation_id.to_owned();
let tname = tool_name.to_owned();
let args = arguments.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT * FROM ai_tool_executions WHERE conversation_id = ?1 AND tool_name = ?2 AND arguments = ?3 ORDER BY requested_at DESC LIMIT 1",
)
.map_err(storage_err)?;
let row = stmt
.query_row(params![cid, tname, args], |row| ai_tool_execution_from_row(row))
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 列出所有 status=pending 的审计行(启动重建 pending_approvals 用) /// 列出所有 status=pending 的审计行(启动重建 pending_approvals 用)
/// ///
/// 专用 SELECT(非 query 宏——后者硬编码 ORDER BY created_at,而本表无该列)。 /// 专用 SELECT(非 query 宏——后者硬编码 ORDER BY created_at,而本表无该列)。
@@ -376,9 +460,9 @@ impl AiToolExecutionRepo {
params_vec.push(Box::new(r.clone())); params_vec.push(Box::new(r.clone()));
} }
if let Some(k) = &kw { if let Some(k) = &kw {
let escaped = k.replace('%', "\\%").replace('_', "\\_"); let escaped = k.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%"); let pat = format!("%{escaped}%");
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1)); where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '|'", params_vec.len() + 1));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -439,9 +523,9 @@ impl AiToolExecutionRepo {
params_vec.push(Box::new(r.clone())); params_vec.push(Box::new(r.clone()));
} }
if let Some(k) = &kw { if let Some(k) = &kw {
let escaped = k.replace('%', "\\%").replace('_', "\\_"); let escaped = k.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%"); let pat = format!("%{escaped}%");
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1)); where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '|'", params_vec.len() + 1));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
@@ -463,6 +547,55 @@ impl AiToolExecutionRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 按工具聚合执行统计(status 分布计数,AC-5 运行时失败率画像数据源)。
///
/// 单条 GROUP BY 取 `(tool_name, status, count)` 三元组;内存聚合与失败率口径
/// (failed_rate = failed / (completed + failed))在命令层完成(record.rs
/// `tool_failure_stats`),本层只负责取数,不掺展示逻辑。
/// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。
///
/// 与本表其他查询同理走专用 SELECT(通用 query 宏硬编码 ORDER BY created_at,
/// 本表无该列)。`requested_at` 存毫秒数字符串,`CAST AS INTEGER` 数值比较
/// (对齐 `cleanup_stale_pending` 同口径)。参数化绑定防注入。
pub async fn stats_by_tool(&self, from: Option<i64>) -> Result<Vec<(String, String, i64)>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 条件分支仅差 WHERE + 参数,一条 GROUP BY 复用(match 分支互斥,stmt 借用安全)
let (sql, param): (&str, Option<i64>) = match from {
Some(f) => (
"SELECT tool_name, status, COUNT(*) FROM ai_tool_executions \
WHERE CAST(requested_at AS INTEGER) >= ?1 GROUP BY tool_name, status",
Some(f),
),
None => (
"SELECT tool_name, status, COUNT(*) FROM ai_tool_executions \
GROUP BY tool_name, status",
None,
),
};
let row_map = |row: &rusqlite::Row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
let mut rows = match param {
Some(f) => stmt.query_map(params![f], row_map).map_err(storage_err)?,
None => stmt.query_map([], row_map).map_err(storage_err)?,
};
let mut out = Vec::new();
for r in &mut rows {
out.push(r.map_err(storage_err)?);
}
Ok(out)
})
.await
.map_err(storage_err)?
}
} }
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。 // AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
@@ -743,4 +876,71 @@ mod tests {
assert_eq!(legacy.model_configs.len(), 2, "老字符串数组应转 2 个默认 ModelConfig"); assert_eq!(legacy.model_configs.len(), 2, "老字符串数组应转 2 个默认 ModelConfig");
assert_eq!(legacy.model_configs[0].model_id, "glm-4-flash"); assert_eq!(legacy.model_configs[0].model_id, "glm-4-flash");
} }
// ---------- find_by_conv_tool_args(重试判重,SC-260811-P0-1) ----------
/// 构造一条审计记录(参数自洽,满足 NOT NULL 约束)。
fn audit_rec(
id: &str, conv: &str, tc_id: &str, tool: &str, args: &str, status: &str,
) -> AiToolExecutionRecord {
AiToolExecutionRecord {
id: id.into(),
conversation_id: Some(conv.into()),
message_id: None,
tool_call_id: tc_id.into(),
tool_name: tool.into(),
arguments: args.into(),
result: None,
status: status.into(),
risk_level: "low".into(),
requested_at: "0".into(),
executed_at: None,
decided_by: None,
}
}
/// P0-1 核心语义:弱模型跨轮复用同 tool_call_id(gen_stream_0),但工具/参数不同 →
/// find_by_conv_tool_args 不得误判为重试(旧 find_by_tool_call_id 会误命中)。
#[tokio::test]
async fn find_by_conv_tool_args_ignores_same_id_different_tool() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiToolExecutionRepo::new(&db);
// 会话开头已执行过 list_tasks(gen_stream_0),之后同会话又出现 gen_stream_0 的 advance_task
repo.insert(audit_rec("a1", "conv1", "gen_stream_0", "list_tasks", "{\"limit\":50}", "completed")).await.expect("insert");
repo.insert(audit_rec("a2", "conv1", "gen_stream_0", "advance_task", "{\"id\":\"t1\",\"target_status\":\"blocked\"}", "completed")).await.expect("insert");
// 查 advance_task 同三元组 → 命中(a2)
let got = repo.find_by_conv_tool_args("conv1", "advance_task", "{\"id\":\"t1\",\"target_status\":\"blocked\"}").await.expect("query");
assert!(got.is_some(), "同 (conv,tool,args) 应命中");
assert_eq!(got.unwrap().id, "a2");
// 查 list_tasks 同三元组 → 命中(a1),互不串扰
let got2 = repo.find_by_conv_tool_args("conv1", "list_tasks", "{\"limit\":50}").await.expect("query");
assert!(got2.is_some());
assert_eq!(got2.unwrap().id, "a1");
// 跨会话同 tool_call_id 不命中(不同 conv)
let got3 = repo.find_by_conv_tool_args("conv2", "advance_task", "{\"id\":\"t1\",\"target_status\":\"blocked\"}").await.expect("query");
assert!(got3.is_none(), "不同会话同参数不应命中");
}
/// 同 (conv,tool,args) 且已落定(completed) → 判重返回记录(调用方 retry_count≥1);
/// pending 视为"尚未落定"(首次挂起审批)不判重。
#[tokio::test]
async fn find_by_conv_tool_args_pending_not_retry() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiToolExecutionRepo::new(&db);
repo.insert(audit_rec("a1", "conv1", "call_x", "write_file", "{\"path\":\"a.go\"}", "pending")).await.expect("insert");
// pending 记录不视为重试
let got = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query");
assert!(got.is_some(), "pending 记录也应能查到(状态判定在调用方 detect_retry_count 做,此处只负责按三元组定位)");
assert_eq!(got.unwrap().status, "pending");
// 同三元组 latest(有 completed 应返回最新一条 requested_at DESC)
let mut rec2 = audit_rec("a2", "conv1", "call_x", "write_file", "{\"path\":\"a.go\"}", "completed");
rec2.requested_at = "1".into(); // 晚于 a1 的 "0",保证 DESC 序可辨
repo.insert(rec2).await.expect("insert");
let got2 = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query");
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
}
} }
+189 -5
View File
@@ -301,11 +301,12 @@ 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 escaped = kw.replace('%', "\\%").replace('_', "\\_"); let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%"); 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}) ESCAPE '\\'")); // ESCAPE 跟单个 LIKE(不能跟括号分组,否则 near "ESCAPE" syntax error)
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' 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));
} }
@@ -401,6 +402,61 @@ impl IdeaRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
///
/// promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
///
/// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。
/// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。
pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let promoted_to = promoted_to.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \
WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL",
params![promoted_to, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。 /// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
/// ///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换); /// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
@@ -538,6 +594,43 @@ impl IdeaRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
/// `Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &IdeaRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.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 AND updated_at = ?14",
params![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
} }
impl KnowledgeRepo { impl KnowledgeRepo {
@@ -546,7 +639,7 @@ 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 escaped = query.replace('%', "\\%").replace('_', "\\_"); let escaped = query.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pattern = format!("%{escaped}%"); 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;
@@ -555,7 +648,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 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') 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))
@@ -565,7 +658,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 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') 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))
@@ -1479,6 +1572,51 @@ mod tests {
assert!(!repo.restore("i1").await.unwrap()); assert!(!repo.restore("i1").await.unwrap());
} }
// ── LW-8 立项认领 CAS(claim_promotion)──────────────────────────
// 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败
// (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。
#[tokio::test]
async fn idea_claim_promotion_claims_once_only() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "立项")).await.unwrap();
// 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库
assert!(repo.claim_promotion("i1", "p-1").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.status.as_str(), "promoted");
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"));
// 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护)
assert!(!repo.claim_promotion("i1", "p-2").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项");
}
#[tokio::test]
async fn idea_claim_promotion_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "回收站")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
assert!(
!repo.claim_promotion("i1", "p-1").await.unwrap(),
"软删灵感不可立项认领(deleted_at IS NULL 收口)"
);
}
// ── LW-6 update_field_active(软删过滤)─────────────────────────
#[tokio::test]
async fn idea_update_field_active_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("i1").await.unwrap();
assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.title, "新标题", "软删后字段不应被改动");
}
#[tokio::test] #[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() { async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await; let repo = setup_idea_repo().await;
@@ -1497,4 +1635,50 @@ mod tests {
// i1 活跃不出现;i3 删除最晚在前 // i1 活跃不出现;i3 删除最晚在前
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC"); assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
} }
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn idea_update_full_cas_success_when_expected_matches() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
let current = repo.get_by_id("i1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("i1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("i1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
} }
+16 -9
View File
@@ -46,6 +46,7 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?, prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?,
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?, prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?, reasoning_tokens: row.get("reasoning_tokens")?,
is_estimated: row.get("is_estimated")?,
}) })
} }
@@ -86,8 +87,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
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, ?19)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -96,7 +97,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -122,7 +123,8 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
is_estimated
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC", FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
@@ -164,13 +166,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3" FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else { } else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2" FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
}; };
let mut stmt = guard.prepare(sql).map_err(storage_err)?; let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -289,8 +291,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
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, ?19)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -299,7 +301,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -373,6 +375,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
} }
} }
@@ -535,6 +538,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -555,6 +559,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -627,6 +632,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}], }],
) )
.await .await
@@ -668,6 +674,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}; };
repo.replace_conversation("c", vec![rec()]).await.expect("1st"); repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+3
View File
@@ -6,6 +6,7 @@
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column) //! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo //! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口) //! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
//! - [`mod@task_validation`]:任务创建参数校验(queue 白名单/parent_id 嵌套铁律/content_json),GUI 与 MCP 工具共享
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1) //! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2) //! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3) //! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
@@ -31,6 +32,7 @@ mod project_service_repo;
mod settings; mod settings;
mod task_link_repo; mod task_link_repo;
mod task_repo; mod task_repo;
mod task_validation;
pub use conversation_repo::*; pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
@@ -43,6 +45,7 @@ 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 task_validation::*;
pub use module_dependency_repo::*; pub use module_dependency_repo::*;
pub use plan_repo::*; pub use plan_repo::*;
@@ -215,19 +215,30 @@ use rusqlite::OptionalExtension;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::crud::project_repo::ProjectRepo;
use crate::db::Database; use crate::db::Database;
use crate::models::{ProjectRecord, ProjectModuleRecord};
use df_types::types::ProjectStatus;
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。 /// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
async fn setup() -> (Database, ProjectModuleRepo, String) { async fn setup() -> (Database, ProjectModuleRepo, String) {
let db = Database::open_in_memory().await.expect("open_in_memory"); let db = Database::open_in_memory().await.expect("open_in_memory");
let project_id = "proj-test".to_string(); let project_id = "proj-test".to_string();
db.conn() // 用异步 repo API 建占位 project(勿在 async 上下文直接 blocking_lock,会触发
.blocking_lock() // "Cannot block the current thread from within a runtime" panic,实证 2026-08-11)。
.execute( ProjectRepo::new(&db)
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \ .insert(ProjectRecord {
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')", id: project_id.clone(),
params![project_id, "Test Project"], name: "Test Project".to_string(),
) description: String::new(),
status: ProjectStatus::InProgress,
idea_id: None,
path: Some("/tmp".into()),
stack: Some("rust".into()),
created_at: "0".to_string(),
updated_at: "0".to_string(),
})
.await
.expect("insert placeholder project"); .expect("insert placeholder project");
let repo = ProjectModuleRepo::new(&db); let repo = ProjectModuleRepo::new(&db);
(db, repo, project_id) (db, repo, project_id)
+152 -7
View File
@@ -43,10 +43,17 @@ pub struct ProjectQuery {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。
///
/// projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用
/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义,
/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。
const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"];
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。 /// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
/// ///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name` /// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。 /// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。 /// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> { fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let Some(raw) = order_by else { let Some(raw) = order_by else {
@@ -60,8 +67,13 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let parts: Vec<&str> = raw.split_whitespace().collect(); let parts: Vec<&str> = raw.split_whitespace().collect();
let col = parts[0]; let col = parts[0];
let dir = parts.get(1).map(|s| s.to_ascii_uppercase()); let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。 // 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。
validate_column_name(col, "projects")?; if !PROJECT_ORDER_BY_ALLOWED.contains(&col) {
return Err(df_types::error::Error::Storage(format!(
"非法 order_by 字段名: {col},合法值: {:?}",
PROJECT_ORDER_BY_ALLOWED
)));
}
match dir.as_deref() { match dir.as_deref() {
None | Some("DESC") => Ok(format!("{col} DESC")), None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")), Some("ASC") => Ok(format!("{col} ASC")),
@@ -303,8 +315,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() {
let escaped = trimmed.replace('%', "\\%").replace('_', "\\_"); let escaped = trimmed.replace('|', "||").replace('%', "|%").replace('_', "|_");
sql.push_str(" AND (name LIKE ? OR description LIKE ?) ESCAPE '\\'"); // ESCAPE 跟单个 LIKE(不能跟括号分组,否则 near "ESCAPE" syntax error)
sql.push_str(" AND (name LIKE ? ESCAPE '|' OR description LIKE ? ESCAPE '|')");
let pattern = format!("%{escaped}%"); 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));
@@ -374,7 +387,7 @@ impl ProjectRepo {
/// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。 /// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。
/// ///
/// DRY(R-PD-11):统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project /// DRY统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project
/// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由 /// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由
/// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。 /// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。
pub async fn find_path_conflict( pub async fn find_path_conflict(
@@ -414,6 +427,67 @@ impl ProjectRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &ProjectRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9",
params![
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6通用 [`update_field`] 不过滤软删(回收站项目仍可改字段),
/// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "projects")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE projects SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复) /// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
/// ///
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
@@ -611,3 +685,74 @@ impl_repo!(
) )
} }
); );
// ============================================================
// 单元测试 — update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn prec(id: &str, name: &str) -> ProjectRecord {
ProjectRecord {
id: id.to_string(),
name: name.to_string(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
async fn setup() -> ProjectRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
}
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
let current = repo.get_by_id("p1").await.unwrap().unwrap();
// 本地构建新版本(name + updated_at 递增)
let mut rec = current.clone();
rec.name = "新项目".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "新项目");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("p1").await.unwrap().unwrap();
other.name = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("p1").await.unwrap().unwrap();
mine.name = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}
+5 -1
View File
@@ -125,7 +125,11 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at", "promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
], ],
"projects" => &[ "projects" => &[
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at", // id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (BE-CMD-3,对标 ideas/tasks 白名单同款防护,防篡改主键/伪造创建时间致子表悬空)。
// 注:projects 排序用 created_at 不受影响 — build_order_clause 走独立排序白名单
// (PROJECT_ORDER_BY_ALLOWED,update_field 白名单与排序白名单解耦,同 ideas 模式)。
"name", "description", "status", "idea_id", "path", "stack",
"updated_at", "updated_at",
], ],
"tasks" => &[ "tasks" => &[
+251 -7
View File
@@ -391,12 +391,17 @@ impl TaskRepo {
params_vec.push(Box::new(mid.clone())); params_vec.push(Box::new(mid.clone()));
} }
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
// ESCAPE 字符用 |(管道符,任务标题/描述几乎不含),不用反斜杠——反斜杠在
// Rust format! → rusqlite 绑定 → SQLite 多层转义里极易出错(SQLite 报
// "ESCAPE expression must be a single character"),改 | 一劳永逸。
// 注意:ESCAPE 只能跟单个 LIKE,不能跟括号分组(实测 `(a OR b) ESCAPE 'x'`
// 报 near "ESCAPE" syntax error),故每个 LIKE 各自 ESCAPE。
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_"); let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%"); 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}) ESCAPE '\\'")); where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' 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));
} }
@@ -455,12 +460,20 @@ impl TaskRepo {
/// ///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT), /// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。 /// 返回满足条件的总行数(忽略分页裁剪)。
///
/// LW-5补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query
/// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致
/// (前端分页 total 与页数据对不上)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> { pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let project_id = query.project_id.clone(); let project_id = query.project_id.clone();
let status = query.status.clone(); let status = query.status.clone();
let priority = query.priority; let priority = query.priority;
let assignee = query.assignee.clone();
let keyword = query.keyword.clone(); let keyword = query.keyword.clone();
let queue = query.queue.clone();
let parent_id = query.parent_id.clone();
let module_id = query.module_id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
@@ -480,15 +493,33 @@ impl TaskRepo {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1)); where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p)); params_vec.push(Box::new(p));
} }
// assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移)
if let Some(ref a) = assignee {
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone()));
}
if let Some(ref kw) = keyword { if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_"); let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%"); 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}) ESCAPE '\\'")); where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' 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));
} }
// queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41)
if let Some(ref q) = queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone()));
}
if let Some(ref pid) = parent_id {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref mid) = module_id {
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(mid.clone()));
}
let sql = format!( let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}", "SELECT COUNT(*) FROM tasks WHERE {}",
@@ -743,11 +774,72 @@ impl TaskRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &TaskRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.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, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19",
params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
} }
// ============================================================ /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB) ///
// ============================================================ /// LW-6通用 [`update_field`] 不过滤软删(回收站任务仍可改字段),
/// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`,
/// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。
/// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "tasks")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -1160,4 +1252,156 @@ mod tests {
assert_eq!(updated.queue, "todo"); assert_eq!(updated.queue, "todo");
assert_eq!(updated.status.as_str(), "todo"); assert_eq!(updated.status.as_str(), "todo");
} }
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
let current = repo.get_by_id("t1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("t1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("t1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
// ============================================================
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
// ============================================================
#[tokio::test]
async fn count_by_query_matches_list_by_query_dimensions() {
let repo = setup().await;
// 父任务 + 3 子/叶任务,覆盖 queue/assignee/parent_id 三维(module_id 需 FK 另测)
repo.insert(trec("parent", "todo", None)).await.unwrap();
let mut c1 = trec("c1", "backlog", Some("parent"));
c1.assignee = Some("alice".to_string());
let mut c2 = trec("c2", "todo", Some("parent"));
c2.assignee = Some("bob".to_string());
let mut c3 = trec("c3", "active", None);
c3.assignee = Some("alice".to_string());
repo.insert(c1).await.unwrap();
repo.insert(c2).await.unwrap();
repo.insert(c3).await.unwrap();
// 单维度:queue=backlog → 1(c1)
let q = TaskQuery { queue: Some("backlog".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// assignee=alice → 2(c1 + c3)
let q = TaskQuery { assignee: Some("alice".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// parent_id=parent → 2(c1 + c2)
let q = TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// 组合:queue=backlog AND parent_id=parent → 1(c1)
let q = TaskQuery {
queue: Some("backlog".to_string()),
parent_id: Some("parent".to_string()),
..Default::default()
};
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// 关键契约:count 与 list_by_query 对同一 query 结果数一致(翻页 total 与页数据对齐)
for q in [
TaskQuery { queue: Some("backlog".to_string()), ..Default::default() },
TaskQuery { assignee: Some("alice".to_string()), ..Default::default() },
TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() },
] {
let count = repo.count_by_query(&q).await.unwrap();
let list_len = repo.list_by_query(&q).await.unwrap().len() as i64;
assert_eq!(count, list_len, "count 与 list 维度必须一致,query={q:?}");
}
}
// ============================================================
// LW-6 update_field_active 软删过滤(回收站任务不可改字段)
// ============================================================
#[tokio::test]
async fn update_field_active_skips_soft_deleted() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("t1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("t1").await.unwrap();
assert!(!repo.update_field_active("t1", "title", "又改").await.unwrap());
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "新标题", "软删后字段不应被改动");
}
// ============================================================
// keyword LIKE 查询(ESCAPE 转义)—— 防 2026-08-11 语法回归
// ============================================================
// 背景:旧写法 `(a LIKE ?1 OR b LIKE ?2) ESCAPE '|'`(ESCAPE 跟括号分组)在 SQLite
// 报 near "ESCAPE" syntax error,keyword 查询全挂。正确写法:ESCAPE 跟每个 LIKE。
// 本测试锁两种语义:普通子串匹配 + 含 %/_ 通配符字面匹配(转义生效)。
#[tokio::test]
async fn list_by_query_keyword_matches_substring() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.insert(trec("t2", "todo", None)).await.unwrap();
// 定制 title:t1 含「支付」,t2 不含
repo.update_field_active("t1", "title", "海外支付集成").await.unwrap();
let rows = repo
.list_by_query(&TaskQuery { keyword: Some("支付".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(rows.len(), 1, "keyword 子串应只命中 t1");
assert_eq!(rows[0].id, "t1");
}
#[tokio::test]
async fn list_by_query_keyword_escapes_wildcards() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.insert(trec("t2", "todo", None)).await.unwrap();
// t1 标题含字面 % 与 _(通配符需转义,按字面匹配)
repo.update_field_active("t1", "title", "比率 100%_cache").await.unwrap();
repo.update_field_active("t2", "title", "比率 100x_cache").await.unwrap();
// 查询字面 "%_"(含两个通配符,转义后应按字面匹配 t1;t2 的 x 不匹配 %)
let rows = repo
.list_by_query(&TaskQuery { keyword: Some("100%_".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(rows.len(), 1, "% 与 _ 应被转义为字面,只命中 t1");
assert_eq!(rows[0].id, "t1");
}
} }
@@ -0,0 +1,82 @@
//! 任务创建参数校验(共享层)
//!
//! GUI AI 工具(ai/tools/task.rs)与 MCP 工具(crates/df-mcp/src/tools.rs)曾各自内联
//! 一套 create_task 校验(queue 白名单 / parent_id 嵌套铁律 / content_json 合法性),
//! 演进易漂移。抽到本模块后两套工具同源复用,一处修改全局生效。
use crate::crud::task_repo::TaskRepo;
/// queue 合法值白名单(queue 是独立管理维度,与 status 执行维度正交)
pub const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
/// 默认优先级(medium)— 新任务默认中优先级,与 IPC create_task 一致
pub const DEFAULT_TASK_PRIORITY: i32 = 2;
/// 校验并归一化 create_task 的 queue:空串/缺省 → "todo";
/// 白名单校验 + create 一致性(新建仅 backlog/todo/decision,active/done 须经 move_task_queue 流转)。
pub fn normalize_queue_for_create(queue: Option<&str>) -> Result<String, String> {
match queue {
None | Some("") => Ok("todo".to_string()),
Some(q) => {
let q = q.trim();
if !TASK_QUEUE_VALUES.contains(&q) {
return Err(format!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES));
}
match q {
"backlog" | "todo" | "decision" => Ok(q.to_string()),
other => Err(format!(
"新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision",
other
)),
}
}
}
}
/// 校验 content_json(非空时须是合法 JSON),返回规范化值(None=未传)。
pub fn validate_content_json(content: Option<&str>) -> Result<Option<String>, String> {
match content {
Some(c) if !c.trim().is_empty() => {
serde_json::from_str::<serde_json::Value>(c)
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
Ok(Some(c.to_string()))
}
_ => Ok(None),
}
}
/// 校验 parent_id 1 级嵌套铁律:父任务存在 + 父任务自身不是子任务(防孙任务)。
/// 返回归一化 parent_id(空串/缺省 → None)。
pub async fn validate_parent_id(
repo: &TaskRepo,
parent_id: Option<&str>,
) -> Result<Option<String>, String> {
let pid = parent_id
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(pid) = &pid {
let parent = repo
.get_by_id(pid)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
if parent.parent_id.is_some() {
return Err(format!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
parent.parent_id
));
}
}
Ok(pid)
}
/// 校验并归一化 create_task 的 priority:缺省 → DEFAULT_TASK_PRIORITY;越界值拒绝(值域 0..=3)。
pub fn normalize_priority(priority: Option<i64>) -> Result<i32, String> {
match priority {
None => Ok(DEFAULT_TASK_PRIORITY),
Some(p) if (0..=3).contains(&p) => Ok(p as i32),
Some(p) => Err(format!(
"非法 priority 值 {p},合法值: 整数 0..=3(0=critical, 1=high, 2=medium, 3=low)"
)),
}
}
+33 -8
View File
@@ -46,7 +46,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
// V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。 // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。
let steps: [(i32, fn(&Connection) -> Result<()>); 41] = [ // V42 = ai_messages 加 is_estimated 列(消息级估算标记,reload 逐条回显「估算」标注)。
let steps: [(i32, fn(&Connection) -> Result<()>); 42] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -88,6 +89,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(39, migrate_v39), (39, migrate_v39),
(40, migrate_v40), (40, migrate_v40),
(41, migrate_v41), (41, migrate_v41),
(42, migrate_v42),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -702,7 +704,7 @@ fn migrate_v26(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V27: 审批状态统一 executed→completed(TD-260621-05) /// V27: 审批状态统一 executed→completed
/// ///
/// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行 /// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行
/// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配; /// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配;
@@ -1257,6 +1259,22 @@ fn migrate_v41(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V42: ai_messages 加 is_estimated 列(消息级估算标记)
///
/// 消息级 token 持久化(V38/V39)已落 prompt/completion/cache/reasoning,但「该轮 prompt 是否
/// estimated 兜底」未存——reload 逐条回显时无法对齐 live 态 AiCompleted.is_estimated 标注。
/// 本迁移补 INTEGER 列(NULL=老消息未标记,向前兼容;0=false 真实,1=true 估算)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v38/v39 模式),对新库/老库均安全。
fn migrate_v42(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_messages", "is_estimated") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN is_estimated INTEGER", [])?;
tracing::info!("v42: ai_messages 加 is_estimated 列(消息级估算标记)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [42])?;
tracing::info!("迁移 v42 完成: ai_messages 加 is_estimated 列");
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)。
@@ -1281,6 +1299,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER, prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER, prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -1532,6 +1551,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER, prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER, prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -1860,7 +1880,7 @@ mod tests {
assert_eq!(v, 20); assert_eq!(v, 20);
} }
/// V27:审批状态统一 executed→completed(TD-260621-05) /// V27:审批状态统一 executed→completed
/// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed) /// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed)
#[test] #[test]
fn v27_unifies_executed_to_completed() { fn v27_unifies_executed_to_completed() {
@@ -2023,7 +2043,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)" "tasks.module_id 列缺失(V41 加)"
); );
// 3. schema_version 应推进到 41(全量迁移成功落版本号) // 3. schema_version 应推进到 42(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2032,8 +2052,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 41, max_version, 42,
"全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 42(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2046,6 +2066,11 @@ mod tests {
column_exists(&conn, "project_modules", "status"), column_exists(&conn, "project_modules", "status"),
"project_modules.status 列缺失(V40 加)" "project_modules.status 列缺失(V40 加)"
); );
// 5. V42 抽查:ai_messages.is_estimated 列存在(消息级估算标记)
assert!(
column_exists(&conn, "ai_messages", "is_estimated"),
"ai_messages.is_estimated 列缺失(V42 加)"
);
} }
// ============================================================ // ============================================================
@@ -2105,7 +2130,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 41, "首轮应推进到 41"); assert_eq!(max_v, 42, "首轮应推进到 42");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2117,7 +2142,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 41, "重跑后应重新推进到 41"); assert_eq!(max_v2, 42, "重跑后应重新推进到 42");
} }
// ============================================================ // ============================================================
+7 -2
View File
@@ -1,7 +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 df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus, ToolCallId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================ // ============================================================
@@ -383,7 +383,9 @@ pub struct AiToolExecutionRecord {
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。 /// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>, pub message_id: Option<String>,
pub tool_call_id: String, /// 工具调用关联键(跨 tool_calls / pending_approvals / ai_tool_executions)。
/// branded newtype 防把 execution_id / conversation_id 等同形 String 误传。
pub tool_call_id: ToolCallId,
pub tool_name: String, pub tool_name: String,
pub arguments: String, pub arguments: String,
pub result: Option<String>, pub result: Option<String>,
@@ -440,6 +442,9 @@ pub struct AiMessageRecord {
pub prompt_cache_miss_tokens: Option<u32>, pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。 /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
pub reasoning_tokens: Option<u32>, pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
pub is_estimated: Option<bool>,
} }
// ============================================================ // ============================================================
+3 -1
View File
@@ -9,7 +9,9 @@
//! //!
//! ## 边界 //! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制) //! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由) //! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor
//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected
//! 原语 + `backoff_delay` 退避工具)
//! //!
//! ## 模块 //! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型 //! - [`events`]:隧道透传事件/指令/消息包装类型
+15 -10
View File
@@ -1,7 +1,9 @@
//! df-tunnel 隧道客户端(Phase2 完整实现) //! df-tunnel 隧道客户端(Phase2 完整实现)
//! //!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`, //! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连 //! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
//! //!
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e) //! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }` //! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
@@ -130,9 +132,9 @@ enum OutFrame {
/// 默认 WS 隧道客户端(完整实现) /// 默认 WS 隧道客户端(完整实现)
pub struct WsTunnelClient { pub struct WsTunnelClient {
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读) /// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连), /// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
/// 该字段保留以备 Phase3「断线后台自动重连」消费 /// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)
server_url: Mutex<Option<String>>, server_url: Mutex<Option<String>>,
device_id: Mutex<Option<String>>, device_id: Mutex<Option<String>>,
token: Mutex<Option<String>>, token: Mutex<Option<String>>,
@@ -155,7 +157,7 @@ impl WsTunnelClient {
} }
} }
/// 当前已连接时取一份重连参数(未来自动重连用) /// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连用)
pub async fn reconnect_params(&self) -> Option<(String, String, String)> { pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
let url = self.server_url.lock().await.clone()?; let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?; let device_id = self.device_id.lock().await.clone()?;
@@ -344,8 +346,9 @@ impl WsTunnelClient {
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping /// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
/// 4. 任一端断开 → 退出 task,置 connected=false /// 4. 任一端断开 → 退出 task,置 connected=false
/// ///
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层 /// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担:
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。 /// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params)参数
/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。
async fn run_loop( async fn run_loop(
mut sink: futures_util::stream::SplitSink< mut sink: futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
} }
} }
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分) /// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
#[allow(dead_code)] ///
fn backoff_delay(attempt: u32) -> Duration { /// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。
/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。
pub fn backoff_delay(attempt: u32) -> Duration {
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... // 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
let secs = RECONNECT_BASE let secs = RECONNECT_BASE
.as_secs() .as_secs()
+2 -2
View File
@@ -74,7 +74,7 @@ pub enum WorkflowEvent {
}, },
/// 工作流执行完成 /// 工作流执行完成
/// ///
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行。 /// b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行。
/// 此前该变体不带 execution_id,AppState.event_bus 全局单例下并发工作流的终态事件 /// 此前该变体不带 execution_id,AppState.event_bus 全局单例下并发工作流的终态事件
/// 会被 forward 循环的 matches! 完成判定(workflow.rs:159-163 只看变体不看 exec_id)误判, /// 会被 forward 循环的 matches! 完成判定(workflow.rs:159-163 只看变体不看 exec_id)误判,
/// 致任一工作流终态触发所有 forward 循环 break(过早断他人链)。 /// 致任一工作流终态触发所有 forward 循环 break(过早断他人链)。
@@ -86,7 +86,7 @@ pub enum WorkflowEvent {
}, },
/// 工作流执行失败 /// 工作流执行失败
/// ///
/// B-03b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。 /// b-R10 ③(波17 治本): execution_id 字段标识本次终态事件归属的工作流执行(同 WorkflowCompleted)。
WorkflowFailed { WorkflowFailed {
#[serde(default)] #[serde(default)]
execution_id: ExecutionId, execution_id: ExecutionId,
+122 -3
View File
@@ -84,6 +84,73 @@ impl PartialEq<String> for ExecutionId {
} }
} }
/// 工具调用 ID(跨 ai_tool_executions / tool_calls 关联键,IPC 边界透明序列化为字符串)
///
/// branded newtype:编译器拒绝把任意 String 当 ToolCallId 传入,防误传(如把
/// execution_id 当 tool_call_id 落库)。serde `transparent` 保证 JSON 形态为纯字符串,
/// 前端零感知。设计同 [`ExecutionId`] / [`ToolCallType`],手写不抽宏(全仓仅 3 个
/// 字符串 newtype,抽 macro_rules 增心智成本无收益)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(transparent)]
pub struct ToolCallId(String);
impl ToolCallId {
/// 构造新工具调用 ID
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolCallId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Deref for ToolCallId {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for ToolCallId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolCallId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<ToolCallId> for String {
fn from(id: ToolCallId) -> Self {
id.0
}
}
// ToolCallId 与 &str/String 的比较(测试/匹配中大量使用,与 ExecutionId 对齐)
impl PartialEq<&str> for ToolCallId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ToolCallId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<String> for ToolCallId {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
/// 决策 ID /// 决策 ID
pub type DecisionId = String; pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review) /// 节点类型(如 ai / human / ai_self_review)
@@ -286,6 +353,8 @@ pub enum TaskStatus {
Blocked, Blocked,
/// 已取消 /// 已取消
Cancelled, Cancelled,
/// 已暂缓
Deferred,
} }
impl TaskStatus { impl TaskStatus {
@@ -302,6 +371,7 @@ impl TaskStatus {
TaskStatus::Done => "done", TaskStatus::Done => "done",
TaskStatus::Blocked => "blocked", TaskStatus::Blocked => "blocked",
TaskStatus::Cancelled => "cancelled", TaskStatus::Cancelled => "cancelled",
TaskStatus::Deferred => "deferred",
} }
} }
@@ -311,7 +381,7 @@ impl TaskStatus {
pub fn is_valid(s: &str) -> bool { pub fn is_valid(s: &str) -> bool {
matches!( matches!(
s, s,
"todo" | "in_progress" | "in_review" | "testing" | "done" | "blocked" | "cancelled" "todo" | "in_progress" | "in_review" | "testing" | "done" | "blocked" | "cancelled" | "deferred"
) )
} }
@@ -325,6 +395,7 @@ impl TaskStatus {
"done", "done",
"blocked", "blocked",
"cancelled", "cancelled",
"deferred",
] ]
} }
@@ -341,6 +412,7 @@ impl TaskStatus {
"done" => TaskStatus::Done, "done" => TaskStatus::Done,
"blocked" => TaskStatus::Blocked, "blocked" => TaskStatus::Blocked,
"cancelled" => TaskStatus::Cancelled, "cancelled" => TaskStatus::Cancelled,
"deferred" => TaskStatus::Deferred,
_ => return None, _ => return None,
}) })
} }
@@ -484,6 +556,22 @@ impl Priority {
Priority::Critical => "critical", Priority::Critical => "critical",
} }
} }
/// 从 i32 解析优先级(对齐**前端约定** 0=Critical/1=High/2=Medium/3=Low
/// 与 src-tauri commands::idea::priority_from_i32 兜底语义一致,统一调用入口)。
///
/// 注意:enum discriminant 与前端相反(Low=0/Critical=3,历史定义),
/// 勿用 `p as Priority` 转换——本函数手动映射前端语义,与 `as_str` 序列化
/// ("low"/"critical") 互不干扰。越界/负值(含 4+、-1 等)统一归并 Low。
pub fn from_i32(p: i32) -> Priority {
match p {
0 => Priority::Critical,
1 => Priority::High,
2 => Priority::Medium,
// 3(低)与越界/负值统一归 Low,对齐现有 priority_from_i32 读侧语义
_ => Priority::Low,
}
}
} }
impl Default for Priority { impl Default for Priority {
@@ -503,7 +591,7 @@ pub fn new_id() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::TaskStatus; use super::{Priority, TaskStatus};
// 合法值:覆盖全部枚举变体(与 as_str 一致) // 合法值:覆盖全部枚举变体(与 as_str 一致)
#[test] #[test]
@@ -515,6 +603,7 @@ mod tests {
assert!(TaskStatus::is_valid("done")); assert!(TaskStatus::is_valid("done"));
assert!(TaskStatus::is_valid("blocked")); assert!(TaskStatus::is_valid("blocked"));
assert!(TaskStatus::is_valid("cancelled")); assert!(TaskStatus::is_valid("cancelled"));
assert!(TaskStatus::is_valid("deferred"));
} }
// as_str 与 is_valid 必须自洽:每个变体的存储值都应被 is_valid 接受 // as_str 与 is_valid 必须自洽:每个变体的存储值都应被 is_valid 接受
@@ -528,6 +617,7 @@ mod tests {
TaskStatus::Done, TaskStatus::Done,
TaskStatus::Blocked, TaskStatus::Blocked,
TaskStatus::Cancelled, TaskStatus::Cancelled,
TaskStatus::Deferred,
]; ];
for v in all { for v in all {
assert!( assert!(
@@ -590,6 +680,35 @@ mod tests {
assert!(TaskStatus::is_valid(v), "valid_values 含 {:?} 但 is_valid 拒绝", v); assert!(TaskStatus::is_valid(v), "valid_values 含 {:?} 但 is_valid 拒绝", v);
} }
// 数量应等于枚举变体数 // 数量应等于枚举变体数
assert_eq!(TaskStatus::valid_values().len(), 7); assert_eq!(TaskStatus::valid_values().len(), 8);
}
// ── Priority::from_i32(对齐前端约定 0=Critical/1=High/2=Medium/3=Low)──
#[test]
fn priority_from_i32_maps_frontend_semantics() {
assert_eq!(Priority::from_i32(0), Priority::Critical);
assert_eq!(Priority::from_i32(1), Priority::High);
assert_eq!(Priority::from_i32(2), Priority::Medium);
assert_eq!(Priority::from_i32(3), Priority::Low);
}
// 越界/负值统一归并 Low(对齐 src-tauri priority_from_i32 的 `_` 兜底)
#[test]
fn priority_from_i32_out_of_range_falls_back_to_low() {
assert_eq!(Priority::from_i32(4), Priority::Low);
assert_eq!(Priority::from_i32(99), Priority::Low);
assert_eq!(Priority::from_i32(-1), Priority::Low);
assert_eq!(Priority::from_i32(i32::MIN), Priority::Low);
assert_eq!(Priority::from_i32(i32::MAX), Priority::Low);
}
// 与 as_str 对偶自洽:from_i32 的每个前端值 round-trip 后 as_str 不变
#[test]
fn priority_from_i32_roundtrip_consistent_with_as_str() {
assert_eq!(Priority::from_i32(0).as_str(), "critical");
assert_eq!(Priority::from_i32(1).as_str(), "high");
assert_eq!(Priority::from_i32(2).as_str(), "medium");
assert_eq!(Priority::from_i32(3).as_str(), "low");
} }
} }
+2 -2
View File
@@ -207,7 +207,7 @@ impl DagExecutor {
Err(e) => { Err(e) => {
let error_msg = e.to_string(); let error_msg = e.to_string();
// 已取消的节点(如 HumanNode 审批取消)跳过 set_failed: // 已取消的节点(如 HumanNode 审批取消)跳过 set_failed:
// Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(B-03b-R1) // Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(b-R1)
if self.state_machine.is_cancelled(&node_id) { if self.state_machine.is_cancelled(&node_id) {
// emit NodeCancelled(非 NodeFailed):取消语义有别于失败,前端按 type 归「取消」非「失败」 // emit NodeCancelled(非 NodeFailed):取消语义有别于失败,前端按 type 归「取消」非「失败」
self.event_bus self.event_bus
@@ -239,7 +239,7 @@ impl DagExecutor {
} }
let total = start.elapsed().as_millis() as u64; let total = start.elapsed().as_millis() as u64;
// B-03b-R10 ③: WorkflowCompleted 携带 execution_id,供 forward 循环按 exec_id 匹配 // b-R10 ③: WorkflowCompleted 携带 execution_id,供 forward 循环按 exec_id 匹配
// (全局 event_bus 单例下并发工作流的终态事件不再误触发他人 forward 的完成判定)。 // (全局 event_bus 单例下并发工作流的终态事件不再误触发他人 forward 的完成判定)。
self.event_bus self.event_bus
.send(WorkflowEvent::WorkflowCompleted { .send(WorkflowEvent::WorkflowCompleted {
+2 -2
View File
@@ -198,7 +198,7 @@ impl Node for CancelSelfNode {
} }
} }
/// B-03b-R1:取消的节点返回 Err 时,executor 跳过 set_failed(Cancelled→Failed transition 非法会 bail), /// b-R1:取消的节点返回 Err 时,executor 跳过 set_failed(Cancelled→Failed transition 非法会 bail),
/// 状态保持 Cancelled,run 返回取消相关 Err 而非状态转换错误。 /// 状态保持 Cancelled,run 返回取消相关 Err 而非状态转换错误。
#[tokio::test] #[tokio::test]
async fn test_cancelled_node_skips_set_failed() { async fn test_cancelled_node_skips_set_failed() {
@@ -248,7 +248,7 @@ impl Node for CancelSelfThenOkNode {
} }
} }
/// R-P1-3:Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled, /// Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled,
/// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail), /// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail),
/// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。 /// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。
#[tokio::test] #[tokio::test]
+235
View File
@@ -0,0 +1,235 @@
//! human 节点端到端集成测试 — DagDef → NodeRegistry.build_dag → DagExecutor.run
//!
//! df-workflow 不依赖 df-nodes(反向依赖),此处用等价阻塞节点模拟 HumanNode 的
//! 「发审批请求 → 挂起等待 → 外部 approve → 返回结果」链路,覆盖
//! DagDef 序列化 → 注册表 → 执行器全链路 + 事件序列断言(R6 send 缺 await /
//! R7 契约失配的存活土壤)。
use std::time::Duration;
use async_trait::async_trait;
use serde_json::json;
use df_types::events::{SelectType, WorkflowEvent};
use df_types::types::NodeStatus;
use df_workflow::dag_def::DagDef;
use df_workflow::eventbus::{EventBus, EventSubscriber};
use df_workflow::executor::DagExecutor;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use df_workflow::registry::NodeRegistry;
/// 前驱节点:sleep 后返回空输出(仿 executor 单测 SleepNode)。
struct SleepNode {
sleep_ms: u64,
}
#[async_trait]
impl Node for SleepNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: json!(null),
output: json!(null),
}
}
fn node_type(&self) -> &str {
"sleep"
}
}
/// 模拟 HumanNode 的阻塞审批节点:先 subscribe 再发 HumanApprovalRequest(broadcast 不回放),
/// select! 等待匹配 execution_id + node_id 的 HumanApprovalResponse,收到则返回 decision。
struct ApprovalNode;
#[async_trait]
impl Node for ApprovalNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
let mut rx = ctx.event_bus.subscribe();
let _ = ctx
.event_bus
.send(WorkflowEvent::HumanApprovalRequest {
execution_id: ctx.execution_id.clone(),
node_id: ctx.node_id.clone(),
title: "确认发布".to_string(),
description: String::new(),
options: vec!["同意".into(), "拒绝".into()],
select_type: SelectType::Single,
})
.await;
let timeout_secs = ctx
.config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(2);
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
tokio::select! {
recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse {
execution_id, node_id, decision, decisions, ..
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
let primary = if decision.is_empty() {
decisions.first().cloned().unwrap_or_default()
} else {
decision
};
return Ok(NodeOutput::from_value(json!({ "decision": primary })));
}
Ok(_) => continue,
Err(_) => return Err(anyhow::anyhow!("事件总线关闭,审批无法完成")),
},
_ = tokio::time::sleep_until(deadline) => {
return Err(anyhow::anyhow!("人工审批等待超时({timeout_secs}s)"));
}
}
}
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: json!(null),
output: json!(null),
}
}
fn is_blocking(&self) -> bool {
true
}
fn node_type(&self) -> &str {
"human"
}
}
/// 从事件流中等待一条 HumanApprovalRequest(跳过 NodeStarted/NodeCompleted 等其他事件)。
async fn wait_approval_request(mut rx: EventSubscriber) -> WorkflowEvent {
tokio::time::timeout(Duration::from_millis(1000), async move {
loop {
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
return ev;
}
}
})
.await
.expect("应收到 HumanApprovalRequest,实际超时")
}
/// 端到端主链路:前驱 sleep(a) → human(b)。
/// 执行到 human 后挂起等审批 → 外部 approve(模拟 approve_human_approval IPC) → 返回结果、工作流完成。
#[tokio::test]
async fn human_workflow_end_to_end_approval() {
let bus = EventBus::new();
let rx = bus.subscribe(); // 先 subscribe 再执行(broadcast 不回放)
let exec_id = "exec-e2e-approve";
// DagDef(可序列化定义)→ NodeRegistry.build_dag → 运行时 Dag
let mut def = DagDef::new();
def.add_node("a", "sleep", json!({}));
def.add_node("b", "human", json!({}));
def.add_edge("a", "b");
let mut registry = NodeRegistry::new();
registry.register("sleep", |_| Box::new(SleepNode { sleep_ms: 10 }));
registry.register("human", |_| Box::new(ApprovalNode));
let dag = registry.build_dag(&def).expect("build_dag 应成功");
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
let run_handle = tokio::spawn(async move { executor.run(&dag, json!({})).await });
// a 层完成、b 层 human subscribe + send Request 后应收到审批请求
let request = wait_approval_request(rx).await;
match request {
WorkflowEvent::HumanApprovalRequest {
node_id, title, options, ..
} => {
assert_eq!(node_id, "b");
assert_eq!(title, "确认发布");
assert_eq!(options, vec!["同意".to_string(), "拒绝".to_string()]);
}
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
}
// 外部 approve(模拟 approve_human_approval IPC 发送 HumanApprovalResponse 到总线)
bus.send(WorkflowEvent::HumanApprovalResponse {
execution_id: exec_id.into(),
node_id: "b".to_string(),
decision: "同意".into(),
decisions: vec![],
comment: Some("可以发布".into()),
})
.await;
let outputs = run_handle
.await
.expect("run 不应 panic")
.expect("run 应成功");
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
assert_eq!(outputs["b"].data["decision"], json!("同意"));
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
}
/// 取消路径:外部 set_cancelled(模拟 cancel_workflow_node IPC 写共享状态机)
/// → human select! 轮询 is_cancelled → Err → 状态保持 Cancelled。
#[tokio::test]
async fn human_workflow_cancel_via_shared_state_machine() {
let bus = EventBus::new();
let rx = bus.subscribe();
let exec_id = "exec-e2e-cancel";
let mut def = DagDef::new();
def.add_node("h", "human", json!({}));
let mut registry = NodeRegistry::new();
registry.register("human", |_| Box::new(ApprovalNode));
let dag = registry.build_dag(&def).expect("build_dag 应成功");
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let sm = executor.state_machine();
let run_handle = tokio::spawn(async move { executor.run(&dag, json!({})).await });
// 等 human 发出审批请求(确认已 subscribe + send + 进入 select! 轮询)
let _ = wait_approval_request(rx).await;
// 外部 cancel:写共享状态机置 Cancelled,humar select! 的 cancel_tick 会读到
sm.set_cancelled("h".to_string());
let result = run_handle.await.unwrap();
assert!(result.is_err(), "取消应致 run 返回 Err");
let err = result.unwrap_err().to_string();
assert!(
err.contains("取消") || err.contains("执行失败"),
"应返回取消相关错误,实际: {err}"
);
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— 取消终态不被失败语义覆盖
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
}
/// 挂起等待路径:收到请求后不 approve → human 挂起直到超时 → run 返 Err。
/// 证明节点真在等待外部响应(而非空转直接返回)。
#[tokio::test]
async fn human_workflow_waits_then_times_out_without_approve() {
let bus = EventBus::new();
let rx = bus.subscribe();
let exec_id = "exec-e2e-timeout";
let mut def = DagDef::new();
def.add_node("h", "human", json!({ "timeout_secs": 1 }));
let mut registry = NodeRegistry::new();
registry.register("human", |_| Box::new(ApprovalNode));
let dag = registry.build_dag(&def).expect("build_dag 应成功");
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
let run_handle = tokio::spawn(async move { executor.run(&dag, json!({})).await });
let _ = wait_approval_request(rx).await;
let result = run_handle.await.unwrap();
assert!(result.is_err(), "无审批响应应超时失败");
// executor 包了「节点 h 执行失败」context,用 {:#} 展开整条错误链断言根因
let chain = format!("{:#}", result.unwrap_err());
assert!(chain.contains("超时"), "应返回超时错误,实际: {chain}");
}
@@ -1,26 +1,33 @@
# df-nodes 节点集合 # df-nodes 节点集合
> 创建: 2026-06-10 | 最后更新: 2026-06-15 > 创建: 2026-06-10 | 最后更新: 2026-08-08
--- ---
## 概述 ## 概述
df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-workflow 的 `Node` trait df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-workflow 的 `Node` trait
(`execute` / `schema` / `node_type`),由 DAG Executor 按拓扑序驱动。
## 当前状态 ## 当前状态
3 种节点全部完整实现`execute()` 非 stub有真实逻辑 + 单测覆盖)。 10 个节点全部完整实现(`execute()` 非 stub,有真实逻辑 + 单测覆盖),另有 `task_state_machine`
(状态机)与 `task_workflow_templates`(流程模板)两个支撑模块。
## 节点清单 ## 节点清单
| 节点 | 功能 | 阻塞 | node_type | 实现状态 | | 节点 | 功能 | 阻塞 | node_type | 实现状态 |
|------|------|------|-----------|---------| |------|------|------|-----------|---------|
| AiNode | 调用 LLM 完成文本生成/分析非流式 complete | 否 | `ai` | ✅ 完整 | | AiNode | 调用 LLM 完成文本生成/分析(非流式 complete) | 否 | `ai` | ✅ 完整 |
| ScriptNode | Shell/脚本执行 | 否 | `script` | ✅ 完整 | | AiSelfReviewNode | AI 对任务产出自审(复用 AiNode 调用链,verdict 闸门) | 否 | `ai_self_review` | ✅ 完整 |
| HumanNode | 人工审批/确认(单选/多选) | | `human` | ✅ 完整 | | ScriptNode | Shell/脚本执行(白/黑名单安全策略) | | `script` | ✅ 完整 |
| HumanNode | 人工审批/确认(单选/多选) | 是 | `human` | ✅ 完整 |
> 历史文档曾列 8 节点(AI/Script/Docker/Git/Human/Notify/HTTP/Subflow)全标骨架。实际仅 AI/Script/Human 3 节点存在,其余 5 节点(Docker/Git/Notify/HTTP/Subflow)从未实现,已从本文档删除。 | TaskAdvanceNode | 任务状态推进链唯一 status 写入路径(状态机 + CAS) | 否 | `task_advance` | ✅ 完整 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | 否 | `git` | ✅ 完整 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | 否 | `http` | ✅ 完整 |
| NotifyNode | 通知(桌面日志 / webhook,尽力而为不阻断) | 否 | `notify` | ✅ 完整 |
| SubflowNode | 嵌套子工作流(返回子 DAG 快照,深度限制防递归) | 否 | `subflow` | ✅ 完整 |
| DockerNode | Docker 容器内执行命令(`docker run --rm`,环境检测+授权) | 否 | `docker` | ✅ 完整 |
## 节点详述 ## 节点详述
@@ -35,12 +42,18 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w
- **输出**`{ text, model, usage: {prompt_tokens, completion_tokens, total_tokens} }` - **输出**`{ text, model, usage: {prompt_tokens, completion_tokens, total_tokens} }`
- **与 AI Chat 区别**AiNode 由 DAG Executor 自动驱动(嵌入自动化链路),非交互对话。 - **与 AI Chat 区别**AiNode 由 DAG Executor 自动驱动(嵌入自动化链路),非交互对话。
### AiSelfReviewNodeai_self_review_node.rs
AI 自审闭环节点:对 AiNode 产出的任务内容做二次 AI 审查,返回 `verdict`(通过/打回)闸门。
复用 AiNode 的 provider 调用链(`ai_node_helpers`),差异在 prompt 模板与 JSON 解析。
### ScriptNodescript_node.rs ### ScriptNodescript_node.rs
执行 Shell 脚本或自定义命令,复用 `df_execute::shell::execute`(跨平台:Windows `cmd /C` / Unix `sh -c`)。 执行 Shell 脚本或自定义命令,复用 `df_execute::shell::execute`(跨平台:Windows `cmd /C` / Unix `sh -c`)。
- **必填**`command` - **必填**`command`
- **可选**`timeout_secs``working_dir` - **可选**`timeout_secs``working_dir`
- **安全策略**:命令名(首词)白/黑名单校验(运行时配置优先,回退环境变量 / 默认黑名单);危险关键词仅告警不阻止。
- **失败语义**:非零退出码视为执行失败(`bail!` 带 exit_code + stderr)。 - **失败语义**:非零退出码视为执行失败(`bail!` 带 exit_code + stderr)。
- **输出**`{ stdout, stderr, exit_code, duration_ms }` - **输出**`{ stdout, stderr, exit_code, duration_ms }`
@@ -49,7 +62,7 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w
阻塞节点,订阅事件总线 → 发 `HumanApprovalRequest``select!` 轮询 `HumanApprovalResponse` / 超时 / 取消。 阻塞节点,订阅事件总线 → 发 `HumanApprovalRequest``select!` 轮询 `HumanApprovalResponse` / 超时 / 取消。
- **执行顺序**:先 `subscribe()``send(Request)`(broadcast 不回放,反序会丢 Response 死等到超时);`send` 必须 `await`(否则 Future 不 poll、Request 不进 channel)。 - **执行顺序**:先 `subscribe()``send(Request)`(broadcast 不回放,反序会丢 Response 死等到超时);`send` 必须 `await`(否则 Future 不 poll、Request 不进 channel)。
- **审批模式**F-260615-01`select_type` 缺省 `Single`,非 `"multiple"` 一律按 Single 处理。 - **审批模式**`select_type` 缺省 `Single`,非 `"multiple"` 一律按 Single 处理。
- Single → 决策数必须 = 1 - Single → 决策数必须 = 1
- Multiple → 决策数必须 ≥ 1 - Multiple → 决策数必须 ≥ 1
- `options` 空 → 允许自由文本(仅受数量约束);非空 → 每项必须 ∈ options - `options` 空 → 允许自由文本(仅受数量约束);非空 → 每项必须 ∈ options
@@ -59,25 +72,66 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w
- **取消**:每 500ms tick 检查 `node_status.is_cancelled` - **取消**:每 500ms tick 检查 `node_status.is_cancelled`
- **输出**`{ decision(首项,向后兼容), decisions(数组), comment }` - **输出**`{ decision(首项,向后兼容), decisions(数组), comment }`
### TaskAdvanceNodetask_advance_node.rs
任务推进链的唯一 status 写入路径(与 IPC/AI 工具/MCP 同源):读当前 TaskRecord → 状态机
`can_transition` 校验 → 原子写(下沉 SQL 防 TOCTOU),退回转换自动累加 `review_rounds`
### GitNodegit_node.rs
通过 `df_execute::shell::execute` 调用本地 git CLI`working_dir` 指定仓库路径。
支持 branch / checkout / commit / merge / push / status / log,输出按 action 提取结构化字段。
### HTTPNodehttp_node.rs
使用 reqwest 发起 GET/POST/PUT/DELETE 请求。config 提供 `method/url/headers/body/timeout_secs`
输出 `status_code + body + content_type`
### NotifyNodenotify_node.rs
桌面(当前为日志占位,后续接 tauri-plugin-notification/ webhook 通知。语义:尽力而为,
通知失败不阻断工作流(输出 `success=false` 但仍 Ok 返回)。
### SubflowNodesubflow_node.rs
加载一个子 DAG 并在当前执行上下文中递归执行(复用通用流程,如「代码审查」作为子步骤)。
返回子 DAG 的 JSON 快照供 DagExecutor 消费展开,`max_depth` 限制嵌套深度防无限递归。
### DockerNodedocker_node.rs
通过 `docker run --rm` 一次性容器执行命令,复用 `df_execute::shell::execute` 调用本地 docker CLI。
含环境检测(本机是否有 docker)与授权校验。
## 文件结构 ## 文件结构
``` ```
crates/df-nodes/src/ crates/df-nodes/src/
├── lib.rs — 模块入口(声明 ai_node / human_node / script_node 三个 pub mod ├── lib.rs — 模块入口(声明 pub mod
├── ai_node.rs — AiNodeLLM 文本生成/分析) ├── ai_node.rs — AiNodeLLM 文本生成/分析)
├── ai_node_helpers.rs — AiNode 纯逻辑(provider 解析/参数构造,非 Node
├── ai_self_review_node.rs — AiSelfReviewNodeAI 自审)
├── script_node.rs — ScriptNodeShell 执行) ├── script_node.rs — ScriptNodeShell 执行)
── human_node.rs — HumanNode(人工审批/确认阻塞) ── human_node.rs — HumanNode(人工审批/确认,阻塞)
├── human_node_helpers.rs — HumanNode 纯逻辑(拒绝语义判定,非 Node)
├── task_advance_node.rs — TaskAdvanceNode(任务状态推进链)
├── task_state_machine.rs — 任务状态机(can_transition 等,非 Node
├── task_workflow_templates.rs — 流程模板(testing 等模板,非 Node
├── git_node.rs — GitNodeGit 操作)
├── http_node.rs — HttpNodeHTTP 请求)
├── notify_node.rs — NotifyNode(通知)
├── subflow_node.rs — SubflowNode(嵌套子工作流)
└── docker_node.rs — DockerNode(容器内执行)
``` ```
## 依赖关系 ## 依赖关系
``` ```
df-core df-workflow (Node trait / DagDef)
← df-workflow (Node trait)
← df-nodes ← df-nodes
← df-ai (AiNode: build_provider / LlmProvider) ← df-ai (AiNode/AiSelfReviewNode: build_provider / LlmProvider)
← df-execute (ScriptNode: shell::execute) ← df-execute (ScriptNode/GitNode/DockerNode: shell::execute)
← df-core (HumanNode: events::WorkflowEvent / SelectType) ← df-storage (TaskAdvanceNode: TaskRepo::advance_status_atomic)
← df-core / df-types (HumanNode: events::WorkflowEvent / SelectType)
``` ```
## 相关文档 ## 相关文档
@@ -0,0 +1,156 @@
# aichat 运行效率走查报告(多角度)
> 走查日期:2026-08-09
> 范围:aichat 前后端全链路(后端 agentic 2910 行 + audit + tools + 流式;前端 composables/ai + MessageList/ChatInput/TopBar
> 方法:6 路并行 Explore agent(单轮成本/工具执行/流式渲染/前端交互/会话生命周期/LLM成本)
> 性质:**只走查登记,不实施代码**
> 触发:用户反馈「aichat 运行的效率很低」+「成本走查,比如有的 LLM 要求的缓存命中率规则」
---
## 一、单轮执行成本(13 项)
### P1
- **R1-1 每轮全量历史重放,无增量**context/mod.rs:184-253 + agentic:1492-1527)——第 k 轮 prompt≈Σ历史,R 轮总 input≈O(R²);追加尾部的每轮新 tool_result 是必然 cache miss
- **R1-2 上一轮已见工具结果本轮仍全量重放**summarize_tool_results view-only:1540-1551)——grep/search 的 `matches:[]` 每轮重复
- **R1-3 自动压缩阈值过惰性**context_lifecycle.rs:88-95>61K token 才触发)——普通会话永不压缩,全量重放无兜底
### P2
- **R2-1 每轮 ≥4 次全历史深克隆**build_for_request + save + count_recent_failures + check_stall_breaker2 次持 session 锁)
- **R2-2 每轮必 save_conversation**(全量 clone + truncate_for_persist + 序列化 + DB 元数据重写)
- **R2-3 estimated_prompt 每轮全量重估 token**(与 history_tokens 增量维护重复)
### P3
R3-1 重试/候选切换全量 to_vec clone · R3-2 summarize 对未变化历史每轮重算 · R3-3 system_prompt 每轮整体克隆(未打 cache_control 断点)· R3-4 build_system_prompt 每次 loop/resume 查库(list_active×2)· R3-5 每轮重建 provider + keyring 解析 · R3-6 process_tool_calls 每工具全量扫历史 · R3-7 无 token 预算型终止(空转到 10 轮)
---
## 二、工具执行开销(9 项)
### P1
- **T1-1 审计每工具 ≥1 次串行 INSERT**audit/mod.rs:684-721,全经单全局 DB 锁,无批量)——单轮 N 工具 = N 次 INSERTjoin_all 后的串行尾巴
- **T1-2 只读缓存是"假缓存"**cache.rs:251-330)——每工具全量扫历史 + 1 次 DB SELECT,非 O(1) 索引;miss 代价>收益
- **T1-3 git 工具不进只读缓存**git.rs:99-133,每轮跑子进程;module.rs 5s git 缓存不共享)——LLM 失忆重调 git 完全没被治
- **T1-4 每轮对完整历史重处理**build_for_request + summarize + estimated_prompt + 2 次全量 clone
### P2
- **T2-1 审批/授权每 Med/High 工具最多 3 次 DB 往返 + 全量历史扫**auto_exec_mode 等低频配置每轮查库)
- **T2-2 read_file/read_symbol 每次全量读文件(≤1MB)**(分段读也全读,offset 不同缓存 miss
- **T2-3 缓存命中把全量内容直接 push messages(不 namespace+ 审计落全量** → 缓存反放大历史
- **T2-4 entity_resolve 每工具 clone args + 每工具一次 list_active 全表查询**
### P3
T3-1 工具事件双写 + 每工具一个心跳 task
---
## 三、流式渲染效率(12 项)
### P1
- **S1-1 每帧全量 lexer + 单块回复整段重 parse,块级 memo 退化**useStreamRenderer.ts:77-97/130-154)——无 `\n\n` 的长文回复每帧全文 lexer+parse+sanitize,整轮 O(n²),长文卡顿主因
- **S1-2 长尾代码块/末块每帧重跑 hljs 高亮**:146-149 + useMarkdown:87-111
### P2
- **S2-1 每次 flush 两次整组件重渲染**currentText 触发 + streamingBlocks rAF 触发,:219-236~40 次/s 整树求值
- **S2-2 renderMd 缓存命中前先跑 wrapNakedDiff 全文扫描**useMarkdown.ts:191-197
- **S2-3 isToolResultJson 对每条 JSON 形消息每帧 JSON.parse**MessageList:698-708
- **S2-4 handleEvent 每活跃事件重复 setConvState('generating')**useAiEvents.ts:122-124,同值无短路)
- **S2-5 AiCommandOutputrun_command 逐行)未合批,事件风暴**command_stream.rs:39-48,几百上千行命令→每秒上百 IPC)
- **S2-6 已完成块每帧重算 simpleHash + 新子串做 Map key 哈希**
### P3
S3-1 每 delta 文本空闲计时器/看门狗重建 · S3-2 切会话缓冲双清 no-op · S3-3 tail-${tailSeq} 恒等于 tail-1(注释误导)· S3-4 messages deep watch 每工具翻转 JSON.stringify 全量
### 已核查无问题(Info
后端 50ms 合批真生效(stream_recv.rs:346-352)· rAF 节流真生效 · 消息侧无 O(n²)(isLastAi O(1))· 内存缓存界内(_blockCache≤300/_mdCache≤200/currentText 完成即清)
---
## 四、前端交互效率(12 项)
### P1
- **F1-1 回合收尾双拉会话列表**useAiLifecycleEvents.ts:119 + notify→listener 再拉,每回合 2 次全量 list IPC)
- **F1-2 notifyConversationChanged 同窗口自触发冗余重拉**aiShared.ts:21 emit 回传同窗口 listener;删除活跃会话可达 5 IPC)
- **F1-3 loadConversations 无防抖**(8+ 处调用,整体替换数组→侧栏全量重渲染)
### P2
- **F2-1 switch 无条件多 1 次 pendingToolCalls IPC**(纯文本会话也白拉)
- **F2-2 switch 主线程同步全量 parse**JSON.parse + parseConvMessages 每条 toolCall 再 parse
- **F2-3 MessageList deep watch 每次 messages 变更全量 JSON.stringify 快照**
- **F2-4 输入联想每键全量过滤 + 首次 @ 全量拉 tasks**
- **F2-5 TopBar 多个 computed 每次 messages 变更全量扫描**historyMsgs/summaryMsgs/contextInfo
### P3
F3-1 滚动边沿 buildActiveToolIds 全量遍历 · F3-2 每 delta 定时器重建(看门狗+文本空闲)· F3-3 AiChat 启动串行 IPC 链(约 5 次)· F3-4 persistUiState 每次写整包 df-ai-ui
---
## 五、会话生命周期(16 项)
### P0
- **L0-1 save_conversation 每轮 O(N) 全量克隆+截断+记录构建,CPU 在主线程**conversation.rs:254-343,单线程 runtime)——"无变化捷径"在 records 构建后才判定,每轮白付全部 CPU;N 轮工具对话 ≈N+1~N+2 次 save
- **L0-2 system prompt 每次用户操作全量重建 + 串行 6-8 次 DB 查询**chat.rs:527-651 + prompt.rs:280-343)——全部落在「点击→首 token」关键路径,project/task 清单可缓存
- **L0-3 自动压缩在 loop 内同步阻塞整轮**context_lifecycle.rs:159-167,非流式 LLM 最长 60s+ 压缩后必全量 DELETE+INSERT 重写
### P1
- **L1-1 最终轮重复 save**agentic:1918 + :2111 收敛时再 spawn
- **L1-2 每轮 build_for_request + sanitize + pairing + estimated_prompt 共 5-6 次全量遍历**
- **L1-3 会话切换全量读库 + 全量重估 token + 全量重跑意图识别**restore 时对每条 re-estimate + re-recognize
- **L1-4 每次生成结束在所有窗口触发会话列表全量刷新 + 事件双写**55 处 emit + publish_event 双写)
- **L1-5 知识注入每次发送全量克隆消息 + 两次 DB 检索**keyword + vector,关键路径)
### P2
L2-1 Checkpoint 每 20 条消息全量序列化 · L2-2 单次 save 对 session 锁加锁 3 次 · L2-3 active provider 在 send→loop 各查一次库 · L2-4 estimated_prompt 每轮全量重算 · L2-5 空标题会话切换触发额外 LLM 标题生成 · L2-6 心跳每 20s 无条件广播
### P3
L3-1 所有重生成路径同样重建 system promptregenerate/edit/force_send)· L3-2 事件总线双写在热点仍存在(app.emit 全窗口广播)
### 关键前提
Tauri Windows 单线程 tokio runtime——所有 CPU 密集操作主线程串行,任一长任务阻塞全部 IPC。最高优先级:L0-1 save 提前判定 + L0-2 缓存 + L0-3 压缩后台化。
---
## 六、LLM 成本 / 缓存命中率(12 项)
> **核心结论**DevFlow **从未向任何 provider 发送 `cache_control`/`cache_breakpoint`**,且 system prompt 每轮用实时 DB 数据重建 → 前缀缓存既没开、前缀也不稳定,**多轮对话几乎 100% 缓存不命中,每轮全价支付全量历史 + 全量 system**。代码只做了「读回 cache_read/cache_creation 计费展示」,从未「请求缓存」。
### P0
- **C0-1 全链路无 cache_control 注解,Anthropic 提示缓存根本没启用**anthropic_helpers.rs:22-36 无字段;system 是纯 String 无法表达 breakpoint,需改 text-block 数组)
- **C0-2 system prompt 每轮用实时 DB 数据重建,前缀字节不稳定 → 跨轮缓存全废**prompt.rs:295/315 list_active 20+20 注入,agent 改数据即变;建议挪到消息流只留静态指令)
### P1
- **C1-1 日期在 system prompt 位置 0,跨天失效整条前缀**prompt.rs:45 当前日期)
- **C1-2 每轮全量重发全部历史,成本 O(n²),无缓存兜底**(10 轮实测单次请求可达 ~12 万 token 全价)
- **C1-3 augmentation/知识注入拼进 system,随轮变化再添失效源**chat.rs:639-652
### P2
- **C2-1 模型路由从不因简单意图降档,贵模型低用**router.rs weight 主键,tier 仅 tiebreakChat 意图也应降档)
- **C2-2 重试发送字节完全相同请求,无缓存 → 每次重试重复全价**
- **C2-3 system prompt 本身膨胀(文档探索段 140-180 行常驻)+ 重复注入技能/工作流/目标**
- **C2-4 标题生成/规划/压缩是独立 LLM 调用,不共享前缀缓存**
### P3
C3-1 tools 每轮全量序列化无 cache_control 与 system 一起缓存 · C3-2 AnthropicRequest.system 纯 String 需结构改造 · C3-3 采样参数合理(确认项)
### 修复优先级(成本 agent 建议)
1. **C0-1 + C0-2 一起修**(加 cache_control + 稳定 system 前缀)才能产生真实命中率
2. C1-1/C1-2/C1-3(日期与易变段挪出稳定前缀、augmentation 改注入消息流)→ 多轮 loop 输入成本砍 ~1/5~1/10
3. C2-1 模型降档(收益独立于缓存,可并行)
---
## 七、优先级建议(合并 6 路)
**成本大头(后端)**:R1-1 全量历史重放 + R2-1 每轮 4 次深克隆 → 增量/窗口化
**卡顿主因(渲染)**:S1-1 单块回复每帧全量渲染 → 增量 lex/parse
**无谓开销(前端)**:F1-1/2/3 列表刷新收敛(合并一个 debounced scheduleConversationsRefresh
**DB 瓶颈**T1-1 审计批量事务 + T1-2 只读缓存 O(1) 索引
**成本(LLM 缓存)**C0-1 + C0-2cache_control + 稳定前缀)→ 命中率从 ~0% 提升到可复用
---
## 八、登记去向
- 本报告:`docs/05-代码审查/aichat效率走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- memory 指针:待补齐
@@ -0,0 +1,53 @@
# df-mcp + 工具 crate 走查报告(2026-08-09
> 走查日期:2026-08-09
> 范围:df-mcpserver/tools/protocol/server_http+ df-execute + df-project + df-ideas + df-types + df-tunnel + df-relay
> 方法:Explore agent 独立核验 + 对齐 08-02 走查
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:全系统模块走查(阶段②)最后一批
---
## 一、df-mcp16 项)
### P1
- **MCP-1 update_project 直接写 status 绕过状态机**tools.rs:424-430vs update_task 收口,可制造 GUI 不可能非法跳态)——建议对齐任务侧收口/校验
### P2
MCP-2 bind_directory 两步非原子写 path+stack · MCP-3 create_idea priority 无值域校验(IPC 拒/MCP 放行漂移 Critical)· MCP-4 create_project/task 不校验 name 非空 · MCP-5 MCP evaluate/score 用独立算法与 df-ideas 漂移 · MCP-6 写操作回调在业务失败时仍触发(假 df-data-changed
### P3
MCP-7 jsonrpc 字段不校验 2.0 · MCP-8 tools/call 缺 name 回错语义 · MCP-9 空串清空字段与文档不符 · MCP-10 pagination 超大 i64 截断 · MCP-11 list_trash 全量内存分页 · MCP-12 create_task FK 错误模糊 · MCP-13 search_knowledge 非数字项静默丢 · MCP-14 insert_knowledge confidence 不校验 · MCP-15 60s 空闲退出误杀长会话(需确认)· MCP-16 HTTP read_only 硬编码 false 无鉴权(需确认)
## 二、df-execute3 项)
EX-1 ShellRequest None 超时无兜底(调用方显式设,共享 crate 风险低)· EX-2 execute_streaming 输出格式与 execute 不一致 · EX-3 env_snapshot fallback 永久冻结 · EX-4 probe_version 单命令无独立超时
## 三、df-project4 项)
- **PRJ-1 【P1】normalize_path 无条件 to_lowercase 被 bind_directory 存库**(大小写敏感系统路径解析失败,需确认目标平台)
PRJ-2 read_readme_raw 整读无上限 · PRJ-3 discover_projects 同步阻塞调用方需 spawn_blocking · PRJ-4 collect_manifests 整读截断
## 四、df-ideas2 项)
IDEA-1 LLM 评估 final_score/recommendation 自洽性未校验(ARC-260618-01-e 已登记,需确认)· IDEA-2 否定前缀检测局限
## 五、df-types2 项)
TYPES-1 Priority 无统一 from_i32 越界处理(散落调用方,配 MCP-3)· TYPES-2 未知状态值静默归 planning(建议 warn 审计)
## 六、df-tunnel4 项)
TUN-1 lib 文档声称指数退避重连但实现无自动重连(断线永久断开,需确认排期)· TUN-2 token 明文 WS · TUN-3 unbounded_channel 无背压 · TUN-4 connected 先置 true 假已连接窗口
## 七、df-relay5 项)
RLY-1 单共享 token 无 per-device 绑定(冒充设备收指令,需确认)· RLY-2 默认 0.0.0.0 无 TLS · RLY-3 入站帧无大小限制 + 无背压 · RLY-4 token 非常量时间比较 · RLY-5 未设 token panic
---
## 优先级建议
**P1**MCP-1 update_project 状态机旁路 → PRJ-1 normalize_path 存库
**P2(跨端/一致性)**:TUN-1 自动重连(断线永久断开,影响跨端)→ RLY-1 per-device 鉴权 → MCP-6 假数据变更
**其余 P2/P3**:分批
## 登记去向
- 本报告:`docs/05-代码审查/df-mcp+工具crate走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- 全系统索引:**阶段②全部完成** ✅
@@ -0,0 +1,59 @@
# Settings + 审计 + 布局 + 后端命令走查报告(2026-08-09)
> 走查日期:2026-08-09
> 范围:Settings.vue + settings 组件 / AuditLog.vue / layout + AppSidebar + App.vue + 全局样式 / 后端 src-tauri/src/commands 全模块
> 方法:3 路并行 Explore agentSettings+AuditLog / layout+sidebar+全局 / 后端命令)
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:全系统模块走查(阶段②)第二批
---
## 一、Settings + AuditLog29 项)
### P28
SA-1 AuditLog 决策者列 i18n 缺键(decided_by 有 auto_trust/auto_dedup/cache_hit 等 6 值,i18n 只 human/auto → 渲染 key 串)· SA-2 对话 tab 角色标签硬编码中文不随 locale · SA-3 审计列表 load 无 seq 守卫(筛选竞态)· SA-4 toggleConv 竞态 · SA-5 ProviderPanel 脏表单切导航静默丢改动 · SA-6 单模型冗余标签全隐藏 · SA-7 池权重 debounce 跨 provider 冲突 · SA-8 连接密码明文存 localStorage
### P321
SA-9 对话 spinner 类名未定义 · SA-10 切回 audit tab 不刷新 · SA-11 对话 tab 无刷新入口 · SA-12 加载中整表替换闪烁 · SA-13 空态文案误导 · SA-14 对话详情全量渲染无分页 · SA-15 对话列表固定 200 条 · SA-16 对话详情无空态 · SA-17 样式 token 混用 · SA-18 maskKey 8 字符泄露 · SA-19 测试连接后 savedSnapshot 未刷新 · SA-20 模型表格 weight 无前端 clamp · SA-21 设为默认无 in-flight 禁用 · SA-22 ConnectionPanel 无脏确认 · SA-23 ConnectionPanel 保存成功无 toast · SA-24 AdvancedSection 假保存反馈 · SA-25 PerformanceSection 并发 0 弹成 3 · SA-26 KnowledgePanel 加载后冗余 save · SA-27 KnowledgePanel min_messages 无 clamp · SA-28 AllowedDirsPanel 删除无确认 · SA-29 AppearanceSection 不监听 OS 主题 · SA-30 .btn/.empty-hint 双源定义(settings.css 覆盖 global.css)· SA-31 搜索滚动部分索引项失效
## 二、layout + sidebar + 全局(21 项)
### P11
- **LS-1 ConfirmDialog Enter 误触危险操作**(焦点在取消按钮按 Enter → emit result(true) 执行删除,preventDefault 抑制取消分支,安全隐患)
### P29
LS-2 AppSidebar 收缩偏好每次重启丢失(子组件 onMounted 早于父组件 loadAll)· LS-3 AppLayout 小屏 media query 被内联样式覆盖(100vw 失效)· LS-4 nav-link 活跃指示条被 overflow 裁剪· LS-5 App.vue startApprovalPopup 未排除 ai-detached 窗口(重复审批浮窗)· LS-6 Paginator 越界无自愈(删除后 page 超界空白)· LS-7 路由切换无 scrollBehavior 滚动残留 · LS-8 亮色主题白色透明底不可见(version 徽章/滚动条)· LS-9 ConfirmDialog 无 aria/focus trap · LS-10 --df-primary 与 --df-accent 重复 token
### P311
LS-11 AiHome 关闭面板后空白 · LS-12 主题启动 FOUC · LS-13 toast 无 aria-live · LS-14 df-data-changed 监听无窗口守卫(分离窗口无用全量 load)· LS-15 data-changed 无 knowledge/workflow 分支 · LS-16 Paginator 无 aria-label + 页码爆炸 · LS-17 纯图标按钮无 aria-label · LS-18 AppLayout resize 监听未清理 · LS-19 router.replace 未 catch · LS-20 退出最大化不持久化 · LS-21 primaryNav badge 死代码 + v-html XSS 面 + Ctrl+I 大小写
## 三、后端命令(45 项)
### P18
- **BE-CMD-1 task update_task 漏 queue 校验**(可写非法 queue/queue-status 不一致)
- **BE-CMD-2 task count_tasks 与 list_tasks 过滤维度不一致**count 超算致翻页空页)
- **BE-CMD-3 project update_project 通用 update_field 裸奔**(白名单含 id/created_at/status/path 零校验,可改主键致子表悬空)
- **BE-CMD-4 idea update_idea 无校验**status 可写任意/绕过 promote_idea 得半立项)
- **BE-CMD-5 task update_field 可更新已软删任务**get_by_id 不过滤 deleted_at
- **BE-CMD-6 idea evaluate_idea 无条件覆盖 pending_review**(打回终态)
- **BE-CMD-7 idea promote_idea 读-改-写竞态**(双击重复立项 + 补偿非原子)
- **BE-CMD-8 task create/reparent/move 不重算父聚合**(父 status 陈旧)
### P215
BE-CMD-9 update_task content_json 无 JSON 校验 · BE-CMD-10 create_task 未校验 priority 值域 · BE-CMD-11 delete_task 级联不清理 task_links · BE-CMD-12 relocate_project_path 存非规范化路径+无 `..` 拦截 · BE-CMD-13 relocate 两步写非原子 · BE-CMD-14 find_binding_conflict 全表扫 N+1 · BE-CMD-15 update_idea 返回 bool 不存在静默假成功 · BE-CMD-16 save_template 可覆盖内置模板 · BE-CMD-17 update_project stack 无 JSON 校验 · BE-CMD-18 create_task 空 title · BE-CMD-19 get_task_by_id 返软删任务 · BE-CMD-20 update_task 合法 status 落白名单错误 · BE-CMD-21 import_projects_batch 同路径重复绑定 · BE-CMD-22 update_idea status 可写 promoted 半立项 · BE-CMD-23 idea related_ids/scores 非法 JSON
### P322
BE-CMD-24~45 见详单(create_idea 空 title / list_ideas status 不校验 / knowledge kind/confidence 不校验 / knowledge_save_config 先改内存 / workflow list_workflow_executions 全表拉 / approve_human_approval 不校验存活 / run_workflow 不校验 task_id / services environment 不校验 / ci_status 静默返空 / commit_sha 未校验 / events total 窗口封顶 等)
---
## 四、优先级建议
**P1(确定性/安全,优先修)**LS-1 ConfirmDialog Enter 误触 → BE-CMD-3/4 update_project/idea 裸奔 → BE-CMD-1 update_task queue → BE-CMD-2 count/list 不一致 → BE-CMD-5 软删可改 → BE-CMD-8 父聚合陈旧
**P2(体验/数据)**SA-1 决策者 i18n → LS-2 sidebar 偏好丢失 → LS-3 小屏布局 → SA-5 脏表单丢改动
## 五、登记去向
- 本报告:`docs/05-代码审查/settings+审计+布局后端走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- 全系统索引:已更新(前端+后端全走查完成)
@@ -0,0 +1,66 @@
# 任务 + 仪表盘 + 灵感知识走查报告(2026-08-09)
> 走查日期:2026-08-09
> 范围:Tasks.vue / TaskDetail.vue(已修项不重复)、Dashboard.vue / Projects.vue / ProjectCard.vue、Ideas.vue / IdeaDetail.vue、Knowledge.vue / KnowledgeDetail.vue
> 方法:3 路并行 Explore agent(任务 / Dashboard+Projects / Ideas+Knowledge
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:全系统模块走查(阶段②)第一批
---
## 一、任务模块(19 项)
### P11
- **TSK-1 任务列表状态徽章无配色**Tasks.vue:784-790 无 status-* 色类,仅 TaskDetail/ProjectDetail scoped 有 → 列表页裸文本)——建议提取全局 components.css
### P28
TSK-2 status-tag 尺寸/圆角三视图不一致 · TSK-3 任务产出「产出」标题重复 · TSK-4 迷你进度条宽度策略不一致(72px vs flex:1)· TSK-5 状态色硬编码与 token 漂移 · TSK-6 列表快捷菜单不做状态机过滤且缺 cancelled · TSK-7 快捷操作失败无反馈(advance 静默/priority 换整表错误态)· TSK-8 快捷菜单被 .task-group overflow:hidden 裁切 · TSK-9 新建弹窗 Esc 不关 + 空项目确认静默
### P310
TSK-10 删除确认未传 dangerLabel · TSK-11 推进中所有按钮同文「推进中」refresh 未禁用 · TSK-12 列表行/快捷按钮键盘不可达 · TSK-13 详情页无删除入口(需确认设计)· TSK-14 并发 loadTasks 无 seq 守卫(搜索被自动刷新冲掉,buildActiveTaskQuery 不含 keyword)· TSK-15 搜索态树形进度失真/子任务浮为顶层 · TSK-16 TaskDetail 路由快速切换竞态(需确认)· TSK-17 limit=500 截断致进度/删除计数不准 · TSK-18 persisted 筛选值过期无校验 · TSK-19 TaskDetail 对任意 task 变更全量重载
---
## 二、Dashboard + Projects17 项)
### P12,同根)
- **DBP-1 【需确认】Dashboard 活跃项目面板恒为空**(后端状态机无 'active'filter status==='active' 永不命中)
- **DBP-2 状态映射只覆盖 planning/active/completed**in_progress 显示 0%「规划中」,badge/stage 与真实生命周期错位)
### P27
DBP-3 Projects 分页 page 越界空白无自愈 · DBP-4 Projects 缺加载态 · DBP-5 Dashboard loadAll 短路 loadStats 被跳过 · DBP-6 IdeasPanel 渲染 archived 裸 i18n key · DBP-7 ideas 统计被 Ideas 筛选污染(需确认,task 侧已修未对称)· DBP-8 ProjectCard 工程数 N+1 拉取 · DBP-9 删除/恢复/彻底删除无成功反馈
### P38
DBP-10 StatCardRow.vue 死代码 · DBP-11 Dashboard 头部与 page-header 不一致(绿点硬编码)· DBP-12 ActiveProjectsPanel 死 CSS 变体 · DBP-13 新建弹窗状态管理不完整(重置/聚焦/Esc/pathWarning)· DBP-14 卡片删除按钮仅 hover + 键盘不可达 · DBP-15 描述展开态跨视图残留 · DBP-16 Dashboard 统计行加载前显示 0 · DBP-17 硬编码间距 + 过时注释
### 核对通过
count_tasks 独立通道正确(P1-b A0 成立)、回收站 updated_at=deleted_at 移入时间正确
---
## 三、Ideas + Knowledge23 项)
### P14
- **IDEA-1 确定:relate/promote/数据变更监听用全量列表冲掉当前筛选视图**loadIdeas() 无参覆盖 store.ideasfilteredIdeas 无二次过滤)
- **IDEA-2 确定【需确认后端校验】:状态菜单可直接置 promoted 绕过立项**promoted_to=null 死状态)
- **KNOW-1 确定:publish/reject/archive 失败仍显示成功反馈并推进详情**runWithCatch 吞错,调用方不查结果)
- **KNOW-2 确定:「自动选中下一条」完全失效**(快照在 filter 后读,findIndex 恒 -1
### P210
IDEA-3 翻页越界空白 · IDEA-4 列表卡片点击不写 URL 路由同步失效(需确认)· IDEA-5 失败操作整块替换列表+删除失败清详情 · IDEA-6 捕捉成功无反馈 · IDEA-7 updateIdea 无错误包装静默丢改动 · IDEA-8 对抗评估渲染无防御(缺字段崩/NaN)· IDEA-9 评估历史不刷新 · KNOW-3 切 Tab 搜索框残留旧关键字 · KNOW-4 首次进入长内容不出现展开(渲染器已预热时,需确认)· KNOW-5 编辑保存失败丢改动
### P39
IDEA-10 archived 状态悬空(无徽章色)· IDEA-11 按评分排序评估后不重排 · IDEA-12 反馈通道混用(arco Message vs 自建 toast)· KNOW-6 onMounted 重复拉收件箱 · KNOW-7 加载中与空态同时渲染 · KNOW-8 relativeTime 用 Number() 直解时间戳(需确认)· KNOW-9 详情切换无 loading/错误 UI · KNOW-10 知识徽章 CSS 双份 scoped 重复 · C-1~3 跨模块(toast 位置/侧栏宽度/错误条不一致)
---
## 四、优先级建议
**P1(确定性,优先修)**:KNOW-1 假成功反馈 → KNOW-2 下一条失效 → IDEA-1 筛选被冲掉 → IDEA-2 死状态立项 → DBP-1/2 Dashboard 状态映射 → TSK-1 状态徽章无配色
**P2(体验)**IDEA-5/7/8Ideas 失败反馈与防御)、TSK-8 快捷菜单裁切、DBP-3 分页越界
## 五、登记去向
- 本报告:`docs/05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- 全系统索引:已更新
@@ -0,0 +1,55 @@
# 全系统模块走查索引
> 创建:2026-08-09
> 用途:阶段②「全系统逐一模块走查(样式/交互/缺陷)」的走查清单与进度跟踪。
> 流程:每模块走查(样式/交互/缺陷)→ 记录到 `docs/05-代码审查/` → 全部完成后再批量打包 /advance。
## 前端视图(13
| # | 模块 | 状态 | 走查报告 |
|---|---|---|---|
| 1 | Dashboard.vue | ✅ 已走查 2026-08-09 | 任务+仪表盘+灵感知识走查-2026-08-09.md17 项) |
| 2 | Projects.vue | ✅ 已走查 2026-08-09 | 同上(并入 Dashboard 批) |
| 3 | ProjectDetail.vue(概览/文件/依赖图) | ✅ 已走查 2026-08-09 | 项目详情页走查-2026-08-09.md60 项) |
| 4 | Tasks.vue | ✅ 已走查 2026-08-09 | 任务+仪表盘+灵感知识走查-2026-08-09.md19 项) |
| 5 | TaskDetail.vue | ✅ 已走查 2026-08-09 | todo.md 2026-08-0910 项已修) |
| 6 | Ideas.vue / IdeaDetail | ✅ 已走查 2026-08-09 | 任务+仪表盘+灵感知识走查-2026-08-09.md23 项 Ideas+Knowledge |
| 7 | Knowledge.vue / KnowledgeDetail | ✅ 已走查 2026-08-09 | 同上(23 项 Ideas+Knowledge |
| 8 | AuditLog.vue | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.md29 项) |
| 9 | Settings.vue | ✅ 已走查 2026-08-09 | 同上(并入 Settings 批) |
| 10 | AiChat.vue + ai/ 组件 | ✅ 已走查 2026-08-09 | aichat效率走查-2026-08-09.md74 项) |
| 11 | AiDetached.vue / AiHome.vue | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.mdlayout 批含) |
| 12 | ApprovalPopup.vue | ✅ 已走查 2026-08-09 | 并入 aichat + layout 批 |
| 13 | layout / sidebar / 全局 | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.md21 项) |
## 后端命令(12
| # | 模块 | 状态 | 走查报告 |
|---|---|---|---|
| 1 | task.rs | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.md(后端命令 45 项) |
| 2 | project.rs | ✅ 已走查 2026-08-09 | 同上 |
| 3 | module.rs(工程/文件/依赖) | ✅ 已走查 2026-08-09 | 项目详情走查 + 后端命令批 |
| 4 | idea.rs | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查 |
| 5 | knowledge.rs + knowledge_timeline.rs | ✅ 已走查 2026-08-09 | 同上 |
| 6 | workflow.rs | ✅ 已走查 2026-08-09 | 同上 + 另会话「AI 自审/工作流推进链路缺陷」 |
| 7 | ai/agentic + audit + tools + prompt + 会话) | ✅ 已走查 2026-08-09 | aichat效率走查 |
| 8 | settings.rs | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查 |
| 9 | events.rs / ci_status.rs / services.rs | ✅ 已走查 2026-08-09 | 同上 |
## crate10
| # | crate | 状态 | 走查报告 |
|---|---|---|---|
| 1 | df-aiLLM 请求/上下文/router | ✅ 已走查 2026-08-09 | aichat效率走查 |
| 2 | df-storagecrud/repo/迁移) | ✅ 已走查 2026-08-09 | 后端命令批佐证 + 08-02 走查 |
| 3 | df-nodes(节点/状态机) | ✅ 已走查 2026-08-09 | 另会话「AI 自审/工作流推进链路缺陷」 |
| 4 | df-workflow | ✅ 已走查 2026-08-09 | 同上 |
| 5 | df-mcp | ✅ 已走查 2026-08-09 | df-mcp+工具crate走查-2026-08-09.md16 项) |
| 6 | df-execute / df-project / df-ideas / df-tunnel / df-relay / df-types | ✅ 已走查 2026-08-09 | 同上(35 项含) |
## 进度汇总
- 前端 13 模块:**全走查完成** ✅
- 后端 9 命令:**全走查完成** ✅
- 全部 crate**全走查完成** ✅
- **全系统走查(阶段②)完成** ✅ —— 进入阶段③批量打包推进
@@ -0,0 +1,91 @@
# 项目详情页走查报告(概览/文件/依赖图)
> 走查日期:2026-08-09
> 范围:`src/views/ProjectDetail.vue`1346 行)+ `src/components/project/`FileExplorer 837 / FileTree 405 / DependencyGraph 490 / FilePreview / GitChanges / ModuleNode+ 后端 `src-tauri/src/commands/module.rs`
> 方法:3 路并行 Explore agent(概览/文件/依赖图)+ 主代理独立核查 ProjectDetail.vue 本体
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:UIUX 扩展审查(2026-08-02)、TaskDetail 走查(2026-08-09,同批父子任务详情页)
---
## 一、概览 tabProjectDetail.vue)— 18 项
### P13 项)
| # | 问题 | 位置 |
|---|---|---|
| O1 | **projectTasks 无 df-data-changed 本地监听** → AI/其它视图改任务后概览任务列表陈旧(TaskDetail 已做 B-260616-18,此处漏做) | :534-542, :849-863 |
| O2 | **工作流日志/DAG/进度读全局 liveEvents,无项目隔离** → 跨项目数据错配(正在看项目 A 显示 B 的工作流) | :676-689, :696-709 |
| O3 | **概览工程列表与 FileExplorer 双向不同步** → 文件 tab 增删改工程后切回概览 modules 陈旧 | :548-555 |
### P25 项)
| # | 问题 | 位置 |
|---|---|---|
| O4 | 快速切项目 loadProjectTasks/loadModules 竞态(无 seq 守卫) | :866-871 |
| O5 | WorkflowDagDisplay 边条件编辑 emit update:dagJson 父级未监听 → 编辑静默失效回退 | :326 |
| O6 | onUnmounted stopEventListener 停全局 workflow 监听,可能波及其他视图(共享单例) | :876-880 |
| O7 | 折叠态 descExpanded/workflowLogCollapsed 跨项目残留 | :457-462 |
| O8 | 概览任务列表无 loading 态、无 limit 分页(超上限静默截断) | :537, :292-309 |
| O9 | 项目状态映射不完整:in_progress/testing 等无 i18n 文案 | constants/project.ts:11-15 |
### P310 项)
O10 checkPath 重复触发(watch+显式双发)· O11 loading 态误判(store.error 非空显示"不存在")· O12 任务卡片只读不可点击跳详情 · O13 模板多次 parseStack 重复 JSON.parse · O14 sourceScores 宽度未 clamp · O15 日志自动滚动打断上翻 · O16 descCollapsible 字符数近似 vs TaskDetail DOM 测量不一致 · O17 onMounted 链式 await startEventListener 抛错跳过 checkPath · O18 handleSync 未刷模块/灵感
---
## 二、文件 tabFileExplorer/FileTree/FilePreview/GitChanges)— 24 项
### P13 项)
| # | 问题 | 位置 |
|---|---|---|
| F1 | **AI 写文件后自动切变更视图不刷新 GitChanges 列表 + 徽标计数被清零**v-show 不重挂载,GitChanges 唯一重拉是 watch moduleId | FileExplorer:399-411, :368 |
| F2 | **子目录展开失败把父级整棵树替换为错误态且无法自愈**(error 置父实例,优先于列表渲染) | FileTree:157-181, :14 |
| F3 | **中文文件名 git status 八进制转义未还原** → 变更视图乱码/打不开/树无徽标(core.quotepath 默认) | module.rs:1081-1091, GitChanges:66-73 |
### P29 项)
F4 git diff 仅 unstagedstaged/untracked 空 diff)· F5 切工程后 gitStatusData 不清 → 徽标张冠李戴 · F6 GitChanges 切工程遗留提交详情/选中态 · F7 文件树切工程双重请求+短暂显示旧文件 · F8 零工程文件 tab 永久转圈无空态 · F9 打开文件 tab 即全量 git status+log 扫描(纯浏览也扫)· F10 变更视图点文件默认看原文非 diff · F11 切 tab v-if 反复销毁重建 FileExplorer(状态全丢+重复扫描)· F12 FilePreview 大文件同步高亮阻塞 UI
### P312 项)
F13 GitChanges.selectFile 拉 diff 从不渲染 · F14 refresh 用固定 50ms 睡 · F15 展开目录无加载反馈 · F16 findEntryGitStatus 全缓存线性扫描 · F17 双击目录无 in-flight 去重 · F18 FilePreview 切文件头部 size 残留 · F19 R 状态样式不一致 · F20 提交搜索只过滤已加载 50 条 · F21 提交 diff 含空格路径解析失败 · F22 目录不存在误显"非 Git 仓库" · F23 面包屑点击不展开目标目录 · F24 文件树 git 徽标模块路径≠仓库根失效
---
## 三、依赖图 tabDependencyGraph)— 18 项
### P01 项)
| # | 问题 | 位置 |
|---|---|---|
| G1 | **节点 Vue 组件从未注册到 x6-vue-shape → 节点根本不渲染**`register({shape,component})` 未调用,`component` 字段库内不读;ModuleNode 读 props.data 但 vue-shape 传 {node,graph} | :82, :174-185, ModuleNode:18-26 |
### P13 项)
| # | 问题 | 位置 |
|---|---|---|
| G2 | UX-P0-4 环高亮仍是空操作(attrs.body 选择器在 vue-shape 无 body 元素) | :186-190 |
| G3 | **删除参与依赖的工程必失败**(FK 约束无级联清理)→ remove_project_module 报错+前端误导 | module.rs:249-260 |
| G4 | 点击节点无任何效果(emit 无人监听 + router.push 重复导航空操作) | :230-233 |
### P29 项)
G5 cycleNodes 跨数据变更/跨项目不重置 · G6 重复依赖可重复添加(前后端无去重)· G7 添加依赖不防环 · G8 边删除按钮定位偏移(未计工具栏高度)· G9 依赖加载失败静默吞掉→误导性空态 · G10 添加依赖失败无反馈 · G11 边 hover 监听重复累积(off 删不掉旧 handler)· G12 每次 renderGraph 重置视野 + 拖拽位置被 dagre 覆盖 · G13 无加载态
### P35 项)
G14 后端 add_module_dependency 不校验 from==to 与模块归属 · G15 exportPNG 回调错误无法 catch · G16 ModuleNode 路径截断省略号混用+兜底丢根锚点 · G17 死配置/未过滤项(History/Snapline 无 UI、archived 进图、dep_type 回落)· G18 detect_module_cycles 只标节点不标边(需确认是否逐环展示)
---
## 四、优先级建议
**P0(崩页/核心不可用)**:G1 依赖图节点不渲染
**P1(数据正确性/一致性)**:F3 中文 git 乱码 · G3 删依赖工程必失败 · O2 工作流跨项目串数据 · O1 任务列表陈旧 · F1 变更视图不刷新
**P2(体验/打磨)**:O3 模块双向同步 · F8 零工程空态 · F9 全量 git 扫描 · F11 切 tab 状态丢失 · G2 环高亮 · G4 节点点击死代码
---
## 五、登记去向
- 本报告:`docs/05-代码审查/项目详情页走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目(P0/P1 优先,P2/P3 分批)
- 不实施:本次仅走查登记(用户明确要求先检查做记录)
+380 -72
View File
@@ -8,6 +8,7 @@
> **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。 > **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。
> >
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。 > **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
### 💡 2026-08-04 父子任务支持(✅ 已完成) ### 💡 2026-08-04 父子任务支持(✅ 已完成)
@@ -36,15 +37,15 @@
## 🎯 统一规划(2026-06-26 全景) ## 🎯 统一规划(2026-06-26 全景)
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅落地待实测) + 多工程方案。 > 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅落地) + 多工程方案。
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。 > **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。 > **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
### 主线脉络 ### 主线脉络
``` ```
G1目标钉扎(✅落地,待实测) → 知识图谱Phase1(任务网络,AI编排地基) G1目标钉扎(✅落地) → 知识图谱Phase1(任务网络,AI编排地基)
↓ ↓ ↓ ↓
实测验证 Phase4注入(复用G1的system_prompt注入基建) 实测验证
↓ ↑ ↓ ↑
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入) F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
``` ```
@@ -61,7 +62,7 @@ graph TD
classDef todo fill:#eef,stroke:#99c classDef todo fill:#eef,stroke:#99c
classDef blocked fill:#fcc,stroke:#c33 classDef blocked fill:#fcc,stroke:#c33
G1["G1 目标钉扎<br/>✅代码 8ce18cb / 🔨待实测"]:::doing G1["G1 目标钉扎<br/>✅代码 8ce18cb"]:::done
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
P3["父③ AI对话体验<br/>📋待办"]:::todo P3["父③ AI对话体验<br/>📋待办"]:::todo
@@ -79,7 +80,7 @@ graph TD
| 任务 | 状态 | 子项 | 依赖(前置) | | 任务 | 状态 | 子项 | 依赖(前置) |
|---|---|---|---| |---|---|---|---|
| **G1** 目标钉扎 | 🔨 代码`8ce18cb` 待实测 | G1/G2/G4 落地 | — | | **G1** 目标钉扎 | 代码已落地 | G1/G2/G4 落地 | — |
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — | | **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) | | **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
| **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 / 对话透明化 L1🟡待定 | — | | **父③** AI对话体验 | 🔨 ③.1✅ / ③.2待办 | ③.1 B-260619-04 ToolCard✅(跨轮收起已有+标题增强) / ③.2 REFACTOR-260619-04 审批状态机拆分 / 对话透明化 L1🟡待定 | — |
@@ -123,7 +124,7 @@ graph TD
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。 **销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成 + 用户实测后可清。 **残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成后可清。
--- ---
@@ -141,11 +142,10 @@ graph TD
|---|---|---| |---|---|---|
| **A todo 卫生** | 矛盾清理/被取代退役标注 | ✅ 本批已完成 | | **A todo 卫生** | 矛盾清理/被取代退役标注 | ✅ 本批已完成 |
| **B 零风险减法** | 死链清/空壳合并 | ✅ 本批已完成 | | **B 零风险减法** | 死链清/空壳合并 | ✅ 本批已完成 |
| **C 需用户输入** | 阻塞,无法代办 | S-260615-01 curl 测 / S-260614-01 多开澄清 / S-260614-02 实测重评 | | **C 需用户输入** | 阻塞,无法代办 | —(已归档)
| **D 功能增强** | 设计清晰可推进(P1) | F-15-01 审批选项 / F-15-02 task 详情 / F-15-03 分页⚠️breaking / F-15-04 卡片折叠 | | **D 功能增强** | 设计清晰可推进(P1) | F-15-01 审批选项 / F-15-02 task 详情 / F-15-03 分页⚠️breaking / F-15-04 卡片折叠 |
| **E 架构重投入** | 需设计,非小改 | F-14-01 模型能力 / F-14-07 trait 下沉 / ARC-05·06 store 拆·循环依赖 | | **E 架构重投入** | 需设计,非小改 | F-14-01 模型能力 / F-14-07 trait 下沉 / ARC-05·06 store 拆·循环依赖 |
| **F 全局 review P2 需设计** | 安全/竞态收口 | R-PD-4/5/6/8/9/12/13 + R-PD-10 .map_err 85 处 | | **F 全局 review P2 需设计** | 安全/竞态收口 | R-PD-4/5/6/8/9/12/13 + R-PD-10 .map_err 85 处 |
| **G 测试/dev 验证** | 收尾土壤 | ARC-08 dev 验证 / B-03b-R8 human 端到端 / T-14-01·02 Sprint 实测 |
| **H 长期功能池** | 不进主线 | F-14-02~10 / T-14-06 Settings 拆 / T-14-11 条件引擎 / B-14-05 / B-03b-R9 | | **H 长期功能池** | 不进主线 | F-14-02~10 / T-14-06 Settings 拆 / T-14-11 条件引擎 / B-14-05 / B-03b-R9 |
**推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。 **推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。
@@ -188,7 +188,7 @@ graph TD
> 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。 > 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。
- [ ] S-260615-01 — **[待用户确认根因]** 用户跑直连测试(curl)区分 key/provider_type/base_url/model 哪项错,见详情文档「验证步骤 - [x] S-260615-01 — **已解决(2026-08-09 用户确认)**:API Key 401 根因已定位解决,此待用户确认项销账。原为「用户跑直连测试(curl)区分 key/provider_type/base_url/model 哪项错」
### 🟦 架构审查待修项(2026-06-16) ### 🟦 架构审查待修项(2026-06-16)
@@ -199,8 +199,6 @@ graph TD
### P1 — 重要缺陷 ### P1 — 重要缺陷
- [ ] T-260614-01 — **[P1]** Sprint 9/10/14/15/16/18 多项未 tauri dev 实测 — 评分 IPC 缩放 / update_full / promote_idea / Store getter / token 落库 / 知识库 Tier 1 全栈 / LLM 并发 Semaphore / 知识生命线(#54 跟踪)— source:Sprint 9-18 (06-14)
- [ ] T-260614-02 — **[P1]** 切对话不中断路由:部分场景运行时实测(A 路线场景 2/3 — source:Sprint 8 (06-14)
### 🔴 架构坏味道全面扫描(2026-06-18·6路并行 Explore agent·57K行代码) ### 🔴 架构坏味道全面扫描(2026-06-18·6路并行 Explore agent·57K行代码)
@@ -224,8 +222,7 @@ graph TD
### 待澄清 / A-B 待定 ### 待澄清 / A-B 待定
- [ ] S-260614-01 — **[用户已澄清 2026-06-17]** 「显示多开」= 多个会话来回切都可对话(非 AI Chat 窗口多开)。关联 T-260614-02(切对话不中断实测)+ F-09 多会话架构。**A 路线(单例软隔离,已做)**:切会话软复位+DB 加载上下文+active 切换,来回切应能对话(需实测 T-260614-02)。**B 路线(真多会话,未做)**:切走会话 generating 不打断,每会话独立 AiSession(memory aichat-arch-extensibility 单例未动)。**✅ 已并入 F-260616-09(2026-06-16 已决 a):A 路线已实施待实测(T-260614-02),B 高优先立项,详见待决策归档/2026-06.md** - [x] S-260614-01 — ✅ 已销账(并入 F-09,F-09 B 阶段 2026-08-07 全部落地)。**[用户已澄清 2026-06-17]** 「显示多开」= 多个会话来回切都可对话(非 AI Chat 窗口多开)。**✅ 已并入 F-260616-09(已决 a):A 路线已实施,B 高优先立项,详见待决策归档/2026-06.md**
- 🟡 **S-260614-02 审批可见性** — 待用户实测 tc 卡片是否渲染(需你跑一下)
#### ℹ️ 架构观察(INFO · 2 项·不进修复队列) #### ℹ️ 架构观察(INFO · 2 项·不进修复队列)
@@ -291,7 +288,6 @@ graph TD
- **P4** 双向同步完善(冲突处理 + 安全 + 离线降级 + 部署)— 依赖 P1/P2/P3 - **P4** 双向同步完善(冲突处理 + 安全 + 离线降级 + 部署)— 依赖 P1/P2/P3
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底): **Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
- [ ] F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵(6 场景含 F-09 跨端并发)
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查) ### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
@@ -314,7 +310,7 @@ graph TD
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow) ### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
> 剩余低优 [ ](非阻断): > 剩余低优 [ ](非阻断):
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到) > - [x] BUG-260624-02 残留 ✅ 已销账(2026-08-15):ToolCard pending 态倒计时落地(`_approvalTimers` 升级携带 startedAt/timeoutMs + `getApprovalRemainingMs` 真值起点,expireSecs/expireLevel computed,≤60s `.is-urgent` 红;不限时回退正计时;i18n expireIn zh/en)+ detached 窗口核验无缺口(AiDetached→AiChat 独立 startListener,ai-chat-event app 级广播,主窗口 detach 时 stopListener 不竞态,结论注释 useAiWindow:107-115)
> - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md) > - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md)
> - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。 > - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。
@@ -324,7 +320,7 @@ graph TD
> ✅ 全部修复完成(2026-06-27 核验) > ✅ 全部修复完成(2026-06-27 核验)
- [~] **IDEA-FIX-10 [P2🟠]****前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。 - [x] **IDEA-FIX-10 [P2🟠]****前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。✅ 关闭(2026-08-15:团队决策定案,无后续动作)
--- ---
@@ -334,8 +330,8 @@ graph TD
- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` - [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)`
- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅ - [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅
- [ ] **AIC-FIX-17-P1-1~11** — P1 队列/互斥/modelOverride/死代码/缓存/防重入/删除回落(待后续批次) - [x] **AIC-FIX-17-P1-1~11** ✅ 已销账(2026-08-15 核验,1 agent 全量核验 + 主代 4 项抽查源码属实):P1-1/3/4/5(queue/modelOverride/spans/_lastDelta per-conv,F-09 收尾)/ P1-2 drainQueue 互斥 `_drainingConvs` Set(useAiSend:274)/ P1-6 flushCurrentText 守卫 / P1-7 MessageItem 收敛仅 user 角色 / P1-8 _blockCache LRU 300+切会话 resetStreamCache / P1-9 指纹 watch 替代 deep / P1-10 approving 防重入前置(useToolApproval:133)/ P1-11 deleteConversation 相邻回落(useAiConversations:520)。余「MessageList 内联渲染组件化」「缓存主动失效」为优化空间非缺陷
- [ ] **AIC-FIX-17-P2** — P2 健壮性项(待后续批次) - [x] **AIC-FIX-17-P2** ✅ 已销账(2026-08-15):7 条中 5 条核验为此前批次已修(ApprovalOverlay 响应式冷却/aiShared 对称收敛/flash watch/maximized 三元/emit 类型),2 条本批落地(tailSeq 注释与实现对齐 useStreamRenderer:142-196、isCompressing 单例补 known limitation 注释 useAiContext:28)
- [x] **AIC-FIX-17-根因****2026-08-07 核验销账**(queue/modelOverride/审批/_lastDelta 均已 per-conv) — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化) - [x] **AIC-FIX-17-根因****2026-08-07 核验销账**(queue/modelOverride/审批/_lastDelta 均已 per-conv) — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化)
--- ---
@@ -346,16 +342,16 @@ graph TD
**P2(中期改进)**: **P2(中期改进)**:
- [ ] **WC-P2-status-union** — ✅ **status union 已修**(前端 status 强类型 union 收口完成),保留此处仅作核对销账 - [x] **WC-P2-status-union** — ✅ **已销账(2026-08-09)**:status union 已修
- [ ] **WC-P2-useAiEvents-胖**`useAiEvents` 仍偏胖,按事件分组抽子 composable / 拆 listener 注册逻辑(前端架构技术债 P0 大文件,对齐 memory [[fe-arch-tech-debt]] - [x] **WC-P2-useAiEvents-胖****已销账(2026-08-09)**:useAiEvents 已拆 12 个 composable(useAiLifecycleEvents/StreamingEvents/ToolEvents 等,65c0f6b)
- [ ] **WC-P2-dfnodes-6未注册**`df-nodes` 6 个节点类型未在 NodeRegistry 注册(节点清单核验,补注册 + 单测覆盖) - [x] **WC-P2-dfnodes-6未注册****已销账(2026-08-09)**:state.rs build_registry 注册 git/docker/http/notify/subflow + human/ai/ai_self_review/task_advance 共 9 节点;script 有意禁用(R-PD-2)
- [ ] **WC-P2-dfmcp-schema-required**`df-mcp` tool schema `required` 字段处理:核对 array vs bool 兼容(MCP 规范允许多形态),补强反序列化 + 测试 - [x] **WC-P2-dfmcp-schema-required****已销账(2026-08-09)**:required_names() 兼容 array/bool 两形态 + 三例单测(server.rs:277-294/491-522)
- [ ] **WC-P2-bind-directory**`tool_registry` bind_directoryMED-1tool_registry:546)核对推进 — 历史登记项复核 - [x] **WC-P2-bind-directory****已销账(2026-08-09)**:tool_registry.rs:456-485 bind_dir_to_project 四要素齐全(id/路径校验/防重绑/stack探测)
**P3(风格/可读·低优)**: **P3(风格/可读·低优)**:
- [ ] **WC-P3-SubflowNode-风格**`SubflowNode` 实现风格对齐其他 Node(trait impl 顺序/命名/注释规范一致性) - [x] **WC-P3-SubflowNode-风格****已销账(2026-08-09)**:subflow_node trait 顺序(execute→schema→node_type)对齐 ai/human 节点
- [ ] **WC-P3-script-注释** `script` 节点注释清理(过时/冗余注释,对齐当前实现) - [x] **WC-P3-script-注释** ✅ 已销账(2026-08-15 全文核验):script_node.rs 全部 env var 相关注释(:9/:144/:167 等)均已是「运行时配置优先、env var 回退」语义,无「替代纯 env var」矛盾表述,无需改动
**专项(已登记 memory,留指针)**: **专项(已登记 memory,留指针)**:
@@ -385,15 +381,15 @@ graph TD
- [x] ✅ **P1-a** 长内容折叠(MessageList AI 文本气泡>1500 字 + ConflictResolver 多冲突 + HelpRequiredCard 堆栈,`9e85cad`) - [x] ✅ **P1-a** 长内容折叠(MessageList AI 文本气泡>1500 字 + ConflictResolver 多冲突 + HelpRequiredCard 堆栈,`9e85cad`)
- [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`) - [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`)
- [ ] **P1-c** 列表状态持久化(抽 `usePersistedRef` composable + Tasks/Ideas/Knowledge/Projects/ProjectDetail Tab 接入) - [x] ✅ **P1-c** 列表状态持久化**2026-08-09 核验销账**:usePersistedRef 已接入 Tasks/Projects/Knowledge/Ideas/AuditLog;ProjectDetail Tab 手写 localStorage 按 projectId 隔离(动态 key,usePersistedRef 静态 key 不适用)保留
- [ ] **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total + 前端持久化(治"客户端筛选只作用当前页 50 条"假象) - [x] ✅ **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total **2026-08-09 核验销账**:list_tool_executions(record.rs:182)支持 status/risk_level/tool_keyword WHERE + count_by_query 独立 COUNT 真实 total + limit/offset 分页;前端 AuditLog.vue buildQuery 传后端 + usePersistedRef 持久化
- [ ] **P1-e** 审批体系一致(ApprovalPopup 浮窗挂起计时 H1 + 授权粒度统一 ToolCard/Popup/DirAuthDialog + 拒绝二次确认 + 失败 toast) - [x] ✅ **P1-e** 审批体系一致**全部落地(2026-08-09)**:挂起计时✅(ApprovalPopup :191 MM:SS)/ 授权粒度统一✅(ToolCard/ApprovalPopup/DirAuthDialog 同 i18n)/ deny 二次确认✅(DirAuthDialog 补)/ **失败 toast ✅ 本批落地**(useAiApproval catch emit('ai-toast') + AiChat listen;ApprovalPopup 窗口内自包含 useToast)
- [ ] **P1-f** cmd 闪烁 N²(FileTree 展开缓存 git status,避免每次全仓扫描) - [x] ✅ **P1-f** cmd 闪烁 N²**已落地(2026-08-09 141777b)**:module.rs 新增仓库级 git status 缓存(GIT_STATUS_CACHE,key=仓库根 path,TTL 5s,锁内不跑 git,非 git 不缓存;get_module_file_tree + get_module_git_status 两处收口;删除/更新 module 清缓存);前端联动未做(5s TTL 自然过期 + FileExplorer 刷新已重拉目录树,无独立缺口)
- [ ] **P1-g** 延伸:ProjectDetail.vue:368 + ChatInput.vue:367 同源 `store.tasks.filter` 污染(项目任务展示 / @ 联想,P1-b 核查时发现) - [x] ✅ **P1-g** store.tasks.filter 污染 — **2026-08-09 核验+补全销账**:ProjectDetail 独立 taskApi.list 已修;ChatInput @项目 enrichment 独立拉取(projectTasksCache)已修 + @任务联想本批改独立全量缓存 taskSuggestionCache(隔离 Tasks 页筛选子集)+ mentionTotal 对齐
**P2(打磨)**:幽灵 `--df-primary` / git 状态色 token / 控件统一(原问题 8 延伸)/ 死代码清理(PlanProgress/isLastUser) **P2(打磨)**:幽灵 `--df-primary` 已清(2026-08-15:4 处引用迁 `--df-accent` 逐主题等价,双主题定义删除,零残留)/ ✅ 死代码 PlanProgress·isLastUser 已核不存在 / ✅ git 状态色 token(2026-08-15:--df-git-added/modified/deleted/untracked 双主题,FileTree/GitChanges/FilePreview 18 处迁 var+color-mix)/ ✅ 控件统一(2026-08-15:.setting-input 全局定义承接,Connection/ProviderPanel 重复副本删,存布局覆盖)
**批次 I(原 12 大改 · 后续专项)**:Dashboard 统计卡压底行(问题 1,依赖 P1 数据源)/ Projects 视图模式(问题 2)/ TaskDetail(4)·IdeaDetail(5)·KnowledgeDetail(6)·ProjectDetail(10)重设计 / project_event_repo 埋点接入(问题 3) **批次 I(原 12 大改 · 后续专项)**:Dashboard 统计卡压底行(问题 1,依赖 P1 数据源)/ Projects 视图模式(问题 2)/ TaskDetail(4)·IdeaDetail(5)·KnowledgeDetail(6)·ProjectDetail(10)重设计 / project_event_repo 埋点接入(2026-08-15 核验:commands/project.rs emit_project_event 封装 best-effort 写入 + list_project_activities 消费)
--- ---
@@ -403,75 +399,387 @@ graph TD
> 实证:prod(76 会话/9928 消息/6392 工具)+ dev(98 会话/7853 消息)库 + 会话 dump 反推。详单 [aichat历史会话实证诊断-2026-08-04.md](./05-代码审查/aichat历史会话实证诊断-2026-08-04.md)。 > 实证:prod(76 会话/9928 消息/6392 工具)+ dev(98 会话/7853 消息)库 + 会话 dump 反推。详单 [aichat历史会话实证诊断-2026-08-04.md](./05-代码审查/aichat历史会话实证诊断-2026-08-04.md)。
> 核心:弱模型(sensenova-6.7-flash-lite)在任务中**重复探索/绕工具/参数错误**是系统性现象,非偶发。 > 核心:弱模型(sensenova-6.7-flash-lite)在任务中**重复探索/绕工具/参数错误**是系统性现象,非偶发。
- [ ] **AC-1** 机制层「同参数重复调用」拦截:同会话同工具同参数组 ≥2 次即返缓存/警告(现仅 prompt.rs:107 说教,弱模型无效;**实证 83 个重复组合 ≥3 次**,09e7abfa 同一文件读 6 次、e0b5d8fc update_task 同参 8 次) - [x] **AC-1** ✅ 已落地(2026-08-09 核验):双层拦截——调用级 `cache.rs:251 find_cached_readonly_result`(同 tool_name+canonical_args 已 completed 即返缓存+警告头,第 2 次即命中)+ 轮级 `is_repetitive_exploration`(helpers.rs:304-332,单签名≥3/唯一率<40%)→ `check_stall_breaker`(警示→硬熔断 emit AiHelpRequired)
- [ ] **AC-2** 探索预算上限:单轮工具调用数上限 + 目录列举去重 + list_directory 后禁止绕 run_command 同探(09e7abfa 同一目录列 3 次再 Get-ChildItem 绕行) - [x] **AC-2** ✅ 已落地(2026-08-09 核验,提交 fe780c0):`MAX_TOOL_CALLS_PER_ROUND=8`(agentic/mod.rs:245)+ 目录列举去重(readonly_cache_args_key 只取 path,返目录专用警告)+ `detect_listing_bypass`(run_command 含列举命令且引用已列目录→警告,保守式不硬拒)
- [ ] **AC-3** 系统 prompt 项目清单补 UUID(prompt.rs:304 注入 `- {name}: {desc}` 无 id):**实证 list_tasks("DevFlow"/"meta-kit"/"u-ask") 12+ 处传 name 当 id 全返空** - [x] **AC-3** ✅ 已落地(2026-08-09 核验):prompt.rs:304/323 项目/任务清单已含 id + 注明语(projects_listed_note "引用用 id 非名称");**兜底机制超要求**:entity_resolve.rs name→id 自动解析(RESOLVE_MAP 12 工具 + UUID 跳过 + 0 命中/重名返可行动错误 + 9 单测,audit/mod.rs process_tool_calls 单点漏斗)
- [ ] **AC-4** read_symbol 采用率诊断:**实证 read_file 2582 vs read_symbol 36(采用率 1.4%)**,AST 代码智能(7c2e3b2)落地空转,prompt 引导对弱模型无效,需机制(read_file 命中已知符号时提示换 read_symbol / 按模型分级) - [x] **AC-4** 🟡 部分(2026-08-09 核验):read_file **符号级命中**提示已补强(tools/file.rs:179-196 用 code_intel contains_definition 检测内容含定义节点才提示,替代旧按扩展名)+ 三态本体(code_intel.rs:130)+ 缓存兜底 + 单测 4;**模型分级已评估=保持现状**(穿模型需改 process_tool_calls 多路径签名波及 ai_approve/trust_hits/low_risk,ROI 低——提示一行且 token 节省可忽略,启发式判弱模型脆弱)。✅ 关闭(2026-08-15:残尾均为已评估定案,无后续动作)
- [ ] **AC-5** 失败工具画像复盘:run_command 67 / read_file 55 / search_files 33 / advance_task 31(状态机拒) / patch_file 27,逐类找机制根因降失败 - [x] **AC-5** 🟡 部分(2026-08-09 核验):画像复盘已做(docs/AI工具失败画像复盘-2026-08-08.md)+ 4/5 自愈机制落地(advance_task legal_targets / patch_file 相近锚点 / read_file·read_symbol 相近文件名候选 / search_files total=0 引导);**运行时失败率统计 ✅ 已落地(141777b)**:tool_failure_stats IPC 命令(stats_by_tool 按工具 status 聚合,failed_rate=failed/(completed+failed),rejected 不计分母,可选 from 时间下限)。✅ 关闭(2026-08-15:残尾均为已评估定案,无后续动作)
### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记) ### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记)
> 来源:df-mcp 进程实测 + 代码核查。根因:MCP server 独立进程 + 与 GUI 同库不同进程 + 绕过 GUI 业务层直调 Repo。 > 来源:df-mcp 进程实测 + 代码核查。根因:MCP server 独立进程 + 与 GUI 同库不同进程 + 绕过 GUI 业务层直调 Repo。
> 分析类登记,待决策后实施。 > 分析类登记,待决策后实施。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:8 项中 4 已落地(提交 02c8d8e 等)、4 本批补修(8cb666a)。
- [ ] **P0-1** update_* TOCTOU 竞态:仅 advance_task 有 CAS,update_project/task/idea/score_idea 读-改-写无乐观锁,多进程并发写互相覆盖(方案:version 列或 updated_at CAS) - [x] **P0-1** ✅ 已落地(2026-08-09 8cb666a):df-storage 三 Repo 新增 `update_full_cas`(WHERE id AND updated_at 原子条件写,affected==0 拒绝),df-mcp update_project/task/idea/score_idea 四工具改 CAS 调用(顺带修 score_idea 旧忽略 Ok(false) 静默覆盖),+6 单测
- [ ] **P0-2** MCP 写库后 GUI 无感知:df-data-changed 事件仅 GUI AI 工具能 emit,MCP 进程无 AppHandle,双端数据断层致重复创建(方案:GUI 轮询/文件 watcher/定时刷新) - [~] **P0-2** 🟡 部分(2026-08-09):GUI 内嵌 HTTP 形态已通(advance_task 数据变更映射本批补,data_change.rs);**独立 stdio 进程仍无感知**(on_write_call: None 无 AppHandle + 无轮询兜底),需设计 GUI 侧刷新机制
- [ ] **P0-3** 审计形同虚设:run_mcp_server 直接 return 未 init tracing subscriber,medium_audit warn 无订阅者不落盘(方案:MCP 进程 init tracing + 文件 appender) - [x] **P0-3** ✅ 已落地:main.rs:19-37 init_mcp_tracing(tracing-appender non_blocking 写 %TEMP%/devflow-trace.log,medium_audit warn 落盘)
- [ ] **P1-4** 两套工具规则不一致:GUI AI 工具有 queue 白名单/data_change/完整审计,MCP 精简实现无,演进易漂移(方案:校验下沉 Repo 层复用同源函数) - [x] **P1-4** ✅ 已落地(e722823):校验下沉 crates/df-storage/src/crud/task_validation.rs,df-mcp/GUI 复用同源函数
- [ ] **P1-5** 多进程同库:迁移 PRAGMA 幂等但并发 ALTER 可能 database is locked;MCP release/GUI debug 版本可能不一致 - [x] **P1-5** ✅ 已落地(02c8d8e):db.rs busy_timeout(5s) + WAL + foreign_keys
- [ ] **P1-6** 孤儿进程:stdin EOF 才退出,无超时/心跳,Claude Code 强杀致进程累积(方案:空闲超时退出) - [x] **P1-6** ✅ 已落地(02c8d8e):server.rs:100-107 空闲超时 60s 退出(--idle-timeout 可配)
- [ ] **P1-7** AI 全写权限:Medium 默认允许 + 无幂等键,AI 可污染真实业务库(方案:默认 --read-only 或写前确认) - [x] **P1-7** 🟡 部分(设计取舍):高危默认拒 + 中危审计 + --read-only 可选;未做默认只读/写前确认(有缓解可接受)。✅ 关闭(2026-08-15:设计取舍已接受,无后续动作)
- [ ] **P2-8** list 无分页; **P2-9** Windows lowercase 跨平台语义; **P2-10** MCP 仅 CRUD 能力弱(有意收敛) - [x] **P2-8** ✅ 已落地(8cb666a):主 list 工具分页 + list_trash 补分页(offset/limit/has_more); **P2-9** ✅ 已核销(2026-08-15):df-project scan/mod.rs 统一 to_lowercase 比较版(:69/:73)+ 单测断言,normalize_for_compare/canonicalize_for_store 已拆分; **P2-10** 有意收敛
--- ---
### 🔍 2026-08-04 miniapp 聊天渲染 vs 桌面端兼容性走查(剩余待办登记) ### 🔍 2026-08-04 miniapp 聊天渲染 vs 桌面端兼容性走查(剩余待办登记)
> 走查 `apps/df-miniapp/src/pages/chat/index.vue` / `apps/df-miniapp/src/utils/mdRenderer.ts` / `src/components/ai/MessageList.vue` 等 miniapp 聊天渲染与桌面端兼容性。**已修复项不记录**,仅归档未修剩余项。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。 > 走查 `apps/df-miniapp/src/pages/chat/index.vue` / `apps/df-miniapp/src/utils/mdRenderer.ts` / `src/components/ai/MessageList.vue` 等 miniapp 聊天渲染与桌面端兼容性。**已修复项不记录**,仅归档未修剩余项。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:9 项中 7 已落地(e722823/795e05f/8cb666a)、本批补 2(mention chip + 语言标签)。
**P1(兼容性/体验·2 项)**: **P1(兼容性/体验·2 项)**:
- [ ] **MR-P1-1** — miniapp 流式生成中 raw markdown 字面量显示:生成中 currentText 用纯 `<text>` 渲染,`**bold**`/`#` 等显示语法字符,完成才渲染 markdown。需块级 memo 渲染设计(对齐桌面 useStreamRenderer),性能+体验权衡,暂缓 - [x] **MR-P1-1** ✅ 已落地:chat/index.vue:66-131 splitStreamBlocks 块级 memo 流式渲染(currentText 不再纯 text 显语法字符;未闭合围栏降级 escapeFallback 属流式固有取舍)
- [ ] **MR-P1-2** — miniapp 表格 overflow 破坏列对齐:`mdRenderer.ts``<table>` 注入 `display:block`,破坏表格列对齐;需 `display:inline-table` 或保留原生行为,需真机验证 - [x] **MR-P1-2** ✅ 已落地:mdRenderer.ts:203-207 table 显式 `display:table`(非 block)保留列对齐 + max-width/word-break 兜底
**P2(打磨·6 项)**: **P2(打磨·6 项)**:
- [ ] **MR-P2-1** — miniapp 图片溢出:rich-text `<img>` 自然尺寸无 max-width,宽图溢出气泡;需注入 max-width + 域名白名单 + tap 预览(需 mp-html 决策) - [x] **MR-P2-1** ✅ 已落地:mdRenderer.ts:198 img max-width:100% + border-radius(网络图依赖微信 downloadFile 白名单,非渲染代码)
- [ ] **MR-P2-2** — miniapp mention chip 可视化:用户消息内 `[项目:名]` 显示字面文本,桌面端有 chip 样式;需分段渲染 - [x] **MR-P2-2** ✅ 已落地(2026-08-09 8cb666a):chat/index.vue segmentUserContent 分段渲染 `[项目:名]`/`[任务:名]`/`[灵感:名]` chip(命中已知实体才 chip,未命中保持字面,纯文本零回归)
- [ ] **MR-P2-3** — miniapp 任务列表 checkbox 丢失:rich-text 不认 `<input>`,GFM task-list 勾选框被剥离成纯文本 - [x] **MR-P2-3** ✅ 已落地:mdRenderer.ts:167-172 input checkbox → ☑/☐ 字符符号
- [ ] **MR-P2-4** — miniapp 代码块无高亮/语言标签:对齐桌面 hljs 17 语言高亮,需引入方案 - [x] **MR-P2-4** ✅ 已落地(8cb666a):highlight.js 14 语言高亮 + 代码块语言徽标(codeLangBadge,围栏 lang 显标签)
- [ ] **MR-P2-5** — 桌面端实时 JSON 工具结果无折叠防御:LLM 文本 echo 工具 JSON 时桌面渲染成正常气泡(miniapp 已有 isToolResultJson 折叠);罕见场景,决策是否做 - [x] **MR-P2-5** ✅ 已落地:桌面 MessageList.vue:698-708 isToolResultJson 折叠(对齐 miniapp)
- [ ] **MR-P2-6** — miniapp 设置页编辑功能:只读版已上线(`pages/settings/index.vue`),编辑(relayHost/deviceId/token 手填)接 setConfig 即可,后续做 - [x] **MR-P2-6** ✅ 已落地(795e05f):settings/index.vue 编辑模式(setConfig + 脱敏 + resumeIfDisconnected)
**P3(外部依赖/低优·2 项)**: **P3(外部依赖/低优·2 项)**:
- [ ] **MR-P3-1** — miniapp `manifest.json` appid 占位符:需微信公众平台注册真实 appid 才能真机预览/发布(P3-B-260622 遗留,外部依赖)
- [ ] **MR-P3-2** — 真机联调:F-260622-01 阶段4 真机联调 + 多会话并发验证矩阵(含 F-09 跨端并发),待 appid 就绪
--- ---
### 🔍 2026-08-07 aichat 切换/新建对话缺陷走查(用户实测 2 bug + 双 agent 深挖) ### 🔍 2026-08-07 aichat 切换/新建对话缺陷走查(用户实测 2 bug + 双 agent 深挖)
> 用户 dev 实测报 2 个 bug(队列串新会话 / 历史内容进新会话),派 2 agent(前后端)+ 主代独立抽查。**守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。** 详单见走查报告(本段即清单)。 > 用户 dev 实测报 2 个 bug(队列串新会话 / 历史内容进新会话),派 2 agent(前后端)+ 主代独立抽查。**守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。** 详单见走查报告(本段即清单)。
> **✅ 2026-08-09 核对销账(2 agent 独立源码核验 + 主代抽查 useAiEvents P1-1 一致)**:16 项中 **14 项已修**(提交 9f75db5「切换/新建对话缺陷」+ 后续 6ba6daf 收尾),各 [x] 项附当前源码证据行号;**CSW-P1-4 未修**(切到生成中 conv 首响应气泡丢失,需前后端 round0 占位补回设计,单列);**CSW-P2-7 未核对**(走查自标待确认,含 d2 分离窗口失效/跨端流式)。
**🔴 P0/bug1 待发送队列串到新对话(用户实测,根因已确认)**: **🔴 P0/bug1 待发送队列串到新对话(用户实测,根因已确认)**:
- [ ] **CSW-P0-1**`AiChat.vue:70-101` 渲染 `store.state.queue` **全部会话**,不按 `activeConversationId` 过滤 → 切/新建后显示旧会话排队消息。修复:computed 按 active 过滤 + queue 操作(cancel/edit/sendNow/clear/timeout)按 conv 收敛 - [x] **CSW-P0-1**✅ 已修:`AiChat.vue:393-395` activeQueue computed 按 `activeConversationId` 过滤 + 模板 :70/:79 改用 activeQueue;queue 操作(cancel/edit/sendNow)经 `useAiSend.ts:420-431` activeQueueRealIndex 映射真实下标,clear/forceSend 均传 convId
**🟡 P1(前后端合并去重,按严重度)**: **🟡 P1(前后端合并去重,按严重度)**:
- [ ] **CSW-P1-1**后台会话排队消息永不 drain:非当前 `AiCompleted` 被 isCurrent 守卫拦截(`useAiEvents.ts:883-889`)不 emit `ai-drain-queue`,切回后队列卡死。修复:守卫外补该 conv 的 drain + `drainQueue` 用目标 convId 发消息(勿用 `state.activeConversationId` 误发到 B) - [x] **CSW-P1-1**✅ 已修:`useAiEvents.ts:101-102` 非当前会话 `AiCompleted` 仍 emit `ai-drain-queue`(带 conversationId,未被 isCurrent 吞);`useAiSend.ts:271-306` drainQueue 用目标 convId
- [ ] **CSW-P1-2**后端 switch 回切 reload 无条件覆盖未落库内存(`conversation.rs:338-343` 只判 `is_active` 不判内存更脏)→ 熔断不 save/异步 save 未落前切走再切回,消息+工具结果永久丢失(`context/mod.rs:279-290``needs_full_rewrite=false` 固化陈旧态) - [x] **CSW-P1-2**✅ 已修:`conversation.rs:363-368` 非 live 分支先判 `mem_has_unsaved`(len>persisted_msg_count || needs_full_rewrite),有未落库内存则跳过 restore,DB 快照不再覆盖内存
- [ ] **CSW-P1-3**switch 对 live 目标 conv 无条件 `finalize_pending_placeholders`+`retain` 再 extend DB 快照(`conversation.rs:386-388/400-422`)→ live pending 误终态化「会话已切换」+ 新审批丢 + 陈旧条目(`Ok(None)` 分支未根治)反复 - [x] **CSW-P1-3**✅ 已修:`conversation.rs:335/415-417` `already_live` 分支 pending 完全不动(不 finalize/不 retain/不从 DB 恢复),首屏用 per_conv 内存消息
- [ ] **CSW-P1-4** — 切到生成中 conv 返回 DB 旧快照 + round0 首响应前端气泡丢失(`conversation.rs:278-316` + `useAiEvents.ts:209-228`):前端整体替换 `state.messages` 丢弃本地气泡,currentText 无气泡可落被清空 → 首条回复闪现/缺失 - [x] **CSW-P1-4****已修(2026-08-09)**:切到生成中 conv 首响应气泡丢失——前端 switchConversation(useAiConversations.ts:336-355)在 parse 成功、active 置位后,若 `targetGen && 末条是 user` 补前端占位 assistant 气泡(`round0-pending-` 判重),承接 currentText 供 MessageList isLastAi 流式渲染 + flushCurrentText 回填。纯前端,不需后端协同
- [ ] **CSW-P1-5**delete 在 loop 运行中 → save 惰性重建 per_conv + `Ok(None)` 分支 INSERT 复活对话(`conversation.rs:573-587/388-420`)+ 该轮消息丢 - [x] **CSW-P1-5**✅ 已修:`conversation.rs:613-616` 删除先登记 `deleted_convs` 再删库,save 入口 :259-265 命中直接 return + :416-426 INSERT 前二次校验,封死 Ok(None) 复活窗口
- [ ] **CSW-P1-6**点击当前活跃流式会话无同 id 短路(`useAiConversations.ts:236`)→ 清 in-flight 文本 + 全量重拉 + 重复恢复审批(P1-2) - [x] **CSW-P1-6**✅ 已修:`useAiConversations.ts:255-259` `if (!force && id === state.activeConversationId) return` 同 id 短路,不清文本不重拉
- [ ] **CSW-P1-7**切后台生成中会话丢「切换 IPC 往返窗口」的流式 token(`useAiConversations.ts:240-276` active 在 await 后置 + `useAiEvents.ts:881-890` 非当前 delta 直接 drop)→ 回复中间缺块 - [x] **CSW-P1-7**✅ 已修:`useAiConversations.ts:269/303-305` switchingConvs 集合缓冲切换往返窗口;`useAiEvents.ts:110-115` 非当前命中 switchingConvs 的 delta 累积到 per-conv 流式态而非 drop
- [ ] **CSW-P1-8**switch parse 失败 active/messages 错配(`useAiConversations.ts:276,303` catch 向旧 messages push 错误气泡但 active 已置新 id)→ 视图显示旧会话 - [x] **CSW-P1-8**✅ 已修:`useAiConversations.ts:333-335` 先 messages 后 active 同一同步块,parse 失败 catch :336-348 不切走,无 active/messages 错配
**🔵 P2(打磨/边缘)**: **🔵 P2(打磨/边缘)**:
- [ ] **CSW-P2-1** — newConversation `clearAllApprovalTimers`(switch/delete 都有,`useAiConversations.ts:67-98` 没有)→ 旧 conv 审批超时到点误拒+气泡进新会话 - [x] **CSW-P2-1** ✅ 已修:`useAiConversations.ts:71-73` newConversation 首行 `clearAllApprovalTimers()`
- [ ] **CSW-P2-2**后端 reload 清 session_trust/model_override/agent_language(`conversation.rs:343-348`)→ 切走再切回信任遗忘,重复审批 - [x] **CSW-P2-2**✅ 已修:`conversation.rs:356/400-405``!conv_was_present` 才复位 session_trust/model_override/agent_language
- [ ] **CSW-P2-3**save 并发全量重写竞态(「单线程 save 串行」不成立,`conversation.rs:427`),DELETE+INSERT 吞新行 + token 丢 - [x] **CSW-P2-3**✅ 已修:`conversation.rs:211-226` save_conversation_serialized 持 per-conv 锁(`mod.rs:696-701` save_locks)串行化 DELETE+INSERT,20+ 调用点无旁路
- [ ] **CSW-P2-4**clear_session_allowed_dirs 使后台 loop 文件工具静默重挂审批(真并发下 session 授权语义错配) - [x] **CSW-P2-4**✅ 已修:`state.rs:561-565` clear_session_allowed_dirs_if_idle 仅在 `active_convs().is_empty()` 才清,后台 loop 文件工具不丢临时授权(三调用点 create/switch/delete 均用 _if_idle)
- [ ] **CSW-P2-5**新建无防抖/双击无短路 → 多个空会话 + 虚拟项堆积;虚拟会话切回触发 create-new 复制(P2-7/8) - [x] **CSW-P2-5**✅ 已修:`useAiConversations.ts:67/75-76/110` `_newConvLock` 防抖 + 300ms 释放
- [ ] **CSW-P2-6**setStreaming 忽略 `opts.convId` 按键 active(`streamingGuard.ts:51-80`)→ detach/restore 生成态错乱(待确认) - [x] **CSW-P2-6**✅ 已修:`streamingGuard.ts:78` 带 convId 写 per-conv 流式态,无 convId 才写全局
- [ ] **CSW-P2-7** — 切生成中会话快照陈旧 + 恢复双占位气泡;d2 分离窗口视图不随主窗口失效;跨端生成中会话不流式(待确认)
**其他已销项**:CSW-P2-7(需实测确认,从待办去除)
---
### 🔍 2026-08-09 TaskDetail 操作按钮缺陷走查(用户实测 + AI 深度审查)
> 用户反馈"产出部分只有标题空白",触发对 TaskDetail.vue 所有操作按钮的全量走查。覆盖模板/脚本/后端 advance_task / run_workflow 回调链路。共发现 10 项缺陷,按严重度 P0×2 / P1×4 / P2×4 分组。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
>
> **✅ 2026-08-09 全部修复并核验销账**:主代理独立核查(vue-tsc EXIT 0 + diff 语义审)通过。补充说明:P1-4 核验当前代码已满足(advanceChild/setChildPriority 已含 `childMenuId.value = null`),仅销账未改;P1-5 实现位置优化为 watch(taskId)/refresh/handleAdvance 成功三入口调用 `resetWorkflowUi()`(load() 内不加,避免工作流推进中 df-data-changed 触发刷新误清进度面板);P2-10 描述编辑对齐 IdeaDetail 模式(有描述/空描述均可编辑,taskApi.update 落库)。验收标准 1-9 全部满足。
**涉及文件**
- `src/views/TaskDetail.vue`987 行,主要改动点)
- `src-tauri/src/commands/task.rs`advance_task,仅参考)
- `src-tauri/src/commands/workflow.rs`run_workflow 回调,仅参考)
- `crates/df-nodes/src/task_advance_node.rs`advance_task_atomic,仅参考)
---
## 🔴 P0 缺陷(2 项,必须修)
### P0-1 双重推进竞态:工作流推进中手动推进按钮仍可用
- **位置**TaskDetail.vue:48 vs :55
- **现状**:工作流按钮 `:disabled="wfAdvancing || advancing"`(检查两者),手动按钮 `:disabled="advancing"`(只检查 advancing
- **问题**`wfAdvancing=true` 时手动按钮仍可点 → 同时发起 `run_workflow`(回调 advance+ 直调 `advance_task` 两个推进请求 → 第一个成功改 status,第二个 CAS 失败返回 Err → 前端显示红色错误,用户困惑"明明成功了为什么报错"
- **修复**:手动推进按钮 `:disabled``|| wfAdvancing`1 行)
### P0-2 工作流推进与手动推进按钮语义重复(todo 状态双"开始"
- **位置**TaskDetail.vue:45-57
- **现状**:todo 状态同时显示「工作流推进」+「开始」两个按钮,都推进到 in_progress
- **问题**:用户不知道该点哪个;设计意图是工作流推进会跑 AiNode 生成产出,手动"开始"只是纯改状态,但按钮文案没有区分
- **修复**:工作流推进按钮文案改为「AI 执行推进」或「工作流推进(含 AI 产出)」
---
## 🟠 P1 缺陷(4 项)
### P1-3 子任务快捷菜单不校验状态机合法性
- **位置**TaskDetail.vue:286-296
- **现状**`quickStatuses` 硬编码列出全部 6 态 [todo, in_progress, in_review, testing, done, blocked],不按子任务当前 status 过滤
- **问题**:todo 状态子任务的菜单显示 testing/done/in_review 等非法目标,点下去后端 can_transition 拒绝 → 红色错误
- **对比**:顶部手动推进按钮用 `ADVANCE_MAP` 严格按状态机过滤,子任务菜单却无此逻辑
- **修复**:子任务菜单复用 `ADVANCE_MAP[child.status]` 过滤合法目标
### P1-4 子任务操作后快捷菜单不关闭
- **位置**TaskDetail.vue:326 advanceChild / :337 setChildPriority
- **现状**:操作完成后不 `childMenuId.value = null`
- **问题**:点了"推进到 done"或"改优先级"后菜单仍挂着悬浮
- **修复**:两函数操作完成后 `childMenuId.value = null`2 处)
### P1-5 refresh/切任务后工作流进度/DAG 残留
- **位置**TaskDetail.vue:619 load() 函数
- **现状**load() 只重置 task/projects/ideas,不清 `wfExecId` / `wfDagJson` / `wfResult`
- **问题**:切任务或点 refresh 后右栏仍显示旧任务的工作流进度和 DAG
- **修复**load() 开头加 `wfExecId.value = null; wfDagJson.value = ''; wfResult.value = null`3 行)
### P1-6 手动推进后工作流进度面板不清空
- **位置**TaskDetail.vue:470 handleAdvance
- **现状**:成功后只 `task.value = updated`,不清工作流相关 ref
- **问题**:手动推进后右栏仍显示旧工作流进度
- **修复**handleAdvance 成功后清空 wfExecId/wfDagJson/wfResult
---
## 🟡 P2 缺陷(4 项)
### P2-7 子任务创建无长度/重名校验
- **位置**TaskDetail.vue:365-381 confirmSubtask
- **现状**:标题只 `trim()` 判空,不校验长度上限/重名;成功路径不清 `errorMsg.value`
- **修复**:加长度上限(如 128 字)、同父重名校验、成功路径清 errorMsg
### P2-8 wfResult timer 生命周期不严谨
- **位置**TaskDetail.vue:424 watch 回调
- **现状**:工作流完成后 setTimeout 3s 清 wfResult;切任务时 timer 仍在但 wfExecId 已清
- **影响**:低风险(最多多等 3s),但 timer 引用管理不严谨
- **修复**load() 重置时清 `_wfResultTimer`
### P2-9 子任务页无"是子任务"提示
- **位置**TaskDetail.vue:108(父面包屑)+ :133(子任务面板仅顶层显示)
- **现状**:子任务详情页结构与顶层任务几乎一样,只有顶部多一行父面包屑
- **修复**:加一行提示「该任务为子任务,隶属于:{parentTitle}」
### P2-10 空描述无编辑入口
- **位置**TaskDetail.vue:68 `<section v-if="task.description">`
- **现状**:无描述时描述面板整块消失,无"添加描述"入口
- **修复**:无描述时显示空态 + "添加描述"入口
---
## 验收标准
1. 工作流推进中手动推进按钮禁用,无双重推进竞态
2. todo 状态两个按钮文案可区分(工作流推进 vs 纯手动开始)
3. 子任务快捷菜单只显示状态机合法目标
4. 子任务操作后菜单自动关闭
5. 切任务/refresh/手动推进后右栏工作流进度正确重置
6. 子任务创建有长度/重名校验,错误提示清晰
7. 子任务页有"是子任务"明确提示
8. 空描述有编辑入口
9. vue-tsc EXIT 0
---
**关联**:父任务「总览页 + 项目列表 UI/UX 优化(持续更新)」(id: 0115aea6-b423-482a-8a64-b793a66a0417)—— 本走查作为该任务的子任务延伸。
---
### 🔍 2026-08-09 项目详情页走查(概览/文件/依赖图 · 3 agent 并行 + 主代核查)
> 用户要求「检查项目详情(概览、文件、依赖图)存在的问题」并「先检查做记录,不直接改」。详单见 [项目详情页走查-2026-08-09.md](./05-代码审查/项目详情页走查-2026-08-09.md)。共 **60 项**P0×1 / P1×6 / P2×14 / P3×27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P0(核心不可用)**:
- [x] **PD-P0-1** — ✅ 已修(2026-08-09 e6760e8):DependencyGraph 调 `register({shape:'vue-shape',component:ModuleNode})` + ModuleNode 改 `props.node?.getData?.()` 双兜底(vue-shape 传 {node,graph} 非 data,主代核 x6 源码确认)
**P1(数据正确性/一致性·6 项)**:
- [x] **PD-P1-1** — ✅ 已修(a9cb802):module.rs git status 加 `-c core.quotepath=false` 一行根治中文乱码
- [x] **PD-P1-2** — ✅ 已修(e6760e8):remove_project_module 删工程前级联清 module_dependencies(FK 约束)
- [~] **PD-P1-3** — 🟡 已降级标注(2026-08-09 a9cb802):WorkflowEventPayload 无 project 维度无法按项目过滤 → 实时进度面板默认隐藏(wfProgressEnabled)+ 日志默认折叠+「全局事件」标注,不改事件协议(跨层大改留设计)
- [x] **PD-P1-4** — ✅ 已修(a9cb802):projectTasks 加 df-data-changed 本地监听(entity=task,200ms 防抖,overview 活跃才刷)
- [x] **PD-P1-5** — ✅ 已修(a9cb802):df-data-changed 写文件后重拉 gitStatus + GitChanges refreshKey prop(修徽标清零+不刷新)
- [x] **PD-P1-6** — ✅ 已修(a9cb802):FileTree toggleDir 错误改行内态(toggleErrors 按 path),不炸整树
**P2(体验/打磨·14 项)**:
- [x] **PD-P2-1** ✅ 已修(a9cb802):watch(activeTab) 切回 overview 重拉 modules / **PD-P2-2** ✅ 已修:loadProjectTasks/loadModules 加 seq 序号守卫 / **PD-P2-3** 🟡 DAG 边条件编辑父级未监听(a9cb802 概览进度面板默认隐藏,编辑入口随隐藏;TaskDetail 场景不受影响,待评估) / **PD-P2-4** 🟡 stopEventListener 波及他视图(共享单例,留设计) / **PD-P2-5** ✅ 已修:watch(projectId) 重置折叠态
- [x] **PD-P2-6** 🟡 概览任务无 loading/分页(未做,列表上限 500 通常够,待评估) / **PD-P2-7** ✅ 已修:constants/project.ts 补全 5 状态 + i18n / **PD-P2-8** ✅ 已修(e6760e8):环高亮改 isCycle 注入 data + ModuleNode 红框 / **PD-P2-9** ✅ 已修:节点点击改组件内 selectedId 选中高亮(移除死代码 emit+push) / **PD-P2-10** ✅ 已修(a9cb802):零工程空态引导 CTA
- [x] **PD-P2-11** 🟡 打开文件 tab 全量 git 扫描(后端 5s 缓存已缓解,懒加载留设计) / **PD-P2-12** ✅ 已修:FileExplorer/DependencyGraph 用 KeepAlive 保活(onActivated 守卫) / **PD-P2-13** ✅ 已修(e6760e8):loadModules 重置 cycleNodes / **PD-P2-14** ✅ 已修:前端禁用已存在组合 + 后端幂等
**P3(边缘/打磨·27 项)**: 见详单文档(概览 O10-18 / 文件 F13-24 / 依赖图 G14-18) — 部分已随批修(见下)
> 已随批修 P3O7 sourceScores clamp / O8 descCollapsible DOM 测量 / O9 handleSync 补模块+灵感 / O10 parseStack 缓存 / 依赖图 G10 防环文案 cycleAddError / G14 后端 from!=to 校验 / G16 省略号统一。其余待后续批。
> **优先级建议**P0 G1 节点不渲染 → P1 F3 中文乱码 + G3 删依赖必失败 + O2 跨项目串数据 + O1 列表陈旧 + F1 变更不刷新 → P2 分批。
> **memory 指针**[[devflow-project-detail-walkthrough]]
---
### 🔍 2026-08-09 aichat 运行效率走查(6 路并行 · 单轮成本/工具/流式/前端/会话/LLM成本)
> 用户反馈「aichat 运行的效率很低」+「成本走查,比如有的 LLM 要求的缓存命中率规则」。详单见 [aichat效率走查-2026-08-09.md](./05-代码审查/aichat效率走查-2026-08-09.md)。共 **74 项**(单轮13/工具9/流式12/前端12/会话16/LLM成本12)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**LLM 成本/缓存命中(用户重点 · P0×2)**:
- [x] **AC-EFF-C0-1** — ✅ 已修(2026-08-09 d09d136):AnthropicRequest.system 改 untagged SystemBlock 枚举(Plain/Cached),cache_control:{type:ephemeral},开关 ANTHROPIC_CACHE_ENABLED 默认关
- [x] **AC-EFF-C0-2** — ✅ 已修(d09d136):system 稳定段/易变段分离——日期+项目/任务清单+augmentation+知识注入挪消息流末尾 merge_volatile_tail(治跨天失效+前缀稳定),开关关逐字等价现状;df-ai 492 测试通过
- [~] **AC-EFF-C1-1** ✅ 日期挪易变段已修 / **C1-2** 🟡 每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批) / **C1-3** ✅ augmentation+知识注入挪消息流已修
**后端效率(单轮成本 + 会话 + 工具 · P0×4)**:
- [x] **AC-EFF-R1-1** 🟡 全量历史重放(cache_control 命中后 O(n²) 实际降为 cache_read,增量机制待后续) / **AC-EFF-R2-1** ✅ 已修(0300764):count_recent_failures+check_stall_breaker 改 recent_messages 尾窗口 40 条
- [x] **AC-EFF-L0-1** — ✅ 已修(0300764):save_conversation 无变化捷径提前判定(无变化不 clone/truncate/构建 records)
- [x] **AC-EFF-L0-2** — ✅ 已修(0300764):system prompt 数据指纹缓存(OnceLock+entity_fingerprint,省每轮 DB 查询+拼接)
- [x] **AC-EFF-L0-3** — ✅ 已修(c41f4ad):压缩后台化 tokio::spawn fire-and-forget + is_compressing 防重入,当前轮正常走不阻塞
- [x] **AC-EFF-T1-1** ✅ 已修(c41f4ad):AiToolExecutionRepo.insert_batch 单事务批量,审计砍串行尾巴 / **T1-2** ✅ 已修:只读缓存轮内去重(同轮重复 (tool,args_key) 只真执行一次) / **T1-3** 🟡 git 工具不进缓存(留待后续,module.rs git 缓存未共享)
**前端效率(流式 + 交互 · P1×4)**:
- [x] **AC-EFF-S1-1** ✅ 已修(c41f4ad):流式增量渲染(已完成块跨帧缓存 + 只重 lexer 末块增长段,单块>12000 降级纯文本) / **S1-2** 🟡 长尾代码块 hljs(随 S1-1 缓解,高亮缓存留待)
- [x] **AC-EFF-F1-1~3** — ✅ 已修(0300764):scheduleConversationsRefresh 250ms trailing debounce 收敛回合双拉+notify 自触发+无防抖
- [x] **AC-EFF-S2-1** ✅ 已修(c41f4ad):MessageList 双渲染合并(currentText 条件改 streamingBlocks.length>0,每 delta 只 rAF 一次) / **S2-5** ✅ 已修:AiCommandOutput 50ms+4KB 合批(砍事件风暴)
**P2/P3 其余 50+ 项**: 见详单文档(R2/R3/T2/T3/S2/S3/F2/F3/L1/L2/L3/C2/C3 全量)
> **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。
> **memory 指针**[[aichat-efficiency-walkthrough-2026-08-09]]
### 🔍 2026-08-09 任务/仪表盘/灵感知识走查(3 路并行 · 阶段②第一批)
> 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性,优先修)**:
- [x] **WK-1** ✅ 已修(2026-08-09 0554cc1):ActiveProjectsPanel 改 status !== completed/cancelled + 状态映射对齐 7 态(in_progress→coding/testing→testing/releasing→release),删 active 键
- [x] **WK-2** ✅ 已修:Knowledge 操作前快照 index,selectNextCandidate 直接按同位置取后续
- [x] **WK-3** ✅ 已修:updateStatus/archive 返布尔,失败 toast + 中止推进
- [x] **WK-4** ✅ 已修:promoteIdea/relateIdeas 去内部 loadIdeas + _activeIdeaQuery + Ideas.vue loadCurrentView()
- [x] **WK-5** ✅ 已修:Ideas/IdeaDetail 状态菜单排除 promoted
- [x] **WK-6** ✅ 已修:7 态 status-* 色类提取全局 components.css(列表+详情+项目统一)
- [x] **WK-7** ✅ 已修:Ideas 改 error-banner,失败不清理选中态,deleteIdea 返布尔
**P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续
**P327 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3
> **优先级**WK-1 Dashboard 状态映射 → WK-2/3 Knowledge 确定性 → WK-4/5 Ideas → WK-6 状态徽章 → WK-7 错误条统一。
### 🔍 2026-08-09 Settings/审计/布局/后端命令走查(3 路并行 · 阶段②第二批)
> 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**Settings+AuditLog29 / layout+sidebar21 / 后端命令45P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性/安全,优先修)**:
- [x] **LW-1** ✅ 已修(0554cc1):ConfirmDialog Enter 分支加 BUTTON 排除(焦点在取消不再误触删除)
- [x] **LW-2** ✅ 已修(7795fe6):update_project 白名单剔除 id/created_at + status 值校验 + path relocate 同款校验 + stack JSON 校验
- [x] **LW-3** ✅ 已修(7795fe6):update_idea status 值校验 + 拒绝 status=promoted + related_ids/scores JSON 校验
- [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验
- [x] **LW-5** ✅ 已修(7795fe6):count_by_query 补齐 assignee/queue/parent_id/module_id 维度
- [x] **LW-6** ✅ 已修(7795fe6):update_field_active(WHERE id AND deleted_at IS NULL) 软删拒改
- [x] **LW-7** ✅ 已修(7795fe6):evaluate_idea 起始 guard 终态返 Err
- [x] **LW-8** ✅ 已修(7795fe6):claim_promotion CAS(WHERE id AND promoted_to IS NULL) 防双击重复立项
- [x] **LW-9** ✅ 已修(7795fe6):create_task/update_task parent_id/move_task_queue 后 recompute_parent_status
**P2(体验/数据·32 项)**: 见详单文档(SA-1~8 审计 i18n/脏表单/竞态、LS-2~10 侧栏偏好/小屏布局/审批浮窗/Paginator、BE-CMD-9~23 后端校验/聚合)
**P354 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45
> **优先级**LW-1 ConfirmDialog 安全 → LW-2/3 update 裸奔 → LW-4 queue → LW-5 count/list → LW-6 软删 → LW-8 父聚合。
> **全系统索引**:前端 13 + 后端 9 命令全走查完成 ✅,剩 crate df-mcp/df-execute/df-project/df-ideas/df-tunnel/df-relay/df-types。
### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批)
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1**:
- [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口)
- [x] **MC-2** ✅ 已修(4387695):df-project normalize_path 拆 normalize_for_compare/canonicalize_for_store,存库保留大小写(调用点迁移留后续)
- [x] **MC-3** ✅ 已修(4387695):df-tunnel 文档更正——自动重连已在 src-tauri supervisor 实现(lib.rs:292 轮询+指数退避),避免双重竞争;backoff_delay 改 pub 供 supervisor 复用
- [x] **MC-4** ✅ 已修(4387695):df-relay 成本评估选文档明示风险 + validate_device_id 最小加固(per-device token 留 Phase3)
- [x] **MC-5** ✅ 已修(7795fe6):is_success_tool_call 按 result.isError 判定,业务失败不触发写回调
- [x] **MC-6** ✅ 已修(7795fe6):create_idea priority normalize + create_project/task 拒空名 + bind_directory 降级;evaluate 复用 ScoringEngine 未做(独立口径声明,df-mcp 不依赖 df-ideas 避免重 HTTP 依赖树)
- [x] **MC-7** ✅ 已修(2026-08-09):df-ideas 自洽性校验(enforce_score_consistency 分档一致性,ARC-260618-01-e 已实施) + df-types Priority::from_i32(0-3/Low 兜底对齐读侧) + script 措辞修正
- [x] **RLY 附加** ✅ 已修(4387695):token 固定时间比较 + 入站帧 1MB 上限 + 未设 token 改错误退出(不再 panic)
**P325 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(task_id 断裂 + AiNode 缺 prompt + 审批无 UI
> 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**核心结论:工作流推进按钮从 TaskDetail 触发时,三条推进链(in_progress/testing/done)全部不可用**。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
**涉及文件**
- `src-tauri/src/commands/workflow.rs`run_workflow_inner 核心)
- `crates/df-nodes/src/task_workflow_templates.rs`(三模板)
- `crates/df-nodes/src/ai_node.rs` / `ai_self_review_node.rs` / `ai_node_helpers.rs` / `human_node.rs`
- `crates/df-workflow/src/executor.rs` / `dag.rs` / `registry.rs` / `dag_def.rs`
- `src/views/TaskDetail.vue` / `src/stores/project/workflow.ts` / `src/views/ProjectDetail.vue`
---
## 🔴 P0 缺陷(3 项,工作流推进 100% 失败)
### P0-A task_id 断裂:AiSelfReviewNode 必定失败
- **位置**`workflow.rs:295` `executor.run(&runtime_dag, config)` + `executor.rs:153`
- **现状**:前端传 `config = {}`TaskDetail.vue:590),`run_workflow_inner` 拿到 `task_id` 但**只存进 WorkflowRecord + 回调闭包 cb_task_id,从未注入传给 executor 的 config**。DagExecutor 构造 NodeContext 时 `deep_merge(config={}, node_configs[id])`testing 模板节点 config 只含 `{"gate":true}`。最终 `ctx.config` **无 task_id**
- **结果**`ai_self_review_node.rs:100` `ctx.config.get("task_id")` → None → `Err("AiSelfReviewNode 缺少必填参数: task_id")` → 节点失败 → 工作流 failed。
- **修复**`run_workflow_inner``executor.run` 前把 `task_id` 注入 config`config["task_id"] = task_id`1 行)。
### P0-B AiNode 缺 promptin_progress 模板必定失败
- **位置**`task_workflow_templates.rs:45-47` `dag.add_node("ai_execute", "ai", json!({}))`
- **现状**in_progress 模板 AiNode config 为空对象。注释称「prompt / provider / task_id 均由 run_workflow 全局 config 注入」,但前端传空 config、后端不注入。AiNode 的 `parse_params`ai_node_helpers.rs:189-200)取 `inputs["prompt"]` > `config.prompt`,两者皆无 → `Err("AiNode 缺少必填参数: prompt")`
- **结果**todo→in_progress 推进链 AiNode 必定报错,任务原地不动,无 AI 产出。
- **修复**AiNode 加默认 prompt 兜底(基于 task.description 生成执行方案),或 run_workflow 注入。
### P0-C TaskDetail 无审批 UItesting/done 模板的 HumanNode 审批无法操作
- **位置**`TaskDetail.vue`(无 ApprovalDialog 组件)+ `ProjectDetail.vue:353-356`(唯一渲染处)
- **现状**:工作流 store 的 `startEventListener`workflow.ts:111-144)收到 `human_approval_request` 会设 `state.pendingApproval`。但**只有 ProjectDetail.vue 有 `watch(() => store.pendingApproval)` 打开 ApprovalDialog**ProjectDetail.vue:838-841)。TaskDetail.vue 只调 `store.startEventListener()`TaskDetail.vue:674),**不渲染 ApprovalDialog、不 watch pendingApproval**。
- **结果**:用户从 TaskDetail 点「工作流推进」进入 testingin_review→testing 模板含 ai_self_review + human_review)或 donetesting→done 模板含 human)模板,HumanNode 发出审批请求 → `state.pendingApproval` 被设但**无弹窗** → 用户无法批准/拒绝 → HumanNode 等满 30min 默认超时(human_node.rs:34 DEFAULT_TIMEOUT_SECS=1800)→ 工作流 failed。
- **修复**TaskDetail 渲染 ApprovalDialog + watch pendingApproval,或将审批弹窗做成全局(独立于 ProjectDetail/TaskDetail)。
---
## 🟠 P1 缺陷(3 项)
### P1-D in_progress 失败无退回信号
- **位置**`task_state_machine.rs:156` `regression_target(IN_PROGRESS) => None`
- **现状**in_progress 模板失败(AiNode 缺 promptP0-B)时,`regression_target("in_progress")` 返回 None → 回调跳过推进。任务**原地留在 todo**,无错误提示、无退回态、无任何信号(回调只 `tracing::warn!`)。
- **结果**:用户点「工作流推进」→ 后台静默失败 → 任务不动,前端 wfResult=failed 提示但无具体原因。
- **修复**:AiNode 失败时把错误信息透出到前端(WorkflowFailed.error 已含,但前端 wfFailedHint 只显通用文案)。
### P1-E done 模板失败后任务卡 testing 无退回
- **位置**`task_state_machine.rs` `regression_target(DONE) => None`
- **现状**testing→done 模板(human 节点)若用户拒绝(返回 Err)→ 工作流 failed → `regression_target("done")` → None → 任务**留在 testing**,未退回 in_review。
- **问题**:done 是终态,无可退态合理,但用户拒绝后应停留 testing 让人工处理,当前无明确信号提示「推进未完成,任务仍在 testing」。
### P1-F AiSelfReview 审查空产出
- **位置**`ai_self_review_node.rs:129-134`
- **现状**:若 in_progress 阶段 AiNode 失败(P0-B),output_json 为空 → 自审节点审查占位文本 `"(无产出,仅依据需求审查)"`
- **结果**:即使 task_id 修好,无产出的自审也只是空洞审查,无意义。
---
## 🟡 P2 缺陷(3 项)
### P2-G 前端注释误导:「运行时注入」实际无注入
- **位置**`TaskDetail.vue:590` 注释「全局 config 留空(节点级 config 由模板/运行时注入)」
- **现状**:模板只注入 gate:true,运行时(run_workflow_inner)不注入 task_id/prompt。注释承诺与实际不符,误导后续维护者。
### P2-H 三模板归一度差:in_progress 纯 AI / testing AI+Human / done 纯 Human
- **位置**`task_workflow_templates.rs`
- **现状**:三模板职责不统一——in_progress 无人工确认直接改状态,testing 有 AI 自审 + 人工,done 纯人工。用户对「工作流推进」在三个状态的体验差异巨大但不透明。
- **建议**:至少统一「推进是否有人工确认」的预期,或在按钮文案/进度提示中说明当前推进链内容。
### P2-I WorkflowRecord 未存 project_id
- **位置**`workflow.rs:170` `project_id: None`
- **现状**:即使 task_id 关联任务(任务有关联 project_id),执行记录 `project_id` 恒为 None。按项目过滤工作流执行记录时无法直接从 workflow 关联项目。
- **修复**task_id 有值时反查任务 project_id 填入。
---
## 验收标准
1. task_id 注入 AiNode/AiSelfReviewNode config,自审节点能读到任务
2. in_progress 模板 AiNode 有默认 prompt,能生成产出
3. TaskDetail 触发工作流后,HumanNode 审批弹窗正常弹出可操作
4. 工作流失败时前端显示具体错误原因(非通用文案)
5. 老工作流(无 task_id/target_status)零行为破坏
6. cargo check --workspace EXIT 0 + vue-tsc EXIT 0
---
**🟡 P1**:
- [x] **SC-260811-P1-1** ✅ 已销账(2026-08-15 核验,1b012bc 覆盖):`task_state_machine.rs:155 shortest_path` BFS 推导 + `task_advance_node.rs:81-84` 非法转换错误信息附加最短路径提示(路径>2 跳时),+6 单测
**🔧 既有测试失败修复(2026-08-11)**:累计修 7 项既有失败,df-nodes 175 全过 / df-storage 166 全过:
- ✅ `ai_self_review_node::clamp_dimension_scores_in_place` 类型保真:区间内整数保持整数(9 不转 9.0),仅越界 clamp——修 parse_review_json_valid_passes_through,同步调 clamps_out_of_range_scores 边界值断言(10 保持整数)
- ✅ `build_review_prompt` 模板说明文字裸 `<task_requirements>` 与真实标签同名,测试 find 首现误命中行内引用→真实标签加换行锚定定位(`\n<tag>\n`),说明文字标签加反引号
- ✅ `project_module_repo::setup` async 上下文直接 `blocking_lock()` 触发 "Cannot block the current thread" panic→改用 `ProjectRepo::insert` 异步 API(对齐 service_repo 风格)
---
**关联**:本走查与「2026-08-09 TaskDetail 操作按钮缺陷走查」同属 TaskDetail 交互审查线,但本段聚焦 AI 工作流推进链路(后端 + 审批 UI),按钮缺陷聚焦 TaskDetail 自身交互。修复时建议合并处理(都在 TaskDetail + workflow 链路)。
+65
View File
@@ -18,8 +18,10 @@
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"sortablejs": "^1.15.7",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
@@ -28,6 +30,7 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.7", "vite": "^6.0.7",
@@ -1633,6 +1636,13 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1861,6 +1871,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
@@ -1936,6 +1955,15 @@
"layout-base": "^1.0.0" "layout-base": "^1.0.0"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -2666,6 +2694,19 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -3092,6 +3133,12 @@
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3107,6 +3154,15 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz",
@@ -3171,6 +3227,15 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "14.0.1", "version": "14.0.1",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz",
+3
View File
@@ -23,8 +23,10 @@
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"sortablejs": "^1.15.7",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "9", "vue-i18n": "9",
@@ -33,6 +35,7 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.7", "vite": "^6.0.7",
@@ -1,11 +1,11 @@
//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。 //! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。
//! //!
//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更 //! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)
//! //!
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 //! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
//! //!
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要; //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); //! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
//! //!
@@ -14,14 +14,21 @@
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens, //! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。 //! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。 //! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发 //! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁;L0-3 后台化后窗口拉长
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。 //! (loop 继续推进,可能 push 新消息),但 loop 只 push 增长、protect_start 基于触发时 len,
//! 压缩区间始终落在当前轮之前,不误伤当前轮(见 maybe_auto_compress L0-3 注释)。
//! //!
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve //! 安全(L0-3 后台化后):provider 在后台 task 内经 `secret::build_provider_for`
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。 //! (不再复用 loop 顶部已 build 的 provider——后者是 Box 非 Clone 无法移入 task;多一次
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。 //! keyring resolve 在后台线程不阻塞 loop),api_key 经 df_storage::secret 闭环;
//! summary/error payload/日志均不含 api_key。
//! is_compressing 防重入:set_compressing(true) 在触发时置位,后台 task 各出口
//! (成功/失败/conv 已删/provider 重建失败)均复位,防重入不泄漏。
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 //! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
//! messages 操作改 per_conv。 //! messages 操作改 per_conv。
//!
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::sync::Arc; use std::sync::Arc;
@@ -29,7 +36,7 @@ use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::{ChatMessage, LlmProvider}; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::state::{AppState, LlmConcurrency}; use crate::state::{AppState, LlmConcurrency};
@@ -53,19 +60,26 @@ const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// ///
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值: /// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
/// 触发时仅做零成本准备(读触发条件 / 读 active 克隆 / 算关键词兜底)后立即 `tokio::spawn`
/// 独立 task fire-and-forget 执行压缩,本函数返回 `false` 让当前轮**用未压缩上下文正常走**
/// (build_for_request 不受影响,当前轮回复不丢);压缩结果由后台 task 在完成时更新
/// per_conv.messages(标 compressed + 插摘要),**下一轮** build_for_request 生效。
/// `is_compressing` 防重入:触发时置 true,后台 task 各出口(成功/失败/conv 已删)均复位。
///
/// 返回值:
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。 /// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。 /// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
/// ///
/// 参数沿用原内联代码引用的局部: /// 参数沿用原内联代码引用的局部:
/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。 /// - `provider_config`:compress 沿用主 candidate(非 fallback 范围)。后台 task 内经
/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。 /// `secret::build_provider_for` 重建 provider(复用 loop 顶部同款三步打包,仅多一次
/// keyring resolve,在后台线程不阻塞 loop)。
/// - `iteration`:当前轮次(仅用于日志)。 /// - `iteration`:当前轮次(仅用于日志)。
pub(super) async fn maybe_auto_compress( pub(super) async fn maybe_auto_compress(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
app_handle: &AppHandle, app_handle: &AppHandle,
provider: &Box<dyn LlmProvider>,
provider_config: &AiProviderRecord, provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency, llm_concurrency: &LlmConcurrency,
iteration: usize, iteration: usize,
@@ -142,7 +156,7 @@ pub(super) async fn maybe_auto_compress(
} }
} }
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 // 改进3 B:在 active_msgs move 进压缩前,先算关键词摘要兜底文本。
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED { let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
@@ -151,78 +165,68 @@ pub(super) async fn maybe_auto_compress(
String::new() String::new()
}; };
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 // 保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop // 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() { if active_msgs.is_empty() {
Ok(None) session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
} else { return false;
compress_via_llm( }
provider.as_ref(),
provider_config,
active_msgs,
&lang,
conv_id,
llm_concurrency,
).await.map(Some)
};
match compress_outcome { // ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
{
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
}
tracing::info!(
conv_id = %conv_id,
iteration,
pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功,摘要已插首位"
);
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.to_string()),
summary: summary.nl_summary,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
Ok(None) => {
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
}
Err(e) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
// //
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 // 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): // 期间 loop 无法推进 build_for_request / stream,当前轮回复整体延迟。
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, //
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); // 现改为 spawn 独立 task 后台执行压缩,本函数立即返回 false:
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; // - 当前轮 build_for_request 用**未压缩上下文**,回复不受压缩阻塞影响(关键:不丢当前轮);
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 // - 后台 task 完成后更新 per_conv.messages(compress_old_messages + insert 摘要),
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 // 下一轮 build_for_request 生效;
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) + // - is_compressing 防重入:触发时已置 true(上方),后台 task 各出口(成功/失败/conv 已删)
// 消息状态不变,降级 build_for_request 裁剪) // 均复位,下一轮 maybe_auto_compress 检测到 true 即跳过,不会并发二次压缩
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); //
// 选型理由(后台化 vs 降级独立线程):并发安全成立——所有 per_conv 访问经
// session_arc 锁(数据无竞争),且压缩只读「触发时刻快照的 active_msgs」+ 完成后
// 一次性 mutate,与 loop 的 push 均为「锁内短段」,protect_start 基于触发时 len
// (loop 只 push 增长,压缩区间始终落在当前轮之前),故选 tokio::spawn 后台化,
// 无需降级独立线程。provider 在 task 内重建(secret::build_provider_for),避免
// 与 loop 共享 Box<dyn LlmProvider>(后者非 Clone,无法移入 task)。
//
// 无感知降级:provider 重建失败 / LLM 失败 → 仅复位 is_compressing + 关键词兜底,
// 不发 AiError(前端 AiError 会误判生成结束致"压缩后停止"),loop 继续 stream。
let session_arc = session_arc.clone();
let app_handle = app_handle.clone();
let conv_id = conv_id.to_string();
let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.clone();
tokio::spawn(async move {
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
// 下轮 maybe_auto_compress 会重复触发(每次仅 spawn 后台任务,不阻塞 loop,但浪费)。
let provider = match super::super::secret::build_provider_for(&provider_config) {
Ok(p) => p,
Err(e) => {
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
error = %e, error = %e,
keyword_fallback_len = keyword_fallback.len(), keyword_fallback_len = keyword_fallback.len(),
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", "[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED, KEYWORD_FALLBACK_ENABLED,
); );
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。
{
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
if KEYWORD_FALLBACK_ENABLED { if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 // 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = { let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() { if !keyword_fallback.is_empty() {
@@ -236,16 +240,122 @@ pub(super) async fn maybe_auto_compress(
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
pre_tokens = pre_compress_tokens, pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)" "[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
); );
} }
} }
return;
}
};
// 压缩调用(复用主 candidate 的 provider_config,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err。
let compress_outcome: Result<Option<CompressedSummary>, String> =
compress_via_llm(
provider.as_ref(),
&provider_config,
active_msgs,
&lang,
&conv_id,
&llm_concurrency,
).await.map(Some);
match compress_outcome {
Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
// is_compressing 标志随 conv 销毁无意义,直接 return。
{
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
}
tracing::info!(
conv_id = %conv_id,
iteration,
pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功(后台),摘要已插首位"
);
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.clone()),
summary: summary.nl_summary,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
Ok(None) => {
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
Err(e) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
//
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有) // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm, // token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。 // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
{
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
} }
} }
tracing::warn!(
conv_id = %conv_id,
error = %e,
keyword_fallback_len = keyword_fallback.len(),
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
}
}
});
} }
} }
false false
+26 -5
View File
@@ -4,8 +4,8 @@
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppState; use crate::state::AppState;
use super::approval_timeout; use super::approval_timeout;
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider}; use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt; use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry}; use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState; use super::conv_state::ConvState;
@@ -94,7 +94,12 @@ pub async fn try_continue_agent_loop(
}; };
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string(); let conv_id_owned = conv_id.to_string();
let system_prompt = build_system_prompt(state, &lang).await; // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分,
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await
&& matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat");
let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
let session_arc = state.ai_session.clone(); let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone(); let tools_arc = state.ai_tools.clone();
@@ -103,7 +108,23 @@ pub async fn try_continue_agent_loop(
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await; // 知识注入:缓存模式拼到易变段末尾(追加消息流,不失效 system 缓存),否则拼 system
// (与旧路径逐字等价)。
let volatile_tail = if cache_on {
let knowledge = build_knowledge_context_for_conv(state, conv_id, &knowledge_config).await;
let mut tail = base_volatile;
if !knowledge.is_empty() {
tail = if tail.is_empty() { knowledge } else { format!("{}\n\n---\n{}", tail, knowledge) };
}
if tail.is_empty() { None } else { Some(tail) }
} else {
None
};
let system_prompt = if cache_on {
system_prompt
} else {
inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await
};
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst); let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
@@ -141,7 +162,7 @@ pub async fn try_continue_agent_loop(
}; };
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await; super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await;
}); });
} }
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -28,7 +28,7 @@ use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCa
/// - mediumLow+Medium 自动 /// - mediumLow+Medium 自动
/// - all:全自动无审批(完全 AI 接管) /// - all:全自动无审批(完全 AI 接管)
/// ///
/// C-260627patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。 /// patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。
pub(super) fn classify_risk_and_auto( pub(super) fn classify_risk_and_auto(
risk_level: RiskLevel, risk_level: RiskLevel,
auto_exec_mode: &str, auto_exec_mode: &str,
@@ -163,7 +163,7 @@ pub(super) async fn insert_pending_approval(
// High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。 // High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。 // 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。 // 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
let retry_count = detect_retry_count(audit_repo, &draft.id).await; let retry_count = detect_retry_count(audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 { if retry_count >= 1 {
let skip_msg = format!( let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
@@ -219,28 +219,41 @@ pub(super) async fn insert_pending_approval(
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
} }
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。 /// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
///
/// 判重语义:同会话内同工具同参数的重试。区别于旧实现按裸 tool_call_id 判重——
/// 弱模型 provider(如 sensenova)的 tool_call_id 每轮从 0 重排(gen_stream_0/1/2...),
/// 跨轮不同会话的合法调用会复用同一 id,裸 id 判重会把合法新操作误判为「同 id 死循环重试」
/// 静默跳过(实证 2026-08-11:推进任务被跳过 4 次,AI 却宣称成功)。
/// ///
/// 返回语义: /// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。 /// - 0:审计表无该 (conv, tool, args) 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该 /// - ≥1:审计表已有该 (conv, tool, args) 的落定记录(executed/failed/rejected/skipped_retry),
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed /// 即该调用此前已被审批执行过一次,LLM 又重试同参数 → 调用方据 ≥1 跳过执行 + emit Completed
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。 /// 断「超时/权限错→LLM 死循环重试→重新挂起→用户被迫二次授权」循环。
/// ///
/// 实现:查 `find_by_tool_call_id`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的 /// 实现:查 `find_by_conv_tool_args`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过), /// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。 /// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
/// ///
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。 /// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 { pub(super) async fn detect_retry_count(
audit_repo: &AiToolExecutionRepo,
conv_id: &str,
tool_name: &str,
args: &str,
) -> u32 {
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程) // 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
let rec = match audit_repo.find_by_tool_call_id(tc_id).await { let rec = match audit_repo.find_by_conv_tool_args(conv_id, tool_name, args).await {
Ok(opt) => match opt { Ok(opt) => match opt {
Some(r) => r, Some(r) => r,
None => return 0, // 无记录 = 首次 None => return 0, // 无记录 = 首次
}, },
Err(e) => { Err(e) => {
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e); tracing::warn!(
"[approval-retry] 查审计表 (conv={}, tool={}) 失败(降级无重试防护): {}",
conv_id, tool_name, e
);
return 0; return 0;
} }
}; };
+5 -2
View File
@@ -96,7 +96,7 @@ fn resolve_namespace_content(session: &AiSession, content: &str) -> String {
/// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_idprovider 每轮新 id /// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_idprovider 每轮新 id
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。 /// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。
/// ///
/// **治本F-05**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史, /// **治本**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史,
/// 若发现**已落定**executed/failed/rejected,非 pending)的同类同参高危调用,返回其 /// 若发现**已落定**executed/failed/rejected,非 pending)的同类同参高危调用,返回其
/// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。 /// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。
/// ///
@@ -215,7 +215,10 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存, /// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 /// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 /// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { ///
/// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key,
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
if matches!(tool_name, "list_directory" | "list_dir") { if matches!(tool_name, "list_directory" | "list_dir") {
args.get("path") args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -25,6 +25,10 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
"update_project" => ("project", "update"), "update_project" => ("project", "update"),
"update_task" => ("task", "update"), "update_task" => ("task", "update"),
"update_idea" => ("idea", "update"), "update_idea" => ("idea", "update"),
// advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
"advance_task" => ("task", "update"),
"delete_project" | "purge_project" => ("project", "delete"), "delete_project" | "purge_project" => ("project", "delete"),
"delete_task" => ("task", "delete"), "delete_task" => ("task", "delete"),
"delete_idea" => ("idea", "delete"), "delete_idea" => ("idea", "delete"),
+120 -9
View File
@@ -41,9 +41,12 @@ use approval::{detect_retry_count, handle_approval_tool};
// list_tool_executions 是 #[tauri::command]ToolExecutionDto 供前端 DTO 序列化)。 // list_tool_executions 是 #[tauri::command]ToolExecutionDto 供前端 DTO 序列化)。
pub mod record; pub mod record;
#[allow(unused_imports)] #[allow(unused_imports)]
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit}; pub(crate) use record::{audit_tool_call, build_audit_record, query_audit_history, record_audit};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use record::{list_tool_executions, ToolExecutionDto, ToolExecutionPage, ToolExecQuery}; pub use record::{
list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery,
ToolFailureStat, ToolFailureStats, ToolFailureStatsQuery,
};
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason // reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 // 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
@@ -67,7 +70,7 @@ pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。 // 第三批从本文件抽离,行为零变更。
mod cache; mod cache;
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for}; pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key};
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
@@ -373,7 +376,7 @@ pub(crate) async fn process_tool_calls(
.unwrap_or_default(); .unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default(); let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 // 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await; let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 { if retry_count >= 1 {
let skip_msg = format!( let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
@@ -533,7 +536,7 @@ pub(crate) async fn process_tool_calls(
}; };
let _ = app_clone.emit("ai-chat-event", ev.clone()); let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-11数据变更类工具执行成功后 emit df-data-changed 联动刷新 // 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop // write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(content)) (draft, risk_level, Ok(content))
@@ -591,16 +594,37 @@ pub(crate) async fn process_tool_calls(
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。 // 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。 // 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
// //
// AC-EFF-T1-2(2026-08-09)轮内去重:同轮重复 (tool, args_key) 只真执行一次。
// LLM 单轮可能发多条同参只读调用(失忆重调),原实现首查未命中(本轮首个尚未落 messages)
// 致重复全部真执行(重复 I/O + 重复 tool_result 回灌)。现进程内暂存首个结果
// (in_round_cache),后续同 key 复用——语义同跨轮缓存,仅因首个在本轮未落库故内存暂存。
// 命中计数递增威慑(cache_hit_warning 同口径);key 与跨轮缓存同源(readonly_cache_args_key),
// 防两处 key 口径漂移。跨轮重复仍走 find_cached_readonly_result(历史扫描,未变)。
//
// find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。 // find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
// in_round_cache:同轮 (tool, args_key) -> (首个结果内容, 已见同参次数)。首个出现:
// 跨轮缓存命中 → 存 (content, hit_count);未命中 → 真执行,backfill 后存 (content, 1)。
// in_round_dupes:同轮重复的 draft(延后到 backfill 后复用 in_round_cache,防双执行)。
let mut in_round_cache: HashMap<(String, String), (String, u32)> = HashMap::new();
let mut seen_round_keys: HashSet<(String, String)> = HashSet::new();
let mut in_round_dupes: Vec<(ToolCallDraft, RiskLevel, String)> = Vec::new();
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len()); let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
for (draft, args, risk_level) in low_risk { for (draft, args, risk_level) in low_risk {
let args_key = readonly_cache_args_key(&draft.name, &args);
let key = (draft.name.clone(), args_key.clone());
if !seen_round_keys.insert(key) {
// 轮内重复:延后复用首个结果(首个已在 seen/uncached/backfill),不重复历史扫描+DB 查。
in_round_dupes.push((draft, risk_level, args_key));
continue;
}
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await; let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
if let Some((cached_content, hit_count)) = cached { if let Some((cached_content, hit_count)) = cached {
// 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源), // 缓存命中:存轮内缓存(供后续同 key 复用)+ 直接 push tool_result + 审计
// 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次) // (decided_by=cache_hit 标记缓存来源),不走真执行 + 不重emit Started/Completed。
// AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的 // AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的
// tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result), // tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result),
// 命中次数递增威慑,告知勿再重复调用相同参数的工具。 // 命中次数递增威慑,告知勿再重复调用相同参数的工具。
in_round_cache.insert((draft.name.clone(), args_key), (cached_content.clone(), hit_count));
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content); let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content);
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。 // emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
@@ -677,7 +701,12 @@ pub(crate) async fn process_tool_calls(
} }
})).await; })).await;
// 串行回填 tool_result + 审计短 lock push + 锁外 audit // 串行回填 tool_result + 审计收集(短 lock push + 锁外收集),审计改单事务批量插入。
// AC-EFF-T1-1(2026-08-09):原每结果 audit_tool_call(每次 spawn_blocking + 单行 INSERT,
// 单连接 Mutex 串行)合并为 build_audit_record 收集 + insert_batch 一次事务批量,
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(results.len());
for (draft, raw_result, risk_level, outcome) in results { for (draft, raw_result, risk_level, outcome) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
@@ -714,7 +743,89 @@ pub(crate) async fn process_tool_calls(
RiskLevel::Medium => "auto_takeover_medium", RiskLevel::Medium => "auto_takeover_medium",
RiskLevel::High => "auto_takeover_all", RiskLevel::High => "auto_takeover_all",
}; };
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await; // AC-EFF-T1-2 轮内去重回填:真执行结果存入 in_round_cache,供同轮后续同 key 复用
// (首个执行 → count=1,轮内重复命中时递增;key 与跨轮缓存同源)。
{
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
let exec_key = (draft.name.clone(), readonly_cache_args_key(&draft.name, &args_val));
in_round_cache.entry(exec_key).or_insert_with(|| (content.clone(), 1));
}
audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
Some(content), Some(decided_by), current_message_id,
));
}
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
if !audit_records.is_empty() {
let batch_len = audit_records.len();
if let Err(e) = audit_repo.insert_batch(audit_records).await {
tracing::error!(
conv_id = %conv_id,
batch = batch_len,
"[ai] insert_batch 批量写审计记录失败(已回滚,共 {} 条): {}",
batch_len,
e
);
}
}
// AC-EFF-T1-2 轮内重复处理:复用首个结果(已回填 in_round_cache),跳过重复真执行。
// 语义同跨轮缓存命中:emit Completed + push tool_result + 审计(decided_by=cache_hit),
// 命中计数递增威慑(cache_hit_warning 同口径)。首个结果来自跨轮命中(前段已存)或
// 本轮真执行 backfill(上段已存),故必命中;防御性缺失则丢弃(不重复执行,LLM 下轮可重调)。
let mut dupe_audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(in_round_dupes.len());
for (draft, risk_level, args_key) in in_round_dupes {
let key = (draft.name.clone(), args_key);
let Some((content, count)) = in_round_cache.get(&key).cloned() else {
tracing::warn!(
conv_id = %conv_id,
tool = %draft.name,
tc_id = %draft.id,
"[ai] 轮内去重:首个结果缺失(理论不可达),丢弃重复调用(防御降级)"
);
continue;
};
let new_count = count + 1;
in_round_cache.insert(key, (content.clone(), new_count));
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, new_count), content);
// emit Completed 携带缓存结果供前端折叠卡片展示。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(warned_content.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
}
dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level,
Some(warned_content), Some("cache_hit"), current_message_id,
));
tracing::info!(
conv_id = %conv_id,
tool = %draft.name,
tc_id = %draft.id,
hit_count = new_count,
"[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行",
new_count
);
}
if !dupe_audit_records.is_empty() {
let dup_len = dupe_audit_records.len();
if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await {
tracing::error!(
conv_id = %conv_id,
batch = dup_len,
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}",
dup_len,
e
);
}
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ use df_storage::db::Database;
/// 查项目可读标签:id → "「项目名」(id=x)"。 /// 查项目可读标签:id → "「项目名」(id=x)"。
/// ///
/// AR-3审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。 /// 审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。
/// 三臂区分: /// 三臂区分:
/// - Ok(Some) → 项目名标签 /// - Ok(Some) → 项目名标签
/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id) /// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id)
+255 -26
View File
@@ -10,6 +10,7 @@
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。 //! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::State; use tauri::State;
use df_ai::ai_tools::RiskLevel; use df_ai::ai_tools::RiskLevel;
@@ -23,10 +24,51 @@ use crate::state::AppState;
use super::risk_str; use super::risk_str;
/// 一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) /// 构造一条工具执行审计记录(纯函数,单条 [`audit_tool_call`] / 批量插入路径共用)。
/// ///
/// `decided_by` 有值auto/human= 已决策执行 → 记 executed_at /// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
/// `None`pending 待审批→ executed_at 留空待 audit_finalize 回填。 /// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
pub(crate) fn build_audit_record(
conv_id: &str,
tool_call_id: &str,
tool_name: &str,
arguments: &str,
status: &str,
risk_level: RiskLevel,
result: Option<String>,
decided_by: Option<&str>,
message_id: Option<&str>,
) -> AiToolExecutionRecord {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.to_string()),
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
message_id: message_id.map(|s| s.to_string()),
// &str → ToolCallId(From<&str>),branded newtype 防外部 String 误传
tool_call_id: tool_call_id.into(),
tool_name: tool_name.to_string(),
arguments: arguments.to_string(),
result,
status: status.to_string(),
risk_level: risk_str(risk_level).to_string(),
requested_at: now_millis(),
executed_at,
decided_by: decided_by.map(|s| s.to_string()),
}
}
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
///
/// 单条写入路径。批量路径(audit/mod.rs process_tool_calls 回填循环)经
/// [`build_audit_record`] 收集记录后调 `AiToolExecutionRepo::insert_batch`
/// 单事务批量插入(治 aichat 效率 AC-EFF-T1-1,N 次串行 INSERT → 一次事务)。
///
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
pub(crate) async fn audit_tool_call( pub(crate) async fn audit_tool_call(
repo: &AiToolExecutionRepo, repo: &AiToolExecutionRepo,
conv_id: &str, conv_id: &str,
@@ -39,28 +81,11 @@ pub(crate) async fn audit_tool_call(
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
) { ) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let record = build_audit_record(
if let Err(e) = repo conv_id, tool_call_id, tool_name, arguments,
.insert(AiToolExecutionRecord { status, risk_level, result, decided_by, message_id,
id: new_id(), );
conversation_id: Some(conv_id.to_string()), if let Err(e) = repo.insert(record).await {
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
message_id: message_id.map(|s| s.to_string()),
tool_call_id: tool_call_id.to_string(),
tool_name: tool_name.to_string(),
arguments: arguments.to_string(),
result,
status: status.to_string(),
risk_level: risk_str(risk_level).to_string(),
requested_at: now_millis(),
executed_at,
decided_by: decided_by.map(|s| s.to_string()),
})
.await
{
tracing::error!( tracing::error!(
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}", "audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
conv_id, conv_id,
@@ -204,7 +229,8 @@ pub async fn list_tool_executions(
.map(|r| ToolExecutionDto { .map(|r| ToolExecutionDto {
id: r.id, id: r.id,
conversation_id: r.conversation_id, conversation_id: r.conversation_id,
tool_call_id: r.tool_call_id, // ToolCallId → String(DTO 边界,前端按字符串消费)
tool_call_id: r.tool_call_id.into(),
tool_name: r.tool_name, tool_name: r.tool_name,
arguments_brief: super::truncate_chars(&r.arguments, 120), arguments_brief: super::truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)), result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
@@ -223,3 +249,206 @@ pub async fn list_tool_executions(
has_more, has_more,
}) })
} }
// ============================================================
// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库)
// ============================================================
/// 按工具失败率统计查询入参(前端透传,空值=全量)。
///
/// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。
/// AC-5 诊断期 ad-hoc 查库无运行时统计机制,本命令提供聚合统计供审计面板/诊断查询。
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ToolFailureStatsQuery {
pub from: Option<i64>,
}
/// 单工具执行统计(AC-5 失败率画像)。
///
/// `failed_rate` = failed / (completed + failed)——仅"真正执行过"的算成功率分母;
/// rejected/skipped_retry 是用户/AI 决策非执行失败,不计分母,但单独计数展示。
/// completed+failed=0(从未真正执行,如纯决策挂起)时 failed_rate=0。
#[derive(Debug, Clone, Serialize)]
pub struct ToolFailureStat {
pub tool_name: String,
pub total: i64,
pub completed: i64,
pub failed: i64,
pub rejected: i64,
pub interrupted: i64,
pub skipped_retry: i64,
pub failed_rate: f64,
}
/// 失败率统计聚合结果(跨工具汇总)。
#[derive(Debug, Clone, Serialize)]
pub struct ToolFailureStats {
/// 按 total 降序(使用最多的工具在前,面板聚焦高流量工具)
pub stats: Vec<ToolFailureStat>,
pub total_executions: i64,
pub total_failed: i64,
}
/// 把 `stats_by_tool` 的 (tool_name, status, count) 三元组聚合为 DTO(纯函数,命令 + 单测共用)。
///
/// 内存聚合(单条 GROUP BY 已按 tool,status 分组):HashMap 二次归并 → 每工具 status 分布,
/// 计算 failed_rate(分母 completed+failed,四舍五入到 4 位小数防浮点长尾)。排序按 total 降序、
/// tool_name 升序破平。`total_executions` = 全工具 total 之和,`total_failed` = failed 之和。
fn aggregate_tool_stats(rows: Vec<(String, String, i64)>) -> ToolFailureStats {
let mut by_tool: HashMap<String, HashMap<String, i64>> = HashMap::new();
for (tool, status, cnt) in rows {
*by_tool.entry(tool).or_default().entry(status).or_insert(0) += cnt;
}
let mut total_executions = 0i64;
let mut total_failed = 0i64;
let mut stats: Vec<ToolFailureStat> = by_tool
.into_iter()
.map(|(tool_name, m)| {
let completed = m.get("completed").copied().unwrap_or(0);
let failed = m.get("failed").copied().unwrap_or(0);
let rejected = m.get("rejected").copied().unwrap_or(0);
let interrupted = m.get("interrupted").copied().unwrap_or(0);
let skipped_retry = m.get("skipped_retry").copied().unwrap_or(0);
let total: i64 = m.values().sum();
total_executions += total;
total_failed += failed;
let failed_rate = if completed + failed > 0 {
((failed as f64 / (completed + failed) as f64) * 10_000.0).round() / 10_000.0
} else {
0.0
};
ToolFailureStat {
tool_name,
total,
completed,
failed,
rejected,
interrupted,
skipped_retry,
failed_rate,
}
})
.collect();
stats.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.tool_name.cmp(&b.tool_name)));
ToolFailureStats { stats, total_executions, total_failed }
}
/// AC-5 运行时失败率统计:按工具聚合 ai_tool_executions 的 status 分布与失败率。
///
/// 只读诊断 IPC:`query.from` 可选时间下限(millis),默认全量。数据源 ai_tool_executions 表
/// (GUI audit 模块写,已完成记录落盘)。口径:failed_rate = failed / (completed + failed);
/// rejected/skipped_retry/interrupted 单独计数展示(不计失败率分母,属用户/AI 决策非执行失败)。
#[tauri::command]
pub async fn tool_failure_stats(
state: State<'_, AppState>,
query: Option<ToolFailureStatsQuery>,
) -> Result<ToolFailureStats, String> {
let from = query.and_then(|q| q.from);
let rows = state
.ai_tool_executions
.stats_by_tool(from)
.await
.map_err(err_str)?;
Ok(aggregate_tool_stats(rows))
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::db::Database;
/// 插入一条指定 tool/status/requested_at 的审计记录(测试构造数据用)。
/// requested_at 传毫秒(与生产 `audit_tool_call` 落 now_millis() 同口径)。
async fn insert_record(repo: &AiToolExecutionRepo, tool: &str, status: &str, t: i64) {
repo.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: None,
message_id: None,
tool_call_id: new_id().into(),
tool_name: tool.to_string(),
arguments: "{}".to_string(),
result: None,
status: status.to_string(),
risk_level: "low".to_string(),
requested_at: t.to_string(),
executed_at: None,
decided_by: None,
})
.await
.expect("测试数据插入应成功");
}
/// failed_rate 分母口径:rejected/skipped_retry/interrupted 不计分母,单独计数。
///
/// read_file: 8 completed + 2 failed + 3 rejected → rate=2/10=0.2,rejected=3
/// patch_file: 1 completed + 4 failed → rate=4/5=0.8
/// run_command: 2 skipped_retry + 1 rejected + 1 interrupted(无 completed/failed)→ rate=0
#[tokio::test]
async fn tool_failure_stats_denominator_and_counts() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t = 2_000_000_000_000i64;
for _ in 0..8 { insert_record(&repo, "read_file", "completed", t).await; }
for _ in 0..2 { insert_record(&repo, "read_file", "failed", t).await; }
for _ in 0..3 { insert_record(&repo, "read_file", "rejected", t).await; }
insert_record(&repo, "patch_file", "completed", t).await;
for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t).await; }
for _ in 0..2 { insert_record(&repo, "run_command", "skipped_retry", t).await; }
insert_record(&repo, "run_command", "rejected", t).await;
insert_record(&repo, "run_command", "interrupted", t).await;
let out = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap());
assert_eq!(out.total_executions, 8 + 2 + 3 + 1 + 4 + 2 + 1 + 1);
assert_eq!(out.total_failed, 6);
let rf = out.stats.iter().find(|s| s.tool_name == "read_file").unwrap();
assert_eq!(rf.total, 13);
assert_eq!(rf.completed, 8);
assert_eq!(rf.failed, 2);
assert_eq!(rf.rejected, 3);
assert_eq!(rf.failed_rate, 0.2, "rejected 不计分母,failed/(completed+failed)=2/10");
let pf = out.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
assert_eq!(pf.failed_rate, 0.8, "4/(1+4)=0.8");
let rc = out.stats.iter().find(|s| s.tool_name == "run_command").unwrap();
assert_eq!(rc.completed + rc.failed, 0, "无真正执行记录");
assert_eq!(rc.failed_rate, 0.0, "分母为 0 应归零");
assert_eq!(rc.skipped_retry, 2);
assert_eq!(rc.interrupted, 1);
// 排序:total 降序 → read_file(13) 应在 patch_file(5) 之前
assert_eq!(out.stats[0].tool_name, "read_file");
}
/// from 时间过滤:仅统计 requested_at >= from 的记录。
///
/// patch_file 追加一条更早(early)的 completed → from=late 时其不计入,
/// completed 由 2 降为 1,failed_rate 由 4/6≈0.6667 变为 4/5=0.8。
#[tokio::test]
async fn tool_failure_stats_from_time_filter() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t_late = 2_000_000_000_000i64;
let t_early = 1_000_000_000_000i64;
for _ in 0..8 { insert_record(&repo, "read_file", "completed", t_late).await; }
for _ in 0..2 { insert_record(&repo, "read_file", "failed", t_late).await; }
insert_record(&repo, "patch_file", "completed", t_late).await;
for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t_late).await; }
insert_record(&repo, "patch_file", "completed", t_early).await;
// 全量:patch_file completed=2(early+late)
let all = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap());
let pf_all = all.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
assert_eq!(pf_all.completed, 2);
assert_eq!(pf_all.failed_rate, 0.6667, "4/(2+4)≈0.6667");
// from=t_late:early 不计,patch_file completed=1
let late = aggregate_tool_stats(repo.stats_by_tool(Some(t_late)).await.unwrap());
let pf_late = late.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
assert_eq!(pf_late.completed, 1, "early 记录应被 from 过滤");
assert_eq!(pf_late.failed_rate, 0.8, "4/(1+4)=0.8");
assert_eq!(late.total_executions, 8 + 2 + 1 + 4, "不含 early 记录");
}
}
+7 -5
View File
@@ -21,7 +21,7 @@ use super::risk_from_str;
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve → /// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。 /// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务 /// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务
/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂), /// 语义而非路由键)。conversation_id=None 的无主审批不建 per_conv(无 conv_id 可挂),
/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。 /// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。
pub async fn restore_pending_approvals(state: &AppState) { pub async fn restore_pending_approvals(state: &AppState) {
// 清理超 24 小时的残留 pending(旧会话遗留,不再有意义) // 清理超 24 小时的残留 pending(旧会话遗留,不再有意义)
@@ -64,7 +64,7 @@ pub async fn restore_pending_approvals(state: &AppState) {
continue; continue;
} }
// 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。 // 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。
// 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals // 无 conversation_id 的无主审批不建 per_conv——无 conv_id 可挂,仍进 pending_approvals
// 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。 // 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。
if let Some(cid) = rec.conversation_id.as_deref() { if let Some(cid) = rec.conversation_id.as_deref() {
if !cid.is_empty() && convs_restored.insert(cid.to_string()) { if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
@@ -75,9 +75,11 @@ pub async fn restore_pending_approvals(state: &AppState) {
} }
} }
session.pending_approvals.insert( session.pending_approvals.insert(
rec.tool_call_id.clone(), // ToolCallId → String(AiSession pending_approvals 是 String HashMap,
// 与 LLM provider draft.id 同形直接比较;DB 边界已 newtype 防误传)
rec.tool_call_id.to_string(),
PendingApproval { PendingApproval {
tool_call_id: rec.tool_call_id, tool_call_id: rec.tool_call_id.to_string(),
tool_name: rec.tool_name, tool_name: rec.tool_name,
arguments: args, arguments: args,
conversation_id: rec.conversation_id, conversation_id: rec.conversation_id,
@@ -131,7 +133,7 @@ mod tests_multi_conv_restore {
("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())), ("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())),
("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())), ("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())),
("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())), ("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())),
("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9) ("tc-4".to_string(), "create_task".to_string(), None), // 无主
]; ];
// 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。 // 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。
+305 -5
View File
@@ -83,15 +83,61 @@ pub fn grammar_for(ext: &str) -> Option<Language> {
} }
} }
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。 /// 判断 ext 是否为支持语言(含 Vue 借 TS)。file_outline 据此提前判 ext(不支持不读文件)。
///
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
#[allow(dead_code)]
pub fn is_supported_ext(ext: &str) -> bool { pub fn is_supported_ext(ext: &str) -> bool {
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py") matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
} }
/// 判断 source 内容是否真的含可提取的函数/类/结构体定义(AC-4 read_file 符号级命中提示)。
///
/// read_file 读取成功后据此决定是否追加「改用 read_symbol」提示:仅当内容里真的存在
/// DEFINITION_KINDS 定义节点才提示——比旧按扩展名判断更准,无定义的文件不瞎提示。
/// 与 read_symbol 同语法树逻辑(grammar_for + DEFINITION_KINDS + Vue 切 <script> 段借 ts),
/// 但不提取符号只探测存在性,开销低。**复用现有函数,不新写正则**。
/// 无 grammar / 解析失败 / Vue 无 script / 无定义节点 → false(不提示)。
pub(crate) fn contains_definition(source: &str, ext: &str) -> bool {
let ext_lower = ext.to_lowercase();
let (parse_source, parse_ext): (String, &str) = if ext_lower == "vue" {
// Vue 逻辑在 <script> 段,切段借 ts(与 read_symbol 的 extract_vue_script 同源)
match extract_vue_script(source) {
Some((script, _)) => (script, "ts"),
None => return false, // 纯 template/style 无可提取符号
}
} else {
(source.to_string(), ext_lower.as_str())
};
let grammar = match grammar_for(parse_ext) {
Some(g) => g,
None => return false, // 不支持的语言不提示
};
let mut parser = Parser::new();
if parser.set_language(&grammar).is_err() {
return false; // grammar 加载失败(ABI 不兼容)不提示
}
let tree = match parser.parse(&parse_source, None) {
Some(t) => t,
None => return false, // 解析失败不提示
};
tree_has_definition(&tree.root_node())
}
/// 递归遍历语法树,判断是否含任一 DEFINITION_KINDS 定义节点(contains_definition 的遍历内核)。
fn tree_has_definition(node: &Node<'_>) -> bool {
if DEFINITION_KINDS.contains(&node.kind()) {
return true;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if tree_has_definition(&child) {
return true;
}
}
false
}
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。 /// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
/// ///
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。 /// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
@@ -357,6 +403,118 @@ fn collect_calls_inner(
} }
} }
/// 文件符号概览(outline)— 提取**顶层**定义符号列表,供前端预览面板做符号树。
///
/// 与 read_symbol 复用同一套解析(grammar_for + DEFINITION_KINDS + Vue 切 script 段),
/// 但只返精简元数据 `{ name, kind, line, signature }`(不含全文/调用,轻量)。
/// 遍历语义:命中 DEFINITION_KINDS 的节点记入并**不下钻其子树** —— impl 内方法、
/// 函数/类内嵌套定义不算顶层(与 IDE 符号树顶层视图一致)。
///
/// **不报错底线**:无 grammar / 解析失败 / 无符号 / Vue 无 script → 空 Vec,不 Err 不 panic。
pub fn extract_outline(source: &str, ext: &str) -> Vec<serde_json::Value> {
let ext_lower = ext.to_lowercase();
// Vue 借 TS:切 script 段,行号偏移映射回原文件(与 read_symbol 同源)。
let (parse_source, parse_ext, line_offset): (String, &str, usize) = if ext_lower == "vue" {
match extract_vue_script(source) {
Some((script, byte_off)) => {
let off_lines = source[..byte_off].matches('\n').count();
(script, "ts", off_lines)
}
None => return Vec::new(), // 纯 template/style 无可提取符号
}
} else {
(source.to_string(), ext_lower.as_str(), 0)
};
let grammar = match grammar_for(parse_ext) {
Some(g) => g,
None => return Vec::new(), // 不支持的语言
};
let mut parser = Parser::new();
if parser.set_language(&grammar).is_err() {
return Vec::new(); // grammar 加载失败(ABI 不兼容)
}
let tree = match parser.parse(&parse_source, None) {
Some(t) => t,
None => return Vec::new(), // 解析失败
};
let bytes = parse_source.as_bytes();
let mut symbols = Vec::new();
collect_top_level_symbols(tree.root_node(), bytes, line_offset, &mut symbols);
symbols
}
/// 收集顶层定义符号(记入 + 不下钻:命中定义节点后不再进其子树)。
fn collect_top_level_symbols(
node: Node<'_>,
source: &[u8],
line_offset: usize,
out: &mut Vec<serde_json::Value>,
) {
if DEFINITION_KINDS.contains(&node.kind()) {
let name = symbol_name(node, source);
let kind = node.kind().to_string();
let line = node.start_position().row + 1 + line_offset;
// Python 等无 `{`/`;` 终结符的语言,extract_signature 会含函数体:outline 只取首行(头行即签名)。
let signature = extract_signature(&node_text(node, source))
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
out.push(serde_json::json!({
"name": name,
"kind": kind,
"line": line,
"signature": signature,
}));
return; // 不下钻(顶层符号语义)
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_top_level_symbols(child, source, line_offset, out);
}
}
/// 取定义节点展示名:name 字段 → type 字段(Rust impl_item)→ 首个 identifier 子节点
/// (Go type_declaration 等 name 藏在一层子节点内)。全空返空串(前端显示签名兜底)。
fn symbol_name(node: Node<'_>, source: &[u8]) -> String {
if let Some(n) = node.child_by_field_name("name") {
let t = node_text(n, source);
if !t.is_empty() {
return t;
}
}
if let Some(n) = node.child_by_field_name("type") {
let t = node_text(n, source);
if !t.is_empty() {
return t;
}
}
find_first_identifier(node, source).unwrap_or_default()
}
/// DFS 找首个 identifier 类子节点(Go type_declaration → type_spec → type_identifier 隔两层)。
fn find_first_identifier(node: Node<'_>, source: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "identifier" || kind.ends_with("_identifier") {
let t = node_text(child, source);
if !t.is_empty() {
return Some(t);
}
}
}
for child in node.children(&mut cursor) {
if let Some(t) = find_first_identifier(child, source) {
return Some(t);
}
}
None
}
/// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。 /// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。
fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value { fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
@@ -633,4 +791,146 @@ fn outer() {
let calls = skeleton["calls"].as_array().expect("骨架应含 calls"); let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
assert!(!calls.is_empty(), "应提取出内部调用"); assert!(!calls.is_empty(), "应提取出内部调用");
} }
// ============================================================
// contains_definition — AC-4 read_file 符号级命中检测
// ============================================================
#[test]
fn test_contains_definition_hits_definitions() {
// 含 fn/struct 定义 → true(应提示改用 read_symbol)
assert!(contains_definition(RUST_FIXTURE, "rs"));
assert!(contains_definition("pub fn main() {}\n", "rs"));
assert!(contains_definition("struct Config { name: String }\n", "rs"));
// TS/JS
assert!(contains_definition("export function greet() { return 1; }", "ts"));
assert!(contains_definition("const handler = () => {};", "js"));
// Go / Java / Python
assert!(contains_definition("package main\nfunc main() {}\n", "go"));
assert!(contains_definition("class Foo { void bar() {} }", "java"));
assert!(contains_definition("def foo():\n pass\n", "py"));
}
#[test]
fn test_contains_definition_plain_text_false() {
// 无定义节点的纯文本/纯注释 → false(符号级命中,不瞎提示)
assert!(!contains_definition("just some text\nwith no definitions\n", "rs"));
assert!(!contains_definition("// 只有注释\n/* 多行注释 */\n", "rs"));
// 字符串字面量里有 fn 字样但不是定义
assert!(!contains_definition("let msg = \"fn main() {}\";\nconsole.log(msg);", "js"));
}
#[test]
fn test_contains_definition_unsupported_ext_or_parse_fail() {
// Ruby 无 grammar → false
assert!(!contains_definition("def foo()\n puts 'hi'\nend\n", "rb"));
// 无扩展名 → false
assert!(!contains_definition("any content", ""));
// 大小写不敏感("RS" 等价 "rs")
assert!(contains_definition("pub fn main() {}\n", "RS"));
}
#[test]
fn test_contains_definition_vue() {
// Vue 含 <script> 函数 → true
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {}\n</script>\n";
assert!(contains_definition(vue, "vue"));
// 无 <script> 段(纯 template/style)→ false
let no_script = "<template><div>hi</div></template>\n<style></style>";
assert!(!contains_definition(no_script, "vue"));
}
// ============================================================
// extract_outline — 文件符号概览(顶层符号列表)
// ============================================================
#[test]
fn test_extract_outline_rust_top_level() {
let src = "use std::io;\n\npub fn read_symbol(path: &str) {\n helper(path);\n}\n\nfn helper(_x: &str) {}\n\nstruct Config {\n name: String,\n}\n\nimpl Config {\n fn new() -> Self { Config { name: String::new() } }\n}\n";
let symbols = extract_outline(src, "rs");
// 顶层:read_symbol / helper / Config(struct) / Config(impl);impl 内 new 不下钻
let names: Vec<&str> = symbols.iter().map(|s| s["name"].as_str().unwrap()).collect();
assert_eq!(names, vec!["read_symbol", "helper", "Config", "Config"]);
assert_eq!(symbols[0]["kind"], "function_item");
assert_eq!(symbols[2]["kind"], "struct_item");
// impl Config 的 name 来自 type 字段
assert_eq!(symbols[3]["kind"], "impl_item");
assert_eq!(symbols[3]["name"], "Config");
// 行号 1-based
assert_eq!(symbols[0]["line"].as_u64().unwrap(), 3);
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 7);
// signature 只到首个 {,不含函数体
assert_eq!(symbols[0]["signature"].as_str().unwrap(), "pub fn read_symbol(path: &str)");
assert!(!symbols[0]["signature"].as_str().unwrap().contains("helper(path)"));
}
#[test]
fn test_extract_outline_vue_line_offset() {
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {\n helper();\n}\n\nfunction helper() {}\n</script>\n";
let symbols = extract_outline(vue, "vue");
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0]["name"], "greet");
assert_eq!(symbols[0]["kind"], "function_declaration");
// script 内容以 \n 开头(第 3 行 <script> 标签后),greet 在原文件第 4 行、helper 第 8 行
assert_eq!(symbols[0]["line"].as_u64().unwrap(), 4);
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 8);
}
#[test]
fn test_extract_outline_ts_export_and_arrow() {
let src = "export function greet() { return 1; }\nexport const handler = () => {};\ninterface Foo { bar: string }\n";
let symbols = extract_outline(src, "ts");
let kinds: Vec<&str> = symbols.iter().map(|s| s["kind"].as_str().unwrap()).collect();
// export 包装函数仍命中(export_statement 非定义,下钻到 function_declaration)
assert_eq!(kinds, vec!["function_declaration", "variable_declarator", "interface_declaration"]);
assert_eq!(symbols[0]["name"], "greet");
assert_eq!(symbols[1]["name"], "handler");
assert_eq!(symbols[1]["signature"].as_str().unwrap(), "handler = () =>");
}
#[test]
fn test_extract_outline_go_type_declaration() {
let src = "package main\n\nfunc main() {}\n\ntype User struct {\n Name string\n}\n";
let symbols = extract_outline(src, "go");
assert_eq!(symbols[0]["name"], "main");
assert_eq!(symbols[0]["kind"], "function_declaration");
// Go type_declaration 的 name 藏在一层子节点(type_spec)内,走 identifier 回退
assert_eq!(symbols[1]["name"], "User");
assert_eq!(symbols[1]["kind"], "type_declaration");
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 5);
}
#[test]
fn test_extract_outline_java_class_only_top() {
let src = "public class Foo {\n private int x;\n public void bar() {}\n}\n";
let symbols = extract_outline(src, "java");
// 类内 method 不下钻:仅 class Foo 一个顶层符号
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0]["name"], "Foo");
assert_eq!(symbols[0]["kind"], "class_declaration");
}
#[test]
fn test_extract_outline_python() {
let src = "import os\n\ndef foo(a):\n return a\n\nclass Bar:\n def method(self):\n pass\n";
let symbols = extract_outline(src, "py");
// class 内 method 不下钻
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0]["name"], "foo");
assert_eq!(symbols[0]["kind"], "function_definition");
assert_eq!(symbols[1]["name"], "Bar");
assert_eq!(symbols[1]["kind"], "class_definition");
}
#[test]
fn test_extract_outline_unsupported_or_fail_returns_empty() {
// 不支持语言
assert!(extract_outline("# Title\nplain text", "md").is_empty());
// 无 script 的 Vue
assert!(extract_outline("<template><div>hi</div></template>", "vue").is_empty());
// 纯文本(无定义节点)
assert!(extract_outline("just some text\nno definitions", "rs").is_empty());
// 残缺语法(tree-sitter 容错仍产出节点,不 panic 即可,不强行断言空)
let _ = extract_outline("fn broken(", "rs");
}
} }
+149 -8
View File
@@ -13,38 +13,179 @@
//! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler), //! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler),
//! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。 //! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。
//! //!
//! AC-EFF-S2-5(2026-08-09):AiCommandOutput 合批。run_command 逐行回调不再每行单独 emit +
//! publish_event(大输出风暴时 IPC 事件风暴),改为累积进 [`CommandSink`] 共享缓冲(stdout/stderr
//! 分桶),由「后台 50ms 定时 flush + 体积阈值(4KB)立即 flush + Drop 兜底 flush」三路输出,
//! 每个 stream 字段累积的字符串(多行 '\n' 连接)作为单条 AiCommandOutput 输出(对齐
//! stream_recv DELTA_FLUSH_INTERVAL=50ms 的 delta 合批;消费方按文本渲染,多行输出等价逐行)。
//!
//! 未注入 sink(非 run_command / 调用方未配 scope)时 [`emit_output`] 静默 noop, //! 未注入 sink(非 run_command / 调用方未配 scope)时 [`emit_output`] 静默 noop,
//! 兜底不报错不阻断。 //! 兜底不报错不阻断。
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use super::AiChatEvent; use super::AiChatEvent;
use df_execute::shell::StreamKind; use df_execute::shell::StreamKind;
/// AiCommandOutput 合批参数(AC-EFF-S2-5,对齐 stream_recv DELTA_FLUSH_INTERVAL=50ms)。
///
/// 时间维度:后台 flush task 每 50ms 兜底输出一次(与 AR-8 delta 合批同 cadence);
/// 体积维度:单次累积超 [`FLUSH_BYTE_THRESHOLD`] 立即 flush(防大输出风暴 50ms 窗口内积压过多)。
/// 语义:stdout/stderr 各累积的字符串(多行以 '\n' 连接)作为**单条** AiCommandOutput 事件输出,
/// 不再每行单独 emit + publish_event——砍 IPC 事件风暴(与 delta 合批「合并后前端逐条累加,最终一致」
/// 同思路,消费方按文本渲染,多行输出等价于逐行)。
const FLUSH_INTERVAL: Duration = Duration::from_millis(50);
const FLUSH_BYTE_THRESHOLD: usize = 4096;
/// 一次 run_command 调用的输出下沉目标(emit 事件所需上下文)。 /// 一次 run_command 调用的输出下沉目标(emit 事件所需上下文)。
#[derive(Clone)] ///
/// AC-EFF-S2-5(2026-08-09):合批。run_command 逐行回调 → 累积进共享缓冲(stdout/stderr 分桶),
/// 由「后台 50ms 定时 flush + 体积阈值立即 flush + Drop 兜底 flush」三路输出,不再每行单独
/// emit(治 aichat 效率 S2-5)。缓冲用 `std::sync::Mutex`(短临界区无 await,emit 是同步回调)。
pub struct CommandSink { pub struct CommandSink {
app: AppHandle, app: AppHandle,
tool_call_id: String, tool_call_id: String,
conversation_id: Option<String>, conversation_id: Option<String>,
/// 合批共享缓冲(emit 线程写 / flush 定时读)。
buffer: Arc<StdMutex<CommandBuffer>>,
/// 后台 flush task 守卫(Drop 时停止 + abort,并兜底 flush 尾部)。
_flusher: FlushGuard,
}
/// 合批缓冲:stdout/stderr 分开累积(按 stream 字段 emit),bytes 供体积阈值判定。
#[derive(Default)]
struct CommandBuffer {
stdout: String,
stderr: String,
bytes: usize,
}
/// 后台 flush task 守卫:stop 标志 + JoinHandle。Drop 时置停 + abort(任务 50ms 循环读到 stop 退出),
/// 尾部输出由 CommandSink::drop 兜底 flush,不丢命令结尾。
struct FlushGuard {
stop: Arc<AtomicBool>,
handle: Option<tokio::task::JoinHandle<()>>,
} }
impl CommandSink { impl CommandSink {
pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self { pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self {
Self { app, tool_call_id, conversation_id } let buffer = Arc::new(StdMutex::new(CommandBuffer::default()));
let stop = Arc::new(AtomicBool::new(false));
// 后台 50ms flush task。仅当处于 tokio runtime 上下文时 spawn(tokio::spawn 需 runtime)。
// CommandSink::new 由 execute_with_heartbeat / ai_approve(async)调用,正常在 runtime 内;
// 非 runtime(如单测直接 new)跳过定时 task,退化为「体积阈值 + Drop 兜底」flush(仍不丢尾部)。
let handle = if tokio::runtime::Handle::try_current().is_ok() {
let buf = buffer.clone();
let stop_f = stop.clone();
let app_f = app.clone();
let id_f = tool_call_id.clone();
let conv_f = conversation_id.clone();
Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(FLUSH_INTERVAL);
interval.tick().await; // 弃首 tick(tokio interval 首 tick 立即返回,对齐心跳弃首模式)
loop {
interval.tick().await;
if stop_f.load(Ordering::SeqCst) {
break;
}
Self::flush_inner(&app_f, &id_f, &conv_f, &buf);
}
}))
} else {
None
};
Self {
app,
tool_call_id,
conversation_id,
buffer,
_flusher: FlushGuard { stop, handle },
}
} }
/// emit 一行 stdout/stderr(AiCommandOutput,双写 app.emit + ai_event_bus)。 /// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。
/// emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。 ///
/// 不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush
/// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
fn emit(&self, kind: StreamKind, line: &str) { fn emit(&self, kind: StreamKind, line: &str) {
let over_threshold = {
let mut buf = self.buffer.lock().unwrap();
let target = if kind == StreamKind::Stdout { &mut buf.stdout } else { &mut buf.stderr };
if !target.is_empty() {
target.push('\n');
}
target.push_str(line);
buf.bytes += line.len();
buf.bytes >= FLUSH_BYTE_THRESHOLD
};
if over_threshold {
Self::flush_inner(&self.app, &self.tool_call_id, &self.conversation_id, &self.buffer);
}
}
/// 兜底 flush(把当前缓冲整体取出并 emit)。Drop 时调用,保证尾部输出不丢。
fn flush(&self) {
Self::flush_inner(&self.app, &self.tool_call_id, &self.conversation_id, &self.buffer);
}
/// 内部 flush:单次锁内 take 缓冲(不持锁 emit),stdout/stderr 各输出一条(多行拼接)。
/// 与后台 task / Drop 并发调用安全:take 语义下同一批只被 drain 一次(无重复 emit)。
fn flush_inner(
app: &AppHandle,
tool_call_id: &str,
conversation_id: &Option<String>,
buffer: &Arc<StdMutex<CommandBuffer>>,
) {
let drained = {
let mut buf = buffer.lock().unwrap();
if buf.stdout.is_empty() && buf.stderr.is_empty() {
return;
}
Some(std::mem::take(&mut *buf))
};
if let Some(buf) = drained {
if !buf.stdout.is_empty() {
Self::emit_line(app, tool_call_id, conversation_id, StreamKind::Stdout, &buf.stdout);
}
if !buf.stderr.is_empty() {
Self::emit_line(app, tool_call_id, conversation_id, StreamKind::Stderr, &buf.stderr);
}
}
}
/// emit 一条 AiCommandOutput(stdout/stderr 累积字符串,双写 app.emit + ai_event_bus)。
fn emit_line(
app: &AppHandle,
tool_call_id: &str,
conversation_id: &Option<String>,
kind: StreamKind,
line: &str,
) {
let ev = AiChatEvent::AiCommandOutput { let ev = AiChatEvent::AiCommandOutput {
id: self.tool_call_id.clone(), id: tool_call_id.to_string(),
stream: kind.as_str().to_string(), stream: kind.as_str().to_string(),
line: line.to_string(), line: line.to_string(),
conversation_id: self.conversation_id.clone(), conversation_id: conversation_id.clone(),
}; };
let _ = self.app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
let _ = self.app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
}
impl Drop for CommandSink {
fn drop(&mut self) {
// 停后台 flush task + abort(任务在 stop 后下次 tick 退出),再兜底 flush 剩余缓冲
// (命令结束尾部输出不丢)。并发安全:flush_inner 的 take 语义保证不重复 emit。
self._flusher.stop.store(true, Ordering::SeqCst);
if let Some(h) = self._flusher.handle.take() {
h.abort();
}
self.flush();
} }
} }
+126 -42
View File
@@ -37,15 +37,15 @@ use super::super::audit::{audit_finalize, emit_data_changed};
use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize; use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation; use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt; use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded}; use super::super::prompt::{build_system_prompt_parts, cache_enabled};
use super::super::{AiChatEvent, ApprovalKind, SessionState}; use super::super::{AiChatEvent, ApprovalKind, SessionState};
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER /// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
/// "需要用户审批,等待确认")就地替换为终态文本。 /// "需要用户审批,等待确认")就地替换为终态文本。
/// ///
/// 背景(SW-260618-02):审批占位文本在 stop/clear/create/delete/force_send 等 /// 背景审批占位文本在 stop/clear/create/delete/force_send 等
/// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。 /// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。
/// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。 /// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。
/// ///
@@ -60,7 +60,7 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv, /// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。 /// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) { pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。 // 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传 // 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。 // &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。 // 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
@@ -176,7 +176,18 @@ async fn resolve_and_inject(
return String::new(); return String::new();
} }
let loc = sanitize::locality_of(provider); let loc = sanitize::locality_of(provider);
let (augs, _errors) = state.resolvers.resolve_all(&refs, loc).await; let (augs, errors) = state.resolvers.resolve_all(&refs, loc).await;
// 技能/提及注入失败透出:单条 resolve 失败不阻断整批注入(既有语义保留),
// 但失败不能静默——warn 日志让技能注入失败可见(不改前端行为)。
if !errors.is_empty() {
tracing::warn!(
skill = ?skill,
fail_count = errors.len(),
"[ai] 技能/提及注入失败 {} 条(已跳过,不阻断注入): {:?}",
errors.len(),
errors
);
}
build_augmentation_segment(&augs, lang) build_augmentation_segment(&augs, lang)
} }
@@ -229,11 +240,25 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
(proj, task) (proj, task)
} }
/// 当前 provider 是否 anthropic 协议(仅此类型启用 prompt caching 拆分,OpenAI 路径零影响)。
fn is_anthropic_type(provider_type: &str) -> bool {
matches!(provider_type, "anthropic" | "anthropic_compat")
}
/// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。
fn append_volatile(volatile: String, seg: &str) -> String {
if volatile.is_empty() {
seg.to_string()
} else {
format!("{}\n\n---\n{}", volatile, seg)
}
}
// ============================================================ // ============================================================
// 发送 / 审批 / 控制 // 发送 / 审批 / 控制
// ============================================================ // ============================================================
/// 重新生成最后一条 AI 回复(UX-02:消息操作栏「重新生成」) /// 重新生成最后一条 AI 回复消息操作栏「重新生成」)
/// ///
/// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的 /// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的
/// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑 /// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑
@@ -290,24 +315,38 @@ pub async fn ai_regenerate(
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone(); let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。 // 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复: // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active, // 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
// 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。 // 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。
let mut system_prompt = { {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await if cache_on {
}; let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
} }
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
@@ -329,9 +368,9 @@ pub async fn ai_regenerate(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) // loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照 // 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。 // F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。
// 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。 // 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。
@@ -341,13 +380,13 @@ pub async fn ai_regenerate(
}; };
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值) /// 查询后端真实 generating 状态方案 A 发送前 IPC 查后端真值)
/// ///
/// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护: /// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护:
/// 后端 loop 异常退出 / AiError 已复位 generating=false 时,前端若仅查本地 streaming /// 后端 loop 异常退出 / AiError 已复位 generating=false 时,前端若仅查本地 streaming
@@ -567,7 +606,7 @@ pub async fn ai_chat_send(
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone(); conv.agent_language = language.clone();
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。 // 新对话生命周期 iteration 从头计数(累计计数器复位)。
conv.iteration_used = 0; conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验 // F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验
// (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。 // (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。
@@ -622,13 +661,20 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。 // (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。 // mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await; // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。 // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
} }
}
// conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。 // conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。
// 知识注入:检索相关知识拼到 system prompt 前(可配置开关 auto_inject,默认开) // 知识注入:检索相关知识拼到 system prompt 前(可配置开关 auto_inject,默认开)
@@ -637,8 +683,16 @@ pub async fn ai_chat_send(
// 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。 // 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{ {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
} }
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。 // 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。
// ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键: // ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键:
@@ -656,9 +710,9 @@ pub async fn ai_chat_send(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) // loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// F-260616-07: 流式失败重试次数快照 // 流式失败重试次数快照
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。 // F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。
// 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。 // 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。
@@ -668,7 +722,7 @@ pub async fn ai_chat_send(
}; };
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -691,8 +745,8 @@ pub async fn ai_approve(
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。 // 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。
// 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表, // 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表,
// 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。 // 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错) // 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
// BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把 // 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。 // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
Ok(Some(rec)) => { Ok(Some(rec)) => {
@@ -745,7 +799,7 @@ pub async fn ai_approve(
// 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages // 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages
// (审计仍记 rejected),避免写错 conv。 // (审计仍记 rejected),避免写错 conv。
if let Some(ref cid) = approval.conversation_id { if let Some(ref cid) = approval.conversation_id {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 // BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
// (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。 // (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。
let reject_payload = serde_json::json!({ let reject_payload = serde_json::json!({
"status": "rejected", "status": "rejected",
@@ -1130,7 +1184,7 @@ pub async fn ai_authorize_dir(
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ── // ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
if decision == "deny" { if decision == "deny" {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 // BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
let err_msg = serde_json::json!({ let err_msg = serde_json::json!({
"status": "rejected", "status": "rejected",
"reason": "path_auth_denied", "reason": "path_auth_denied",
@@ -1406,7 +1460,7 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
// ── ③ 内存清(DB 成功后;内存 clear 不失败,回滚只需保证 DB 原子)── // ── ③ 内存清(DB 成功后;内存 clear 不失败,回滚只需保证 DB 原子)──
if let Some(ref id) = active_id { if let Some(ref id) = active_id {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear, // 清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear,
// 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。 // 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。
// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写; // F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写;
// pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。 // pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
@@ -1658,7 +1712,7 @@ pub async fn ai_chat_compress_context(
Ok(()) Ok(())
} }
/// 编辑最后一条 user 消息并重新生成UX-09 /// 编辑最后一条 user 消息并重新生成
/// ///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 → /// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
/// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删, /// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删,
@@ -1698,7 +1752,7 @@ pub async fn ai_chat_edit(
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
conv.stop_flag.store(false, Ordering::SeqCst); conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone(); conv.agent_language = language.clone();
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。 // 编辑重生成 = 新生命周期起点,iteration 从头计数。
conv.iteration_used = 0; conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。 // F-01 阶段6: 记录用户指定模型 override(主对话专用)。
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
@@ -1707,23 +1761,37 @@ pub async fn ai_chat_edit(
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone(); let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。 // 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user, // 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
// helper 读到的末条 active user 即编辑后的新文本,语义等价。 // helper 读到的末条 active user 即编辑后的新文本,语义等价。
let mut system_prompt = { {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await if cache_on {
}; let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
} }
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复) // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
@@ -1750,6 +1818,7 @@ pub async fn ai_chat_edit(
app_handle, app_handle,
provider_config, provider_config,
system_prompt, system_prompt,
volatile_tail,
conv_id, conv_id,
knowledge_config, knowledge_config,
llm_concurrency, llm_concurrency,
@@ -1765,14 +1834,14 @@ pub async fn ai_chat_edit(
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 强制发送消息B-260616-02: L2 发送韧性;BUG-260617-04: 原子复位+占用消除双锁竞态) /// 强制发送消息L2 发送韧性;原子复位+占用消除双锁竞态)
/// ///
/// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时, /// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时,
/// 前端可调此命令强制复位 generating + 清审批,然后发送新消息。 /// 前端可调此命令强制复位 generating + 清审批,然后发送新消息。
/// ///
/// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock,两锁之间另一个发送 /// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock,两锁之间另一个发送
/// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被 /// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被
/// reject → 前端以为已强制发送但后端拒绝,状态不一致(BUG-260617-04) /// reject → 前端以为已强制发送但后端拒绝,状态不一致。
/// ///
/// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口; /// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口;
/// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit /// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit
@@ -1838,7 +1907,7 @@ pub async fn ai_chat_force_send(
// was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed), // was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。 // 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。
let was_gen = state.conv_states.is_active(&target); let was_gen = state.conv_states.is_active(&target);
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages // 强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
// 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。 // 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
finalize_pending_placeholders(&mut *session, &target, "已取消"); finalize_pending_placeholders(&mut *session, &target, "已取消");
// 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。 // 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。
@@ -1867,7 +1936,7 @@ pub async fn ai_chat_force_send(
// 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。 // 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。
// 注:stop_flag 保持 true(旧 loop 退出用),新 loop spawn 前由下方锁外等待窗口后置 false。 // 注:stop_flag 保持 true(旧 loop 退出用),新 loop spawn 前由下方锁外等待窗口后置 false。
conv.agent_language = language.clone(); conv.agent_language = language.clone();
// F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。 // 强制发送 = 新对话生命周期起点,iteration 从头计数。
conv.iteration_used = 0; conv.iteration_used = 0;
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
// user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send // user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send
@@ -1949,19 +2018,34 @@ pub async fn ai_chat_force_send(
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。 // 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await; // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
} }
}
// conv_id 已在上方状态占用块得出。 // conv_id 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。 // 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{ {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
} }
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。 // 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。
// force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。 // force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。
@@ -1981,7 +2065,7 @@ pub async fn ai_chat_force_send(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -2025,7 +2109,7 @@ pub async fn ai_chat_stop(
let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval; let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval;
if has_pending { if has_pending {
// 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用 // 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用
// SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。 // messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
// 清 pending 前先终态化为"会话已停止"。仅清目标 conv 的 pending(retain 保留其他 conv)。 // 清 pending 前先终态化为"会话已停止"。仅清目标 conv 的 pending(retain 保留其他 conv)。
finalize_pending_placeholders(&mut *session, &target, "会话已停止"); finalize_pending_placeholders(&mut *session, &target, "会话已停止");
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
@@ -2068,7 +2152,7 @@ pub async fn ai_chat_stop(
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.clone() conv.notify.clone()
}; };
// B-260615-14:置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select! // 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。 // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
notify.notify_one(); notify.notify_one();
@@ -2177,7 +2261,7 @@ pub async fn ai_chat_stop(
Ok(()) Ok(())
} }
/// 续跑 agentic 循环F-260616-03达 max_iterations 暂停态用户点「继续」) /// 续跑 agentic 循环达 max_iterations 暂停态用户点「继续」)
/// ///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出) /// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出)
@@ -2224,7 +2308,7 @@ pub async fn ai_continue_loop(
Ok("ok".to_string()) Ok("ok".to_string())
} }
/// 停止 agentic 循环并走完成流程F-260616-03达 max_iterations 暂停态用户点「停止」) /// 停止 agentic 循环并走完成流程达 max_iterations 暂停态用户点「停止」)
/// ///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。 /// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。
@@ -106,6 +106,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens, prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens, prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens, reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated,
} }
} }
@@ -155,6 +156,7 @@ pub fn message_to_record(
prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens, prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens, prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens, reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated,
} }
} }
@@ -174,7 +176,7 @@ pub async fn ai_conversation_create(
let now = now_millis(); let now = now_millis();
// F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。 // F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。
// 旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀 // 旧实现生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀
// 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv), // 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv),
// 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。 // 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。
// //
@@ -203,7 +205,7 @@ pub async fn ai_conversation_create(
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为 // F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
// "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。 // "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。
drop(session); drop(session);
state.clear_session_allowed_dirs().await; state.clear_session_allowed_dirs_if_idle().await;
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。 // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留 // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
@@ -327,7 +329,7 @@ pub async fn ai_conversation_switch(
// 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为"当前活跃会话的临时授权", // 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为"当前活跃会话的临时授权",
// 不跨会话继承)。 // 不跨会话继承)。
drop(session); drop(session);
state.clear_session_allowed_dirs().await; state.clear_session_allowed_dirs_if_idle().await;
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中 // 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中
// (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照 // (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照
@@ -352,6 +354,8 @@ pub async fn ai_conversation_switch(
} }
} else { } else {
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
// 首次切入(内存无该 conv 状态)才复位会话级临时态;切走再切回保留信任/语言/模型覆盖,避免信任遗忘重复审批。
let conv_was_present = session.conv_read(&conversation_id).is_some();
let conv = session.conv(&conversation_id); let conv = session.conv(&conversation_id);
// reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages: // reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages:
// 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或 // 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或
@@ -395,10 +399,12 @@ pub async fn ai_conversation_switch(
}; };
conv.pinned_goals = pinned_goals; conv.pinned_goals = pinned_goals;
} }
// 会话级临时态复位(无论是否 restore messages,重入即复位) // 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位
if !conv_was_present {
conv.model_override = None; conv.model_override = None;
conv.session_trust.clear(); conv.session_trust.clear();
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);
} }
@@ -626,6 +632,8 @@ pub async fn ai_conversation_delete(
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));
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
session.per_conv.remove(&conversation_id); session.per_conv.remove(&conversation_id);
// 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。
session.save_locks.remove(&conversation_id);
// conv 删除同步清理生成状态条目,防已删 conv 残留活跃态致 id 复用时脏状态。 // conv 删除同步清理生成状态条目,防已删 conv 残留活跃态致 id 复用时脏状态。
state.conv_states.remove(&conversation_id); state.conv_states.remove(&conversation_id);
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id); let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
@@ -639,7 +647,7 @@ pub async fn ai_conversation_delete(
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为 // F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
// "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。 // "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。
if was_active { if was_active {
state.clear_session_allowed_dirs().await; state.clear_session_allowed_dirs_if_idle().await;
} }
Ok(()) Ok(())
} }
@@ -674,7 +682,7 @@ pub async fn ai_conversation_archive(
Ok(()) Ok(())
} }
/// 置顶/取消置顶对话(UX-17:对话置顶) /// 置顶/取消置顶对话对话置顶)
/// ///
/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。 /// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。
/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。 /// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。
@@ -719,7 +727,7 @@ pub async fn ai_update_conversation_goals(
Ok(()) Ok(())
} }
/// 导出对话为指定格式(UX-18:对话导出) /// 导出对话为指定格式对话导出)
/// ///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出 /// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出
@@ -830,6 +838,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
} }
} }
+14 -14
View File
@@ -13,7 +13,7 @@
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`, //! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 //! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//! //!
//! 纯搬迁:不改 IPC 签名/行为(FR-S1 密钥迁移 / F-260614-04c 池配置编辑保留 / F-01 阶段5 探测 //! 纯搬迁:不改 IPC 签名/行为(FR-S1 密钥迁移 / c 池配置编辑保留 / F-01 阶段5 探测
//! 等逻辑原样保留)。 //! 等逻辑原样保留)。
use tauri::State; use tauri::State;
@@ -37,7 +37,7 @@ use super::super::secret::{
// 提供商管理 // 提供商管理
// ============================================================ // ============================================================
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识。 /// api_key 脱敏:IPC 不传明文给前端,保留首尾各 4 字符便于辨识。
/// ///
/// `pub(crate)` 供 get_app_config AI 工具复用(查 DevFlow 自身 provider 配置时同样脱敏, /// `pub(crate)` 供 get_app_config AI 工具复用(查 DevFlow 自身 provider 配置时同样脱敏,
/// 不向 LLM 回灌明文 key,与 ai_list_providers IPC 走同一套脱敏规则,单点维护)。 /// 不向 LLM 回灌明文 key,与 ai_list_providers IPC 走同一套脱敏规则,单点维护)。
@@ -83,7 +83,7 @@ pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProvi
} }
} }
} }
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 // IPC 不传明文 api_key前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
for p in &mut providers { for p in &mut providers {
let real = if !p.api_key.is_empty() { let real = if !p.api_key.is_empty() {
@@ -111,7 +111,7 @@ pub async fn ai_save_provider(
// 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), // 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
// 编辑已有提供商时保留原 created_at,避免被覆盖 // 编辑已有提供商时保留原 created_at,避免被覆盖
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 // c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
// 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。
let existing = match &id { let existing = match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?, Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?,
@@ -125,13 +125,13 @@ pub async fn ai_save_provider(
.map_err(err_str)? .map_err(err_str)?
.iter().any(|p| p.is_default), .iter().any(|p| p.is_default),
}; };
// F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库); // c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库);
// 新建默认进池(enabled=true,weight=50)。 // 新建默认进池(enabled=true,weight=50)。
let (enabled, weight) = match &existing { let (enabled, weight) = match &existing {
Some(p) => (p.enabled, p.weight), Some(p) => (p.enabled, p.weight),
None => (true, 50), None => (true, 50),
}; };
// FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 // 密钥存 OS keyring,DB api_key 列恒空(不入明文)。
// api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。
let provider_id = id.clone().unwrap_or_else(new_id); let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() { if !api_key.is_empty() {
@@ -140,7 +140,7 @@ pub async fn ai_save_provider(
return Err(format!("密钥保存到系统钥匙串失败: {}", e)); return Err(format!("密钥保存到系统钥匙串失败: {}", e));
} }
} else if let Some(pid) = &id { } else if let Some(pid) = &id {
// 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1) // 空 key 编辑:保住密钥,防未迁移态静默丢失。
// 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key,
// 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。
// 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态);
@@ -181,7 +181,7 @@ pub async fn ai_save_provider(
config: None, config: None,
created_at, created_at,
updated_at: now_millis(), updated_at: now_millis(),
// F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 // c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。
enabled, enabled,
weight, weight,
}; };
@@ -194,7 +194,7 @@ pub async fn ai_save_provider(
Ok(id) Ok(id)
} }
/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。 /// c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。
/// ///
/// 与 `ai_save_provider` 的区别: /// 与 `ai_save_provider` 的区别:
/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥 /// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥
@@ -277,7 +277,7 @@ pub async fn ai_delete_provider(
provider_id: String, provider_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(err_str)?; state.ai_providers.delete(&provider_id).await.map_err(err_str)?;
// CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, // DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方,
// 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写)
if let Err(e) = delete_provider_secret_async(provider_id.clone()).await { if let Err(e) = delete_provider_secret_async(provider_id.clone()).await {
tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e);
@@ -310,7 +310,7 @@ fn normalize_provider_type_for_fetch(provider_type: &str) -> String {
/// ///
/// 流程: /// 流程:
/// 1. DB 取 AiProviderRecord(get_by_id) /// 1. DB 取 AiProviderRecord(get_by_id)
/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB, /// 2. 经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB,
/// 绝不进日志/返回值/错误信息) /// 绝不进日志/返回值/错误信息)
/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 /// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调
/// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度) /// df_ai::model_fetch::fetch_and_probe → Vec<ModelConfig>(每个模型名已探测出 4 维度)
@@ -329,7 +329,7 @@ pub async fn ai_fetch_models(
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?; .ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
// FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息) // 内存解析密钥,绝不外泄(不入日志/返回值/错误信息)
let api_key = df_storage::secret::resolve_provider_secret(&provider); let api_key = df_storage::secret::resolve_provider_secret(&provider);
let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type);
@@ -381,7 +381,7 @@ pub async fn ai_fetch_models(
Ok(merged) Ok(merged)
} }
/// 一次性测试连接(P0-1):用**表单当前值**而非 DB 值测试,不写库。 /// 一次性测试连接用**表单当前值**而非 DB 值测试,不写库。
/// ///
/// 解决:`ai_fetch_models` 从 DB 读 provider,用户改了表单未保存就点「测试连接」时 /// 解决:`ai_fetch_models` 从 DB 读 provider,用户改了表单未保存就点「测试连接」时
/// 测的是旧值(base_url/api_key 已改但 DB 未落),给出误导性「成功/失败」。 /// 测的是旧值(base_url/api_key 已改但 DB 未落),给出误导性「成功/失败」。
@@ -416,7 +416,7 @@ pub async fn ai_test_connection(
.await .await
.map_err(err_str)? .map_err(err_str)?
.ok_or_else(|| format!("提供商不存在: {}", pid))?; .ok_or_else(|| format!("提供商不存在: {}", pid))?;
// FR-S1:内存解析,绝不外泄(不入日志/返回值/错误信息) // 内存解析,绝不外泄(不入日志/返回值/错误信息)
df_storage::secret::resolve_provider_secret(&provider) df_storage::secret::resolve_provider_secret(&provider)
}; };
+10 -3
View File
@@ -10,11 +10,11 @@
use df_ai::context_helpers::CompressedSummary; use df_ai::context_helpers::CompressedSummary;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化 // 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 // 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, is_likely_image_model, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
@@ -81,6 +81,13 @@ pub(crate) async fn compress_via_llm(
}; };
let model = select_model_id(&compress_req, &provider_config.model_configs) let model = select_model_id(&compress_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone()); .unwrap_or_else(|| provider_config.default_model.clone());
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
if is_likely_image_model(&model, &provider_config.model_configs) {
return Err(format!(
"压缩模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)",
model
));
}
// 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。 // 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。
let mut prompt = vec![ChatMessage::system(compress_prompt(lang))]; let mut prompt = vec![ChatMessage::system(compress_prompt(lang))];
@@ -322,7 +329,7 @@ mod tests {
assert_eq!(clean_summary(""), ""); assert_eq!(clean_summary(""), "");
} }
// ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ── // ── BUG-2026-07-07/LLM 退化重复输出检测 ──
#[test] #[test]
fn clean_summary_rejects_degenerated_repetition() { fn clean_summary_rejects_degenerated_repetition() {
// 实测样本(9357c27c seq0):同一短语重复 41 次 // 实测样本(9357c27c seq0):同一短语重复 41 次
+66 -23
View File
@@ -94,7 +94,7 @@ pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
/// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增 /// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增
/// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。 /// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。
/// ///
/// B-260619-02:50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对 /// 50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对
/// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码 /// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码
/// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。 /// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。
/// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。 /// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。
@@ -196,7 +196,7 @@ pub(crate) async fn save_conversation(
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 // 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
if let Err(_elapsed) = tokio::time::timeout( if let Err(_elapsed) = tokio::time::timeout(
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS), Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at), save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at),
) )
.await .await
{ {
@@ -208,6 +208,23 @@ pub(crate) async fn save_conversation(
} }
} }
/// 按 conv 串行化 save(取 per-conv 锁后执行 inner;tokio Mutex lock 可取消,超时放弃不持锁)。
async fn save_conversation_serialized(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>,
touch_updated_at: bool,
) {
let save_lock = {
let mut session = session_arc.lock().await;
session.save_lock(conv_id)
};
let _save_guard = save_lock.lock().await;
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at).await;
}
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。 /// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
async fn save_conversation_inner( async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
@@ -218,14 +235,14 @@ async fn save_conversation_inner(
touch_updated_at: bool, touch_updated_at: bool,
) { ) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at // 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段 // 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
// 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发 // 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request // 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
// 仍读全量 messages。 // 仍读全量 messages。
// //
// per_conv.messages 是唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。 // per_conv.messages 是唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
// conv() 惰性建:save 路径 conv 必然已建;极端未建(如启动恢复无 live conv)建空态,save 空消息(幂等)。 // conv() 惰性建:save 路径 conv 必然已建;极端未建(如启动恢复无 live conv)建空态,save 空消息(幂等)。
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。 // lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级, // 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+ // 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
// 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。 // 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
@@ -234,7 +251,9 @@ async fn save_conversation_inner(
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。 // 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow, // 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = { // BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -247,29 +266,45 @@ async fn save_conversation_inner(
return; return;
} }
let __wait = __lock_start.elapsed(); let __wait = __lock_start.elapsed();
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let provider_id = session.active_provider_id.clone(); let provider_id = session.active_provider_id.clone();
let created_at = session.active_conv_created_at.clone(); let created_at = session.active_conv_created_at.clone();
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
let cloned = ( let msg_len = conv.messages.len();
conv.messages.all_messages_clone(), let persisted_count = conv.messages.persisted_msg_count();
let needs_full_rewrite = conv.messages.needs_full_rewrite();
// 无变化捷径提前判定:消息数未增且非 dirty → DB ai_messages 与内存完全一致,
// 免 clone(下方直接走元数据 update_full,消息写入整段跳过)。
let cloned = if !needs_full_rewrite && msg_len == persisted_count {
Vec::new()
} else {
conv.messages.all_messages_clone()
};
let pinned_goals = conv.pinned_goals.clone();
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
(
provider_id, provider_id,
created_at, created_at,
conv.pinned_goals.clone(), pinned_goals,
conv.messages.persisted_msg_count(), msg_len,
conv.messages.needs_full_rewrite(), persisted_count,
); needs_full_rewrite,
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) cloned,
cloned )
}; };
let __lock_total = __lock_start.elapsed(); let __lock_total = __lock_start.elapsed();
if __lock_total > std::time::Duration::from_millis(50) { if __lock_total > std::time::Duration::from_millis(50) {
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total); eprintln!("[LOCK-DIAG] save 快照段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
} }
// BE1:仅当消息确有可能变化(非无变化捷径)才对 clone 副本做 namespace 展开 + truncate。
let no_message_change = !needs_full_rewrite && msg_len == persisted_count;
if !no_message_change {
// truncate 在锁外(clone 副本上操作,不影响 session 真相源) // truncate 在锁外(clone 副本上操作,不影响 session 真相源)
// T2: 展开 namespace 引用为原文再落库 // T2: 展开 namespace 引用为原文再落库
{ {
let session = session_arc.lock().await; let session = session_arc.lock().await;
for m in &mut msgs { for m in &mut persist_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) { if df_ai::namespace_store::is_namespace_ref(&m.content) {
if let Some(original) = session.namespace_store.read_only(&m.content) { if let Some(original) = session.namespace_store.read_only(&m.content) {
m.content = original.to_string(); m.content = original.to_string();
@@ -277,8 +312,8 @@ async fn save_conversation_inner(
} }
} }
} }
for m in &mut msgs { for m in &mut persist_msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样), // tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。 // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
if !matches!(m.role, df_ai::provider::MessageRole::Tool) { if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
@@ -290,7 +325,7 @@ async fn save_conversation_inner(
m.parts = truncate_parts_for_persist(parts); m.parts = truncate_parts_for_persist(parts);
} }
} }
let persist_msgs = msgs; }
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目, // 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
@@ -318,12 +353,19 @@ async fn save_conversation_inner(
// 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。 // 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
let now = now_millis(); let now = now_millis();
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone()); let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
let total_len = persist_msgs.len(); // BE1:no_message_change 时 persist_msgs 为空,records 也空(下方无变化捷径直接跳过消息写入,
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs // 仅元数据 update_full);非无变化时 records 由全量 persist_msgs 构建(append 切片仍按 persisted_count)。
let records: Vec<df_storage::models::AiMessageRecord> = if no_message_change {
Vec::new()
} else {
persist_msgs
.iter() .iter()
.enumerate() .enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at)) .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
.collect(); .collect()
};
// BE1:total_len 用 clone 后权威条数(no_message_change 时用 lock 读的 msg_len,=persisted_count)。
let total_len = if no_message_change { msg_len } else { persist_msgs.len() };
let conv_repo = AiConversationRepo::new(db); let conv_repo = AiConversationRepo::new(db);
let msg_repo = df_storage::crud::AiMessageRepo::new(db); let msg_repo = df_storage::crud::AiMessageRepo::new(db);
@@ -574,7 +616,7 @@ mod tests {
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048) // 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048)
assert!(result.contains("已截断")); assert!(result.contains("已截断"));
assert!(result.contains("省略 3048 字符")); assert!(result.contains("省略 3048 字符"));
// B-260619-02:截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文 // 截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文
assert!(result.contains("原 9192 字节")); assert!(result.contains("原 9192 字节"));
// 结果总长应远小于原长(20k 头 + 20k 尾 + 标注) // 结果总长应远小于原长(20k 头 + 20k 尾 + 标注)
assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000); assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000);
@@ -645,7 +687,7 @@ mod tests {
assert_eq!(truncate_parts_for_persist(&[]), None); assert_eq!(truncate_parts_for_persist(&[]), None);
} }
// ---------- P0-2: tool role content 不截断(防 JSON 结构破坏) ---------- // ---------- tool role content 不截断(防 JSON 结构破坏) ----------
/// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型 /// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型
fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage { fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage {
@@ -664,6 +706,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
} }
} }
+1 -1
View File
@@ -795,7 +795,7 @@ mod tests {
); );
} }
// ── 真实 SenseNova U1-Fast 图像生成验证(#[ignore],手动跑) ── // ── 真实 SenseNova ast 图像生成验证(#[ignore],手动跑) ──
// //
// 端到端验证:用真实 db 副本(SenseNova provider 配置)+ OS keyring 解析 api_key + // 端到端验证:用真实 db 副本(SenseNova provider 配置)+ OS keyring 解析 api_key +
// 真实调 https://token.sensenova.cn/v1/images/generations 生成一张测试图并下载落地。 // 真实调 https://token.sensenova.cn/v1/images/generations 生成一张测试图并下载落地。
+43 -25
View File
@@ -10,7 +10,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// - 嵌入: TaskRequirements(Capability::Embedding,无工具)— 在 embedding provider 的池中选, // - 嵌入: TaskRequirements(Capability::Embedding,无工具)— 在 embedding provider 的池中选,
// 池空兜底 config.embedding_model(行为不变,现有 KnowledgeConfig 配置即生效)。 // 池空兜底 config.embedding_model(行为不变,现有 KnowledgeConfig 配置即生效)。
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, is_likely_image_model, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo}; use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo};
use df_storage::db::Database; use df_storage::db::Database;
@@ -351,6 +351,39 @@ pub(crate) async fn build_knowledge_context(
out out
} }
/// 取末条 active user 消息文本 + id,构建知识库上下文段(DRY:注入 [`inject_knowledge_into_prompt`]
/// 与 Anthropic prompt caching 易变段共用)。
///
/// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
/// 导致两值取自不同消息。auto_inject 关 / 无命中返回空串。
pub(crate) async fn build_knowledge_context_for_conv(
state: &AppState,
conv_id: &str,
config: &crate::state::KnowledgeConfig,
) -> String {
let (last_user_text, user_message_id) = {
let __lock_t = std::time::Instant::now();
let session = state.ai_session.lock().await;
let msgs = session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default();
let found = msgs
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold);
}
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
}
};
build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await
}
/// 知识注入 system prompt 的单一入口(DRY:F-09 agentic + chat 五处合一)。 /// 知识注入 system prompt 的单一入口(DRY:F-09 agentic + chat 五处合一)。
/// ///
/// 把原本散落在 `try_continue_agent_loop`(agentic/mod.rs)+ `ai_chat_send` / /// 把原本散落在 `try_continue_agent_loop`(agentic/mod.rs)+ `ai_chat_send` /
@@ -378,30 +411,7 @@ pub(crate) async fn inject_knowledge_into_prompt(
system_prompt: String, system_prompt: String,
config: &crate::state::KnowledgeConfig, config: &crate::state::KnowledgeConfig,
) -> String { ) -> String {
// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤 let knowledge_context = build_knowledge_context_for_conv(state, conv_id, config).await;
// 导致两值取自不同消息。
let (last_user_text, user_message_id) = {
let __lock_t = std::time::Instant::now();
let session = state.ai_session.lock().await;
let msgs = session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default();
let found = msgs
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold);
}
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
}
};
let knowledge_context =
build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await;
if knowledge_context.is_empty() { if knowledge_context.is_empty() {
system_prompt system_prompt
} else { } else {
@@ -672,6 +682,14 @@ async fn extract_knowledge_from_conversation(
}; };
let extract_model = select_model_id(&extract_req, &provider_config.model_configs) let extract_model = select_model_id(&extract_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone()); .unwrap_or_else(|| provider_config.default_model.clone());
// 防御:选中/兜底模型是图像模型时跳过 LLM 提取(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
if is_likely_image_model(&extract_model, &provider_config.model_configs) {
tracing::warn!(
"知识提取跳过:模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)",
extract_model
);
return Ok(0);
}
let request = CompletionRequest { let request = CompletionRequest {
model: extract_model, model: extract_model,
messages: extract_messages, messages: extract_messages,
+20 -9
View File
@@ -94,7 +94,7 @@ pub use self::augmentation::build_resolver_registry;
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到) // 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================ // ============================================================
/// AI 错误分类(B-260615-42)供前端按错误源差异化提示(如 auth 引导填 key, /// AI 错误分类供前端按错误源差异化提示(如 auth 引导填 key,
/// network 提示检查连接,provider_config 引导检查 base_url/model)。 /// network 提示检查连接,provider_config 引导检查 base_url/model)。
/// ///
/// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`, /// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`,
@@ -178,7 +178,7 @@ pub enum AiChatEvent {
}, },
/// 错误 /// 错误
/// ///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。 /// `error_type` 为错误源分类,`None` 表示未分类(向后兼容旧 emit 点)。
AiError { AiError {
error: String, error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None /// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
@@ -207,13 +207,13 @@ pub enum AiChatEvent {
AiAgentRound { round: u32, conversation_id: Option<String> }, AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」) /// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> }, AiHeartbeat { conversation_id: Option<String> },
/// 达最大轮次仍未收敛F-260616-03转暂停态询问用户继续/停止) /// 达最大轮次仍未收敛转暂停态询问用户继续/停止)
/// ///
/// 触发时 generating 仍为 true(仿审批等待),前端展示独立操作卡: /// 触发时 generating 仍为 true(仿审批等待),前端展示独立操作卡:
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false
AiMaxRoundsReached { conversation_id: Option<String> }, AiMaxRoundsReached { conversation_id: Option<String> },
/// 流式调用自动重试提示F-260616-07流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。 /// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
@@ -238,7 +238,7 @@ pub enum AiChatEvent {
AiCompressing { conversation_id: Option<String> }, AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。 /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。
/// ///
/// 治 Task#1(BUG-260624-05):自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷, /// 治 Task#1自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷,
/// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。 /// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。
/// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。 /// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。
AiManualCompressed { conversation_id: Option<String>, summary: String }, AiManualCompressed { conversation_id: Option<String>, summary: String },
@@ -502,7 +502,7 @@ pub enum SessionState {
/// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化) /// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化)
/// - `Execute { dir }`run_command 在 `dir` 工作目录下 /// - `Execute { dir }`run_command 在 `dir` 工作目录下
/// ///
/// 命中即自动放行,跳过 pending 审批 + 二次确认AE-05,不写 audit 仍记一条 /// 命中即自动放行,跳过 pending 审批 + 二次确认,不写 audit 仍记一条
/// `decided_by=auto_trust` 的审计记录留痕。 /// `decided_by=auto_trust` 的审计记录留痕。
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum TrustKey { pub enum TrustKey {
@@ -518,7 +518,7 @@ pub enum TrustKey {
/// run_command handler 自身仅 validate_path 不限定 workspace,信任目录用 LLM 传入或 /// run_command handler 自身仅 validate_path 不限定 workspace,信任目录用 LLM 传入或
/// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐) /// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐)
/// ///
/// FR-S1不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。 /// 不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。
pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> { pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
match tool { match tool {
"write_file" => { "write_file" => {
@@ -617,6 +617,8 @@ pub struct AiSession {
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留 /// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
/// (单条 ~40B,删除量级下可忽略)。 /// (单条 ~40B,删除量级下可忽略)。
pub deleted_convs: HashSet<String>, pub deleted_convs: HashSet<String>,
/// 每会话 save 串行锁:同会话并发 save(loop 与 IPC)串行化,防全量重写互踩吞新行
pub save_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列) /// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
pub namespace_store: df_ai::namespace_store::NamespaceStore, pub namespace_store: df_ai::namespace_store::NamespaceStore,
} }
@@ -630,6 +632,7 @@ impl AiSession {
pending_approvals: HashMap::new(), pending_approvals: HashMap::new(),
per_conv: HashMap::new(), per_conv: HashMap::new(),
deleted_convs: HashSet::new(), deleted_convs: HashSet::new(),
save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(), namespace_store: df_ai::namespace_store::NamespaceStore::default(),
} }
} }
@@ -688,6 +691,14 @@ impl AiSession {
pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> { pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> {
self.per_conv.get(conv_id) self.per_conv.get(conv_id)
} }
/// 取或惰性创建某会话的 save 串行锁(返回 Arc 供 save 全程持有)。
pub fn save_lock(&mut self, conv_id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.save_locks
.entry(conv_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1153,10 +1164,10 @@ pub(crate) async fn execute_run_workflow_for_tool(
let config = serde_json::Value::Object(serde_json::Map::new()); let config = serde_json::Value::Object(serde_json::Map::new());
let name = format!("AI 推进任务 {}{}", task_id, target_status); let name = format!("AI 推进任务 {}{}", task_id, target_status);
// 转调工作流执行核心(B-260617-01:run_workflow_inner 是 run_workflow 命令的共用核心, // 转调工作流执行核心run_workflow_inner 是 run_workflow 命令的共用核心,
// 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开 // 同 invoke('run_workflow') IPC 入口的执行逻辑,持 &AppState 非 tauri::State,绕开
// tauri::State 在非命令上下文不可构造的限制)。 // tauri::State 在非命令上下文不可构造的限制)。
// CR-52: 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。 // 本路径是 AI 工具经 ai_approve 审批后执行 → triggered_by="ai"(区别命令层 manual)。
// 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。 // 返回 execution_id 字符串;失败(模板不存在/DAG 校验失败/DB 写入失败)上抛 Err。
let execution_id = crate::commands::workflow::run_workflow_inner( let execution_id = crate::commands::workflow::run_workflow_inner(
app, app,
+234 -20
View File
@@ -1,5 +1,9 @@
//! 系统提示词构建 + 活跃提供商获取 //! 系统提示词构建 + 活跃提供商获取
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::db::Database;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::commands::err_str; use crate::commands::err_str;
@@ -43,7 +47,24 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。 /// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
fn env_profile_line() -> String { fn env_profile_line() -> String {
let today = chrono::Local::now().format("%Y-%m-%d"); let today = chrono::Local::now().format("%Y-%m-%d");
let (os, shell, interp) = if cfg!(target_os = "windows") { format!("当前日期: {today} | {}", env_profile_stable_body())
}
/// env_profile_line 的静态稳定段(OS + shell + 执行姿势,不含日期)。
///
/// Anthropic prompt caching 模式把日期挪到易变段(env_profile_date_volatile,拼首条 user 消息),
/// 本稳定段留在 system 供缓存命中——日期每日变,留 system 会每日失效整段缓存。
fn env_profile_stable_body() -> String {
let (os, shell, interp) = env_os_shell_interp();
format!(
"运行环境: {os} | shell: {shell}\n\
姿: (//$) .ps1 .py , `powershell -File x.ps1` `python x.py` , -c (): {interp}\n\n"
)
}
/// 平台对应 (os, shell, 可用解释器) 三元组(cfg! 分支,见 env_profile_line 文档)。
fn env_os_shell_interp() -> (&'static str, &'static str, &'static str) {
if cfg!(target_os = "windows") {
("Windows", "PowerShell", "python(无 python3)、node") ("Windows", "PowerShell", "python(无 python3)、node")
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
("macOS", "bash/zsh", "python3、node") ("macOS", "bash/zsh", "python3、node")
@@ -51,11 +72,13 @@ fn env_profile_line() -> String {
("Linux", "bash", "python3、node") ("Linux", "bash", "python3、node")
} else { } else {
("Unknown", "sh", "python3、node") ("Unknown", "sh", "python3、node")
}; }
format!( }
"当前日期: {today} | 运行环境: {os} | shell: {shell}\n\
姿: (//$) .ps1 .py , `powershell -File x.ps1` `python x.py` , -c (): {interp}\n\n" /// Anthropic prompt caching 模式:日期易变段(拼到首条 user 消息,不进 system)。
) fn env_profile_date_volatile() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
format!("今天是 {today}")
} }
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题) /// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
@@ -103,7 +126,6 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- \n\ - \n\
- 使\n\ - 使\n\
- \n\ - \n\
- **** grep//\n\
- **** read_file/search_files\n\ - **** read_file/search_files\n\
## \n\ ## \n\
- \n\ - \n\
@@ -247,6 +269,98 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
} }
} }
// ── BE3system prompt 会话内缓存 ──
//
// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 +
// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。
//
// 方案:按「数据指纹」缓存已拼好的 system 串(指纹 = 日期 + lang + custom_prompt + 活跃
// project/task 的 id+updated_at + 去重排除集)。任何依赖数据变更 → 指纹变 → 重建;未变直接
// 复用缓存串(省 2 次全行物化查询 + 字符串拼接)。
//
// 指纹键而非 conv_id:system prompt 内容本就与 conv_id 无关,同数据跨会话共享同一缓存串,
// 免 per-conv 生命周期清理(换会话/删会话零维护)。模块级静态(OnceLock+Mutex),不新增
// AppState 字段,进程内单例,懒初始化。
//
// 指纹查询只取轻量列(id+updated_at 两列,省 list_active 的全行物化);查询失败回退「强制重建」,
// 正确性不受损(只是多一次重建)。缓存按指纹封顶,超上限清空防长跑进程内存膨胀。
static SYSTEM_PROMPT_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。
const SYSTEM_PROMPT_CACHE_MAX: usize = 32;
fn system_prompt_cache() -> &'static Mutex<HashMap<String, String>> {
SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。
/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。
async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<String> {
let conn = db.conn().clone();
let result = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard.prepare(sql).ok()?;
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let updated: String = row.get(1)?;
Ok(format!("{}@{};", id, updated))
})
.ok()?;
let mut out = String::new();
for r in rows {
out.push_str(&r.ok()?);
}
Some(out)
})
.await;
result.ok().flatten()
}
/// 计算 system prompt 缓存指纹(返回 None 表示指纹依赖查询失败,调用方按 miss 处理强制重建)。
///
/// 指纹覆盖全部依赖输入:
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint(
state: &AppState,
lang: &str,
excl_proj: &[String],
excl_task: &[String],
) -> Option<String> {
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let custom = state
.settings
.get("custom_prompt")
.await
.ok()
.flatten()
.unwrap_or_default();
let proj_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
)
.await?;
let task_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM tasks WHERE deleted_at IS NULL",
)
.await?;
let mut key = format!("{date}|{lang}|{custom}|{proj_fp}|{task_fp}");
for id in excl_proj {
key.push_str("|p:");
key.push_str(id);
}
for id in excl_task {
key.push_str("|t:");
key.push_str(id);
}
Some(key)
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**) /// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
/// ///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path), /// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path),
@@ -260,6 +374,11 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
/// ///
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给 /// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。 /// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
///
/// 注:caching 拆分后主链(send/regenerate/edit/force_send/续跑)已改走
/// [`build_system_prompt_parts`](开关关逐字等价),本 wrapper 暂零调用方——保留作预留 API
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
#[allow(dead_code)]
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String { pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
build_system_prompt_with_excluded(state, lang, &[], &[]).await build_system_prompt_with_excluded(state, lang, &[], &[]).await
} }
@@ -283,7 +402,15 @@ pub(crate) async fn build_system_prompt_with_excluded(
excluded_project_ids: &[String], excluded_project_ids: &[String],
excluded_task_ids: &[String], excluded_task_ids: &[String],
) -> String { ) -> String {
let (prefix, proj_label, task_label) = system_prompt_parts(lang); // BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
if let Some(key) = &fp_key {
let cache = system_prompt_cache();
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
return cached.clone();
}
}
let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line(); let mut prompt = env_profile_line();
prompt.push_str(prefix); prompt.push_str(prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试) // 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
@@ -291,55 +418,142 @@ pub(crate) async fn build_system_prompt_with_excluded(
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) // 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang)); prompt.push_str(app_config_query_guidance_section(lang));
// 当前数据上下文(项目/任务全局清单)+ 自定义提示词 —— 拆出共用 helper,行为逐字等价。
prompt.push_str(&build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await);
prompt.push_str(&build_custom_section(state).await);
// BE3:未命中 → 存入缓存供下轮复用(按指纹封顶,超上限清空防长跑进程内存膨胀)。
if let Some(key) = fp_key {
let mut cache = system_prompt_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SYSTEM_PROMPT_CACHE_MAX {
cache.clear();
}
cache.insert(key, prompt.clone());
}
prompt
}
/// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段
/// [`build_system_prompt_parts`] 共用)。
///
/// 拼接项目段 + 任务段(含机制层注明语);无项目/任务返回空串。
/// 语义与旧 build_system_prompt_with_excluded 内联块逐字一致(纯提取,零行为变更)。
async fn build_context_lists(
state: &AppState,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
lang: &str,
) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new();
// 附加当前数据上下文 // 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await { if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() { if !projects.is_empty() {
prompt.push_str(proj_label); out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) { for p in projects.iter().take(20) {
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!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description)); out.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description));
if let Some(ref dir) = p.path { if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir)); out.push_str(&format!(" 目录: {}\n", dir));
} }
} }
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用 // 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
prompt.push_str(&projects_listed_note(lang, 20)); out.push_str(&projects_listed_note(lang, 20));
} }
} }
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层) // 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
// 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。 // 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。
if let Ok(tasks) = state.tasks.list_active().await { if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() { if !tasks.is_empty() {
prompt.push_str(task_label); out.push_str(task_label);
// 去重:被 @ 的任务跳过(同项目去重机理) // 去重:被 @ 的任务跳过(同项目去重机理)
for tk in tasks.iter().take(20) { for tk in tasks.iter().take(20) {
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!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description)); out.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description));
} }
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang)); out.push_str(&tasks_listed_note(lang));
} }
} }
out
}
// 自定义提示词(设置中配置,追加到系统 prompt 末尾) /// 自定义提示词(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
async fn build_custom_section(state: &AppState) -> String {
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await { if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
if !custom.is_empty() { if !custom.is_empty() {
let clean = custom.trim().trim_matches('"'); let clean = custom.trim().trim_matches('"');
if !clean.is_empty() { if !clean.is_empty() {
prompt.push_str("\n## 自定义指令\n"); return format!("\n## 自定义指令\n{}\n", clean);
prompt.push_str(clean);
prompt.push('\n');
} }
} }
} }
String::new()
}
prompt /// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。
///
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的 anthropic 端点显式开启。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-anthropic-cache")
.await
.ok()
.flatten()
.unwrap_or_default();
let enabled = v == "true" || v == "1";
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
enabled
}
/// 构建 system prompt(Anthropic prompt caching 模式)。
///
/// 返回 `(system 稳定段, 易变尾段)`:
/// - 开关关(`cache_enabled=false`)→ `(完整 system, 空串)`:与 [`build_system_prompt_with_excluded`]
/// 逐字等价(零行为变更,兼容非官方网关)。
/// - 开关开 → `(静态稳定段, 易变段)`:日期/项目/任务清单从 system 挪出,由上层把易变段
/// **追加到消息流末尾(user 消息,不进 system → 不失效 system 缓存)**。放消息流末尾而非
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。
///
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`,
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。
pub(crate) async fn build_system_prompt_parts(
state: &AppState,
lang: &str,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
cache_enabled: bool,
) -> (String, String) {
if !cache_enabled {
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
return (full, String::new());
}
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body();
stable.push_str(prefix);
stable.push_str(doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await);
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
let volatile = if lists.is_empty() {
env_profile_date_volatile()
} else {
format!("{}\n\n{}", env_profile_date_volatile(), lists)
};
(stable, volatile)
} }
/// 项目清单尾部注明语(中/英)。 /// 项目清单尾部注明语(中/英)。
+1 -1
View File
@@ -11,7 +11,7 @@
//! `secret.rs::build_provider_for` 同属"消费 AiProviderRecord 的 app 层逻辑",故放 src-tauri。 //! `secret.rs::build_provider_for` 同属"消费 AiProviderRecord 的 app 层逻辑",故放 src-tauri。
//! //!
//! ## 与 router 协同(职责切分,不重叠) //! ## 与 router 协同(职责切分,不重叠)
//! - **router.select_model_id**:在**单个 provider 的 model_configs 池**中选最优模型(F-01) //! - **router.select_model_id**:在**单个 provider 的 model_configs 池**中选最优模型。
//! - **ProviderPool::select**:在**多个 provider 间**选主 + 列出备用(本模块)。 //! - **ProviderPool::select**:在**多个 provider 间**选主 + 列出备用(本模块)。
//! 调用顺序:先 router 选模型 → 再 ProviderPool 选 provider 实例(模型在多 provider 间共享时)。 //! 调用顺序:先 router 选模型 → 再 ProviderPool 选 provider 实例(模型在多 provider 间共享时)。
//! //!
+179 -28
View File
@@ -114,7 +114,7 @@ pub(crate) enum StreamResult {
reasoning_content: Option<String>, reasoning_content: Option<String>,
}, },
/// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx,或流建立后空文本各类中断)。 /// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx,或流建立后空文本各类中断)。
/// UX-260618-15: **不再 emit AiError**——重试可恢复路径的 emit 权交调用方(agentic/mod.rs), /// **不再 emit AiError**——重试可恢复路径的 emit 权交调用方(agentic/mod.rs),
/// 由 agentic 在重试耗尽/Fatal 时统一 emit 最终 AiError(单气泡),避免 N 次重试 push N+1 气泡。 /// 由 agentic 在重试耗尽/Fatal 时统一 emit 最终 AiError(单气泡),避免 N 次重试 push N+1 气泡。
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect /// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect
/// 可重试;false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。 /// 可重试;false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
@@ -148,6 +148,27 @@ fn accumulate_tool_calls(
} }
} }
/// 双写 emit: Tauri 事件 + ai_event_bus 总线(跨端透传)。消费 ev。
fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) {
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
/// flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
if pending_delta.is_empty() {
return;
}
let delta = std::mem::take(pending_delta);
emit_ai_chat_event(
app_handle,
AiChatEvent::AiTextDelta {
delta,
conversation_id: Some(conv_id.to_string()),
},
);
}
/// 流式接收 LLM 响应。 /// 流式接收 LLM 响应。
/// ///
/// 三类异常处理(返回 StreamResult 显式区分出口): /// 三类异常处理(返回 StreamResult 显式区分出口):
@@ -175,15 +196,19 @@ pub(crate) async fn stream_llm(
conv_id: &str, conv_id: &str,
) -> StreamResult { ) -> StreamResult {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断 /// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
/// BUG-2026-07-17:原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔 /// 原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔
/// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径 /// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45); const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45);
/// BUG-2026-07-08: 首 chunk 超时(等首字节)。 /// 首 chunk 超时(等首字节)。
/// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容; /// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容;
/// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。 /// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。
const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10); const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10);
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog /// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// 后端 delta 合批窗口。50ms 内的多次 AiTextDelta 合并为一条再 emit,
/// 降低 IPC 事件风暴(前端 rAF 只节流渲染不节流 IPC);合并后前端 currentText
/// 逐条累加,最终一致。
const DELTA_FLUSH_INTERVAL: Duration = Duration::from_millis(50);
// ================================================================ // ================================================================
// BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的 // BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的
@@ -262,10 +287,14 @@ pub(crate) async fn stream_llm(
let mut finished_received = false; let mut finished_received = false;
let mut stopped = false; let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None; let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传) // DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = None; let mut reasoning_content_acc: Option<String> = None;
// per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
// next_flush_at 记录下次 flush 的绝对时刻(首个入 buffer 时置 now+50ms)。
let mut pending_delta = String::new();
let mut next_flush_at: Option<tokio::time::Instant> = None;
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器 // heartbeat interval 提至 loop 外复用,避免每轮重建计时器
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。 // (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发 // tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。 // (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。
@@ -281,7 +310,7 @@ pub(crate) async fn stream_llm(
let mut first_chunk_done = false; let mut first_chunk_done = false;
loop { loop {
// B-260615-04:stop 即时打断。 // stop 即时打断。
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true; stopped = true;
break; break;
@@ -290,6 +319,7 @@ pub(crate) async fn stream_llm(
// BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。 // BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。
if tokio::time::Instant::now() >= idle_deadline { if tokio::time::Instant::now() >= idle_deadline {
if !first_chunk_done { if !first_chunk_done {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed { return StreamResult::InitFailed {
retryable: true, retryable: true,
error: format!( error: format!(
@@ -298,12 +328,18 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
// 已收到 finish_reason 仅剩等 usage 帧:慢 provider 拖过 idle 窗口不降 Partial,
// 按正常完成收尾(usage 缺失用默认 0,文本/tool_calls 已完整)。
if finished_received {
break;
}
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
conv_id = %conv_id, conv_id = %conv_id,
text_len = full_text.len(), text_len = full_text.len(),
"[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)", "[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)",
); );
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial { return StreamResult::Partial {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -312,10 +348,24 @@ pub(crate) async fn stream_llm(
}; };
} }
// 三分支 select!: // 到点 flush 合批的 delta(单条 AiTextDelta 双写 emit + event_bus)。
if let Some(deadline) = next_flush_at {
if tokio::time::Instant::now() >= deadline {
next_flush_at = None;
flush_delta(app_handle, conv_id, &mut pending_delta);
}
}
// 多分支 select!:
// 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等) // 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog // 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) notify.notified():用户停止即时打断 // 3) notify.notified():用户停止即时打断
// 4) flush 定时器(sleep_until next_flush_at):尾部 delta 不因流静默而延迟。
let flush_deadline = next_flush_at
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600));
let flush_fut = tokio::time::sleep_until(flush_deadline);
tokio::pin!(flush_fut);
tokio::select! { tokio::select! {
chunk_result = tokio::time::timeout( chunk_result = tokio::time::timeout(
Duration::from_secs(15), Duration::from_secs(15),
@@ -325,6 +375,7 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => { Err(_elapsed) => {
// 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。 // 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。
if !first_chunk_done { if !first_chunk_done {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed { return StreamResult::InitFailed {
retryable: true, retryable: true,
error: format!( error: format!(
@@ -333,12 +384,18 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
// 已收到 finish_reason 仅剩等 usage 帧:慢/坏 provider 拖过 15s 不降 Partial,
// 按正常完成收尾(与 idle_deadline 分支同口径,usage 缺失用默认 0)。
if finished_received {
break;
}
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
conv_id = %conv_id, conv_id = %conv_id,
text_len = full_text.len(), text_len = full_text.len(),
"[ai] 流中途 idle timeout,保文不重试(incomplete)", "[ai] 流中途 idle timeout,保文不重试(incomplete)",
); );
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial { return StreamResult::Partial {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -355,13 +412,11 @@ pub(crate) async fn stream_llm(
idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT; idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT;
if !chunk.delta.is_empty() { if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta); full_text.push_str(&chunk.delta);
// L3 emit 双写 // 合批入 buffer,首个入 buffer 时置 flush 时刻(到点统一 emit 单条)。
let ev = AiChatEvent::AiTextDelta { pending_delta.push_str(&chunk.delta);
delta: chunk.delta, if next_flush_at.is_none() {
conversation_id: Some(conv_id.to_string()), next_flush_at = Some(tokio::time::Instant::now() + DELTA_FLUSH_INTERVAL);
}; }
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
if let Some(tc_deltas) = &chunk.tool_calls { if let Some(tc_deltas) = &chunk.tool_calls {
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc); accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
@@ -395,6 +450,7 @@ pub(crate) async fn stream_llm(
"[ai] provider 流式错误事件", "[ai] provider 流式错误事件",
); );
if full_text.is_empty() && tool_calls_acc.is_empty() { if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed { return StreamResult::InitFailed {
retryable, retryable,
error: fmt_diag( error: fmt_diag(
@@ -405,6 +461,7 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial { return StreamResult::Partial {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -414,9 +471,16 @@ pub(crate) async fn stream_llm(
} }
if chunk.finished { if chunk.finished {
finished_received = true; finished_received = true;
// usage 携带点:OpenAI 兼容流中 usage 挂在 [DONE] 帧(或 usage-only 帧),
// 而 finish_reason 帧(finished=true, usage=None)在其之前到达。
// 若此刻 break 会错过 [DONE] 帧的 usage → 真实 completion_tokens 丢失,
// 前端 token 显示 0。拿到真实 usage 才停;usage 仍 None 则继续读到
// usage-only/[DONE] 帧或通道关闭(Ok(None) 兜底退出,不断连误判)。
if chunk.usage.is_some() {
break; break;
} }
} }
}
Ok(Some(Err(err_str))) => { Ok(Some(Err(err_str))) => {
// 专用线程上报的流错误(SSE 解析失败/HTTP 错误等) // 专用线程上报的流错误(SSE 解析失败/HTTP 错误等)
let (status_or_class, raw) = extract_error_diag_from_str(&err_str); let (status_or_class, raw) = extract_error_diag_from_str(&err_str);
@@ -429,8 +493,9 @@ pub(crate) async fn stream_llm(
"[ai] 流式接收中途错误(from 专用线程)", "[ai] 流式接收中途错误(from 专用线程)",
); );
if full_text.is_empty() && tool_calls_acc.is_empty() { if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed { return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class), retryable: classify_status_with_body(&status_or_class, &raw),
error: fmt_diag( error: fmt_diag(
provider.name(), provider.name(),
DiagKind::MidStream, DiagKind::MidStream,
@@ -439,6 +504,7 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial { return StreamResult::Partial {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -448,26 +514,32 @@ pub(crate) async fn stream_llm(
} }
} }
} }
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02) // 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset
_ = heartbeat.tick() => { _ = heartbeat.tick() => {
let ev = AiChatEvent::AiHeartbeat { emit_ai_chat_event(app_handle, AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; });
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
// 3) 即时停止唤醒(B-260615-14) // 3) 即时停止唤醒
_ = notify.notified() => { _ = notify.notified() => {
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true; stopped = true;
break; break;
} }
} }
// 4) delta 合批到点 flush(流静默/工具执行等待期也能按时发出尾部文本)
_ = &mut flush_fut => {
if next_flush_at.is_some() {
next_flush_at = None;
flush_delta(app_handle, conv_id, &mut pending_delta);
}
}
} }
} }
// 用户停止 // 用户停止
if stopped { if stopped {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Complete { return StreamResult::Complete {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -490,6 +562,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(), text_len = full_text.len(),
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)", "[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
); );
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial { return StreamResult::Partial {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -499,6 +572,7 @@ pub(crate) async fn stream_llm(
} }
// 正常完成 // 正常完成
flush_delta(app_handle, conv_id, &mut pending_delta);
StreamResult::Complete { StreamResult::Complete {
text: full_text, text: full_text,
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
@@ -515,7 +589,7 @@ pub(crate) async fn stream_llm(
/// - timeout / connect → retryable=true(瞬态,可重试) /// - timeout / connect → retryable=true(瞬态,可重试)
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀) /// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀)
/// ///
/// CR-30-1: 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"), /// 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
/// 避免 stream_recv 与 agentic 各自重写状态码解析。 /// 避免 stream_recv 与 agentic 各自重写状态码解析。
fn classify_status_or_class(status_or_class: &str) -> bool { fn classify_status_or_class(status_or_class: &str) -> bool {
use df_ai::retry::is_status_retryable; use df_ai::retry::is_status_retryable;
@@ -538,7 +612,7 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
i += 1; i += 1;
} }
// 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试) // 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试)
// BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀, // Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀,
// 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。 // 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。
// 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。 // 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。
// //
@@ -557,6 +631,34 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|| lower.contains("broken pipe") || lower.contains("broken pipe")
} }
/// 429 额度/配额耗尽特征检测:区分「额度耗尽型 429」(确定性,重试必失败)与
/// 「普通瞬时限流 429」(可重试)。实证:anthropic/GLM 周月额度耗尽返回
/// `[1310] 您已达到每周/每月使用上限,限额将在 X 重置`(code 1310)。
/// 特征命中 → Fatal 不重试;未命中 → 交 is_status_retryable 正常判定。
/// 入参 combined:状态码串 + 错误体原文(如 "HTTP 429 [1310] 已达到上限...")。
fn is_quota_exhaustion(combined: &str) -> bool {
let lower = combined.to_lowercase();
let quota_markers = [
"额度", "限额", "达到上限", "使用上限", "重置",
"quota", "limit exceeded", "usage limit", "quota exhausted",
"insufficient_quota",
// 注意:不含 rate_limit_error——它是标准 429 错误类型(普通瞬时限流也带),
// 误配会把可恢复的限流当额度耗尽降 Fatal。额度语义用上面明确词。
];
// 仅当文本同时含「429」与额度特征,才判定额度耗尽(避免误伤普通限流)。
lower.contains("429")
&& quota_markers.iter().any(|m| lower.contains(m))
}
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
/// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。
fn classify_status_with_body(status_or_class: &str, body: &str) -> bool {
if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
return false;
}
classify_status_or_class(status_or_class)
}
/// 据 provider 流式 error 帧的 message 文本分类是否可重试(A2-B12 / G4.2)。 /// 据 provider 流式 error 帧的 message 文本分类是否可重试(A2-B12 / G4.2)。
/// ///
/// openai/anthropic helper 仅将 error 帧的 `message` 字段透传进 chunk.error /// openai/anthropic helper 仅将 error 帧的 `message` 字段透传进 chunk.error
@@ -567,8 +669,10 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
/// - 其余 → retryable=true(保守,防误判瞬态为 Fatal) /// - 其余 → retryable=true(保守,防误判瞬态为 Fatal)
fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool { fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool {
// 已有状态码/传输类 → 走既有单一分类源(显式 4xx Fatal,5xx/429/timeout/connect 可重试) // 已有状态码/传输类 → 走既有单一分类源(显式 4xx Fatal,5xx/429/timeout/connect 可重试)
// 额度耗尽型 429(如 [1310] 已达周/月上限)确定性失败,经 body 感知识别降 Fatal,
// 不再被普通 429 路径误判可重试空耗。
if status_or_class != "unknown" { if status_or_class != "unknown" {
return classify_status_or_class(status_or_class); return classify_status_with_body(status_or_class, msg);
} }
// 无状态码:按错误类型关键词明确非重试签名降级 Fatal // 无状态码:按错误类型关键词明确非重试签名降级 Fatal
let lower = msg.to_lowercase(); let lower = msg.to_lowercase();
@@ -590,7 +694,7 @@ fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool {
"api-key", "api-key",
"api_key", "api_key",
"unauthorized", "unauthorized",
// 中文签名(BUG-260617-01:Anthropic 中文参数错误) // 中文签名Anthropic 中文参数错误)
"鉴权", "鉴权",
"认证失败", "认证失败",
"未授权", "未授权",
@@ -642,7 +746,7 @@ mod tests {
assert!(classify_status_or_class("connect")); assert!(classify_status_or_class("connect"));
} }
/// unknown → 保守 retryable=false(BUG-260617-01:防 Anthropic 中文参数错误等 Fatal 空耗重试) /// unknown → 保守 retryable=false防 Anthropic 中文参数错误等 Fatal 空耗重试)
#[test] #[test]
fn classify_unknown_not_retryable() { fn classify_unknown_not_retryable() {
assert!(!classify_status_or_class("unknown")); assert!(!classify_status_or_class("unknown"));
@@ -705,6 +809,53 @@ mod tests {
assert!(classify_error_frame_retryable("stream error", &s2)); assert!(classify_error_frame_retryable("stream error", &s2));
} }
// ---- is_quota_exhaustion / classify_status_with_body:额度耗尽型 429 不重试 ----
/// 额度耗尽型 429anthropic/GLM 周月上限,含 [1310] 与「使用上限/重置」特征)→ 判定额度耗尽
#[test]
fn quota_exhaustion_429_detected() {
let combined = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
assert!(is_quota_exhaustion(combined));
assert!(!classify_status_with_body("HTTP 429", combined));
}
/// 普通瞬时限流 429(无额度特征)→ 不判定额度耗尽,仍可重试
#[test]
fn transient_429_not_quota() {
let combined = "HTTP 429 请求过于频繁,请稍后重试";
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// 额度关键词但无 429 状态码 → 不判定额度耗尽(防误伤普通 5xx)
#[test]
fn quota_marker_without_429_not_detected() {
assert!(!is_quota_exhaustion("HTTP 500 达到上限"));
}
/// classify_status_with_body 非 429 路径行为不变(5xx 仍可重试)
#[test]
fn classify_status_with_body_5xx_retryable() {
assert!(classify_status_with_body("HTTP 500", "Internal Server Error"));
}
/// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤)
#[test]
fn standard_rate_limit_error_not_quota() {
let combined = r#"HTTP 429 {"type":"rate_limit_error","message":"请求过于频繁,请稍后重试"}"#;
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// error 帧 message 含额度耗尽型 429 → 经 classify_error_frame_retryable 降 Fatal 不重试
#[test]
fn error_frame_quota_429_fatal() {
let msg = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
let (s, _) = extract_error_diag_from_str(msg);
assert_eq!(s, "HTTP 429");
assert!(!classify_error_frame_retryable(msg, &s));
}
// ---- extract_error_diag:业务层 bailprovider 串已含状态码)---- // ---- extract_error_diag:业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错) /// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
@@ -790,7 +941,7 @@ mod tests {
/// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字) /// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字)
#[test] #[test]
fn diag_does_not_match_status_inside_longer_digits() { fn diag_does_not_match_status_inside_longer_digits() {
// R-P2-6:边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401) // 边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401)
let e = anyhow::anyhow!("port 14012 used"); let e = anyhow::anyhow!("port 14012 used");
assert_eq!(extract_error_diag(&e).0, "unknown"); assert_eq!(extract_error_diag(&e).0, "unknown");
// 纯长数字串(无白名单码)同样不命中 // 纯长数字串(无白名单码)同样不命中
@@ -806,7 +957,7 @@ mod tests {
assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized"); assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized");
} }
/// R-P2-6:状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出 /// 状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出
/// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全) /// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全)
#[test] #[test]
fn diag_extracts_code_adjacent_to_multibyte_chars() { fn diag_extracts_code_adjacent_to_multibyte_chars() {
+13 -3
View File
@@ -8,9 +8,9 @@ use tokio::sync::Mutex;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// F-01 阶段5: 标题生成路由 — TaskRequirements(无需工具)。 // F-01 阶段5: 标题生成路由 — TaskRequirements(无需工具)。
// 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。 // 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。
// 注:路由已解耦(B-260618-03),原 Lite/Low 智力成本约束已删除,纯 weight 选模型。 // 注:路由已解耦,原 Lite/Low 智力成本约束已删除,纯 weight 选模型。
use df_ai::router::{ use df_ai::router::{
select_model_id, Modality, TaskRequirements, is_likely_image_model, select_model_id, Modality, TaskRequirements,
}; };
use df_storage::crud::AiConversationRepo; use df_storage::crud::AiConversationRepo;
use df_storage::db::Database; use df_storage::db::Database;
@@ -71,6 +71,7 @@ pub(crate) async fn ensure_conversation_title(
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: m.timestamp, timestamp: m.timestamp,
}) })
.collect(); .collect();
@@ -111,7 +112,16 @@ pub(crate) async fn ensure_conversation_title(
}; };
let title_model = select_model_id(&title_req, &provider_config.model_configs) let title_model = select_model_id(&title_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone()); .unwrap_or_else(|| provider_config.default_model.clone());
// B-260617-17: LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积; // 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
// 跳过则保留上方 extract_title 兜底,不锁定差标题。
if is_likely_image_model(&title_model, &provider_config.model_configs) {
tracing::warn!(
"标题 LLM 跳过:模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)",
title_model
);
return;
}
// LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积;
// 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。 // 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。
let title = match tokio::time::timeout( let title = match tokio::time::timeout(
std::time::Duration::from_secs(20), std::time::Duration::from_secs(20),
+30 -25
View File
@@ -33,7 +33,7 @@ use crate::state::AllowedDirs;
pub(crate) const MAX_LIST_RESULTS: usize = 50; pub(crate) const MAX_LIST_RESULTS: usize = 50;
/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。 /// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源F-260616-04 /// 提取为常量便于在超时标注处引用同一来源。
/// ///
/// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源 /// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源
/// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。 /// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。
@@ -48,7 +48,7 @@ pub(crate) const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;
/// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff, /// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff,
/// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。 /// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。
/// ///
/// BUG-260617-07: LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB /// LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB
/// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码 /// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码
/// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为 /// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为
/// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。 /// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。
@@ -118,7 +118,7 @@ pub(crate) fn generate_diff(old: &str, new: &str) -> String {
/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过 /// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过
/// ///
/// 安全要点(BUG-260617-03): /// 安全要点
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。 /// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过, /// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
/// 解码后再走分段归一化校验。 /// 解码后再走分段归一化校验。
@@ -151,7 +151,7 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> {
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) { if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
anyhow::bail!("禁止访问敏感系统目录"); anyhow::bail!("禁止访问敏感系统目录");
} }
// F-260620: AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。 // AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
// AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog // AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
// → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist // → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治—— // 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
@@ -180,7 +180,7 @@ pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) {
} }
// ============================================================ // ============================================================
// patch_file 三模式共用底层F-260617-01 // patch_file 三模式共用底层
// //
// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点), // 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end]1-based 含首尾)替换为 new_text」, // 后两者均归约为「按行号区间 [start, end]1-based 含首尾)替换为 new_text」,
@@ -407,7 +407,7 @@ pub(crate) fn resolve_workspace_path_with_allowed(
resolve_workspace_path_impl(path, allowed) resolve_workspace_path_impl(path, allowed)
} }
/// AE-2025-04mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数 /// mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数 /// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。 /// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
/// ///
@@ -631,7 +631,7 @@ fn register_local_proxy_tools(
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。 /// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
/// ///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。 /// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经 /// 例外:run_workflow handler 防御返回 Err,不持 db 不 clone,真正执行经
/// ai_approve → run_workflow_inner 另走完整 State 路径。 /// ai_approve → run_workflow_inner 另走完整 State 路径。
/// ///
/// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD /// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD
@@ -641,7 +641,7 @@ fn register_local_proxy_tools(
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/ /// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
/// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。 /// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。
/// ///
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。 /// 抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。 /// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
/// ///
/// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数: /// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数:
@@ -761,7 +761,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} }
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。 /// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。 /// 从 build_ai_tool_registry 抽出1091行单函数拆分,build 1090→~390 行)。
/// ///
/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`, /// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`,
/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。 /// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。
@@ -852,11 +852,11 @@ pub(crate) fn list_dir_recursive<'a>(
continue; continue;
} }
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物, // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03) // 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文。
if skip_noise && is_noise_file(&name) { if skip_noise && is_noise_file(&name) {
continue; continue;
} }
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标, // 用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录 // 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
let file_type = entry.file_type().await?; let file_type = entry.file_type().await?;
let is_symlink = file_type.is_symlink(); let is_symlink = file_type.is_symlink();
@@ -929,7 +929,7 @@ pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
"create_idea" => Some("捕获灵感"), "create_idea" => Some("捕获灵感"),
"update_task" => Some("修改任务"), "update_task" => Some("修改任务"),
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool // advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息(UX-260618-14) // 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息
"run_workflow" => Some("触发工作流"), "run_workflow" => Some("触发工作流"),
"delete_task" => Some("删除任务"), "delete_task" => Some("删除任务"),
"update_idea" => Some("修改灵感"), "update_idea" => Some("修改灵感"),
@@ -948,7 +948,7 @@ pub(crate) fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[ const NOISE_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build", ".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory ".trash", // delete_file 软删除回收站,不污染 list_directory
]; ];
NOISE_DIRS.contains(&name) NOISE_DIRS.contains(&name)
} }
@@ -964,7 +964,7 @@ pub(crate) fn is_noise_file(name: &str) -> bool {
} }
// ============================================================ // ============================================================
// grep 工具底层F-260621 // grep 工具底层
// //
// FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时: // FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时:
// - content: 展平 line_matches 为 matches[{file,line,content,context?}] // - content: 展平 line_matches 为 matches[{file,line,content,context?}]
@@ -1101,7 +1101,7 @@ pub(crate) fn grep_recursive<'a>(
} }
continue; continue;
} }
// 文件处理(BUG-260625-01:提取为 grep_one_file,grep handler 单文件路径复用同一逻辑) // 文件处理提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?; grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
} }
Ok(()) Ok(())
@@ -1109,7 +1109,7 @@ pub(crate) fn grep_recursive<'a>(
} }
/// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。 /// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。
/// 提取自 grep_recursive 循环体(BUG-260625-01),供 grep handler 单文件路径复用—— /// 提取自 grep_recursive 循环体,供 grep handler 单文件路径复用——
/// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。 /// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。
/// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。 /// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。
pub(crate) async fn grep_one_file( pub(crate) async fn grep_one_file(
@@ -1317,7 +1317,7 @@ mod tests {
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx());
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 // file 层 10→11(新增 grep 跨文件内容搜索工具)。
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。 // L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
// AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。 // AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
// 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具: // 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具:
@@ -1332,7 +1332,7 @@ mod tests {
// register_git_tools 3 个) // register_git_tools 3 个)
// Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge, // Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge,
// register_git_tools 3→6) // register_git_tools 3→6)
// BUG-2026-07-07: 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。 // 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。
// 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。 // 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。
// update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具, // update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具,
// idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。 // idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。
@@ -1365,15 +1365,18 @@ mod tests {
// list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs, // list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs,
// 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。 // 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。
// 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。 // 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// diff_files(2026-08-08): file 层 14→15(新增任意两文件内容对比,只读 Low,
// 补 git_diff 仅仓库内缺口;复用 generate_diff LCS unified diff;namespace ALWAYS_LARGE_TOOLS 既有引用生效)。
// 73 = 42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
assert_eq!( assert_eq!(
registry.len(), registry.len(),
72, 73,
"工具总数应为 72(42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len() "工具总数应为 73(42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
); );
// 工具名集合基线:防 rename / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 42 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具 // data 层 42 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具
// file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载 // file 层 15 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载/对比
// http 层 1 个(不持 db):http_request // http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![ let mut expected: Vec<&str> = vec![
// ── data 层 (42) ── // ── data 层 (42) ──
@@ -1400,16 +1403,18 @@ mod tests {
"git_status", "git_diff", "git_log", "git_status", "git_diff", "git_log",
// Git 写工具 // Git 写工具
"git_commit", "git_branch", "git_merge", "git_commit", "git_branch", "git_merge",
// ── file 层 (14) ──(run_command 注册顺序已移至末位降低 LLM 偏好, // ── file 层 (15) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621; // 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能; // detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
// download_file 新增跨平台 URL→文件流式下载) // download_file 新增跨平台 URL→文件流式下载;diff_files 新增任意两文件对比
"read_file", "read_symbol", "list_directory", "write_file", "read_file", "read_symbol", "list_directory", "write_file",
"patch_file", "file_info", "append_file", "patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command", "delete_file", "rename_file", "search_files", "run_command",
"grep", "detect_environment", "grep", "detect_environment",
// download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools) // download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools)
"download_file", "download_file",
// diff_files(任意两文件内容对比,只读 Low,补 git_diff 仅仓库内缺口)
"diff_files",
// ── http 层 (1) ── // ── http 层 (1) ──
"http_request", "http_request",
// ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工) // ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
@@ -1775,7 +1780,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// patch_file 三模式共用底层测试F-260617-01 // patch_file 三模式共用底层测试
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。 // 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
// handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成, // handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成,
// 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心;old_text 模式逻辑 // 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心;old_text 模式逻辑
@@ -1937,7 +1942,7 @@ mod tests {
} }
// ============================================================ // ============================================================
// grep 工具测试F-260621 // grep 工具测试
// //
// compile_glob_to_regex:纯函数,无 fs 依赖。 // compile_glob_to_regex:纯函数,无 fs 依赖。
// grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。 // grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。
+238 -32
View File
@@ -81,16 +81,13 @@ pub fn register(
&snap, &snap,
)?; )?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) // 单次 File::open 取 metadata+read 消除 TOCTOU原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let mut file = File::open(path).await let mut file = File::open(path).await
.map_err(|e| { .map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!( file_not_found_error(path)
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
path
)
} else { } else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e) anyhow::anyhow!("无法访问文件 {}: {}", path, e)
} }
@@ -146,7 +143,7 @@ pub fn register(
})); }));
} }
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
// BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件, // 无 offset 时旧实现 content.clone() 全量返回大文件,
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
let line_count = content.lines().count(); let line_count = content.lines().count();
@@ -160,7 +157,7 @@ pub fn register(
} else { } else {
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义), // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。 // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit, // 旧实现固定 take(500) 忽略 limit,
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。 // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
const DEFAULT_PREVIEW_LINES: usize = 500; const DEFAULT_PREVIEW_LINES: usize = 500;
let limit = args["limit"].as_u64() let limit = args["limit"].as_u64()
@@ -182,14 +179,13 @@ pub fn register(
consumed, consumed,
)) ))
} else { None }; } else { None };
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol // 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且**符号级命中**检测通过
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件 // (内容里真的含可提取的函数/类/结构体定义)才追加 read_symbol 引导——比旧按扩展名
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言 // 判断更准,无定义的文件不瞎提示。用全量 content(非分页 result)检测,符号可能在
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。 // offset 范围外。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
let content_field = if offset_used.is_none() let content_field = if offset_used.is_none()
&& crate::commands::ai::code_intel::is_supported_ext( && crate::commands::ai::code_intel::contains_definition(&content, &ext)
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
)
{ {
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。") format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
} else { } else {
@@ -227,7 +223,13 @@ pub fn register(
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let mut file = File::open(path).await let mut file = File::open(path).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; .map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
file_not_found_error(path)
} else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
}
})?;
let metadata = file.metadata().await let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 { if metadata.len() > 1_048_576 {
@@ -259,6 +261,46 @@ pub fn register(
} }
); );
// ── diff_files (Low 只读, 任意两文件对比) ──
// 补 git_diff 缺口:git_diff 只比仓库内版本,本工具对比任意两文件(绝对路径),
// 复用 generate_diff(LCS unified diff)生成差异,只读无副作用。
declare_tool!(
registry,
allowed_dirs: Arc<RwLock<AllowedDirs>>,
"diff_files",
"对比任意两个文件的差异,返回 unified diff(+/- 行前缀,基于 LCS)。参数 path_a/path_b 为两文件绝对路径,均须在授权目录内。只读无副作用,用于两文件/两版本内容对比(git_diff 仅仓库内,本工具补任意两文件缺口)",
RiskLevel::Low,
schema: object_schema(vec![("path_a", "string", true), ("path_b", "string", true)]),
args => {
let snap = allowed_dirs.read().await.clone();
let a_resolved = resolve_workspace_path_with_allowed(
args["path_a"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path_a 参数"))?,
&snap,
)?;
let b_resolved = resolve_workspace_path_with_allowed(
args["path_b"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path_b 参数"))?,
&snap,
)?;
let a_path = a_resolved.to_str().ok_or_else(|| anyhow::anyhow!("path_a 含非法字符"))?.to_string();
let b_path = b_resolved.to_str().ok_or_else(|| anyhow::anyhow!("path_b 含非法字符"))?.to_string();
if a_path == b_path {
anyhow::bail!("两个路径相同,无需对比: {}", a_path);
}
// 读取两文件文本(1MB 上限 + 二进制拦截,解码对齐 read_file 的 UTF-16 BOM 优先)
let (content_a, size_a) = read_text_file(&a_path).await?;
let (content_b, size_b) = read_text_file(&b_path).await?;
let diff = generate_diff(&content_a, &content_b);
Ok(serde_json::json!({
"path_a": a_path,
"path_b": b_path,
"size_a": size_a,
"size_b": size_b,
"identical": content_a == content_b,
"diff": diff,
}))
}
);
// ── list_directory (Low 只读) ── // ── list_directory (Low 只读) ──
declare_tool!( declare_tool!(
registry, registry,
@@ -277,7 +319,7 @@ pub fn register(
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false); let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) // max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
let mut entries = Vec::new(); let mut entries = Vec::new();
@@ -306,7 +348,7 @@ pub fn register(
)?; )?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等) // encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节 // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") { let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
@@ -318,7 +360,7 @@ pub fn register(
} else { } else {
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding); anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding);
}; };
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) // 写入大小上限原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if bytes.len() > 1_048_576 { if bytes.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
} }
@@ -336,8 +378,8 @@ pub fn register(
Err(_) => None, // 不存在(新建) Err(_) => None, // 不存在(新建)
}; };
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 // parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目 // 原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。 // 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
if !snap.is_authorized(parent) { if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录"); anyhow::bail!("禁止在项目目录之外创建目录");
@@ -355,7 +397,7 @@ pub fn register(
let _ = tokio::fs::remove_file(&tmp).await; let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e)); return Err(anyhow::anyhow!("原子替换失败: {}", e));
} }
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); // rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) { if old_size.map(|s| s > 0).unwrap_or(false) {
@@ -422,7 +464,7 @@ pub fn register(
let new_text = args["new_text"].as_str() let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor // 三模式互斥校验(old_text / replace_lines / anchor
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false); let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false); let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
@@ -449,7 +491,7 @@ pub fn register(
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len()); anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
} }
// TD-260621-03:读改写整体锁内防 lost update。 // 读改写整体锁内防 lost update。
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件: // 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。 // A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。 // 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
@@ -486,7 +528,7 @@ pub fn register(
} }
} }
// F-260617-01: 三模式分派计算 new_content + match_count + warning // 三模式分派计算 new_content + match_count + warning
// old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处) // old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处)
// replace_lines 模式:行号区间 splice(越界 Err) // replace_lines 模式:行号区间 splice(越界 Err)
// anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err // anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err
@@ -506,7 +548,7 @@ pub fn register(
})); }));
} }
// L2: old_text 精确匹配(CAS 语义) // L2: old_text 精确匹配(CAS 语义)
// 失败时附相近片段提示(AC-5):把文件里与 old_text 相似的几行列给 LLM, // 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则 // 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
// 退回原提示,不误导。 // 退回原提示,不误导。
if !content.contains(old_text) { if !content.contains(old_text) {
@@ -614,7 +656,7 @@ pub fn register(
let modified = metadata.modified() let modified = metadata.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64); .map(|d| d.as_millis() as i64);
// is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02) // is_binary: 流式读前 8KB 检测 \x00
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192, // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。 // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测, // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
@@ -659,7 +701,7 @@ pub fn register(
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
if let Some(parent) = std::path::Path::new(path).parent() { if let Some(parent) = std::path::Path::new(path).parent() {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 // 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) { if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录"); anyhow::bail!("禁止在项目目录之外创建目录");
} }
@@ -737,7 +779,7 @@ pub fn register(
let backup_name = format!("{}-{}", new_id(), file_name); let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name); let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string(); let backup_path_str = backup_path.to_string_lossy().to_string();
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, // 软删除跨盘降级同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子, // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。 // 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理), // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
@@ -804,7 +846,7 @@ pub fn register(
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败) // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
let to_target = std::path::Path::new(to_path); let to_target = std::path::Path::new(to_path);
if let Some(parent) = to_target.parent() { if let Some(parent) = to_target.parent() {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 // 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) { if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录"); anyhow::bail!("禁止在项目目录之外创建目录");
} }
@@ -917,7 +959,7 @@ pub fn register(
let mut matches_out: Vec<FileGrepHit> = Vec::new(); let mut matches_out: Vec<FileGrepHit> = Vec::new();
let mut total: usize = 0; let mut total: usize = 0;
let mut truncated = false; let mut truncated = false;
// BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。 // path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。 // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。 // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
let root_meta = tokio::fs::metadata(root).await let root_meta = tokio::fs::metadata(root).await
@@ -1058,7 +1100,15 @@ pub fn register(
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?; search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect(); let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_results.len()) < total as usize; let has_more = (offset + page_results.len()) < total as usize;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
});
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 {
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
}
Ok(out)
} }
); );
@@ -1106,7 +1156,7 @@ pub fn register(
timeout_secs: Some(timeout_secs), timeout_secs: Some(timeout_secs),
shell_type: Default::default(), shell_type: Default::default(),
}; };
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 // 超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
@@ -1232,6 +1282,38 @@ pub fn register(
); );
} }
// ============================================================
// diff_files 辅助 — 读取文本文件内容(两文件对比共用)
// ============================================================
/// 读取文本文件内容,返回 (内容, 字节数)。
///
/// diff_files 两文件对比共用:1MB 上限 + 二进制拦截 + UTF-16 BOM 优先解码
/// (复用 decode_bytes_to_string,对齐 read_file/patch_file 的读取语义,单真相源)。
async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!("文件不存在: {}", path)
} else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
}
})?;
let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut raw_bytes).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
let content = decode_bytes_to_string(&raw_bytes)
.map_err(|_| anyhow::anyhow!("文件非 UTF-8 文本(疑似二进制),无法对比: {}", path))?;
Ok((content, metadata.len()))
}
// ============================================================ // ============================================================
// patch_file 失败提示辅助 — old_text 不匹配时给 LLM 相近锚点 // patch_file 失败提示辅助 — old_text 不匹配时给 LLM 相近锚点
// ============================================================ // ============================================================
@@ -1308,6 +1390,83 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
scored.into_iter().map(|(_, s)| s).collect() scored.into_iter().map(|(_, s)| s).collect()
} }
// ============================================================
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
// ============================================================
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
///
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
/// 治 LLM 拼错文件名/少写扩展名后盲目重试。低频错误路径(文件不存在),同步 std::fs::read_dir
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> {
if needle.is_empty() {
return Vec::new();
}
// 预计算 needle 字符多重集计数(各文件名评分复用,同 similar_line_fragments 的探针计数)
use std::collections::HashMap;
let mut needle_counts: HashMap<char, u32> = HashMap::new();
for c in needle.chars() {
*needle_counts.entry(c).or_insert(0) += 1;
}
let needle_len = needle.chars().count() as u32;
let mut scored: Vec<(u32, String)> = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(iter) => iter,
Err(_) => return Vec::new(), // 父目录不可读/非目录 → 无候选,退回原提示
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let name_len = name.chars().count() as u32;
if name == needle || name_len == 0 {
continue;
}
// 公共字符计数(needle 计数约束下扫描文件名)
let mut counts = needle_counts.clone();
let mut common = 0u32;
for c in name.chars() {
if let Some(cnt) = counts.get_mut(&c) {
if *cnt > 0 {
*cnt -= 1;
common += 1;
}
}
}
// Dice = 2*common / (needle_len + name_len),乘 100 转百分比整数(同 similar_line_fragments)
let dice = (common * 200) / (needle_len + name_len).max(1);
if dice >= 40 {
scored.push((dice, name));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0));
scored.truncate(max);
scored.into_iter().map(|(_, s)| s).collect()
}
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选。
///
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
fn file_not_found_error(path: &str) -> anyhow::Error {
let mut msg = format!(
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
path
);
let p = std::path::Path::new(path);
if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) {
let needle = file_name.to_string_lossy().into_owned();
let candidates = similar_file_names(parent, &needle, 3);
if !candidates.is_empty() {
msg.push_str(&format!(
"。相近文件候选:\n{}\n(可核对实际文件名)",
candidates.join("\n")
));
}
}
anyhow::anyhow!(msg)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1499,6 +1658,53 @@ mod tests {
"多行 old_text 应命中首行所在行,实际: {hits:?}" "多行 old_text 应命中首行所在行,实际: {hits:?}"
); );
} }
// ============================================================
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
// ============================================================
#[test]
fn similar_file_names_finds_close_name() {
// 临时目录放真实文件名,needle 少写一个字符(uilts.rs)应命中 utils.rs
let dir = std::env::temp_dir().join(format!("df-similar-files-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["main.rs", "mod.rs", "utils.rs", "README.md"] {
std::fs::write(dir.join(name), "").unwrap();
}
let hits = similar_file_names(&dir, "uilts.rs", 3);
std::fs::remove_dir_all(&dir).ok();
assert!(
hits.iter().any(|h| h == "utils.rs"),
"相近文件名应命中 utils.rs,实际: {hits:?}"
);
// max 截断
assert!(hits.len() <= 3, "候选数应不超过 max,实际: {}", hits.len());
}
#[test]
fn similar_file_names_empty_when_no_relation() {
let dir = std::env::temp_dir().join(format!("df-similar-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("readme.md"), "").unwrap();
let hits = similar_file_names(&dir, "config.yaml", 3);
std::fs::remove_dir_all(&dir).ok();
assert!(hits.is_empty(), "无关文件名不应给候选,实际: {hits:?}");
}
#[test]
fn file_not_found_error_appends_candidates() {
// 少写一个字符的拼写 → 错误文案应含 target.rs 候选 + 保留 list_directory 引导
let dir = std::env::temp_dir().join(format!("df-notfound-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("target.rs"), "fn main() {}\n").unwrap();
let missing = dir.join("targett.rs");
let msg = file_not_found_error(&missing.to_string_lossy()).to_string();
std::fs::remove_dir_all(&dir).ok();
assert!(msg.contains("路径不存在"), "应保留基础提示,实际: {msg}");
assert!(msg.contains("list_directory"), "应保留 list_directory 引导,实际: {msg}");
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
}
} }
+2 -2
View File
@@ -34,7 +34,7 @@ use df_storage::db::Database;
/// 只读工具(status/diff/log)直接调本函数拿退出码做差异语义:git diff 退 1 = 有差异是 /// 只读工具(status/diff/log)直接调本函数拿退出码做差异语义:git diff 退 1 = 有差异是
/// **合法成功**、git log 退 128 = 非 git 仓是失败;单纯 bool(exit==0)无法区分。 /// **合法成功**、git log 退 128 = 非 git 仓是失败;单纯 bool(exit==0)无法区分。
/// ///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout /// 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, /// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime /// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。 /// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
@@ -42,7 +42,7 @@ use df_storage::db::Database;
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 /// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 /// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
/// ///
/// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作 /// 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作
/// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。 /// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。
/// 改返 (String, bool),上层据 success 判定真实成败。 /// 改返 (String, bool),上层据 success 判定真实成败。
async fn exec_git_exit(working_dir: &str, args: &[&str]) -> (String, Option<i32>) { async fn exec_git_exit(working_dir: &str, args: &[&str]) -> (String, Option<i32>) {

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