Compare commits

...
34 Commits
Author SHA1 Message Date
lxy 9c3f27f4ca 修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导
- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
2026-06-27 21:32:36 +08:00
lxy a6d692270f 锁定: Cargo.lock tree-sitter-go/java/python(fc70544 AST 扩展配套) 2026-06-27 01:31:00 +08:00
lxy 9189877a59 修复: B-260626-01 首条 user 协议合规(sanitize 兜底 + ensure_leading_user)
- context.rs: 兜底全量也过 sanitize(对齐分支1/3,防主 loop 唯一 sanitize 漏洞——大体量 tool_result 致超预算且保护区满时,裸返 all_messages_clone 不过滤 truncated/中毒三元组/首条非法直送 provider)
- anthropic_compat.rs + openai_compat.rs: ensure_leading_user 补 user 占位(Anthropic/OpenAI 协议要求首条非 assistant/tool;上游绕过 sanitize 的调用方——标题生成/知识注入/工作流 AI 节点——可能传入首条 assistant 序列,补占位保留上下文,tool_use/tool_result 配对完整无 orphan,远优于砍丢历史)
2026-06-27 01:31:00 +08:00
lxy bb2cfcbb62 修复: grep/read_file 命中行行号与内容分行
行号 gutter 与内容改同一 flex row 并排(IDE 风格),取代原 head 块+content 块垂直堆叠;去冒号前缀改纯数字右对齐。BUG-260626-01
2026-06-27 01:24:50 +08:00
lxy 80239500f1 新增: 知识图谱设计文档 + AI原生上下文地图设计(父②⑥实施基础)
- 项目知识图谱与任务队列系统-2026-06-26.md:AI Working 终局设计(任务网络queue/parent/links + 事件流 + 基础设施 + 注入 + Mission Control 五视图),父②Phase1 + 父⑥Phase2-3 已据此实施落地
- AI原生上下文地图与去AI化进化系统-2026-06-26.md:AI Working 定位体系设计
2026-06-27 01:22:46 +08:00
lxy 4b754fe230 更新: 目标丢失诊断报告 G1/G2/G4 实施状态(commit 8ce18cb)
§五方向建议核对:G1 目标钉扎✅ / G2 探索熔断✅ / G4 话题标记修正✅ 已实施,
G3 状态机收敛(文档衔接),G5 排查结论(历史脏数据,数据治理项不改代码)。
诊断→实施闭环标注。todo DAG + 待决策.md 状态核对一致(本 session 16 提交全景已同步)。
2026-06-27 00:56:55 +08:00
lxy e33e8f86d3 新增: 知识图谱Phase3基础设施(父⑥) — project_services+D10凭证审查+IPC+AI工具
父⑥ Phase 3(对标设计 §2.3):
- V31 迁移:project_services 表(id/project_id FK/name/service_type/endpoint/config_json/environment/remark)+ idx_project_services_project
- ProjectServiceRecord + ProjectServiceRepo(impl_repo! 宏 + insert_validated/update_full_validated/list_by_project/list_by_project_env)
- service_type 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,应用层校验)
- D10 凭证审查:config_json/endpoint/remark 含 password/passwd/secret/api_key/apikey/token 拒绝(不存敏感凭证,凭证走环境变量)
- 4 IPC(add/update/remove/list_project_services,环境过滤)+ lib.rs 注册
- AI 工具 add/list_project_services(Medium/Low)+ 基线 39→41(data 25→27)+ 3 端到端测试

P2 修复(verify agent 发现):
- settings.rs project_services 白名单移除 service_type(对齐注释"不列入"+ status 收口,防 update_field 旁路绕过 validate_service_type)

df-storage 117(105+12) + df-ai 331 + 基线 41 测试全过。
2026-06-27 00:49:09 +08:00
lxy 0c348f2311 新增: 父④F-09 streaming/currentText per-conv(accessor委派,真多会话前端基础)
DEC-07 a(父②后启动)。accessor 委派方案(非 3 独立 Map,禁动域零触碰):
- aiShared.ts: convStreamStates reactive Map<convId,{streaming,currentText}> + getConvStreamState/setConvStreaming/setConvCurrentText/clearConvStreamState
- stores/ai.ts: state.streaming/currentText 改 Object.defineProperty accessor,委派 convStreamStates 按 activeConversationId 索引(消费方零改动透明继承)
- useAiConversations.ts: deleteConversation 调 clearConvStreamState 防 Map 无限增长

铁律达成:
- 单会话回归零变化(activeConversationId 恒定 Map 仅1项,accessor 等价全局单例,13/13 测试)
- 禁动域零触碰(useAiSend/useAiEvents/streamingGuard/MessageList 等全零改动)
- 切会话隔离(BUG-260624-01 单例根因清除,A 后台 currentText 不串 B 视图)
- 内存泄漏防护(deleteConversation + streaming false 收敛删项)

vue-tsc EXIT 0。streamingGuard convId 精确路由留 F-09 后续批次(技术债点,单会话无回归)。
2026-06-27 00:25:30 +08:00
lxy 5a893680b7 新增: 知识图谱Phase2事件流(父⑥) — project_events统一事件流+埋点+timeline
父⑥ Phase 2(对标设计 §2.4):
- V30 迁移:project_events 追加型审计表(id/project_id/event_type/entity_type/entity_id/from_state/to_state/context_json/source/conversation_id/created_at)+ 双索引(project,time / entity)
- ProjectEventRecord + ProjectEventRepo(insert 追加型/get_by_project/get_by_entity/list_recent)
- 事件埋点(best-effort,hook/after,失败 warn 不阻断主操作):
  - task.rs emit_event 辅助 + create_task/advance_task/move_task_queue 埋点
  - idea.rs promote_idea 埋点(idea_promoted,project_id=新项目)
  - project.rs create/delete 埋点
  - 注:idea_created 暂不埋点(idea 无 project_id,待 Inbox 项目落地,NOT NULL+FK 约束)
- get_project_timeline IPC(events 模块)+ lib.rs 注册 + AI 工具(基线 +N)
- source 语义:IPC 标 human,AI 工具路径标 ai

注:workflow 脚本中文变量名 const 致验证 agent 未跑,主控独立 cargo check EXIT 0 + grep 落地齐全核验。
2026-06-27 00:23:17 +08:00
lxy 744b68da5b 新增: 父⑤⑤.2收尾(评分拆const+表单补全+css陷阱) + priority跨层映射修复
父⑤⑤.2(DEC-03 c / DEC-04 a):
- #6 scoring.rs 关键词拆 const(FEASIBILITY_POS/NEG + IMPACT + URGENCY 4 组,零行为变化,df-ideas 22 测试过)
- #9/#10 Ideas.vue 表单补 tags(逗号分隔对标 KnowledgeDetail)+priority 下拉+source,i18n 中英对称
- css 陷阱修:components.css .modal-* 通配→"modal 系列"(避 */ 闭合,memory css-comment-star-slash-trap)

priority 跨层映射修复(verify agent 发现的预存 bug):
- idea.rs priority_from_i32 原跟 df-types 枚举(0=Low),与前端约定(0=critical,api/types.ts:141+Tasks.vue)反
- 改手动映射对齐前端(0=Critical/1=High/2=Medium/3=Low),Priority as_str 序列化不受影响
- 校验注释同步更正(我父①①.3 加的 Low=0 注释)

父⑤ 全闭环(⑤.1/①.4/⑤.2)。css warning 清零。
2026-06-26 23:53:51 +08:00
lxy 40b74e11bf 新增: 知识图谱Phase1业务层(父②) + update_task parent_id 校验(P1)
父② 业务层(②.3+②.4+②.5):
- IPC: create_task 扩展 queue/parent_id/content_json + create/remove/list_task_links + move_task_queue(一致性约束联动 status) + get_task_tree
- 父聚合(②.4): advance_task 后重算父 status(count_children_by_status,set_status_for_aggregation 专用路径绕 D-260616-04 status 收口,父任务=容器模型唯一非状态机写入)
- AI 工具(②.5): register_task_graph_tools 分组 6 工具 + create_task 扩展,基线测试 32→38
- state.task_links: TaskLinkRepo + lib.rs 注册 5 新 IPC

P1 修复(verify agent 发现,主控修):
- update_task 漏 parent_id 校验(可绕 1 级嵌套创建孙任务/悬空 parent_id/自环)
- 补 parent_id 特判(对标 create_task: 父存在 + 父自身无 parent_id + 自环拒)

df-storage 99 lib + df-ai 331 + 基线测试 38 全过。
2026-06-26 23:23:23 +08:00
lxy df8ed7e74f 新增: 知识图谱Phase1数据层(父②) + 修复 idea list_by_query 占位符(父⑤⑤.1)
父② 数据层(V29):
- migrate_v29: tasks +queue/parent_id/content_json + task_links 表+双索引(幂等对标 v14/v24)
- TaskRecord 加 3 字段(15→18 列,INSERT/SELECT×6/from_row/struct 同步)+ 8 构造点补默认
- TaskLinkRepo(新文件,CRUD + 循环依赖 BFS depends_on 链检测,14 测)
- task_repo list_by_query queue/parent_id 筛选 + get_children + 聚合计数(10 测)
- queue DEFAULT 'todo' 向后兼容;task_links 软删保留(无 ON DELETE)

idea_repo list_by_query 占位符修复(父⑤⑤.1 引入的潜伏 bug):
- 加 deleted_at IS NULL 恒带后 where_clauses.len()+1 偏移 → 非空 status/keyword 查询 rusqlite 错误
- 改 params_vec.len()+1(对标 task_repo ②.2 同款修复,父② agent 发现)

df-storage 96 lib + 11 集成测试全过。
2026-06-26 22:50:48 +08:00
lxy 549f198cea 更新: 待决策回填(7DEC+3专题按推荐) + 父②V29
- DEC-01 父② 知识图谱独立 V29(V28 已被灵感软删除 ideas.deleted_at 占用)
- DEC-02 #7 保留 purge / DEC-03 #6 拆 const(接 LLM 后升 a) / DEC-04 #9/#10 逗号 tags
- DEC-05 层2 维持白名单 / DEC-06 父③排父②后 / DEC-07 父④排父②后
- 专题-1 workspace_root ①先行分发前定② / 专题-2 审批超时不做(等待即等待) / 专题-3 F-09归父④
2026-06-26 22:16:30 +08:00
lxy e7c9ed0805 更新: 待决策队列(2026-06-26 任务图谱推进 7 项)
自主推进父①+父⑤(⑤.1/①.4/#5)后,剩余项均需决策,登记:
DEC-01 父②多工程合并 / DEC-02 #7 promote补偿 / DEC-03 #6 评分配置形式 /
DEC-04 #9/#10 表单交互 / DEC-05 BUG-260620-05层2授权 / DEC-06 父③ToolCard排期 / DEC-07 父④F-09排期
2026-06-26 21:51:46 +08:00
lxy 6d979d6646 修复: record_to_idea 读真实 status/related_ids(父⑤⑤.2 #5)
IDEA-FIX-05: record_to_idea 原 status 硬编码 Draft / related_ids 硬编码空,
评估时丢失真实上下文。改读 record.status(加 status_from_str 映射 DB snake_case
→ IdeaStatus 枚举,fallback Draft)+ related_ids(parse JSON Vec,同 tags 模式)。
scores/time 不改(注释明示评估不用)。
2026-06-26 20:48:31 +08:00
lxy 005079f762 新增: 灵感软删除回收站+雷达图正名(父⑤)
- IDEA-FIX-01: ideas 软删除(对标 tasks V14)— V28 迁移 deleted_at(幂等 ALTER) + IdeaRepo soft_delete/restore/list_deleted + list_by_query 恒带 deleted_at IS NULL(防回收站泄漏) + delete_idea 改软删 + restore_idea/list_deleted_ideas IPC + IdeaRecord 不加字段(纯 SQL WHERE 过滤)
- IDEA-FIX-14: 雷达图 CSS class radar→score-bar(IdeaDetail/ProjectDetail 2 文件 template+style+注释,纯正名零行为变更)
- 6 新测试(soft_delete 标记+过滤/幂等/missing, restore 还原/仅已删, list_deleted 排序);df-storage 73 测试过
2026-06-26 20:42:48 +08:00
lxy 308775cf4d 修复: 灵感模块数据安全 + 建立整体任务图谱DAG
- IDEA-FIX-02: ideas 白名单移除 id/created_at(对标 tasks B-260616-16,防主键/创建时间篡改)
- IDEA-FIX-03: create_idea 加 priority ∈ 0..=3 值域校验(对标 tasks B-260615-15,防越界静默吞)
- docs/todo.md: 建立整体任务图谱(DAG·有方向有状态),整合知识图谱设计+22条待办+G1
- 核销 BUG-260620-05 层1: 已被 F-260619-03 方案①弱化取代(default_with_root 空,reload 不塞 workspace_root),层2 黑名单制仍待决策
2026-06-26 20:27:58 +08:00
lxy 8ce18cb482 新增: AI 对话目标钉扎+探索熔断+话题标记降级
治 AI Chat 目标丢失/意图丢失(实测会话 480万token,目标消息被压缩出局,LLM 尾部盲搜死循环):
- G1 目标钉扎:首条 user 目标提取存 PerConvState.pinned_goal,run_agentic_loop 入口拼进
  system_prompt 尾部(loop 不变量,免疫 F-15 压缩/裁剪/sanitize,治目标物理出局)
- G2 探索熔断:连续 N 次空结果无进展 → 两段式警示 + AiHelpRequired 熔断(治游荡死循环)
- G4 话题标记降级:pinned_goal 存在时跳过 topic marker insert(治反向误导+双锚点稀释)
- G3 衔接:pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum
各改进配开关 flag(GOAL_PIN_ENABLED/STALL_BREAKER_*/TOPIC_MARKER_GOAL_AWARE)可单点回退。
诊断报告见 docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md。
2026-06-26 20:01:28 +08:00
lxy c011f864fd 重构: aichat 双轨状态机收口 + AiCompressed 事件拆分
- generating bool + CONV_STATE_ENABLED 开关双轨退役,ConvState enum 单一真相源
- can_accept_request 接入 chat 域入口(覆盖 Stopping 竞态,严谨于 is_active)
- AiCompressed 拆 AiManualCompressed/AiAutoCompressed(治自动压缩误触桌面toast+刷新)
- convStates/getConvState 下沉 aiShared.ts 破循环依赖
2026-06-25 03:20:42 +08:00
lxy fc705443bd 新增: AST代码智能语言扩展 Go/Java/Python(tree-sitter 表驱动)
Phase1 范围 3 grammar/4 类 → 6 grammar/9 类。grammar_for/DEFINITION_KINDS/Cargo.toml 加表项(零架构改动,grammar crate 0.23 ABI 兼容 0.25 主 crate),补 3 处 py 相关测试断言(py 加支持后原无 grammar 样例失效),测试 306 passed。
2026-06-25 02:13:46 +08:00
lxy d7e0d7ac55 修复: AI 工具基线补 read_symbol(31→32)
baseline_tool_count 断言 file 层 12→13(7c2e3b2 read_symbol 符号解析注册未更新基线测试)。cargo test --lib 308 全绿。
2026-06-25 01:21:15 +08:00
lxy c409a675cf 重构: workspace_root 弱化方案①去默认白名单
default_with_root 不再编译期硬塞 CARGO_MANIFEST_DIR(分发后无效路径)+ reload_allowed_dirs KV 空时不兜底塞。用户首次访问走弹窗三档授权,对齐产品定位(不预设源码目录)。3 测试改显式构造。
2026-06-25 01:19:05 +08:00
lxy 4c54288a8d 重构: 移除 AI 消息重新生成入口及配套调整
- MessageList: 移除末条 AI 消息重新生成操作栏入口
- package.json: build:dev 改 tauri build --debug --no-bundle(只编译不打包)
- 文档: workspace_root 弱化决策 + BUG-260625-01/02 工具修复记录
2026-06-25 01:03:52 +08:00
lxy 047b3b91fa 修复: read_file/grep 工具结果处理
BUG-260625-01 grep 单文件路径走 grep_one_file(旧 read_dir 报 os error 267 目录无效);BUG-260625-02 read_file search 模式渲染命中行 matches(旧取 content 显 0 行误判读取失败)。
2026-06-25 00:27:02 +08:00
lxy 24c3fa08f1 新增: 路径授权三档化 once/session/always
Phase B 弹窗 once/always → once/session/always 三档(本次单次执行后清/当前会话切会话清/始终落KV)。修 once 原写 session 致本次实为本会话名实不符。AllowedDirs 加 once 层 + ai_authorize_dir decision 改 match 四分支 + 执行后 clear_once。
2026-06-25 00:23:07 +08:00
lxy 56a08fb5c1 修复: 压缩摘要落可压缩区致循环卡死 2026-06-24 23:26:48 +08:00
lxy 953863af8e 文档: CR-260622-01残留销账(P2-1伪问题核验+979 return过早+P2-2评估降级) 2026-06-24 23:08:30 +08:00
lxy 7d2998e04e 修复: grep搜索误标Medium致每次审批 2026-06-24 23:04:26 +08:00
lxy 93172a6844 修复: 工具执行无心跳致流中断误报 2026-06-24 22:13:48 +08:00
lxy 8df7e248b4 文档: 审查销账与CR-260622-01结案(TD-260621-05双轨销账+BUG残留销账+CR残留登记+REFACTOR描述更新) 2026-06-24 17:53:08 +08:00
lxy ba811ead96 修复: TD-260621-05审批状态双轨统一(executed→completed+V27迁移+测试) 2026-06-24 17:48:42 +08:00
lxy ebba9735ed 修复: BUG-260624-01残留(scrollToFirstPending return过早+listener props过滤) 2026-06-24 17:38:40 +08:00
lxy a7dbd50a4d 修复: aichat P2确定性bug批(audit吞错落日志+switch/delete占位终态化)
finalize: audit_tool_call insert 用 let _ = 吞 DB 错改 match Err tracing::error(对齐 audit_finalize:82模式,控制流不变不阻断,审计写失败落日志可确诊)
conversation+chat: switch/delete retain pending 前补 finalize_pending_placeholders 占位终态化(对齐 stop/clear/force_send,防占位 tool_result 残留下次发送喂 LLM);chat.rs finalize_pending_placeholders 可见性 fn→pub(crate)
cargo check --workspace 零错
2026-06-24 17:18:54 +08:00
lxy 7f6aa1e782 新增: AST代码智能Phase2(read_symbol drill下钻+调用点带行+prompt优先引导)
code_intel: read_symbol 加 drill(目标节点子树递归找嵌套子定义,命中走full)/calls 每项带 line号(与start_row同口径,Vue切段偏移自动对齐)/2 新测试(drill命中+drill_not_found兜底)
prompt: 中英能力列表加 read_symbol 优先引导(治 read_file 全文回灌 prompt 爆)+ 修支持集标错bug(.rs/.ts等原标不支持实为支持集)
tool_registry: read_symbol schema 加 drill 参数 + read_file desc 反向引导
密度: code_intel.rs 全文 25733B vs 骨架 1348B → 降 19.1x;16 单测全绿
2026-06-24 10:22:09 +08:00
91 changed files with 8810 additions and 919 deletions
+5
View File
@@ -0,0 +1,5 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]
+105
View File
@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "tree-sitter"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
dependencies = [
"cc",
"regex",
"regex-syntax",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-rust"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "ts-bench"
version = "0.1.0"
dependencies = [
"tree-sitter",
"tree-sitter-rust",
]
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "ts-bench"
version = "0.1.0"
edition = "2021"
[dependencies]
tree-sitter = "0.23"
tree-sitter-rust = "0.23"
[profile.release]
opt-level = 3
[workspace]
+184
View File
@@ -0,0 +1,184 @@
use std::env;
use std::fs;
use std::hint::black_box;
use std::time::Instant;
use tree_sitter::{Node, Parser, Query, QueryCursor};
fn char_to_token_est(chars: usize) -> usize {
(chars + 3) / 4
}
fn extract_skeleton(root: Node, src: &str, q: &Query) -> (String, usize) {
let mut out = String::new();
let mut count = 0usize;
let mut cursor = QueryCursor::new();
let matches = cursor.matches(q, root, src.as_bytes());
for mat in matches {
for cap in mat.captures {
let n = cap.node;
let start_byte = n.start_byte();
let header_end = {
let from_start = &src[start_byte..];
let mut depth: i32 = 0;
let mut end = from_start.len();
for (i, b) in from_start.char_indices() {
match b {
'{' => {
if depth == 0 {
end = i;
break;
}
depth += 1;
}
';' => {
end = i;
break;
}
_ => {}
}
let _ = depth;
}
start_byte + end.min(from_start.len())
};
let header = &src[start_byte..header_end].trim_end();
out.push_str(header);
out.push_str(" { ... }\n");
count += 1;
}
}
(out, count)
}
fn main() {
let args: Vec<String> = env::args().collect();
let path = args.get(1).expect("usage: ts-bench <file.rs>");
let src = fs::read_to_string(path).expect("read");
let src_chars = src.chars().count();
let src_tokens_est = char_to_token_est(src.len());
let mut parser = Parser::new();
parser.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap();
// warmup
let _ = parser.parse(&src, None).unwrap();
// ---- PURE PARSE timing ----
let n = 200;
let mut times_us = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let tree = parser.parse(black_box(&src), None).unwrap();
let t1 = Instant::now();
drop(tree);
times_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let min = times_us[0];
let med = times_us[n / 2];
let p95 = times_us[(n as f64 * 0.95) as usize];
let mean = times_us.iter().sum::<f64>() / n as f64;
// ---- INCREMENTAL PARSE (edit one byte at end) ----
let mut src_edited = src.clone();
src_edited.push_str("\n// comment\n");
let tree0 = parser.parse(&src, None).unwrap();
let mut times_inc = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let t2 = parser.parse(&src_edited, Some(&tree0)).unwrap();
let t1 = Instant::now();
drop(t2);
times_inc.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_inc.sort_by(|a, b| a.partial_cmp(b).unwrap());
let inc_med = times_inc[n / 2];
// ---- Query compilation timing ----
let query_text = r#"
[
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(enum_item name: (type_identifier) @name)
(trait_item name: (type_identifier) @name)
(impl_item trait: (type_identifier)? @trait type: (type_identifier)? @type)
(const_item name: (identifier) @name)
(static_item name: (identifier) @name)
(type_item name: (type_identifier) @name)
(mod_item name: (identifier) @name)
(macro_definition name: (identifier) @name)
]
"#;
let mut qtimes = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let t1 = Instant::now();
drop(q);
qtimes.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
qtimes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q_med = qtimes[n / 2];
// ---- Skeleton extraction (compiled query reused) ----
let tree = parser.parse(&src, None).unwrap();
let root = tree.root_node();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let m = 200;
let mut ext_us = Vec::with_capacity(m);
let mut last_skel = String::new();
let mut last_count = 0;
for _ in 0..m {
let t0 = Instant::now();
let (skel, cnt) = extract_skeleton(root, &src, &q);
let t1 = Instant::now();
ext_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
last_skel = skel;
last_count = cnt;
}
ext_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let ext_med = ext_us[m / 2];
let skel_chars = last_skel.chars().count();
let skel_tokens_est = char_to_token_est(last_skel.len());
println!("=== FILE ===");
println!("path : {}", path);
println!("lines : {}", src.lines().count());
println!("chars : {}", src_chars);
println!("bytes : {}", src.len());
println!("tokens est (~/4c) : {}", src_tokens_est);
println!();
println!("=== PARSE SPEED (pure tree-sitter-rust, release, {} iters) ===", n);
println!("min={:.2}us median={:.2}us p95={:.2}us mean={:.2}us", min, med, p95, mean);
println!("median ms : {:.3} ms", med / 1000.0);
println!();
println!("=== INCREMENTAL PARSE (small edit) ===");
println!("median : {:.2}us ({:.3}ms)", inc_med, inc_med / 1000.0);
println!();
println!("=== QUERY COMPILATION (once, should be cached) ===");
println!("median : {:.2}us ({:.3}ms)", q_med, q_med / 1000.0);
println!();
println!("=== SKELETON EXTRACTION (compiled query reused) ===");
println!("symbols extracted : {}", last_count);
println!("skeleton chars : {}", skel_chars);
println!("skeleton tokens : {}", skel_tokens_est);
println!("extract median : {:.2}us ({:.3}ms)", ext_med, ext_med / 1000.0);
println!();
println!("=== DENSITY (full vs skeleton) ===");
println!(
"chars : {:.1}x ({:.1}% reduction)",
src_chars as f64 / skel_chars as f64,
(1.0 - skel_chars as f64 / src_chars as f64) * 100.0
);
println!(
"tokens : {:.1}x ({:.1}% reduction)",
src_tokens_est as f64 / skel_tokens_est as f64,
(1.0 - skel_tokens_est as f64 / src_tokens_est as f64) * 100.0
);
println!();
println!("=== SKELETON PREVIEW (first 30 lines) ===");
for line in last_skel.lines().take(30) {
println!("{}", line);
}
}
Generated
+33
View File
@@ -860,7 +860,10 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tree-sitter", "tree-sitter",
"tree-sitter-go",
"tree-sitter-java",
"tree-sitter-javascript", "tree-sitter-javascript",
"tree-sitter-python",
"tree-sitter-rust", "tree-sitter-rust",
"tree-sitter-typescript", "tree-sitter-typescript",
"uuid", "uuid",
@@ -5122,6 +5125,26 @@ dependencies = [
"tree-sitter-language", "tree-sitter-language",
] ]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-java"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "tree-sitter-javascript" name = "tree-sitter-javascript"
version = "0.25.0" version = "0.25.0"
@@ -5138,6 +5161,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "tree-sitter-rust" name = "tree-sitter-rust"
version = "0.24.2" version = "0.24.2"
+4 -1
View File
@@ -615,7 +615,10 @@ export function handleEvent(event: AiChatEvent): void {
// 上下文分段/压缩中,miniapp 无需特殊处理(状态在桌面端) // 上下文分段/压缩中,miniapp 无需特殊处理(状态在桌面端)
break break
case 'AiCompressed': // 治 Task#1:手动/自动两变体都插摘要气泡(miniapp 无独立压缩按钮,
// 两路径都代表"对端发生压缩",插摘要气泡告知用户合理)。
case 'AiManualCompressed':
case 'AiAutoCompressed':
// 压缩完成:在消息首位插入摘要提示 // 压缩完成:在消息首位插入摘要提示
messages.unshift({ messages.unshift({
id: genId('compressed'), id: genId('compressed'),
+4 -1
View File
@@ -68,7 +68,10 @@ export type AiChatEvent =
} }
| { type: 'AiContextCleared'; conversation_id?: string | null } | { type: 'AiContextCleared'; conversation_id?: string | null }
| { type: 'AiCompressing'; conversation_id?: string | null } | { type: 'AiCompressing'; conversation_id?: string | null }
| { type: 'AiCompressed'; conversation_id?: string | null; summary: string } // 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
// miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。
| { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string }
| { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string }
| { | {
type: 'AiHelpRequired' type: 'AiHelpRequired'
reason: string reason: string
+213
View File
@@ -222,6 +222,17 @@ impl AnthropicCompatProvider {
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。 // [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
Self::merge_consecutive_users(&mut messages); Self::merge_consecutive_users(&mut messages);
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
// "首条 role=assistant 非法"。
// 根本解用"补"而非"砍":开头补一条 user 占位,保留全部上下文(assistant 的 tool_use
// 与其后 user 的 tool_result 配对完整),precheck 必过。砍会丢工具调用历史,且多轮
// [asst(tu),user(tr),asst(tu),user(tr),...] 逐对砍到空。占位是异常路径轻量噪声
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
let tools = req.tools.map(|defs| { let tools = req.tools.map(|defs| {
defs.into_iter() defs.into_iter()
.map(|d| AnthropicToolDef { .map(|d| AnthropicToolDef {
@@ -295,6 +306,42 @@ impl AnthropicCompatProvider {
} }
} }
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
///
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
/// 或从历史片段截取时,真正的首条 user 已被裁剪/压缩掉。直接发触发 precheck "首条
/// role=assistant 非法"。
///
/// **用"补"而非"砍"**:在开头插一条 user 占位消息。
/// - 砍掉首条 assistant 会丢失有效上下文(其 tool_use 与后续 user 的 tool_result 是完整
/// 配对),且多轮 [asst(tu),user(tr),asst(tu),user(tr),...] 会被逐对砍到空;
/// - 补占位则全部上下文保留(占位 user 紧贴原首条 assistant,不破坏 user/assistant 交替),
/// tool_use/tool_result 配对完整不动,**无 orphan 产生**(故砍策略那套 orphan 清理在此不需要),
/// 占位 content 非空过 precheck 的"user content 空"校验。
///
/// 仅异常路径触发(正常会话首条本就是 user),占位文案是轻量噪声,远优于丢弃工具调用历史。
fn ensure_leading_user(messages: &mut Vec<serde_json::Value>) {
let first_role = messages
.first()
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or("");
if first_role == "user" {
return;
}
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
// Anthropic 协议要求 messages 至少一条且首条 user,补占位让降级会话能继续(不丢这条兜底)。
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user(含空),补 user 占位(防 Anthropic 首条 assistant/空 messages 非法)"
);
messages.insert(0, serde_json::json!({
"role": "user",
"content": "(continued from previous context)",
}));
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。 /// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。 /// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String { fn summarize_messages(messages: &[serde_json::Value]) -> String {
@@ -878,4 +925,170 @@ mod tests {
// 纯文本 → 字符串简写(非数组) // 纯文本 → 字符串简写(非数组)
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello")); assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
} }
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
ChatMessage::assistant_with_tools(
text,
vec![ToolCall::new(tool_id, tool_name, "{}")],
)
}
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test]
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
msg_assistant_with_tool_use("我来帮你", "call_f892", "read_file"),
ChatMessage::tool_result("call_f892", "file content"),
msg_assistant_with_tool_use("继续", "call_003a", "write_file"),
ChatMessage::tool_result("call_003a", "done"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
// 首条必须是 user(补的占位)
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
assert_eq!(first_role, "user", "首条应为 user(补占位)");
// 上下文零丢失:占位 user + asst(tu) + user(tr) + asst(tu) + user(tr) = 5 条
assert_eq!(
body.messages.len(), 5,
"应保留全部上下文(占位 + 原 4 条),实际 {} 条", body.messages.len()
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"precheck 应通过,实际 messages: {}",
AnthropicCompatProvider::summarize_messages(&body.messages)
);
}
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test]
fn anthropic_ensure_leading_user_plain_assistant() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::assistant("你好"),
ChatMessage::user("请帮我"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
#[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi there"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 2, "正常序列不应补占位");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test]
fn anthropic_ensure_leading_user_three_round_chain() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
msg_assistant_with_tool_use("a1", "call_1", "read_file"),
ChatMessage::tool_result("call_1", "r1"),
msg_assistant_with_tool_use("a2", "call_2", "write_file"),
ChatMessage::tool_result("call_2", "r2"),
msg_assistant_with_tool_use("a3", "call_3", "list_directory"),
ChatMessage::tool_result("call_3", "r3"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
assert_eq!(first_role, "user", "首条应为 user(补占位)");
// 占位 + 3×[asst(tu),user(tr)] = 7 条,全部保留
assert_eq!(
body.messages.len(), 7,
"3 轮链应全保留(占位 + 原 6 条),实际 {} 条", body.messages.len()
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"3 轮链补占位后 precheck 应通过,实际: {}",
AnthropicCompatProvider::summarize_messages(&body.messages)
);
}
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
assert_eq!(body.messages.len(), 1, "空 messages 应补 1 条 user 占位");
assert_eq!(
body.messages[0].get("role").and_then(|r| r.as_str()),
Some("user"),
);
assert!(
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
"补占位后 precheck 应通过"
);
}
} }
+18 -4
View File
@@ -185,7 +185,17 @@ impl ContextManager {
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限", "history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT self.history_tokens, available, PROTECT_COUNT
); );
return (self.all_messages_clone(), false); // B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
let sanitized = Self::sanitize_messages(self.all_messages_clone());
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
} }
let msgs: Vec<ChatMessage> = self.messages[trim_end..] let msgs: Vec<ChatMessage> = self.messages[trim_end..]
@@ -802,9 +812,13 @@ impl ContextManager {
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。 /// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool { pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len()); let end = protect_start.min(self.messages.len());
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
self.messages[..end] self.messages[..end]
.iter() .iter()
.any(|t| t.message.is_active()) .any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
} }
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`, /// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
@@ -1463,10 +1477,10 @@ mod tests {
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量 // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
let mut mgr = ContextManager::new(cfg(10)); let mut mgr = ContextManager::new(cfg(10));
mgr.push(ChatMessage::user("撑爆小预算的长消息内容")); mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
mgr.push(ChatMessage::user("第二条撑爆预算的长消息")); mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
let (msgs, trimmed) = mgr.build_for_request(0); let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)"); assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息"); assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
} }
#[test] #[test]
+86 -1
View File
@@ -105,7 +105,7 @@ impl OpenAICompatProvider {
req.model req.model
}; };
let messages: Vec<OpenAiMessage> = req let mut messages: Vec<OpenAiMessage> = req
.messages .messages
.into_iter() .into_iter()
.map(|m| { .map(|m| {
@@ -177,6 +177,12 @@ impl OpenAICompatProvider {
}) })
.collect(); .collect();
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
Self::ensure_leading_user(&mut messages);
let tools = req.tools.map(|defs| { let tools = req.tools.map(|defs| {
defs.into_iter() defs.into_iter()
.map(|d| serde_json::to_value(d).unwrap_or_default()) .map(|d| serde_json::to_value(d).unwrap_or_default())
@@ -201,6 +207,36 @@ impl OpenAICompatProvider {
} }
} }
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
///
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
if first_role == "user" || first_role == "system" {
return;
}
warn!(
first_role,
msg_count = messages.len(),
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
);
messages.insert(
0,
OpenAiMessage {
role: "user".into(),
content: serde_json::Value::String("(continued from previous context)".into()),
tool_call_id: None,
tool_calls: None,
reasoning_content: None,
},
);
}
/// 解析同步响应中的工具调用 /// 解析同步响应中的工具调用
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> { fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
calls calls
@@ -594,4 +630,53 @@ mod tests {
let msg = &out.messages[0]; let msg = &out.messages[0];
assert_eq!(msg.content, serde_json::Value::String("hello".into())); assert_eq!(msg.content, serde_json::Value::String("hello".into()));
} }
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::assistant("我来帮你"),
ChatMessage::user("继续"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
assert_eq!(out.messages[1].role.as_str(), "assistant");
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
let req = CompletionRequest {
model: "gpt-4o".into(),
messages: vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let out = provider.convert_request(req);
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
assert_eq!(out.messages[0].role.as_str(), "user");
}
} }
+1
View File
@@ -5,3 +5,4 @@ mod adversarial_helpers;
pub mod capture; pub mod capture;
pub mod promotion; pub mod promotion;
pub mod scoring; pub mod scoring;
pub mod scoring_keywords;
+13 -12
View File
@@ -11,6 +11,15 @@ pub use crate::capture::IdeaScores;
use df_types::types::Priority; use df_types::types::Priority;
// ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
//
// 关键词常量已拆至独立文件 `scoring_keywords.rs`。
// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
pub use crate::scoring_keywords::{
FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
};
/// 评分权重配置 /// 评分权重配置
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ScoringWeights { pub struct ScoringWeights {
@@ -71,14 +80,10 @@ impl ScoringEngine {
score -= 0.5; score -= 0.5;
} }
// 可行性正向信号 // 可行性正向信号
let pos = count_any(desc, &[ let pos = count_any(desc, FEASIBILITY_POSITIVE_KEYWORDS);
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
]);
score += (pos as f64) * 0.5; score += (pos as f64) * 0.5;
// 复杂度负向信号 // 复杂度负向信号
let neg = count_any(desc, &[ let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
]);
score -= (neg as f64) * 0.6; score -= (neg as f64) * 0.6;
score.clamp(0.0, 10.0) score.clamp(0.0, 10.0)
} }
@@ -97,9 +102,7 @@ impl ScoringEngine {
score += (idea.tags.len().min(4) as f64) * 0.25; score += (idea.tags.len().min(4) as f64) * 0.25;
} }
let desc = idea.description.trim(); let desc = idea.description.trim();
let value_hits = count_any(desc, &[ let value_hits = count_any(desc, IMPACT_KEYWORDS);
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
]);
score += (value_hits as f64) * 0.5; score += (value_hits as f64) * 0.5;
if desc.chars().count() > 100 { if desc.chars().count() > 100 {
score += 0.5; score += 0.5;
@@ -116,9 +119,7 @@ impl ScoringEngine {
Priority::Low => 3.0, Priority::Low => 3.0,
}; };
let desc = idea.description.trim(); let desc = idea.description.trim();
let time_hits = count_any(desc, &[ let time_hits = count_any(desc, URGENCY_KEYWORDS);
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
]);
score += (time_hits as f64) * 0.5; score += (time_hits as f64) * 0.5;
score.clamp(0.0, 10.0) score.clamp(0.0, 10.0)
} }
+24
View File
@@ -0,0 +1,24 @@
//! 评分引擎关键词 — 从 `scoring.rs` 拆出的独立常量文件。
//!
//! 启发式评分是过渡方案(对抗式评估上线后按需升级为 JSON 配置),
//! 此处仅做最小改动:将硬编码关键词集中于此,保持 `scoring.rs` 逻辑清晰。
/// 可行性正向信号词(描述充实度外的技术/资源信号),命中每个 +0.5
pub const FEASIBILITY_POSITIVE_KEYWORDS: &[&str] = &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
];
/// 可行性负向信号词(复杂度上升信号),命中每个 -0.6
pub const FEASIBILITY_NEGATIVE_KEYWORDS: &[&str] = &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
];
/// 影响力价值信号词(用户/增长/收入...),命中每个 +0.5
pub const IMPACT_KEYWORDS: &[&str] = &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
];
/// 紧急度时效信号词(立即/马上/紧急...),命中每个 +0.5
pub const URGENCY_KEYWORDS: &[&str] = &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
];
+6
View File
@@ -393,6 +393,9 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
@@ -446,6 +449,9 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: existing.review_rounds, review_rounds: existing.review_rounds,
output_json: existing.output_json, output_json: existing.output_json,
idea_id: existing.idea_id, idea_id: existing.idea_id,
queue: existing.queue,
parent_id: existing.parent_id,
content_json: existing.content_json,
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
@@ -311,6 +311,9 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: task_output_json.map(String::from), output_json: task_output_json.map(String::from),
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
}) })
+3
View File
@@ -190,6 +190,9 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
} }
+30
View File
@@ -0,0 +1,30 @@
# df-relay 跨端 WS 中继反向代理(miniapp ↔ 桌面端)
# wss 在 nginx 终止(复用 *.1216.top 通配符证书),明文 ws 转 df-relay :9180
# HTTP → HTTPS 重定向
server {
listen 80;
server_name u-work.1216.top;
return 301 https://$host$request_uri;
}
# HTTPS wss 反代
server {
listen 443 ssl;
server_name u-work.1216.top;
include /etc/nginx/snippets/ssl-1216.conf;
# df-relay 两个 WS 端点原样转发(/ws/device + /ws/miniapp)
# proxy_pass 不带路径后缀 → 完整 URI 透传给 df-relay
location /ws/ {
proxy_pass http://127.0.0.1:9180;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
+298 -9
View File
@@ -73,6 +73,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
dot / (norm_a * norm_b + 1e-8) dot / (norm_a * norm_b + 1e-8)
} }
/// 解析 JSON 数组字符串为 Vec<String>。NULL / 空 / 非法 → 空 Vec。
fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
match raw {
Some(s) if !s.is_empty() => match serde_json::from_str::<Vec<String>>(s) {
Ok(v) => v,
Err(_) => Vec::new(),
},
_ => Vec::new(),
}
}
// ============================================================ // ============================================================
// from_row 辅助函数 // from_row 辅助函数
// ============================================================ // ============================================================
@@ -271,28 +282,31 @@ impl IdeaRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
// 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符) // 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
let mut where_clauses: Vec<String> = Vec::new(); // deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(s) = &status { if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1)); // 占位符编号用 params_vec.len()+1(参数实际位置),非 where_clauses.len()+1
// (where_clauses 含 deleted_at IS NULL 常量无占位符子句,len() 会偏移致 ?N 与参数错位
// — 父⑤⑤.1 加 deleted_at 恒带引入的潜伏 bug,非空 status/keyword 查询 rusqlite 报
// "needed N, got M"。对标 task_repo list_by_query ②.2 同款修复)
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let pat = format!("%{kw}%");
let p1 = where_clauses.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
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));
} }
let where_sql = if where_clauses.is_empty() { // where_clauses 恒含 deleted_at IS NULL(初始项),永非空,直接 join 拼 WHERE。
String::new() let where_sql = format!(" WHERE {}", where_clauses.join(" AND "));
} else {
format!(" WHERE {}", where_clauses.join(" AND "))
};
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。 // 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。 // order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
@@ -341,6 +355,184 @@ impl IdeaRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删灵感,返回是否命中。
/// 对标 TaskRepo::soft_delete / ProjectRepo::soft_delete。
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删灵感,返回是否命中。
/// 对标 TaskRepo::restore / ProjectRepo::restore。
pub async fn restore(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
/// 新增的关联目标追加 `subject_id` 到其 `related_ids`;
/// 移除的关联目标从中删除 `subject_id`。
/// 全部操作在同一 SQLite 事务中完成,保证原子性。
pub async fn sync_related_ids(
&self,
subject_id: &str,
new_target_ids: &[String],
) -> Result<()> {
use rusqlite::Transaction;
let conn = self.conn.clone();
let subject_id = subject_id.to_owned();
let new_target_ids = new_target_ids.to_vec();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock();
let tx: Transaction = guard
.transaction()
.map_err(storage_err)?;
// 1. 读主体当前 related_ids
let old_raw: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![subject_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
// 2. 解析新旧集合
let old_set: std::collections::HashSet<String> =
parse_json_id_array(&old_raw).into_iter().collect();
let new_set: std::collections::HashSet<String> =
new_target_ids.iter().cloned().collect();
let added: Vec<&str> = new_set
.difference(&old_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id) // 不自关联
.collect();
let removed: Vec<&str> = old_set
.difference(&new_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id)
.collect();
// 3. 更新 added 目标:追加 subject_id
for target_id in &added {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
if !ids.iter().any(|i| i == &subject_id) {
ids.push(subject_id.clone());
}
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 4. 更新 removed 目标:移除 subject_id
for target_id in &removed {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
ids.retain(|i| i != &subject_id);
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 5. 更新主体
let new_json = serde_json::to_string(&new_target_ids)
.map_err(|e| storage_err::<df_types::error::Error>(e.into()))?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![new_json, &now, &subject_id],
)
.map_err(storage_err)?;
tx.commit().map_err(storage_err)?;
Ok(())
})
.await
.map_err(storage_err)?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC"
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| idea_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
} }
impl KnowledgeRepo { impl KnowledgeRepo {
@@ -1202,4 +1394,101 @@ mod tests {
let rec = repo.get_by_id("k1").await.unwrap().unwrap(); let rec = repo.get_by_id("k1").await.unwrap().unwrap();
assert_eq!(rec.embedding_status.as_deref(), Some("done")); assert_eq!(rec.embedding_status.as_deref(), Some("done"));
} }
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
/// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
fn irec(id: &str, title: &str) -> IdeaRecord {
IdeaRecord {
id: id.to_string(),
title: title.to_string(),
description: String::new(),
status: "draft".to_string(),
priority: 1,
score: None,
tags: None,
source: None,
promoted_to: None,
ai_analysis: None,
scores: None,
related_ids: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
async fn setup_idea_repo() -> IdeaRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
IdeaRepo::new(&db)
}
#[tokio::test]
async fn idea_soft_delete_marks_and_filters_from_list_by_query() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "保留")).await.unwrap();
repo.insert(irec("i2", "软删")).await.unwrap();
// 软删 i2:返回 true(命中)
assert!(repo.soft_delete("i2").await.unwrap());
// list_by_query(空 query = 等价全量未删)应只返回 i1,i2 进回收站被过滤
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["i1"], "soft_delete 后 list_by_query 应过滤回收站");
}
#[tokio::test]
async fn idea_soft_delete_idempotent_on_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "t")).await.unwrap();
assert!(repo.soft_delete("i1").await.unwrap());
// 已删再删:WHERE deleted_at IS NULL 不命中 → false
assert!(!repo.soft_delete("i1").await.unwrap());
}
#[tokio::test]
async fn idea_soft_delete_missing_returns_false() {
let repo = setup_idea_repo().await;
// 不存在的 id → affected=0
assert!(!repo.soft_delete("ghost").await.unwrap());
}
#[tokio::test]
async fn idea_restore_brings_back_to_active() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "t")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
// 恢复:返回 true,回到 list_by_query
assert!(repo.restore("i1").await.unwrap());
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["i1"], "restore 后灵感应回到活跃列表");
}
#[tokio::test]
async fn idea_restore_only_affects_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "t")).await.unwrap();
// 未删灵感 restore:WHERE deleted_at IS NOT NULL 不命中 → false
assert!(!repo.restore("i1").await.unwrap());
}
#[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "活跃")).await.unwrap();
repo.insert(irec("i2", "回收1")).await.unwrap();
repo.insert(irec("i3", "回收2")).await.unwrap();
// 软删 i2 / i3(updated_at 在 soft_delete 时被刷新为 now)
repo.soft_delete("i2").await.unwrap();
// 让 i3 的删除时间晚于 i2,保证 updated_at DESC 顺序确定(i3 在前)
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
repo.soft_delete("i3").await.unwrap();
let deleted = repo.list_deleted().await.unwrap();
let ids: Vec<_> = deleted.iter().map(|r| r.id.as_str()).collect();
// i1 活跃不出现;i3 删除最晚在前
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
}
} }
+13 -1
View File
@@ -6,6 +6,9 @@
//! - [`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_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo //! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
@@ -18,16 +21,22 @@ mod conversation_repo;
mod idea_eval_repo; mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod project_event_repo;
mod project_repo; mod project_repo;
mod project_service_repo;
mod settings; mod settings;
mod task_link_repo;
mod task_repo; mod task_repo;
pub use conversation_repo::*; pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use project_event_repo::*;
pub use project_repo::*; pub use project_repo::*;
pub use project_service_repo::*;
pub use settings::*; pub use settings::*;
pub use task_link_repo::*;
pub use task_repo::*; pub use task_repo::*;
// ============================================================ // ============================================================
@@ -266,7 +275,7 @@ mod baseline_tests {
let tables = [ let tables = [
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions", "ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions", "node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
"knowledges", "knowledge_events", "knowledges", "knowledge_events", "project_services",
]; ];
for t in tables { for t in tables {
assert!( assert!(
@@ -293,6 +302,9 @@ mod baseline_tests {
let _ = IdeaRepo::new(&db); let _ = IdeaRepo::new(&db);
let _ = ProjectRepo::new(&db); let _ = ProjectRepo::new(&db);
let _ = TaskRepo::new(&db); let _ = TaskRepo::new(&db);
let _ = TaskLinkRepo::new(&db);
let _ = ProjectEventRepo::new(&db);
let _ = ProjectServiceRepo::new(&db);
let _ = BranchRepo::new(&db); let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db); let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::new(&db); let _ = WorkflowRepo::new(&db);
@@ -0,0 +1,391 @@
//! 项目事件流 Repo:ProjectEventRepo(project_events 表,知识图谱 Phase 2 V30)
//!
//! 统一事件流——一张表承载跨实体(idea/task/workflow/knowledge/module/service/project)的
//! 全部事件,AI 精准检索的基础(回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出")。
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4。
//!
//! 关键设计(对标 TaskLinkRepo 全专用路径):
//! - **追加型审计表**:只 INSERT,无 UPDATE/DELETE(历史不改可追溯,对标 knowledge_events V10 /
//! idea_evaluations V22)。故不走 `impl_repo!` 宏(宏生成的 update/delete/update_field 对审计
//! 语义是错的),手写专用 insert + 三个查询方法。
//! - 不登记通用列白名单(`allowed_columns_for("project_events")` 返回 None):同 task_links,
//! 无通用 update_field 路径故无需白名单。`baseline_tests::all_known_tables_have_column_whitelist`
//! 的已知表数组不含本表(与 task_links 同款豁免)。
//! - `event_type` / `entity_type` 白名单**应用层校验**(类比 task_links.link_type),不进 DB 约束
//! (保留扩展性,新增事件/实体类型无需迁移)。本 Repo insert **不做白名单校验**——埋点层
//! (commands/IPC hook)是事件唯一生产者,字符串字面量自带约束;开放校验留给未来若开放外部写入。
//! - 埋点策略(设计 §2.4 hook/after)与 knowledge_events 双写均在调用方(commands 层)实现,
//! 非 Repo 职责。事件写入失败 best-effort 不阻断主操作(设计 §10.1)。
use std::sync::Arc;
use rusqlite::{params, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::ProjectEventRecord;
use super::{now_millis_str, storage_err};
// ============================================================
// from_row 辅助函数
// ============================================================
fn project_event_from_row(row: &Row<'_>) -> std::result::Result<ProjectEventRecord, rusqlite::Error> {
Ok(ProjectEventRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
event_type: row.get("event_type")?,
entity_type: row.get("entity_type")?,
entity_id: row.get("entity_id")?,
from_state: row.get("from_state")?,
to_state: row.get("to_state")?,
context_json: row.get("context_json")?,
source: row.get("source")?,
conversation_id: row.get("conversation_id")?,
created_at: row.get("created_at")?,
})
}
/// project_events SELECT 列清单(11 字段,防 COLS 漂移,对标 KNOWLEDGE_COLS)。
const PROJECT_EVENT_COLS: &str = "id,project_id,event_type,entity_type,entity_id,from_state,\
to_state,context_json,source,conversation_id,created_at";
// ============================================================
// ProjectEventRepo
// ============================================================
/// 项目统一事件流表 Repo(project_events,V30,追加型审计表)。
///
/// 不走 `impl_repo!` 宏:① 追加型审计表无 UPDATE/DELETE(宏生成的 update/update_field/delete
/// 对审计语义是错的);② 表无 `updated_at`(宏的 update_field 硬编码 `SET ... updated_at = ?`
/// 会触发 "no such column: updated_at")。故手写专用方法,对标 TaskLinkRepo 全专用路径。
pub struct ProjectEventRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ProjectEventRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 追加一条事件(只 INSERT,无 UPDATE,审计语义不可篡改)。
///
/// 返回插入的事件 id。`created_at` 由本方法内部取当前毫秒时间戳填充(调用方无需传),
/// 与 IdeaRepo::insert(now_millis_str)同款收口——事件生产时机即落库时机,时间由存储层定。
///
/// 不做 event_type / entity_type 白名单校验(埋点层是唯一生产者,字符串字面量自带约束)。
pub async fn insert(&self, record: ProjectEventRecord) -> Result<String> {
let conn = self.conn.clone();
// created_at 由存储层覆盖(调用方传入的不可信),保证「事件生产时机即落库时机」。
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT INTO project_events \
(id, project_id, event_type, entity_type, entity_id, from_state, to_state, \
context_json, source, conversation_id, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
record.id,
record.project_id,
record.event_type,
record.entity_type,
record.entity_id,
record.from_state,
record.to_state,
record.context_json,
record.source,
record.conversation_id,
now,
],
)
.map_err(storage_err)?;
Ok(record.id)
})
.await
.map_err(storage_err)?
}
/// 按项目查询事件流(time DESC,id DESC 兜底同毫秒并列,limit 钳制上限 200,
/// 命中 idx_project_events_project)。
///
/// AI / Dashboard 项目时间线视图用:取某项目最近 N 条事件(设计 §2.4 / §10.3.4)。
/// 同毫秒插入的事件(高频埋点常见)以 id DESC 兜底——生产 id 是 ULID(字典序单调递增),
/// 字典序 DESC 等价插入逆序,保证「最后发生的事件在最前」语义稳定(纯 created_at DESC
/// 同毫秒并列时顺序不确定,审计表必须可复现)。
pub async fn get_by_project(
&self,
project_id: &str,
limit: u32,
) -> Result<Vec<ProjectEventRecord>> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
// 钳制 limit 防滥用(最大 200,对标 KnowledgeEventsRepo::list_recent)
let safe_limit = limit.min(200) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
WHERE project_id = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![project_id, safe_limit], project_event_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 按实体反查事件流(命中 idx_project_events_entity)。
///
/// AI「这个任务/灵感发生过什么」反查:取某实体的全部事件(时间倒序)。跨项目——
/// 同一实体(如被移动过项目的任务)的全部历史事件都返回。同毫秒并列以 id DESC 兜底
/// (语义稳定,见 get_by_project 说明)。
pub async fn get_by_entity(
&self,
entity_type: &str,
entity_id: &str,
) -> Result<Vec<ProjectEventRecord>> {
let conn = self.conn.clone();
let entity_type = entity_type.to_owned();
let entity_id = entity_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
WHERE entity_type = ?1 AND entity_id = ?2 ORDER BY created_at DESC, id DESC"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![entity_type, entity_id], project_event_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 跨项目列最近 N 条事件(全表 created_at DESC,id DESC 兜底,top-N,命中
/// idx_project_events_project 的 created_at 维度)。
///
/// 对标 KnowledgeEventsRepo::list_recent:供「全局事件流 / 跨项目时间线」视图使用
/// (设计 §10.3.4 模式 B 事件流)。limit 上限钳制 200,防恶意/失误传超大值。
/// 同毫秒并列以 id DESC 兜底(语义稳定,见 get_by_project 说明)。
pub async fn list_recent(&self, limit: u32) -> Result<Vec<ProjectEventRecord>> {
let conn = self.conn.clone();
let safe_limit = limit.min(200) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
ORDER BY created_at DESC, id DESC LIMIT ?1"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![safe_limit], project_event_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — ProjectEventRepo insert + get_by_project + get_by_entity(内存 DB,
// 对标 task_link_repo 测试:先建占位 project 满足 FK,再插事件)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectEventRepo) {
let db = crate::db::Database::open_in_memory()
.await
.expect("open_in_memory");
let repo = ProjectEventRepo::new(&db);
let project_repo = ProjectRepo::new(&db);
project_repo
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
(db, repo)
}
/// 构造一条 ProjectEventRecord fixture(可指定 created_at 便于排序断言)。
fn erec(id: &str, project_id: &str, created_at: &str) -> ProjectEventRecord {
ProjectEventRecord {
id: id.to_string(),
project_id: project_id.to_string(),
event_type: "task_advanced".to_string(),
entity_type: Some("task".to_string()),
entity_id: Some("task-A".to_string()),
from_state: Some("todo".to_string()),
to_state: Some("in_progress".to_string()),
context_json: None,
source: Some("ai".to_string()),
conversation_id: Some("conv-1".to_string()),
created_at: created_at.to_string(),
}
}
#[tokio::test]
async fn insert_and_read_back_full_fields() {
let (_db, repo) = setup().await;
let id = repo.insert(erec("e1", "proj-1", "1700000000001")).await.unwrap();
assert_eq!(id, "e1");
let got = repo
.get_by_project("proj-1", 10)
.await
.unwrap()
.into_iter()
.next()
.expect("应有 1 条事件");
// 全字段回读校验
assert_eq!(got.id, "e1");
assert_eq!(got.project_id, "proj-1");
assert_eq!(got.event_type, "task_advanced");
assert_eq!(got.entity_type.as_deref(), Some("task"));
assert_eq!(got.entity_id.as_deref(), Some("task-A"));
assert_eq!(got.from_state.as_deref(), Some("todo"));
assert_eq!(got.to_state.as_deref(), Some("in_progress"));
assert!(got.context_json.is_none());
assert_eq!(got.source.as_deref(), Some("ai"));
assert_eq!(got.conversation_id.as_deref(), Some("conv-1"));
// created_at 由存储层覆盖为当前毫秒(非 fixture 传入的旧值)
assert_ne!(got.created_at, "1700000000001", "created_at 应由存储层覆盖");
assert!(!got.created_at.is_empty());
}
#[tokio::test]
async fn get_by_project_orders_desc_and_filters() {
let (_db, repo) = setup().await;
// 三条事件不同时间(create_at 由存储层覆盖,但插入顺序决定落库 created_at 单调递增,
// 故 DESC 应按插入逆序返回——最后插的最前)
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
repo.insert(erec("e3", "proj-1", "1")).await.unwrap();
let got = repo.get_by_project("proj-1", 10).await.unwrap();
assert_eq!(got.len(), 3, "应返回全部 3 条事件");
// DESC:最后插入的 e3 在前
assert_eq!(got[0].id, "e3");
assert_eq!(got[1].id, "e2");
assert_eq!(got[2].id, "e1");
// 过滤:不存在的项目返回空
let empty = repo.get_by_project("no-such-proj", 10).await.unwrap();
assert!(empty.is_empty());
}
#[tokio::test]
async fn get_by_project_limit_clamped() {
let (_db, repo) = setup().await;
for i in 0..5 {
repo.insert(erec(&format!("e{i}"), "proj-1", "1"))
.await
.unwrap();
}
// limit=2 只返回 2 条(最新两条)
let got = repo.get_by_project("proj-1", 2).await.unwrap();
assert_eq!(got.len(), 2);
// limit=99999 钳制为 200,但只有 5 条,返回 5 条
let got_all = repo.get_by_project("proj-1", 99999).await.unwrap();
assert_eq!(got_all.len(), 5, "钳制后仍返回全部存在的事件");
}
#[tokio::test]
async fn get_by_entity_cross_project_and_filters() {
let (_db, repo) = setup().await;
// 同一实体 task-A 在 proj-1 下产生 2 条事件
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
// 另一实体 task-B 1 条(改 entity_id)
let mut other = erec("e3", "proj-1", "1");
other.entity_id = Some("task-B".to_string());
repo.insert(other).await.unwrap();
// task-A 反查:返回 2 条(DESC)
let got_a = repo.get_by_entity("task", "task-A").await.unwrap();
assert_eq!(got_a.len(), 2);
assert_eq!(got_a[0].id, "e2");
assert_eq!(got_a[1].id, "e1");
// task-B 反查:返回 1 条
let got_b = repo.get_by_entity("task", "task-B").await.unwrap();
assert_eq!(got_b.len(), 1);
assert_eq!(got_b[0].id, "e3");
// 不存在的实体返回空
let empty = repo.get_by_entity("task", "no-such").await.unwrap();
assert!(empty.is_empty());
}
#[tokio::test]
async fn list_recent_cross_project() {
let (_db, repo) = setup().await;
for i in 0..3 {
repo.insert(erec(&format!("e{i}"), "proj-1", "1"))
.await
.unwrap();
}
// list_recent 跨项目取最近 N 条(此处仅 proj-1)
let got = repo.list_recent(10).await.unwrap();
assert_eq!(got.len(), 3);
// DESC:最后插入的最前
assert_eq!(got[0].id, "e2");
}
/// entity_type/entity_id 为 None(纯决策日志无明确实体)也能正常插入与查询。
#[tokio::test]
async fn insert_with_null_entity_fields() {
let (_db, repo) = setup().await;
let mut rec = erec("e1", "proj-1", "1");
rec.event_type = "decision_made".to_string();
rec.entity_type = None;
rec.entity_id = None;
rec.from_state = None;
rec.to_state = None;
repo.insert(rec).await.unwrap();
let got = repo.get_by_project("proj-1", 10).await.unwrap();
assert_eq!(got.len(), 1);
assert!(got[0].entity_type.is_none());
assert!(got[0].entity_id.is_none());
assert!(got[0].from_state.is_none());
assert!(got[0].to_state.is_none());
}
}
@@ -0,0 +1,552 @@
//! 项目基础设施配置 Repo:ProjectServiceRepo(project_services 表,知识图谱 Phase 3 V31)
//!
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计
//! docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3。
//!
//! 关键设计(对标 IdeaEvalRepo 走宏 + 专用方法模式):
//! - **走 `impl_repo!` 宏**:表有 `updated_at`(基础设施配置可改,非审计不可篡改),
//! 故宏生成的 insert/get_by_id/list_all/query/update_field/update_full/delete 全可用,
//! 白名单已在 settings.rs 登记(allowed_columns_for("project_services") 返回 Some)。
//! - `service_type` 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other)应用层校验,
//! 非进 DB 约束(对标 task_links.link_type / project_events.event_type,保留扩展性)。
//! - **敏感凭证审查(D10)**:insert/update_full 时检测 config_json / endpoint / remark 中是否
//! 含 password/secret/token/api_key 子串,命中即拒(防密码/密钥误存进表)。审查在应用层
//! 非 DB 约束(SQLite CHECK 对 JSON 内容无法表达),best-effort 兜底,非加密级安全。
//! - 专用 `list_by_project(project_id)` / `list_by_project_env(project_id, environment)`
//! 覆盖最高频查询(命中 idx_project_services_project)。
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::ProjectServiceRecord;
use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name};
// ============================================================
// service_type 白名单 + 校验
// ============================================================
/// `service_type` 白名单:基础设施类型枚举(对标设计 §2.3)。
///
/// 防拼写漂移 / 非法值进库。新增类型(如 elasticsearch / kafka)在此数组追加 + 设计文档
/// §2.3 同步更新,无需 DB 迁移(应用层校验,保留扩展性)。
pub const PROJECT_SERVICE_TYPES: &[&str] = &[
"mysql",
"postgresql",
"sqlite",
"redis",
"mongodb",
"mq",
"api",
"other",
];
/// 校验 `service_type` 在白名单内,否则返回 Err(对标 task_links.validate_link_type)。
pub fn validate_service_type(service_type: &str) -> Result<()> {
if PROJECT_SERVICE_TYPES.contains(&service_type) {
Ok(())
} else {
Err(df_types::error::Error::Storage(format!(
"非法 service_type: {service_type},合法值: {:?}",
PROJECT_SERVICE_TYPES
)))
}
}
// ============================================================
// 敏感凭证审查(D10 安全边界)
// ============================================================
/// 敏感字段名子串黑名单(小写匹配)。config_json / endpoint / remark 含任一即视为误存凭证。
///
/// D10 边界:本表不存密码/密钥/Token,凭证走环境变量。此审查是 best-effort 兜底——检测常见
/// 凭证字段名子串拒绝,防用户/AI 误把 `{"password": "xxx"}` 灌进 config_json。非加密级安全,
/// 非穷尽(如把密码放在非标准字段名仍可能漏检),核心防御仍依赖调用方纪律 + 环境变量约定。
const SENSITIVE_TOKENS: &[&str] = &["password", "passwd", "secret", "api_key", "apikey", "token"];
/// 审查给定文本(可空)是否含敏感字段名子串(大小写不敏感),命中即返回 Err(D10 边界)。
///
/// 对 config_json / endpoint / remark 三字段调用。endpoint 含 "token=" query 参数(如
/// `?token=abc`)或 config_json 含 `"password":` 都会被拦截。空/None 文本直接放行。
fn reject_sensitive(text: Option<&str>, field_name: &str) -> Result<()> {
let Some(t) = text else { return Ok(()) };
if t.is_empty() {
return Ok(());
}
let lower = t.to_ascii_lowercase();
for tok in SENSITIVE_TOKENS {
if lower.contains(tok) {
return Err(df_types::error::Error::Storage(format!(
"project_services.{field_name} 含敏感凭证字段「{tok}」,违反 D10 安全边界:\
不存敏感凭证,密码/密钥走环境变量。请改用环境变量引用"
)));
}
}
Ok(())
}
/// 对一条 ProjectServiceRecord 做应用层前置校验(service_type 白名单 + 凭证审查)。
/// insert / update_full 共用,保证两条写入路径校验一致。
fn validate_record(rec: &ProjectServiceRecord) -> Result<()> {
validate_service_type(&rec.service_type)?;
reject_sensitive(rec.config_json.as_deref(), "config_json")?;
reject_sensitive(rec.endpoint.as_deref(), "endpoint")?;
reject_sensitive(rec.remark.as_deref(), "remark")?;
Ok(())
}
// ============================================================
// from_row 辅助函数
// ============================================================
fn project_service_from_row(row: &Row<'_>) -> std::result::Result<ProjectServiceRecord, rusqlite::Error> {
Ok(ProjectServiceRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
name: row.get("name")?,
service_type: row.get("service_type")?,
endpoint: row.get("endpoint")?,
config_json: row.get("config_json")?,
environment: row.get("environment")?,
remark: row.get("remark")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
// ============================================================
// Repo 实现
// ============================================================
impl_repo!(
/// 项目基础设施配置表 CRUD(project_services,V31,可改非审计)。
///
/// 走 `impl_repo!` 宏:表有 `updated_at`,标准 CRUD 全可用,白名单在 settings.rs 登记。
/// 注:宏生成的 `update_field` 经 settings 白名单(不含 service_type)收口——service_type
/// 变更须走 `update_full`(过应用层校验 + 凭证审查),不可旁路 update_field 改类型。
ProjectServiceRepo,
ProjectServiceRecord,
"project_services",
from_row => |row| project_service_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
rec.id, rec.project_id, rec.name, rec.service_type,
rec.endpoint, rec.config_json, rec.environment, rec.remark,
rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
params![
rec.project_id, rec.name, rec.service_type,
rec.endpoint, rec.config_json, rec.environment, rec.remark,
rec.updated_at, rec.id
],
)
}
);
impl ProjectServiceRepo {
/// 插入一条基础设施配置(应用层校验 service_type 白名单 + 凭证审查 D10 后再落库)。
///
/// 校验 fail-fast 在 spawn_blocking 前(非法值不进 DB 层),与 TaskLinkRepo::create_link
/// 同款。`created_at` / `updated_at` 由本方法内部取当前毫秒时间戳覆盖(调用方传入的不可信),
/// 保证「写入时机即落库时机」(对标 ProjectEventRepo::insert)。
pub async fn insert_validated(&self, record: ProjectServiceRecord) -> Result<String> {
// 应用层校验先行(fail-fast,非法值/凭证不进 DB 层)
validate_record(&record)?;
let conn = self.conn.clone();
let now = now_millis_str();
let mut rec = record;
rec.created_at = now.clone();
rec.updated_at = now;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let id = rec.id.clone();
let conn = &*guard;
let r = &rec;
conn.execute(
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
r.id, r.project_id, r.name, r.service_type,
r.endpoint, r.config_json, r.environment, r.remark,
r.created_at, r.updated_at
],
)
.map_err(storage_err)?;
Ok(id)
})
.await
.map_err(storage_err)?
}
/// 整体更新(应用层校验 service_type + 凭证审查 D10 后再落库,保留 id 与 created_at)。
///
/// 对标宏生成的 `update_full`,但在落库前过 `validate_record`(宏无业务前置逻辑)。
/// `updated_at` 由本方法内部取当前毫秒时间戳覆盖。
pub async fn update_full_validated(&self, record: &ProjectServiceRecord) -> Result<bool> {
validate_record(record)?;
let conn = self.conn.clone();
let mut rec = record.clone();
rec.updated_at = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let conn = &*guard;
let r = &rec;
let affected = conn.execute(
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
params![
r.project_id, r.name, r.service_type,
r.endpoint, r.config_json, r.environment, r.remark,
r.updated_at, r.id
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按项目查询全部基础设施配置(命中 idx_project_services_project,created_at DESC)。
///
/// AI 项目上下文注入 / Dashboard 项目基础设施视图用:取某项目的全部服务配置。
pub async fn list_by_project(
&self,
project_id: &str,
) -> Result<Vec<ProjectServiceRecord>> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
FROM project_services WHERE project_id = ?1 ORDER BY created_at DESC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![project_id], project_service_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 按项目 + 环境查询基础设施配置(命中 idx_project_services_project,environment 等值过滤)。
///
/// AI 执行任务时按当前环境取基础设施:production 部署任务只取 production 服务,
/// 开发调试取 development 服务。环境隔离避免误连生产库。
pub async fn list_by_project_env(
&self,
project_id: &str,
environment: &str,
) -> Result<Vec<ProjectServiceRecord>> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
let environment = environment.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
FROM project_services WHERE project_id = ?1 AND environment = ?2 ORDER BY created_at DESC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![project_id, environment], project_service_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — ProjectServiceRepo CRUD + 校验 + 环境过滤(内存 DB,
// 对标 project_event_repo 测试:先建占位 project 满足 FK,再插服务)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
let db = crate::db::Database::open_in_memory()
.await
.expect("open_in_memory");
let repo = ProjectServiceRepo::new(&db);
let project_repo = ProjectRepo::new(&db);
project_repo
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
(db, repo)
}
/// 构造一条 ProjectServiceRecord fixture(全字段,环境可指定)。
fn srec(id: &str, service_type: &str, environment: &str) -> ProjectServiceRecord {
ProjectServiceRecord {
id: id.to_string(),
project_id: "proj-1".to_string(),
name: format!("svc-{id}"),
service_type: service_type.to_string(),
endpoint: Some("localhost:3306".to_string()),
config_json: Some(r#"{"pool_size":10}"#.to_string()),
environment: environment.to_string(),
remark: Some("主库".to_string()),
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
#[tokio::test]
async fn validate_service_type_rejects_unknown() {
// 白名单内全部通过
assert!(validate_service_type("mysql").is_ok());
assert!(validate_service_type("postgresql").is_ok());
assert!(validate_service_type("sqlite").is_ok());
assert!(validate_service_type("redis").is_ok());
assert!(validate_service_type("mongodb").is_ok());
assert!(validate_service_type("mq").is_ok());
assert!(validate_service_type("api").is_ok());
assert!(validate_service_type("other").is_ok());
// 非法值拒绝
assert!(validate_service_type("elasticsearch").is_err());
assert!(validate_service_type("").is_err());
}
#[tokio::test]
async fn insert_validated_and_read_back_full_fields() {
let (_db, repo) = setup().await;
let id = repo
.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
assert_eq!(id, "s1");
let got = repo.get_by_id("s1").await.unwrap().expect("应存在");
// 全字段回读校验
assert_eq!(got.id, "s1");
assert_eq!(got.project_id, "proj-1");
assert_eq!(got.name, "svc-s1");
assert_eq!(got.service_type, "mysql");
assert_eq!(got.endpoint.as_deref(), Some("localhost:3306"));
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
assert_eq!(got.environment, "development");
assert_eq!(got.remark.as_deref(), Some("主库"));
// created_at / updated_at 由存储层覆盖为当前毫秒
assert!(!got.created_at.is_empty());
assert!(!got.updated_at.is_empty());
}
#[tokio::test]
async fn insert_rejects_invalid_service_type() {
let (_db, repo) = setup().await;
let err = repo
.insert_validated(srec("s1", "elasticsearch", "development"))
.await;
assert!(err.is_err(), "非法 service_type 应被拒绝");
// 未入库
assert!(repo.get_by_id("s1").await.unwrap().is_none());
}
#[tokio::test]
async fn insert_rejects_sensitive_in_config_json() {
// D10 边界:config_json 含 password 应被拒绝
let (_db, repo) = setup().await;
let mut rec = srec("s1", "mysql", "development");
rec.config_json = Some(r#"{"password":"secret123"}"#.to_string());
let err = repo.insert_validated(rec).await;
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
assert!(repo.get_by_id("s1").await.unwrap().is_none());
}
#[tokio::test]
async fn insert_rejects_sensitive_in_endpoint() {
// endpoint query 参数带 token= 应被拒绝
let (_db, repo) = setup().await;
let mut rec = srec("s1", "api", "development");
rec.endpoint = Some("https://api.example.com?token=abc123".to_string());
let err = repo.insert_validated(rec).await;
assert!(err.is_err(), "endpoint 含 token 应被 D10 拒绝");
}
#[tokio::test]
async fn insert_rejects_sensitive_in_remark() {
let (_db, repo) = setup().await;
let mut rec = srec("s1", "redis", "development");
rec.remark = Some("API_KEY=sk-xxxx".to_string());
let err = repo.insert_validated(rec).await;
assert!(err.is_err(), "remark 含 api_key 应被 D10 拒绝");
}
#[tokio::test]
async fn update_full_validated_rejects_sensitive() {
// 先插一条合法的,update 时灌入凭证应被拒
let (_db, repo) = setup().await;
repo.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
let mut bad = srec("s1", "mysql", "development");
bad.config_json = Some(r#"{"password":"new"}"#.to_string());
let err = repo.update_full_validated(&bad).await;
assert!(err.is_err(), "update 灌凭证应被 D10 拒绝");
// 原记录未变(config_json 仍是 pool_size)
let got = repo.get_by_id("s1").await.unwrap().unwrap();
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
}
#[tokio::test]
async fn update_full_validated_changes_fields() {
let (_db, repo) = setup().await;
repo.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
let mut updated = srec("s1", "redis", "production");
updated.name = "缓存".to_string();
let hit = repo.update_full_validated(&updated).await.unwrap();
assert!(hit, "应命中更新");
let got = repo.get_by_id("s1").await.unwrap().unwrap();
assert_eq!(got.name, "缓存");
assert_eq!(got.service_type, "redis");
assert_eq!(got.environment, "production");
}
#[tokio::test]
async fn list_by_project_returns_all() {
let (_db, repo) = setup().await;
repo.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
repo.insert_validated(srec("s2", "redis", "development"))
.await
.unwrap();
repo.insert_validated(srec("s3", "mq", "production"))
.await
.unwrap();
// 按项目查返回全部 3 条(含所有环境)
let got = repo.list_by_project("proj-1").await.unwrap();
assert_eq!(got.len(), 3);
// 不存在的项目返回空
let empty = repo.list_by_project("no-such").await.unwrap();
assert!(empty.is_empty());
}
#[tokio::test]
async fn list_by_project_env_filters_environment() {
let (_db, repo) = setup().await;
repo.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
repo.insert_validated(srec("s2", "redis", "development"))
.await
.unwrap();
repo.insert_validated(srec("s3", "mysql", "production"))
.await
.unwrap();
// 只取 development:2 条(s1/s2)
let dev = repo
.list_by_project_env("proj-1", "development")
.await
.unwrap();
assert_eq!(dev.len(), 2);
for s in &dev {
assert_eq!(s.environment, "development");
}
// 只取 production:1 条(s3)
let prod = repo
.list_by_project_env("proj-1", "production")
.await
.unwrap();
assert_eq!(prod.len(), 1);
assert_eq!(prod[0].id, "s3");
// 不存在的环境返回空
let staging = repo
.list_by_project_env("proj-1", "staging")
.await
.unwrap();
assert!(staging.is_empty());
}
#[tokio::test]
async fn delete_removes_record() {
let (_db, repo) = setup().await;
repo.insert_validated(srec("s1", "mysql", "development"))
.await
.unwrap();
assert!(repo.delete("s1").await.unwrap());
assert!(repo.get_by_id("s1").await.unwrap().is_none());
// 再删返回 false
assert!(!repo.delete("s1").await.unwrap());
}
/// None 字段(config_json/endpoint/remark)也能正常插入与查询。
#[tokio::test]
async fn insert_with_null_optional_fields() {
let (_db, repo) = setup().await;
let rec = ProjectServiceRecord {
id: "s1".to_string(),
project_id: "proj-1".to_string(),
name: "bare-svc".to_string(),
service_type: "api".to_string(),
endpoint: None,
config_json: None,
environment: "development".to_string(),
remark: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
};
repo.insert_validated(rec).await.unwrap();
let got = repo.get_by_id("s1").await.unwrap().unwrap();
assert!(got.endpoint.is_none());
assert!(got.config_json.is_none());
assert!(got.remark.is_none());
}
}
+20 -2
View File
@@ -119,8 +119,10 @@ impl SettingsRepo {
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
Some(match table { Some(match table {
"ideas" => &[ "ideas" => &[
"id", "title", "description", "status", "priority", "score", "tags", "source", // IDEA-FIX-02: id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
"promoted_to", "ai_analysis", "scores", "related_ids", "created_at", "updated_at", // (对标 tasks 白名单 B-260616-16 同款防护,防篡改主键/伪造创建时间)
"title", "description", "status", "priority", "score", "tags", "source",
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
], ],
"projects" => &[ "projects" => &[
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at", "id", "name", "description", "status", "idea_id", "path", "stack", "created_at",
@@ -150,6 +152,13 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感, // idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。 // 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
"idea_id", "idea_id",
// 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。
// - queue:管理维度池,走 move_task_queue IPC(设计 §五),IPC 层校验白名单 + 一致性约束。
// - parent_id:父子纵向关联,1 级嵌套铁律由 IPC 层(create_task)校验不进白名单。
// - content_json:结构化需求规格,走未来 update_content IPC(设计 §五)。
// 注:status 仍不列入(D-260616-04 收口不变),父聚合/move_task_queue 联动 status
// 走专用方法 set_status_for_aggregation,不经通用 update_field。
"queue", "parent_id", "content_json",
"updated_at", "updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。 // TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。 // 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
@@ -190,6 +199,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"knowledge_events" => &[ "knowledge_events" => &[
"id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp", "id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp",
], ],
// 知识图谱 Phase 3 V31(对标设计 §2.3):project_services 表有 updated_at,走 impl_repo! 宏
// 标准 CRUD(insert/get_by_id/list_all/query/update_field/update_full/delete),故登记白名单。
// id/created_at 不列入:主键与创建时间不可经通用 update_field 改写(对标 tasks/projects 防护)。
// service_type 不列入白名单(类比 status 收口思路):service_type 是类型枚举,变更应走整行
// update_full(过 service_type 应用层校验 + 凭证审查),而非 update_field 旁路绕过校验。
"project_services" => &[
"project_id", "name", "endpoint", "config_json", "environment",
"remark", "updated_at",
],
_ => return None, _ => return None,
}) })
} }
@@ -0,0 +1,580 @@
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
//!
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
//!
//! 关键设计:
//! - `link_type` 白名单(depends_on/blocks/relates_to)应用层校验,非 DB 约束。
//! - 循环依赖(`depends_on` 链 A→B→A)在 `create_link` BFS 检测拒绝(非 DB 约束,对标 D8)。
//! - 跨项目依赖允许(现实中有跨项目依赖)。
//! - 软删除语义:Task 软删不级联删 link(恢复后关系还在)。
use std::collections::HashSet;
use rusqlite::{params, OptionalExtension, Row};
use df_types::error::Result;
use crate::db::Database;
use crate::models::TaskLinkRecord;
use super::{now_millis_str, storage_err};
// ============================================================
// link_type 白名单 + 校验
// ============================================================
/// `link_type` 白名单:只允许这三种关联类型(防拼写漂移 / 非法值进库)。
///
/// 对标设计 §2.2 link_type 语义:
/// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
pub const TASK_LINK_TYPES: &[&str] = &["depends_on", "blocks", "relates_to"];
/// 校验 `link_type` 在白名单内,否则返回 Err。
fn validate_link_type(link_type: &str) -> Result<()> {
if TASK_LINK_TYPES.contains(&link_type) {
Ok(())
} else {
Err(df_types::error::Error::Storage(format!(
"非法 link_type: {link_type},合法值: {:?}",
TASK_LINK_TYPES
)))
}
}
// ============================================================
// from_row 辅助函数
// ============================================================
fn task_link_from_row(row: &Row<'_>) -> std::result::Result<TaskLinkRecord, rusqlite::Error> {
Ok(TaskLinkRecord {
id: row.get("id")?,
source_id: row.get("source_id")?,
target_id: row.get("target_id")?,
link_type: row.get("link_type")?,
remark: row.get("remark")?,
created_at: row.get("created_at")?,
})
}
// ============================================================
// TaskLinkRepo
// ============================================================
/// 任务横向关联表 Repo(task_links,V29)。
///
/// 不走 `impl_repo!` 宏:① 表无 `updated_at`/`deleted_at`(link 不可改只增删,审计简单);
/// ② `create_link` 需在 INSERT 前做 BFS 循环依赖检测(宏生成的 insert 无业务前置逻辑)。
/// 故手写专用方法,对标 SettingsRepo 全专用路径。
pub struct TaskLinkRepo {
conn: std::sync::Arc<tokio::sync::Mutex<rusqlite::Connection>>,
}
impl TaskLinkRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 创建任务关联(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。
///
/// 对标设计 §2.2 边界约束:
/// - `link_type` 必须在白名单(depends_on/blocks/relates_to),否则 Err。
/// - 自环(source_id == target_id)直接拒绝(无意义的自依赖)。
/// - 循环依赖检测:**仅对 depends_on 链**做 BFS。blocks 是 depends_on 的反向声明,
/// relates_to 无执行约束,二者不参与环检测(否则弱关联也会触发拒绝,过度约束)。
/// BFS 思路:新增 source→target 后,从 target 出发沿 depends_on 链向下遍历,
/// 若能回到 source 则成环,拒绝。数据量小(单项目 ~50 任务),全表遍历无压力(对标 D8)。
/// - 跨项目允许(不做 project_id 一致性校验,现实有跨项目依赖)。
///
/// 返回插入的 link id。
pub async fn create_link(
&self,
id: &str,
source_id: &str,
target_id: &str,
link_type: &str,
remark: Option<&str>,
) -> Result<String> {
// 应用层校验先行(fail-fast,非法值不进 DB 层)
validate_link_type(link_type)?;
if source_id == target_id {
return Err(df_types::error::Error::Storage(format!(
"非法 task_link: source_id 与 target_id 相同({source_id}),自环无意义"
)));
}
let conn = self.conn.clone();
let id = id.to_owned();
let source_id_o = source_id.to_owned();
let target_id_o = target_id.to_owned();
let link_type_o = link_type.to_owned();
let remark_o = remark.map(|s| s.to_owned());
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// depends_on 链 BFS 环检测:新增 source→target 边后,从 target 沿 depends_on
// 向下走,若能回到 source 即成环。在 INSERT 前检测,避免脏数据(检测与插入非原子,
// 但单用户桌面应用无并发,够用)。
if link_type_o == "depends_on" {
if bfs_reaches(&guard, &target_id_o, &source_id_o)? {
return Err(df_types::error::Error::Storage(format!(
"非法 task_link: {} depends_on {} 会形成循环依赖",
source_id_o, target_id_o
)));
}
}
guard
.execute(
"INSERT INTO task_links (id, source_id, target_id, link_type, remark, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![id, source_id_o, target_id_o, link_type_o, remark_o, now],
)
.map_err(storage_err)?;
Ok(id)
})
.await
.map_err(storage_err)?
}
/// 删除关联(按 id),返回是否命中。
pub async fn delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute("DELETE FROM task_links WHERE id = ?1", params![id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 按 id 取单条(对标 IdeaRepo::get_by_id 模式)。
pub async fn get_by_id(&self, id: &str) -> Result<Option<TaskLinkRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let row = guard
.query_row(
"SELECT id, source_id, target_id, link_type, remark, created_at \
FROM task_links WHERE id = ?1",
params![id],
task_link_from_row,
)
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 按 source_id 查询(source 主动声明的全部关联),命中 idx_task_links_source。
/// AI 编排调度用:取某任务的全部依赖 / 阻塞 / 关联。
pub async fn get_by_source(&self, source_id: &str) -> Result<Vec<TaskLinkRecord>> {
let conn = self.conn.clone();
let source_id = source_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, source_id, target_id, link_type, remark, created_at \
FROM task_links WHERE source_id = ?1 ORDER BY created_at ASC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![source_id], task_link_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 按 target_id 查询(谁指向了 target),命中 idx_task_links_target。
/// AI 编排调度用:「谁依赖了我」「谁被我阻塞」反向查询(JSON 列方案无法高效反查的痛点)。
pub async fn get_by_target(&self, target_id: &str) -> Result<Vec<TaskLinkRecord>> {
let conn = self.conn.clone();
let target_id = target_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, source_id, target_id, link_type, remark, created_at \
FROM task_links WHERE target_id = ?1 ORDER BY created_at ASC",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![target_id], task_link_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 列出全部关联(调试/管理用,按创建时间升序)。
pub async fn list_all(&self) -> Result<Vec<TaskLinkRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id, source_id, target_id, link_type, remark, created_at \
FROM task_links ORDER BY created_at ASC",
)
.map_err(storage_err)?;
let rows = stmt.query_map([], task_link_from_row).map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// BFS 循环依赖检测(仅 depends_on 链)
// ============================================================
/// BFS:从 `start` 出发沿 depends_on 链(source→target 方向)向下遍历,判断能否到达 `target_node`。
///
/// 用于 `create_link(source, depends_on, target)` 前的环检测:新增 source→target 边后,
/// 若 target 能经 depends_on 链回到 source,则形成 source→target→...→source 闭环。
/// 故调用 `bfs_reaches(conn, start=target, target_node=source)`:从 target 出发看能否到 source。
///
/// 实现细节:
/// - 遍历 `WHERE source_id = ? AND link_type = 'depends_on'` 取下一跳(对标设计 D8:数据量小,
/// 全表遍历无压力,不走 SQL 递归 CTE,纯 Rust BFS)。
/// - `visited` HashSet 防重复访问(菱形依赖 A→B,A→C,B→D,C→D 不应死循环)。
/// - 防御性深度上限(10000 跳):万一数据异常成环未被拦截,BFS 不致无限循环。
fn bfs_reaches(
conn: &rusqlite::Connection,
start: &str,
target_node: &str,
) -> Result<bool> {
use std::collections::VecDeque;
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(start.to_owned());
visited.insert(start.to_owned());
let mut depth = 0u32;
const MAX_DEPTH: u32 = 10_000;
while let Some(node) = queue.pop_front() {
depth += 1;
if depth > MAX_DEPTH {
// 防御性兜底:正常依赖链不可能这么深,到这要么数据异常要么已有环未被拦截。
// 保守视为成环(拒绝创建),避免无限循环 + 暴露异常数据。
tracing::warn!(
"task_links BFS 超过 {MAX_DEPTH} 跳,疑似数据异常,保守拒绝创建"
);
return Ok(true);
}
if node == target_node {
return Ok(true);
}
// 取该节点作为 source 的全部 depends_on 边的 target(下一跳)
let mut stmt = conn
.prepare(
"SELECT target_id FROM task_links \
WHERE source_id = ?1 AND link_type = 'depends_on'",
)
.map_err(storage_err)?;
let nexts: Vec<String> = stmt
.query_map(params![node], |row| row.get::<_, String>(0))
.map_err(storage_err)?
.filter_map(|r| r.ok())
.collect();
for next in nexts {
if visited.insert(next.clone()) {
queue.push_back(next);
}
}
}
Ok(false)
}
// ============================================================
// 单元测试 — TaskLinkRepo CRUD + 循环依赖拒绝(内存 DB,对标 idea_repo 测试)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::TaskRepo;
use crate::models::TaskRecord;
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: project_id.to_string(),
title: format!("task-{id}"),
description: String::new(),
status: "todo".to_string(),
priority: 1,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
/// 构造内存 DB + 注入若干任务(task_links FK 要求 tasks 存在,
/// tasks.project_id FK 要求 projects 存在 → 先建占位 project 满足 FK 约束,
/// 对标 tests/project_soft_delete.rs 集成测试的 setup 模式)。
async fn setup_with_tasks(ids: &[&str]) -> (crate::db::Database, TaskLinkRepo, TaskRepo) {
let db = crate::db::Database::open_in_memory()
.await
.expect("open_in_memory");
let link_repo = TaskLinkRepo::new(&db);
let task_repo = TaskRepo::new(&db);
// 先建占位 project 满足 tasks.project_id FK(PRAGMA foreign_keys=ON,db.rs:37/37)
let project_repo = crate::crud::ProjectRepo::new(&db);
project_repo
.insert(crate::models::ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
for id in ids {
task_repo.insert(trec(id, "proj-1")).await.unwrap();
}
(db, link_repo, task_repo)
}
#[tokio::test]
async fn validate_link_type_rejects_unknown() {
assert!(validate_link_type("depends_on").is_ok());
assert!(validate_link_type("blocks").is_ok());
assert!(validate_link_type("relates_to").is_ok());
assert!(validate_link_type("unknown").is_err());
assert!(validate_link_type("").is_err());
}
#[tokio::test]
async fn create_link_basic_depends_on() {
let (_db, repo, _task_repo) = setup_with_tasks(&["a", "b"]).await;
let id = repo
.create_link("l1", "a", "b", "depends_on", None)
.await
.unwrap();
assert_eq!(id, "l1");
let got = repo.get_by_id("l1").await.unwrap().expect("link 存在");
assert_eq!(got.source_id, "a");
assert_eq!(got.target_id, "b");
assert_eq!(got.link_type, "depends_on");
assert!(got.remark.is_none());
}
#[tokio::test]
async fn create_link_with_remark() {
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
repo.create_link("l1", "a", "b", "blocks", Some("阻塞说明"))
.await
.unwrap();
let got = repo.get_by_id("l1").await.unwrap().unwrap();
assert_eq!(got.link_type, "blocks");
assert_eq!(got.remark.as_deref(), Some("阻塞说明"));
}
#[tokio::test]
async fn create_link_rejects_invalid_type() {
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
let err = repo
.create_link("l1", "a", "b", "invalid_type", None)
.await;
assert!(err.is_err(), "非法 link_type 应被拒绝");
// 未入库
assert!(repo.get_by_id("l1").await.unwrap().is_none());
}
#[tokio::test]
async fn create_link_rejects_self_loop() {
let (_db, repo, _) = setup_with_tasks(&["a"]).await;
let err = repo.create_link("l1", "a", "a", "depends_on", None).await;
assert!(err.is_err(), "自环 source==target 应被拒绝");
}
#[tokio::test]
async fn get_by_source_and_target() {
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await;
repo.create_link("l1", "a", "b", "depends_on", None)
.await
.unwrap();
repo.create_link("l2", "a", "c", "relates_to", None)
.await
.unwrap();
repo.create_link("l3", "c", "b", "blocks", None)
.await
.unwrap();
// a 作为 source 声明了 2 条(l1/l2)
let from_a = repo.get_by_source("a").await.unwrap();
let ids: Vec<_> = from_a.iter().map(|l| l.id.as_str()).collect();
assert_eq!(ids, vec!["l1", "l2"]);
// b 作为 target 被 2 条指向(l1/l3)
let to_b = repo.get_by_target("b").await.unwrap();
let ids: Vec<_> = to_b.iter().map(|l| l.id.as_str()).collect();
assert_eq!(ids, vec!["l1", "l3"]);
}
#[tokio::test]
async fn delete_link() {
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
repo.create_link("l1", "a", "b", "depends_on", None)
.await
.unwrap();
assert!(repo.delete("l1").await.unwrap());
assert!(repo.get_by_id("l1").await.unwrap().is_none());
// 再删返回 false
assert!(!repo.delete("l1").await.unwrap());
}
// ---------- 循环依赖 BFS 检测(仅 depends_on 链)----------
#[tokio::test]
async fn cycle_direct_a_depends_b_then_b_depends_a_rejected() {
// A→B 合法;再 B→A 应形成 A→B→A 闭环,拒绝
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
repo.create_link("l1", "a", "b", "depends_on", None)
.await
.unwrap();
let err = repo
.create_link("l2", "b", "a", "depends_on", None)
.await;
assert!(err.is_err(), "A→B→A 循环依赖应被拒绝");
// l2 未入库
assert!(repo.get_by_id("l2").await.unwrap().is_none());
}
#[tokio::test]
async fn cycle_three_nodes_rejected() {
// A→B→C 合法;再 C→A 形成 A→B→C→A 闭环,拒绝
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await;
repo.create_link("l1", "a", "b", "depends_on", None)
.await
.unwrap();
repo.create_link("l2", "b", "c", "depends_on", None)
.await
.unwrap();
let err = repo
.create_link("l3", "c", "a", "depends_on", None)
.await;
assert!(err.is_err(), "A→B→C→A 三节点循环依赖应被拒绝");
}
#[tokio::test]
async fn diamond_dependency_not_cycle() {
// 菱形依赖 A→B, A→C, B→D, C→D 是 DAG 非环,D 不应再指向 A/B/C
// 关键:BFS 遇菱形不误判(D 被两条路径到达,visited 去重不死循环)
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c", "d"]).await;
repo.create_link("l1", "a", "b", "depends_on", None)
.await
.unwrap();
repo.create_link("l2", "a", "c", "depends_on", None)
.await
.unwrap();
repo.create_link("l3", "b", "d", "depends_on", None)
.await
.unwrap();
repo.create_link("l4", "c", "d", "depends_on", None)
.await
.unwrap();
// 全部成功(菱形合法)
assert_eq!(repo.list_all().await.unwrap().len(), 4);
}
#[tokio::test]
async fn blocks_link_does_not_trigger_cycle_check() {
// blocks 是 depends_on 的反向声明,不参与环检测:A blocks B + B blocks A 应都合法
// (虽然语义重复,但环检测只管 depends_on 链,blocks/relates_to 弱约束不强拦)
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
repo.create_link("l1", "a", "b", "blocks", None)
.await
.unwrap();
// B blocks A 不触发 depends_on 环检测,合法
repo.create_link("l2", "b", "a", "blocks", None)
.await
.unwrap();
assert_eq!(repo.list_all().await.unwrap().len(), 2);
}
#[tokio::test]
async fn relates_to_link_does_not_trigger_cycle_check() {
// relates_to 弱关联无执行约束,不参与环检测
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
repo.create_link("l1", "a", "b", "relates_to", None)
.await
.unwrap();
repo.create_link("l2", "b", "a", "relates_to", None)
.await
.unwrap();
assert_eq!(repo.list_all().await.unwrap().len(), 2);
}
#[tokio::test]
async fn cross_project_link_allowed() {
// 跨项目依赖允许(设计 §2.2 边界):不做 project_id 一致性校验
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; // fixture 同 proj-1,但 repo 不校验
let res = repo.create_link("l1", "a", "b", "depends_on", None).await;
assert!(res.is_ok(), "跨项目依赖应允许(repo 层不校验 project 一致性)");
}
#[tokio::test]
async fn cycle_check_mixed_chain_only_depends_on_matters() {
// 混合链:A relates_to B(弱关联),B depends_on A 应合法(relates_to 不构成环路径)
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
repo.create_link("l1", "a", "b", "relates_to", None)
.await
.unwrap();
// B depends_on A:BFS 从 A 出发沿 depends_on 找 B,A 无 depends_on 出边 → 不到 B → 合法
repo.create_link("l2", "b", "a", "depends_on", None)
.await
.unwrap();
assert_eq!(repo.list_all().await.unwrap().len(), 2);
}
}
+408 -18
View File
@@ -33,6 +33,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
review_rounds: row.get("review_rounds")?, review_rounds: row.get("review_rounds")?,
output_json: row.get("output_json")?, output_json: row.get("output_json")?,
idea_id: row.get("idea_id")?, idea_id: row.get("idea_id")?,
// 知识图谱 Phase 1 V29 三列(queue/parent_id/content_json),15→18 列同步之一。
queue: row.get("queue")?,
parent_id: row.get("parent_id")?,
content_json: row.get("content_json")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -54,6 +58,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
/// ///
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/ /// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。 /// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1)新增 queue / parent_id 两个筛选维度:
/// - `queue`:管理池筛选(backlog/todo/decision/active/done),看板视图按池分列的数据源;
/// - `parent_id`:父任务筛选(Some(id)=某父的子任务;特殊语义 None 仅叶子 vs 全部 由调用方拼)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskQuery { pub struct TaskQuery {
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id) /// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
@@ -66,6 +74,14 @@ pub struct TaskQuery {
pub assignee: Option<String>, pub assignee: Option<String>,
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。 /// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
pub keyword: Option<String>, pub keyword: Option<String>,
/// 管理池过滤(知识图谱 Phase 1 V29):backlog/todo/decision/active/done。
/// 看板视图按池分列的数据源。值合法性由上层 queue 语义校验兜底(非法值 DB 无匹配返回空)。
#[serde(default)]
pub queue: Option<String>,
/// 父任务 ID 过滤(知识图谱 Phase 1 V29):Some(id) = 查某父任务的子任务;
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
#[serde(default)]
pub parent_id: Option<String>,
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。 /// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
pub order_by: Option<String>, pub order_by: Option<String>,
/// 分页上限(钳制 ≤500)。P3 基建就绪。 /// 分页上限(钳制 ≤500)。P3 基建就绪。
@@ -103,22 +119,26 @@ impl_repo!(
from_row => |row| task_from_row(row), from_row => |row| task_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.created_at, rec.updated_at rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, updated_at = ?13 WHERE id = ?14", "UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.updated_at, rec.id rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
rec.updated_at, rec.id
], ],
) )
} }
@@ -127,14 +147,14 @@ impl_repo!(
impl TaskRepo { impl TaskRepo {
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active /// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
/// ///
/// 显式列出全部 15 个 TaskRecord 列名(同 ProjectRepo::list_active 写法), /// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。 /// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> { pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -235,7 +255,7 @@ impl TaskRepo {
} }
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。 // 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE id = ?1") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(storage_err)?; .map_err(storage_err)?;
let row = stmt let row = stmt
.query_row(params![id], |row| task_from_row(row)) .query_row(params![id], |row| task_from_row(row))
@@ -257,7 +277,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pid], |row| task_from_row(row)) .query_map(params![pid], |row| task_from_row(row))
@@ -284,7 +304,7 @@ impl TaskRepo {
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致) /// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路) /// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭) /// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
/// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列) /// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
/// ///
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。 /// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集 /// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
@@ -311,35 +331,51 @@ impl TaskRepo {
let priority = query.priority; let priority = query.priority;
let assignee = query.assignee.clone(); 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();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)── // ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = params_vec.len()+1)──
// 占位符序号必须按「实际参数位置」(params_vec.len()+1)而非「子句数」(where_clauses.len()+1)
// 编号:deleted_at IS NULL 是常量条件无占位符却占 where_clauses[0],用子句数编号会让首个
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。 // deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()]; let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(pid) = &project_id { if let Some(pid) = &project_id {
where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone())); params_vec.push(Box::new(pid.clone()));
} }
if let Some(s) = &status { if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(p) = priority { if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p)); params_vec.push(Box::new(p));
} }
if let Some(a) = &assignee { if let Some(a) = &assignee {
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone())); params_vec.push(Box::new(a.clone()));
} }
// queue(知识图谱 Phase 1 V29):管理池等值过滤,看板视图按池分列数据源
if let Some(q) = &queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone()));
}
// parent_id(知识图谱 Phase 1 V29):父任务等值过滤(查某父的子任务)
if let Some(pid) = &parent_id {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let pat = format!("%{kw}%");
let p1 = where_clauses.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
@@ -358,10 +394,13 @@ impl TaskRepo {
}; };
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。 // 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
// 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at:
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
let sql = format!( let sql = format!(
"SELECT id, project_id, title, description, status, priority, branch_name, \ "SELECT id, project_id, title, description, status, priority, branch_name, \
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \ assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
created_at, updated_at FROM tasks WHERE {} ORDER BY {} DESC{}", queue, parent_id, content_json, created_at, updated_at \
FROM tasks WHERE {} ORDER BY {} DESC{}",
where_clauses.join(" AND "), where_clauses.join(" AND "),
order_col, order_col,
limit_sql_bound limit_sql_bound
@@ -393,6 +432,106 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
/// 不走状态机,由子任务聚合计算。本方法取子任务列表供聚合规则消费。
///
/// 注:嵌套深度限制 1 级(无孙任务)由 IPC 层校验不进 DB 约束,本方法只做等值查询。
pub async fn get_children(&self, parent_id: &str) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
let pid = parent_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 父任务聚合:按 status 分组统计子任务计数(对标设计 §2.1 聚合规则)。
///
/// 父任务 status 由子任务聚合计算(不走状态机),聚合规则:
/// - 全 todo → 父 todo
/// - 任一 in_progress → 父 in_progress
/// - 任一 blocked → 父 blocked
/// - 全 done/cancelled → 父 done
///
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
pub async fn count_children_by_status(
&self,
parent_id: &str,
) -> Result<Vec<(String, i64)>> {
let conn = self.conn.clone();
let pid = parent_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT status, COUNT(*) AS cnt FROM tasks \
WHERE deleted_at IS NULL AND parent_id = ?1 \
GROUP BY status",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 父任务聚合专用 status 写入(知识图谱 Phase 1 V29,对标设计 §2.1 D3 父任务=容器模型)。
///
/// **这是父任务 status 的唯一写入路径**,绕过 D-260616-04 status 收口(通用 update_field
/// 白名单不含 status,所有叶子任务 status 走 advance_status_atomic 状态机)。父任务 status
/// **不走状态机**(容器模型,由子任务聚合计算),故需专用写入路径。
///
/// 防护:
/// - 方法名 `set_status_for_aggregation` 显式表明语义,非通用 setter,防误用。
/// - 调用方(commands::task::recompute_parent_status)负责聚合规则计算,本方法只落库。
/// - 不动 review_rounds(父任务不执行工作流,无 review 退回语义)。
///
/// 返回是否命中(父任务不存在/已删 → false)。
pub async fn set_status_for_aggregation(&self, id: &str, new_status: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let new_status = new_status.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET status = ?1, updated_at = ?2 \
WHERE id = ?3 AND deleted_at IS NULL",
params![new_status, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。 /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
/// ///
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id), /// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
@@ -402,7 +541,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -417,3 +556,254 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
} }
// ============================================================
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
trec_full(id, queue, parent_id, "todo")
}
/// 全参 fixture(聚合测试需自定义 status 时用)。
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "proj-1".to_string(),
title: format!("task-{id}"),
description: String::new(),
status: status.to_string(),
priority: 1,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
queue: queue.to_string(),
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
/// 构造内存 DB + 占位 project(满足 tasks.project_id FK,PRAGMA foreign_keys=ON)。
async fn setup() -> TaskRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_repo = ProjectRepo::new(&db);
project_repo
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
TaskRepo::new(&db)
}
#[tokio::test]
async fn list_by_query_queue_filter() {
let repo = setup().await;
repo.insert(trec("t1", "backlog", None)).await.unwrap();
repo.insert(trec("t2", "todo", None)).await.unwrap();
repo.insert(trec("t3", "todo", None)).await.unwrap();
repo.insert(trec("t4", "done", None)).await.unwrap();
// queue=todo → 只返回 t2/t3
let q = TaskQuery {
queue: Some("todo".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"t2"));
assert!(ids.contains(&"t3"));
// queue=backlog → 只返回 t1
let q = TaskQuery {
queue: Some("backlog".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["t1"]);
}
#[tokio::test]
async fn list_by_query_parent_id_filter() {
let repo = setup().await;
// parent 父任务(叶子,parent_id=None)+ 3 子任务(parent_id=parent)
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("orphan", "todo", None)).await.unwrap();
// parent_id=parent → 只返回 c1/c2(父任务自身不匹配)
let q = TaskQuery {
parent_id: Some("parent".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"c1"));
assert!(ids.contains(&"c2"));
}
#[tokio::test]
async fn list_by_query_queue_and_parent_id_combined() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "backlog", Some("parent"))).await.unwrap();
// queue=todo AND parent_id=parent → 只 c1(c2 是 backlog)
let q = TaskQuery {
queue: Some("todo".to_string()),
parent_id: Some("parent".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["c1"]);
}
#[tokio::test]
async fn list_by_query_empty_returns_all_active() {
// 空 query(全 None)→ 等价 list_active 全量未删,向后兼容
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.insert(trec("t2", "backlog", None)).await.unwrap();
let res = repo.list_by_query(&TaskQuery::default()).await.unwrap();
assert_eq!(res.len(), 2);
}
#[tokio::test]
async fn get_children_returns_only_direct_children() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "in_progress", Some("parent"))).await.unwrap();
repo.insert(trec("orphan", "todo", None)).await.unwrap();
let children = repo.get_children("parent").await.unwrap();
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"c1"));
assert!(ids.contains(&"c2"));
}
#[tokio::test]
async fn get_children_empty_when_no_children() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
let children = repo.get_children("parent").await.unwrap();
assert!(children.is_empty());
}
#[tokio::test]
async fn count_children_by_status_groups() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
.await
.unwrap();
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
.await
.unwrap();
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
.await
.unwrap();
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
.await
.unwrap();
let counts = repo.count_children_by_status("parent").await.unwrap();
// 转 map 便于断言(顺序由 GROUP BY 决定,不依赖)。父任务自身不计入(parent_id 非自身)。
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
assert_eq!(map.get("todo"), Some(&2));
assert_eq!(map.get("in_progress"), Some(&1));
assert_eq!(map.get("done"), Some(&1));
assert!(!map.contains_key("cancelled"));
}
#[tokio::test]
async fn count_children_by_status_empty_when_no_children() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
let counts = repo.count_children_by_status("parent").await.unwrap();
assert!(counts.is_empty());
}
#[tokio::test]
async fn get_children_excludes_soft_deleted() {
// 软删子任务不进 get_children 结果(deleted_at IS NULL 过滤)
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
repo.soft_delete("c2").await.unwrap();
let children = repo.get_children("parent").await.unwrap();
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["c1"], "软删子任务应被过滤");
}
// ============================================================
// 父聚合专用 status 写入:set_status_for_aggregation(知识图谱 Phase 1 V29)
// 父任务=容器模型,status 不走状态机,由子任务聚合计算后经此方法落库。
// 锁定:① 写入命中 + status 变更;② 不动 review_rounds;③ 软删任务返回 false。
// ============================================================
#[tokio::test]
async fn set_status_for_aggregation_writes_status() {
let repo = setup().await;
// 父任务初始 todo(queue=todo, parent_id=None 容器模型)
repo.insert(trec_full("parent", "todo", None, "todo"))
.await
.unwrap();
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
assert!(ok, "应命中写入");
let after = repo.get_by_id("parent").await.unwrap().unwrap();
assert_eq!(after.status, "in_progress", "status 应被聚合写入更新");
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
}
#[tokio::test]
async fn set_status_for_aggregation_skips_soft_deleted() {
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
let repo = setup().await;
repo.insert(trec_full("parent", "todo", None, "todo"))
.await
.unwrap();
repo.soft_delete("parent").await.unwrap();
let ok = repo.set_status_for_aggregation("parent", "done").await.unwrap();
assert!(!ok, "软删任务不应被聚合写入命中");
}
#[tokio::test]
async fn set_status_for_aggregation_nonexistent_returns_false() {
let repo = setup().await;
let ok = repo.set_status_for_aggregation("ghost", "done").await.unwrap();
assert!(!ok, "不存在的任务应返回 false");
}
}
+327 -1
View File
@@ -29,7 +29,21 @@ pub fn run(conn: &Connection) -> Result<()> {
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底); // V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底); // V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。 // V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
let steps: [(i32, fn(&Connection) -> Result<()>); 26] = [ // V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed,
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
// V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。
// V29 = 知识图谱 Phase 1 任务网络基础(对标 docs/02-架构设计/专项设计/
// 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/
// parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表
// (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。
// V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表,
// 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索
// 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -56,6 +70,11 @@ pub fn run(conn: &Connection) -> Result<()> {
(24, migrate_v24), (24, migrate_v24),
(25, migrate_v25), (25, migrate_v25),
(26, migrate_v26), (26, migrate_v26),
(27, migrate_v27),
(28, migrate_v28),
(29, migrate_v29),
(30, migrate_v30),
(31, migrate_v31),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -657,6 +676,232 @@ fn migrate_v26(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V27: 审批状态统一 executed→completed(TD-260621-05)
///
/// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行
/// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配;
/// ② 前端 AuditLog statusClass/i18n auditLog.status 无 executed 键 → executed 记录显示蓝pending
/// 标签 + raw"executed"文案矛盾(用户见"待审批 executed");③ 未来 WHERE status='completed'
/// 统计会漏 executed 路径。统一为 completed(find_cached SW-16 透传已兼容双值,无破坏)。
/// 数据迁移:存量 executed→completed,新库/老库均跑(UPDATE 0 行也安全)。
fn migrate_v27(conn: &Connection) -> Result<()> {
conn.execute(
"UPDATE ai_tool_executions SET status = 'completed' WHERE status = 'executed'",
[],
)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [27])?;
tracing::info!("迁移 v27 完成(审批状态 executed→completed 统一)");
Ok(())
}
/// V28: 幂等补 ideas.deleted_at 列(灵感软删回收站,对标 tasks.deleted_at V14)
///
/// 删除灵感改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
/// 与 tasks/projects.soft_delete 同模板:IdeaRecord 不带该字段,纯靠 SQL WHERE
/// deleted_at IS NULL 过滤;关联数据(idea_evaluations 评估历史)不动,FK 仍满足,
/// 灵感数据完整保留待恢复。用 PRAGMA 探测列存在性,缺失才 ALTER
/// (同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17/v18/v19/v20/v23/v24 模式),对新库/老库均安全。
fn migrate_v28(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ideas", "deleted_at") {
conn.execute("ALTER TABLE ideas ADD COLUMN deleted_at TEXT", [])?;
tracing::info!("v28: 补建 ideas.deleted_at 列(灵感软删回收站)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [28])?;
tracing::info!("迁移 v28 完成");
Ok(())
}
/// V29:知识图谱 Phase 1 任务网络基础 — 幂等补 tasks.queue / parent_id / content_json 三列
///
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.1,
/// 为「AI 拥有完整项目知识图谱、自主分解任务编排依赖」打数据层地基。三列各司其职:
///
/// - `queue TEXT NOT NULL DEFAULT 'todo'`:管理维度池标记,与 status(执行维度)正交。
/// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。
/// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),向后兼容:历史任务原 status=todo,
/// 落 todo 池语义一致。queue 与 status 一致性约束由 IPC 层校验(不进状态机,不进 DB 约束)。
///
/// - `parent_id TEXT REFERENCES tasks(id)`:父任务纵向关联(AI 分解-执行编排结构)。
/// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务。
/// 限制 1 级嵌套(无孙任务)由 IPC 层校验,不进 DB 约束。父任务=容器模型,status 由
/// 子任务聚合计算(不走状态机)。TEXT NULL 向后兼容(老任务无 parent → None)。
///
/// - `content_json TEXT`:结构化需求规格 JSON 字符串(AI 可读写的执行规格)。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。
/// TEXT NULL 向后兼容(老任务无 content_json → None)。
///
/// 对标 v14(v14 tasks.deleted_at)/ v24(v24 ideas.related_ids)幂等模式:每列用 PRAGMA
/// 探测存在性,缺失才 ALTER,对新库/老库/坏库均安全(列已存在时跳过 ALTER 不报 duplicate column)。
fn migrate_v29(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "queue") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo'",
[],
)?;
tracing::info!("v29: 补建 tasks.queue 列(管理池,知识图谱 Phase 1)");
}
if !column_exists(conn, "tasks", "parent_id") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id)",
[],
)?;
tracing::info!("v29: 补建 tasks.parent_id 列(父任务纵向关联,知识图谱 Phase 1)");
}
if !column_exists(conn, "tasks", "content_json") {
conn.execute("ALTER TABLE tasks ADD COLUMN content_json TEXT", [])?;
tracing::info!("v29: 补建 tasks.content_json 列(结构化需求规格,知识图谱 Phase 1)");
}
// task_links 表(横向关联,对标设计 §2.2):depends_on/blocks/relates_to。
// CREATE TABLE IF NOT EXISTS 幂等:新库建、老库(V29 之前的库已跑过前半三列)已有则跳过。
// 循环依赖检测走应用层(TaskLinkRepo::create_link BFS),非 DB 约束(对标设计 D8)。
// 软删除语义:Task 软删不级联删 link(恢复后关系还在),故无 ON DELETE,FK 仅引用完整性。
conn.execute(
"CREATE TABLE IF NOT EXISTS 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
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_links_source ON task_links(source_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_links_target ON task_links(target_id)",
[],
)?;
tracing::info!("v29: 建 task_links 表 + 索引(任务横向关联,知识图谱 Phase 1)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [29])?;
tracing::info!("迁移 v29 完成");
Ok(())
}
/// V30:知识图谱 Phase 2 统一事件流 — project_events 追加型审计表
///
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4,
/// 为「AI 精准检索项目事件流」打数据层地基。一张表承载跨实体(idea/task/workflow/
/// knowledge/module/service/project)的全部事件,回答"上周做了什么 / 这个任务为何 blocked /
/// 这个决策何时做出"——此前需跨 6 张表 JOIN。
///
/// **追加型审计表**(对标 knowledge_events V10 / idea_evaluations V22):只 INSERT 不 UPDATE/
/// DELETE,历史不改可追溯。故表无 updated_at,无通用 update/delete 路径,无列白名单登记
/// (ProjectEventRepo 走专用方法,与 TaskLinkRepo 同款全专用路径)。
///
/// **与 knowledge_events 的关系**(对标设计 D9 / §2.4):knowledge_events 保持不变(知识库专属,
/// 向后兼容),project_events 是全局事件流;knowledge 相关事件**同时写入两者**——本表只管写入,
/// 双写策略在埋点层(commands/IPC hook)实现,非本表职责。
///
/// 列语义(对标设计 §2.4):
/// - `event_type`:事件类型白名单(idea_created/idea_promoted/...task_created/task_advanced/
/// .../decision_made 等,见设计 §2.4 注释枚举)。白名单**应用层校验**(类比 task_links
/// link_type),不进 DB 约束(保留扩展性,新增事件类型无需迁移)。
/// - `entity_type` / `entity_id`:事件指向的实体(可空——部分事件无明确实体,如纯决策日志)。
/// entity_type 白名单同样应用层校验(idea/project/task/workflow/knowledge/module/service)。
/// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值,如 task_advanced;
/// created/referenced 类为 NULL)。
/// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。
/// - `source`:ai / human / system(AI Working 溯源——区分是 AI 自主操作还是人操作)。
/// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。
///
/// **埋点策略**(设计 §2.4 hook/after):在现有 IPC 命令(create_task/advance_task/create_project
/// /idea_promote 等)执行后追加事件写入,不侵入业务逻辑。事件写入失败 best-effort 不阻断主操作
/// (设计 §10.1 风险已识别),由埋点层实现,非本表职责。
///
/// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V30 之前的库)已有则跳过,均安全。
/// 索引覆盖最高频查询:
/// - `idx_project_events_project(project_id, created_at)`:按项目查事件流(时间倒序,Dashboard 时间线)
/// - `idx_project_events_entity(entity_type, entity_id)`:按实体反查(AI「这个任务发生过什么」)
fn migrate_v30(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS 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,
conversation_id TEXT,
created_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_events_project \
ON project_events(project_id, created_at)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_events_entity \
ON project_events(entity_type, entity_id)",
[],
)?;
tracing::info!("v30: 建 project_events 表 + 索引(统一事件流,知识图谱 Phase 2)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [30])?;
tracing::info!("迁移 v30 完成");
Ok(())
}
/// V31:知识图谱 Phase 3 基础设施数据层 — project_services 表
///
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3,
/// 为「AI 执行任务时拥有项目基础设施上下文」打数据层地基。AI 知道"这项目用了什么数据库、
/// Redis 在哪、有没有 MQ、API 在哪个地址",从而在编码/部署/排障时不必反复问人。
///
/// **定位**:项目基础设施配置的元数据层(对标设计 §2.3)。
/// - `service_type`:基础设施类型(mysql/postgresql/sqlite/redis/mongodb/mq/api/other),
/// **应用层校验**(类比 task_links.link_type / project_events.event_type,不进 DB 约束,
/// 保留扩展性,新增类型无需迁移)。
/// - `endpoint`:连接地址(localhost:3306 / URL),纯连接信息。
/// - `config_json`:类型相关配置 JSON 字符串(如数据库连接池参数、MQ topic 列表)。
/// - `environment`:环境标识(development/staging/production),DEFAULT 'development'。
/// 同一服务可在不同环境各存一行(项目维度 + 环境维度组合定位)。
///
/// **边界(D10,对标设计 §2.3)**:⚠️ **不存敏感凭证**(密码/密钥/Token)。本表只存连接信息,
/// 凭证走环境变量/外部密钥管理。config_json 不应含 password/secret/key 字段——此约束由
/// 应用层(ProjectServiceRepo)在 insert/update_full 时做内容审查(检测 password/secret/key/
/// token 子串拒绝),DB 层无 CHECK 约束(SQLite CHECK 对 JSON 内容无法表达)。
///
/// **元数据非运维工具**:不做连接池/健康检查/探活,仅记录"项目用了什么基础设施"这一事实,
/// 供 AI 与 Dashboard 检索消费。运维能力是后续独立模块的职责。
///
/// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V31 之前的库)已有则跳过,均安全。
/// 索引 `idx_project_services_project(project_id)` 覆盖最高频查询:按项目列其全部基础设施
/// (Dashboard 项目视图 / AI 项目上下文注入)。
fn migrate_v31(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS project_services (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
service_type TEXT NOT NULL,
endpoint TEXT,
config_json TEXT,
environment TEXT NOT NULL DEFAULT 'development',
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_services_project \
ON project_services(project_id)",
[],
)?;
tracing::info!("v31: 建 project_services 表 + 索引(基础设施数据层,知识图谱 Phase 3)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [31])?;
tracing::info!("迁移 v31 完成");
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)。
@@ -1261,4 +1506,85 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(v, 20); assert_eq!(v, 20);
} }
/// V27:审批状态统一 executed→completed(TD-260621-05)
/// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed)
#[test]
fn v27_unifies_executed_to_completed() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY,
tool_call_id TEXT,
tool_name TEXT,
status TEXT NOT NULL,
requested_at TEXT,
executed_at TEXT
);",
)
.expect("create ai_tool_executions");
// 混合状态:2 executed(待转)+ completed/rejected/failed(应不变)
conn.execute_batch(
"INSERT INTO ai_tool_executions (id, tool_call_id, tool_name, status) VALUES
('e1', 'tc1', 'write_file', 'executed'),
('e2', 'tc2', 'read_file', 'executed'),
('c1', 'tc3', 'list_directory', 'completed'),
('r1', 'tc4', 'write_file', 'rejected'),
('f1', 'tc5', 'run_command', 'failed');",
)
.unwrap();
migrate_v27(&conn).expect("v27 应成功统一状态");
// executed 全部转 completed
let executed_left: i64 = conn
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'executed'", [], |r| r.get(0))
.unwrap();
assert_eq!(executed_left, 0, "executed 应全部转为 completed");
let completed: i64 = conn
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'completed'", [], |r| r.get(0))
.unwrap();
assert_eq!(completed, 3, "原 2 executed + 1 completed = 3 completed");
// 其他状态不受影响
let rejected: i64 = conn
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'rejected'", [], |r| r.get(0))
.unwrap();
assert_eq!(rejected, 1, "rejected 不变");
let failed: i64 = conn
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'failed'", [], |r| r.get(0))
.unwrap();
assert_eq!(failed, 1, "failed 不变");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 27, "应写入版本号 27");
}
/// V27 幂等:无 executed 时再跑不崩(UPDATE 0 行),版本号照写
#[test]
fn v27_idempotent_no_executed() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE ai_tool_executions (id TEXT PRIMARY KEY, status TEXT NOT NULL);",
)
.expect("create tables");
conn.execute(
"INSERT INTO ai_tool_executions (id, status) VALUES ('c1', 'completed')",
[],
)
.unwrap();
migrate_v27(&conn).expect("v27 无 executed 时应幂等成功(UPDATE 0 行)");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 27);
}
} }
+118
View File
@@ -98,6 +98,118 @@ pub struct TaskRecord {
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。 /// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)] #[serde(default)]
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 管理维度池标记(知识图谱 Phase 1,V29 列)。与 status(执行维度)正交。
/// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。
/// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),TaskRecord 字段为 String(非 Option)。
/// #[serde(default = "default_queue")] 兼容旧前端 JSON(无该字段时为 "todo")。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID(知识图谱 Phase 1,V29 列,父任务纵向关联)。
/// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务(限制 1 级嵌套,无孙任务,
/// 由 IPC 层校验不进 DB 约束)。父任务=容器模型,status 由子任务聚合计算(不走状态机)。
/// TEXT NULL 向后兼容,TaskRecord 字段为 Option<String>(老任务无 parent → None)。
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1,V29 列)。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。
/// TEXT NULL 向后兼容,TaskRecord 字段为 Option<String>。
/// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_json: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 任务横向关联记录(task_links 表,知识图谱 Phase 1 V29,对标设计 §2.2)。
///
/// 表达任务间依赖/阻塞/弱关联关系,AI 拓扑排序编排调度的基础。独立表方案替代 JSON 列
/// (Idea related_ids 的缺陷:无类型区分/单向/无法高效反向查询)。
///
/// - `source_id` / `target_id`:关联两端任务 id(FK tasks.id)。软删除语义:Task 软删不
/// 级联删 link(对标设计 §2.2 边界「软删除保留」——恢复后关系还在),故无 ON DELETE。
/// - `link_type`:关联类型白名单(应用层校验,非 DB 约束):
/// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord {
pub id: String,
pub source_id: String,
pub target_id: String,
pub link_type: String,
pub remark: Option<String>,
pub created_at: String,
}
/// 项目事件流记录(project_events 表,知识图谱 Phase 2 V30,追加型审计表)。
///
/// 一张表承载跨实体(idea/task/workflow/knowledge/module/service/project)的全部事件,
/// 是 AI 精准检索项目事件流的基础(回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出")。
/// 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4。
///
/// **追加型审计**(对标 KnowledgeEventRecord / IdeaEvaluationRecord):只 INSERT 不 UPDATE/
/// DELETE,历史不改可追溯。无 updated_at 字段,无通用 update/delete 路径。
///
/// - `project_id`:所属项目(FK projects.id)。
/// - `event_type`:事件类型白名单(idea_created/task_advanced/decision_made 等,应用层校验)。
/// - `entity_type` / `entity_id`:事件指向的实体(可空——纯决策日志等无明确实体)。entity_type
/// 白名单应用层校验(idea/project/task/workflow/knowledge/module/service)。
/// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值;created/referenced 类为 None)。
/// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。
/// - `source`:ai / human / system(AI Working 溯源——区分 AI 自主操作还是人操作)。
/// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。
///
/// **与 knowledge_events 关系**(设计 D9):knowledge_events 保持不变(知识库专属),knowledge
/// 相关事件同时写入两者,双写在埋点层(commands/IPC hook)实现。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEventRecord {
pub id: String,
pub project_id: String,
pub event_type: String,
pub entity_type: Option<String>,
pub entity_id: Option<String>,
pub from_state: Option<String>,
pub to_state: Option<String>,
pub context_json: Option<String>,
pub source: Option<String>,
pub conversation_id: Option<String>,
pub created_at: String,
}
/// 项目基础设施配置记录(project_services 表,知识图谱 Phase 3 V31)。
///
/// 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
/// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计 docs/02-架构设计/专项设计/
/// 项目知识图谱与任务队列系统-2026-06-26.md §2.3。
///
/// - `project_id`:所属项目(FK projects.id)。
/// - `name`:服务名(如"主数据库"/"缓存"/"消息队列",人/AI 可读标识)。
/// - `service_type`:基础设施类型白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,
/// 应用层校验,不进 DB 约束)。
/// - `endpoint`:连接地址(localhost:3306 / URL),可空(部分服务无固定 endpoint,如 serverless API)。
/// - `config_json`:类型相关配置 JSON 字符串(如连接池参数/MQ topic 列表),可空。
/// - `environment`:环境标识(development/staging/production),同一服务可在不同环境各存一行。
/// - `remark`:备注说明,可空。
///
/// ⚠️ **不存敏感凭证(D10)**:本记录仅存连接信息,密码/密钥/Token 走环境变量/外部密钥管理,
/// 不进 config_json。内容审查在 ProjectServiceRepo insert/update_full 时检测敏感字段拒绝。
///
/// **元数据非运维工具**:不做连接池/健康检查,仅记录"项目用了什么基础设施"这一事实。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectServiceRecord {
pub id: String,
pub project_id: String,
pub name: String,
pub service_type: String,
pub endpoint: Option<String>,
pub config_json: Option<String>,
pub environment: String,
pub remark: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -205,6 +317,12 @@ fn default_weight() -> u32 {
50 50
} }
/// TaskRecord.queue 的 serde 默认("todo",知识图谱 Phase 1 V29)。老库/缺字段 JSON → "todo"。
/// 对齐 DB 列 DEFAULT 'todo' 语义,保证字段始终非空 String。
fn default_queue() -> String {
"todo".to_string()
}
/// AI 对话记录 /// AI 对话记录
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiConversationRecord { pub struct AiConversationRecord {
@@ -44,6 +44,9 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now_ts(), created_at: now_ts(),
updated_at: now_ts(), updated_at: now_ts(),
} }
@@ -0,0 +1,401 @@
# AI 原生工作模式:上下文地图与去 AI 化进化系统
> **文档状态**:构想 → 架构设计
> **创建日期**:2026-06-26
> **关联灵感**:Idea `84797cb1-37f2-4599-ba5f-ae8d8555dc0f`
> **核心理念**:让 AI 做得越来越少——高频路径逐步结晶为确定性逻辑,AI 退守到真正需要推理的边界。
---
## 0. 问题诊断:当前 AI 工作流的资源浪费
### 0.1 现状痛点
| 症状 | 根因 | 代价 |
|------|------|------|
| 每次对话都从零检索项目结构 | 无持久化的工程地图 | 30-40% token 浪费在重复探索 |
| 简单文件操作也要 LLM 推理 | 无模式结晶机制 | 推理延迟 + API 成本 |
| AI 不知道上次改了什么 | 无上下文缓存层 | 重复扫描 + 上下文断裂 |
| 高频任务路径无法优化 | 无执行频度感知 | 永远全量推理,无法降级 |
### 0.2 核心洞察
> AI 原生不等于"什么都让 AI 做"。真正的 AI 原生是:**AI 构建认知基础设施,然后将高频路径交给确定性逻辑,自身退守到创造性边界。**
---
## 1. 架构总览:三层地图 + 缓存层 + 结晶器
```
┌─────────────────────────────────────────────────────────────────────┐
│ AI Agentic Loop │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ ┌────────────┐ │
│ │ Working │ │ Coding │ │ Tool │ │ Context │ │
│ │ Map │──▶│ Map │──▶│ Map │──▶│ Cache │ │
│ │ (任务态) │ │ (代码态) │ │ (工具态) │ │ (会话态) │ │
│ └──────────┘ └──────────────┘ └───────────┘ └─────┬──────┘ │
│ │ │
│ ┌──────────▼───────┐ │
│ │ Pattern │ │
│ │ Crystallizer │ │
│ │ (模式结晶器) │ │
│ └──────────┬───────┘ │
└─────────────────────────────────────────────────┼─────────┼────────┘
│ │
┌─────────────▼─┐ ┌───▼──────────┐
│ Deterministic │ │ Frequency │
│ Scripts │ │ Tracker │
│ (确定性脚本) │ │ (频度追踪) │
└───────────────┘ └──────────────┘
```
### 1.1 设计原则
1. **地图先行**:AI 的每次操作都基于预构建的地图,而非实时盲搜
2. **缓存就近**:会话级上下文缓存,避免跨轮重复推理
3. **频度驱动结晶**:高频路径自动进入结晶候选队列
4. **渐进去 AI 化**:结晶后的路径从 LLM 推理降级为确定性脚本
---
## 2. 三层地图设计
### 2.1 Working Map(工作地图 — 任务态)
**职责**:描述"当前在做什么"——任务、项目、灵感、工作流的实时状态拓扑。
```
WorkingMap
├── active_projects[] // 活跃项目清单(含 stack/path/bind 状态)
├── active_tasks[] // 进行中任务(含 status/assignee/blocker)
├── task_dependencies[] // 任务间依赖(子任务/阻塞关系)
├── recent_decisions[] // 近期决策记录(灵感→任务转化轨迹)
├── workflow_state // 当前工作流执行状态
└── project_modules[] // 多工程结构(复用 modules 表)
```
**DevFlow 现状对接**:
- `projects` 表 → `active_projects`
- `tasks` 表 → `active_tasks`
- `ideas` 表 → `recent_decisions`(灵感→任务转化链)
- `modules` + `module_dependencies` 表 → `project_modules`(多工程功能,子1-5 任务在建)
**构建时机**:
- 冷启动:从 SQLite 全量加载活跃实体
- 热更新:CRUD 操作触发增量更新(事件驱动,复用全局事件总线)
### 2.2 Coding Map(代码地图 — 结构态)
**职责**:描述"代码长什么样"——文件树、符号索引、依赖图、技术栈。
```
CodingMap
├── module_index[] // 每个工程的文件树 + 技术栈
│ ├── file_tree // 目录结构(懒加载,按需展开)
│ ├── symbol_index // AST 符号索引(函数/结构体/类)
│ └── stack_signature // 技术栈指纹(Cargo.toml/package.json/...)
├── cross_module_deps[] // 跨工程依赖(API 调用/类库引入/消息通信)
├── recent_changes[] // 近期文件变更(git diff + 时间窗口)
└── hotspots[] // 热点文件(高频访问/修改的文件)
```
**DevFlow 现状对接**:
- `read_symbol` 工具 → `symbol_index` 的查询入口
- `detect_stack` 逻辑 → `stack_signature`
- `modules` + `module_dependencies` → `cross_module_deps`
- 知识库 Tier1 → `CodingMap` 的知识层补充
**构建策略**:
- 符号索引:按需解析(首次访问某文件时触发 AST 解析,结果缓存)
- 依赖图:module 绑定目录时构建,变更时增量更新
- 热点追踪:记录工具调用中的文件访问频度
### 2.3 Tool Map(工具地图 — 能力态)
**职责**:描述"AI 能做什么"——工具注册表、工具调用历史、工具审批策略。
```
ToolMap
├── registered_tools[] // 已注册工具清单(名称/参数schema/风险等级)
├── tool_call_history[] // 近期调用历史(参数/结果/耗时/是否审批)
├── approval_policies[] // 审批策略(自动批准/人工审批/拒绝)
├── tool_success_rate // 各工具成功率统计
└── parameter_patterns[] // 常见参数模式(用于结晶候选识别)
```
**DevFlow 现状对接**:
- 工具注册表(68 IPC commands)→ `registered_tools`
- 工具审批机制(Agentic Loop + 审批)→ `approval_policies`
- `tool_call_history` → 新增,记录到 SQLite 或内存环形缓冲
---
## 3. Context Cache(上下文缓存层)
**职责**:在 Agentic Loop 的多轮对话中,缓存已检索/已推理的上下文,避免重复劳动。
### 3.1 缓存结构
```rust
pub struct ContextCache {
/// 会话级:当前对话已探索的文件/符号
explored_files: HashMap<PathBuf, FileSnapshot>,
/// 任务级:当前任务关联的上下文窗口
task_context: HashMap<TaskId, TaskContext>,
/// 推理缓存:相同输入的推理结果(短 TTL)
inference_cache: LruCache<InferenceKey, InferenceResult>,
/// 工具结果缓存:相同参数的工具调用结果
tool_result_cache: LruCache<ToolCallSignature, ToolResult>,
}
```
### 3.2 缓存失效策略
| 缓存类型 | TTL | 失效条件 |
|----------|-----|----------|
| `explored_files` | 会话级 | 文件被修改(mtime 变化) |
| `task_context` | 任务级 | 任务状态变更 |
| `inference_cache` | 5 分钟 | 时间过期或上下文窗口滑动 |
| `tool_result_cache` | 10 分钟 | 时间过期或目标文件变更 |
### 3.3 预期收益
- 减少 **50-60%** 的重复推理量
- 工具调用延迟降低(缓存命中直接返回)
- 对话连续性增强(AI"记得"上次探索的结果)
---
## 4. Pattern Crystallizer(模式结晶器)
**职责**:识别高频任务路径,将其从 LLM 推理降级为确定性脚本/规则。
### 4.1 结晶流水线
```
Frequency Tracker Pattern Analyzer Crystallizer
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 记录每次工具 │──▶ N 次 ─▶│ 分析参数模式 │──▶ 稳定 ─▶│ 生成确定性 │──▶ 注册为
│ 调用的签名 │ 以上 │ + 成功率 │ 模式 │ 脚本/规则 │ 新工具
└──────────────┘ └──────────────┘ └──────────────┘
```
### 4.2 数据结构
```rust
pub struct FrequencyTracker {
/// 工具调用签名 → 出现次数
call_signatures: HashMap<ToolCallSignature, u32>,
/// 参数模式聚类(相同工具 + 相似参数归为一类)
parameter_clusters: HashMap<ToolName, Vec<ParameterPattern>>,
}
pub struct CrystallizedPattern {
id: String,
/// 触发条件(何时使用此结晶路径)
trigger: PatternTrigger,
/// 确定性执行逻辑(脚本/规则/模板)
execution: DeterministicExecution,
/// 置信度(基于历史成功率)
confidence: f32,
/// 回退策略(结晶路径失败时回退到 LLM 推理)
fallback: FallbackStrategy,
}
pub enum DeterministicExecution {
/// 预定义脚本(Shell/Python)
Script(PathBuf),
/// 参数化模板(填充变量后直接执行)
Template(Template),
/// 规则引擎(条件→动作映射)
Rule(RuleSet),
}
```
### 4.3 结晶示例
| 高频模式 | 结晶前(LLM 推理) | 结晶后(确定性逻辑) |
|----------|-------------------|---------------------|
| 创建新 Rust crate | LLM 推理目录结构 + Cargo.toml | 脚本:`cargo new --lib` + 模板填充 |
| 添加 IPC command | LLM 生成 handler + 注册代码 | 模板:参数化生成 + 自动注册 |
| 运行测试套件 | LLM 推理测试命令 | 直接执行 `cargo test` |
| 代码审查 | LLM 全量分析 | 规则引擎:lint 规则 + diff 分析 |
### 4.4 回退机制
结晶路径不是"永久替代"——当确定性执行失败或结果异常时,自动回退到 LLM 推理:
```
执行结晶路径
├── 成功 → 直接返回结果(跳过 LLM)
├── 失败 → 记录失败 + 回退到 LLM 推理
└── 连续失败 N 次 → 标记结晶路径为"待审查",暂停使用
```
---
## 5. 演进路线图
### Phase 1:地图构建(预计减少 30-40% 无效检索)
**目标**:三张地图的基础设施就位,AI 操作基于地图而非盲搜。
| 工作项 | 依托 | 状态 |
|--------|------|------|
| Working Map 数据层 | projects/tasks/ideas/modules 表 | ✅ 已有 |
| Coding Map 符号索引 | read_symbol 工具 | ✅ 已有 |
| Coding Map 跨工程依赖 | module_dependencies 表 | 🔨 子1-5 在建 |
| Tool Map 注册表 | 68 IPC commands | ✅ 已有 |
| Tool Map 调用历史 | 新增 tool_call_log 表 | ❌ 待建 |
| 地图查询 API | 新增 IPC commands | ❌ 待建 |
### Phase 2:上下文缓存(预计减少 50-60% 推理量)
**目标**:Agentic Loop 内置缓存层,跨轮复用已检索上下文。
| 工作项 | 说明 |
|--------|------|
| ContextCache Rust 结构 | 内存级缓存 + LRU 淘汰 |
| 文件快照机制 | mtime 监控 + 增量失效 |
| 工具结果缓存 | 签名匹配 + TTL 失效 |
| 缓存命中率指标 | 可观测性面板 |
### Phase 3:模式结晶器(高频任务去 AI 化)
**目标**:高频路径自动结晶为确定性逻辑,LLM 退守创造性边界。
| 工作项 | 说明 |
|--------|------|
| FrequencyTracker | 工具调用签名记录 + 聚类 |
| PatternAnalyzer | 参数模式分析 + 稳定性评估 |
| Crystallizer 引擎 | 确定性脚本/模板/规则生成 |
| 回退机制 | 结晶失败自动回退 LLM |
| 结晶管理 UI | 查看/审批/禁用结晶路径 |
### Phase 4:自治进化(长期愿景)
**目标**:系统自主识别优化机会,提出并验证新的结晶路径。
| 工作项 | 说明 |
|--------|------|
| 异常检测 | 识别低效路径(高 token / 低成功率) |
| 结晶提案 | 自动生成结晶候选方案 |
| A/B 验证 | 结晶路径 vs LLM 路径对比 |
| 自治注册 | 验证通过后自动注册新工具 |
---
## 6. 与 DevFlow 现有架构的融合点
```
DevFlow 现有架构 本方案融合点
┌────────────────────┐
│ Vue 3 前端 │ ← 结晶管理 UI / 缓存命中率面板 / 地图可视化
├────────────────────┤
│ Tauri IPC 层 │ ← 地图查询 API / 缓存管理 IPC / 结晶审批 IPC
├────────────────────┤
│ Rust 后端 │
│ ├─ df-storage │ ← tool_call_log 表 / crystallized_patterns 表
│ ├─ df-project │ ← Working Map + Coding Map 数据源
│ ├─ df-workflow │ ← 结晶路径作为工作流节点
│ ├─ df-agent │ ← ContextCache 注入 Agentic Loop
│ └─ df-tools │ ← Tool Map + FrequencyTracker + Crystallizer
├────────────────────┤
│ SQLite │ ← 新增迁移 V17+(patterns/tool_call_log)
└────────────────────┘
```
### 6.1 新增 Crate 规划
| Crate | 职责 | 依赖 |
|-------|------|------|
| `df-context` | ContextCache + 三张地图的内存结构 | df-storage, df-project |
| `df-crystallize` | FrequencyTracker + PatternAnalyzer + Crystallizer | df-tools, df-context |
### 6.2 新增数据表(V17+ 迁移)
```sql
-- 工具调用历史
CREATE TABLE tool_call_log (
id TEXT PRIMARY KEY,
task_id TEXT,
tool_name TEXT NOT NULL,
parameters TEXT, -- JSON
result_summary TEXT, -- 截断的结果摘要
success INTEGER NOT NULL,
duration_ms INTEGER,
approved INTEGER,
created_at INTEGER NOT NULL
);
-- 结晶模式
CREATE TABLE crystallized_patterns (
id TEXT PRIMARY KEY,
trigger_condition TEXT NOT NULL, -- JSON: 何时触发
execution_type TEXT NOT NULL, -- script / template / rule
execution_body TEXT NOT NULL, -- 脚本路径 / 模板内容 / 规则集
confidence REAL NOT NULL,
status TEXT NOT NULL, -- active / suspended / reviewing
success_count INTEGER DEFAULT 0,
failure_count INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
---
## 7. 关键设计决策
### 7.1 为什么是"三层地图"而非"一张大地图"?
- **职责分离**:任务态(Working)、结构态(Coding)、能力态(Tool)的生命周期和更新频率不同
- **按需加载**:AI 当前操作只需要相关地图层,避免全量加载
- **独立演进**:每层地图可以独立优化数据结构和索引策略
### 7.2 为什么结晶器需要回退机制?
- 结晶路径基于历史模式,无法覆盖所有边界情况
- 回退到 LLM 推理是安全网,确保系统鲁棒性
- 连续失败自动暂停,避免反复走死路
### 7.3 为什么缓存层放在 Rust 后端而非前端?
- Rust 后端是 Agentic Loop 的执行体,缓存就近放置延迟最低
- 避免前后端数据同步的复杂性
- Rust 的内存管理适合 LRU 缓存场景
---
## 8. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 地图数据过期 | AI 基于过期信息做决策 | mtime 监控 + 事件驱动增量更新 |
| 结晶路径覆盖不全 | 部分场景回退频繁 | 回退机制 + 逐步扩大结晶覆盖 |
| 缓存内存膨胀 | 内存占用过高 | LRU 淘汰 + TTL 过期 + 容量上限 |
| 结晶脚本安全风险 | 恶意/错误脚本执行 | 审批机制 + 沙箱执行 + 权限限制 |
---
## 9. 总结
本方案的核心贡献是将 AI 工作流从"每次全量推理"进化为"地图导航 + 缓存加速 + 高频结晶"的三级加速体系:
1. **地图层**解决"去哪找"——减少 30-40% 无效检索
2. **缓存层**解决"别重复"——减少 50-60% 推理量
3. **结晶器**解决"别想了,直接做"——高频路径去 AI 化
最终愿景:**AI 构建认知基础设施,然后将确定性路径交给脚本/规则,自身专注于真正需要创造力的边界问题。这才是 AI 原生的终局——让 AI 做得越来越少,但每一步都精准。**
---
> **下一步行动**:
> 1. 将 Phase 1 中待建项(tool_call_log 表、地图查询 API)拆解为具体任务
> 2. 在 `df-context` crate 中实现 ContextCache 原型
> 3. 设计 FrequencyTracker 的数据采集埋点方案
@@ -0,0 +1,108 @@
# AI 对话「目标丢失 / 意图丢失」诊断报告
> 日期:2026-06-26
> 触发:AI Chat 使用中,用户发送 Bug / 分析需求目标后,分析过程中目标丢失,系统不知道做什么、完全跑偏。
> 方法:直读对话数据库(`AppData/Roaming/top.1216.devflow/devflow-dev.db`)实测会话 + 源码机制走查。
> 性质:基于代码 + 数据库实证的机制级诊断(非模型分析猜测)。
---
## 一、现场铁证(会话 `54c8b5bd`)
| 指标 | 实测值 | 含义 |
|---|---|---|
| `prompt_tokens` | **4,809,714**(约 480 万) | 远超 GLM 1M 窗口,反复触发压缩 / 裁剪 |
| 消息数 | 308 条 | 长 ReAct 链,工具结果堆积 |
| 原始目标 `[seq5]` | `分析一下 灵感模块 存在的不足 [项目: DevFlow]`,`status=compressed` | **目标消息已被踢出 LLM 上下文**(`sanitize_messages` step0 过滤 `is_active`) |
| 话题标记 `[seq0]` | `task → project`(active) | 系统判定「当前 project 话题」,但真实目标是 idea 域(灵感模块) |
| 尾部 `[seq287–307]` | 连续 10+ 次 `search_files`,关键词 `auto-execute-mode / auto_execute / 全部接管 / autoExecute / execute-mode / execute_mode / auto-exec / 接管 / df-ai-auto`,**全部 `total:0` 空结果** | 失去目标后的游荡死循环 |
**结论**:原始目标是「分析灵感模块存在的不足」,LLM 最终在反复搜索「自动执行模式 / 全部接管」——与目标毫无关系。目标彻底丢失。
---
## 二、根因(机制级)
### 根因 1:上下文爆炸 → 目标消息物理出局(核心)
- `df-ai/src/context.rs`:`sanitize_messages` step0 按 `is_active()` 过滤发送视图。目标 user 消息被 F-15 压缩标 `status=compressed` 后,**不再进入 LLM 上下文**。
- 目标仅靠**压缩摘要 system 消息**「续命」。`prompt.rs::compress_prompt` 四段式(意图 / 决策 / 文件 / 约束)+ 主题词锚点保留——但摘要是 **LLM 二次生成,信息降级严重**。「分析灵感模块不足」这类具体目标,在数次压缩后语义稀释到无法指导后续动作。
- 480 万 token 的会话,目标消息经历多轮压缩,保真度无法保证。
### 根因 2:话题切换标记误判,反向强化错误方向
- `agentic/mod.rs:735-772`:`pending_topic_marker` 基于末两条 user 消息的 `IntentRecognizer` 关键词推断(双高置信 ≥0.7 才标),`insert_at(0)` 软提示 system「请以新话题为准」。
- 失效点:
- 长链里 user 消息早被压缩出局,topic 推断基于**已失真历史**。
- 本例最后 active 标记是 `task→project`,与真实目标(灵感模块 / idea 域)**背离**——软提示反而告诉 LLM「现在是 project 话题」,帮倒忙。
- marker 只在 push **user 消息**时检测,工具轮次间不参与。
### 根因 3:单链 ReAct,无目标重锚定(架构本质)
- `df-ai/src/coordinator.rs`:**纯空壳 TODO**(B 路线占位,`AgentCoordinator::run` 直接返 `"TODO: Agent 协作结果"`)。
- `df-ai/src/planner.rs`:Plan DAG + Kahn 分层 + validate 齐全,但文件头明确「纯函数零 IO 零状态」「不接入 agentic loop,待 Phase B+C」。`PLANNING_ENABLED=true` 只门控**工具排序**(`filter_tool_defs_planned`),**不做多步规划执行**。
- agentic loop 每轮只在 push 新 user 消息时检测话题切换,**工具轮次间不重申目标**。没有「当前目标 + 进度」结构化状态,LLM 每轮自行决定下一步,长链必然游荡(`[seq287–307]` 实证)。
### 根因 4:无探索熔断
- `[seq287–307]` 连续 10+ 次空结果不中断。系统不跟踪「已搜过什么 / 连续 N 次无进展」,LLM 无限换关键词。
- 这是目标丢失的**放大器**:不知道目标 → 不知道该停 → 无限游荡,token 失控(480 万)。
### 根因 5:system prompt「聚焦准则」是文字说教,非机制
- `prompt.rs:104-108` 有「聚焦准则:始终围绕用户当前请求的核心目标」——纯 prompt 文字,靠 LLM 自觉。
- 目标一旦从上下文(消息流)消失,文字准则**无锚点可抓**。没有「每轮把目标钉在 system 顶部」的机制。
---
## 三、为什么现有设计压不住
| 现有机制 | 性质 | 为什么压不住 |
|---|---|---|
| 压缩摘要(F-15) | **被动**信息降级 | 摘要保真靠 LLM,多轮压缩后目标语义稀释 |
| 话题切换标记 | 软提示 + 关键词推断 | 基于失真历史,本例反向误导 |
| 聚焦准则 prompt | 文字说教 | 目标消息出局后无锚点 |
| `insert_at(0)` system 锚点 | 多个堆首位 | 话题标记 + 压缩摘要 + 关键词兜底三个锚点互相稀释 |
三个 `insert_at(0)`(话题标记 `agentic/mod.rs:762`、压缩摘要 `:918`、关键词兜底 `:969`)堆在首位,互相稀释,没有一个是「权威目标」。
---
## 四、关联现状
- 当前分支 `conv-state-convergence` + `agentic/conv_state.rs`(452 行状态机)已在尝试收敛对话状态,**但目标作为一等状态尚未固化进状态机**——这是未完成的工作。
- memory `devflow-aichat-session-analysis-2026-06-22` 已标的「最高杠杆 P0-2 编排规划能力弱」即此问题的上层概括。本报告是其**数据库实证版**。
---
## 五、方向建议(G1/G2/G4 已实施 commit 8ce18cb,2026-06-26)
最高杠杆:**把「用户当前目标」从消息流提升为一等结构化状态**。
1. **目标钉扎** ✅ G1 已实施(8ce18cb):首轮提取用户目标为 `PerConvState.pinned_goal`,run_agentic_loop 入口拼进 system_prompt(loop 不变量,免疫 F-15 压缩 / build_eviction_units 裁剪 / sanitize step0 is_active 过滤)。目标消息压缩出局后,结构化目标仍在。
2. **探索熔断** ✅ G2 已实施(8ce18cb):连续 N 次空结果无进展 → 两段式警示 + AiHelpRequired 熔断,防游荡死循环 + token 失控(实测 seq287-307 连续 10+ 次盲搜死循环的场景)。
3. **状态机收敛** — G3 文档衔接(conv_state.rs / mod.rs 注释:pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum)。
4. **话题标记修正** ✅ G4 已实施(8ce18cb):TOPIC_MARKER_GOAL_AWARE guard,pinned_goal 存在时 take 后丢弃跳过 topic marker insert(消除 seq0 task→project 与真实 idea 目标背离的反向误导)。
附 G5 排查结论:seq1/seq4 system 异常消息是会话早期**旧版本遗留脏数据**(thinking 独白 + tool_result 被错存 system,早期 timestamp),非当前 compress 机制问题(摘要四段式正常),最新 system(seq0 topic marker)正常。**数据治理项,不改代码**。
---
## 附:待查 Bug
- `[seq1]` 一条 LLM 内部独白("等等,`process_tool_calls` 是从 `super::audit` 导入的。让我检查一下…")被存为 `system` 消息(`status=active`)。疑似 reasoning / 思考内容泄漏进 system 消息(`stream_recv` 归位问题),进一步污染上下文。待独立排查。
---
## 证据定位(源码)
| 机制 | 位置 |
|---|---|
| 上下文压缩 / sanitize / topic 字段 | `crates/df-ai/src/context.rs` |
| 话题切换 marker 消费 | `src-tauri/src/commands/ai/agentic/mod.rs:735-772` |
| 压缩 insert_at 摘要 | `src-tauri/src/commands/ai/agentic/mod.rs:820, 918, 969` |
| 多 Agent 协调(空壳) | `crates/df-ai/src/coordinator.rs` |
| Plan 规划(未接入 loop) | `crates/df-ai/src/planner.rs` |
| 意图识别(关键词) | `crates/df-ai/src/intent.rs` |
| system prompt 聚焦准则 / 压缩 prompt | `src-tauri/src/commands/ai/prompt.rs:104, 233` |
| 对话状态机(进行中) | `src-tauri/src/commands/ai/agentic/conv_state.rs` |
@@ -102,8 +102,11 @@ AST 是语法层(parse tree)。**语法层能**:
| `.ts`/`.tsx` | tree-sitter-typescript | 前端 script | | `.ts`/`.tsx` | tree-sitter-typescript | 前端 script |
| `.js`/`.jsx` | tree-sitter-javascript | | | `.js`/`.jsx` | tree-sitter-javascript | |
| `.vue` | **借 tree-sitter-typescript** | 切 `<script>` 段喂 ts;template/style 不做符号(Vue 逻辑在 script) | | `.vue` | **借 tree-sitter-typescript** | 切 `<script>` 段喂 ts;template/style 不做符号(Vue 逻辑在 script) |
| `.go` | tree-sitter-go | 2026-06-25 扩展 |
| `.java` | tree-sitter-java | 2026-06-25 扩展 |
| `.py` | tree-sitter-python | 2026-06-25 扩展 |
**3 个 grammar 覆盖 4 类文件**。Vue 借 TS 省 grammar + 避 SFC 嵌套复杂度。 **6 个 grammar 覆盖 9 类文件**。Vue 借 TS 省 grammar + 避 SFC 嵌套复杂度。Go/Java/Python 2026-06-25 扩展(表驱动加表项:`DEFINITION_KINDS` 补 kind + `grammar_for` 加分支 + `Cargo.toml` 加 crate 0.23,ABI 兼容 0.25 主 crate,零架构改动)。
**扩展**:加标准语言 = 加 grammar crate + 一张节点映射表(表驱动,~30 行/语言)。小众(C++/Kotlin)按需;动态加载/集成插件化见插件机制设计文档。 **扩展**:加标准语言 = 加 grammar crate + 一张节点映射表(表驱动,~30 行/语言)。小众(C++/Kotlin)按需;动态加载/集成插件化见插件机制设计文档。
@@ -277,8 +277,40 @@ if has_tool_calls { /* push */ }
--- ---
## 收口记录(2026-06-25)
> 双轨状态机退役,enum 单一真相源。本文档 §3.2「轻量状态机,不引入框架」的轻量路线在批3 完成**升级**:原方案是「bool 保留 + enum 视图只读」,收口后 enum 成为写侧真相源,bool 与开关双轨全部删除。
### 收口了什么
1. **双轨退役(写侧)**:
- `generating: bool` 字段(顶层单例 + `PerConvState` 内)删除。
- `CONV_STATE_ENABLED` 灰度开关删除(批2 常量删,批3 全部门控分支删)。
- `ConvState` enum(`conv_state.rs`,5 态 Idle/Generating/Stopping/Error/Compressed)成为**唯一真相源**,所有状态写入经 `transition_to` 守卫(非法转换拒绝 `Err(InvalidTransition)`)。
- `GeneratingGuard`(guard.rs)的 new/reset/drop 改为无条件迁移 `ConvState` + emit `AiConvStateChanged`(原 `if CONV_STATE_ENABLED` 门控删)。
2. **AiCompressed 拆 Manual/Auto(治 BUG-260624-05)**:
- 原 `AiCompressed` 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话。
- 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 `isCompressing` 防按钮卡死,miniapp 仍插摘要气泡)。
3. **can_accept_request 接入(读侧)**:
- `ConvState::can_accept_request()`(Idle/Error 可接)接入 chat 域 4 处入口拦截:`ai_chat_send` / `ai_regenerate` / `ai_chat_edit` / `ai_is_generating`。
- 比旧 `is_active()` 更严谨:覆盖 Stopping 态(漏拦停止中接新请求的竞态)。
### 为什么收口
- **双轨隐患**:bool 字段与 enum 开关共存期间,任一 guard 入口漏加门控即绕过 enum 守卫回退旧 bool 写,状态机形同虚设。开关是渐进过渡的临时灰度机制,收口即删才是终态,长期保留等于把「过渡态」固化成「架构债」。
- **AiCompressed 合流**:手动(用户主动压缩,要反馈)与自动(loop 内压缩,静默)是两种 UX 语义,共用事件必致前端按「最严」语义(弹 toast)兜底,自动路径每次都误触发。拆分是按语义而非按字段。
### 残留过渡分支(非回归,待清理)
代码内保留若干 `CONV_STATE_ENABLED off 回退分支` 注释(agentic/mod.rs:1608/1722、chat.rs:1533、remote_bridge.rs:762、前端 types.ts/ChatInput.vue/MaxRoundsCard.vue/useAiEvents.ts),均标注「enum 单路径」,用于兼容老后端/前端未追踪态的兜底。开关常量已删,这些注释分支实际不可触发,属说明性遗留,后续清理时一并删。
---
## 相关文档 ## 相关文档
- [df-ai AI 集成模块](../../03-模块文档/df-ai-AI集成模块-2026-06-12.md) - [df-ai AI 集成模块](../../03-模块文档/df-ai-AI集成模块-2026-06-12.md)
- [aichat 审查报告-2026-06-14](../构想审查/aichat审查报告-2026-06-14.md) - [aichat 审查报告-2026-06-14](../构想审查/aichat审查报告-2026-06-14.md)
- [流式 Markdown 渲染调研-2026-06-15](../构想审查/aichat流式Markdown渲染调研-2026-06-15.md) - [流式 Markdown 渲染调研-2026-06-15](../构想审查/aichat流式Markdown渲染调研-2026-06-15.md)
- `src-tauri/src/commands/ai/agentic/conv_state.rs`(`ConvState` enum 实现源)
@@ -0,0 +1,832 @@
# 项目知识图谱与任务队列系统 — 设计
> **日期**: 2026-06-26
> **状态**: 设计阶段(待评审)
> **前提**: AI Working — AI 是系统的主要操作者,人是监督者与决策者
> **关联**: ARCHITECTURE.md §AI Working 定位体系 / 多工程方案(子1-子5)/ todo.md 灵感模块 + AI 对话目标丢失
---
## 〇、核心命题
**DevFlow 的终局是:AI 拥有完整的项目信息知识图谱,比任何人都了解项目。**
人在 AI Chat 中描述目标,AI 基于图谱自主分解任务、编排依赖、执行推进、自检完成度。人的角色是决策确认和方向把控,不是填表单和翻列表。
本文档定义支撑这一终局所需的数据结构、关联关系和实现路径。
---
## 一、问题诊断
### 1.1 当前缺失
| 缺失 | 后果 |
|------|------|
| **无全局工作队列** | 任务散落在各项目下,无法跨项目看"所有待办" |
| **需求无承载体** | Idea 是模糊灵感,Task 是具体执行,中间缺"已确认要做但还没排期"的需求层 |
| **决策无队列** | AI Agentic Loop 中产生的"需要人决策"事项散落在对话流里,错过即丢失 |
| **任务无纵向关联** | 无法表达"一个需求拆出多个子任务"(当前用 `[子N]` 标题前缀硬编码) |
| **任务无横向关联** | 无法表达任务间依赖关系,AI 无法做拓扑排序编排 |
| **需求无结构化规格** | description 是纯文本,AI 无法提取验收标准做自检 |
| **无基础设施记录** | AI 不知道项目用了什么数据库/缓存/MQ |
| **无统一事件流** | 回答"上周做了什么"需跨 6 张表 JOIN |
| **溯源链断裂** | 无法从任务回溯到灵感、从灵感到对话、从对话到决策 |
### 1.2 核心原则
> **每一条数据结构都不是给人填的表单,是给 AI 消费的上下文。**
>
> 前端可视化是最后的投影层,不是数据结构存在的原因。
---
## 二、数据模型设计
> 当前迁移版本 V27(V21=消息拆分 / V22=灵感评估 / V23=嵌入状态 / V24=灵感关联 / V25=评估唯一约束 / V26=任务索引 / V27=审批状态统一)。本设计从 V28 起。
### 2.1 Task 表扩展(V28 迁移)
```sql
-- 队列字段:AI 行为约束信号
ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo';
-- backlog(需求池) / todo(待办池) / decision(待决策池) / active(执行中) / done(已完成)
-- 父任务 ID:AI 分解-执行编排结构
ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id);
-- NULL = 叶子任务;非空 = 子任务(限制 1 级,不允许孙任务)
-- 结构化需求规格:AI 可读写的执行规格
ALTER TABLE tasks ADD COLUMN content_json TEXT;
-- JSON: { background, acceptance_criteria[], scope[], technical_design, custom_fields }
```
#### queue 字段语义
| queue 值 | 含义 | AI 行为约束 |
|----------|------|------------|
| `backlog` | 需求池:确认有价值,未排期 | AI **不自主推进**,等待人确认排期 |
| `todo` | 待办池:已排期,待执行 | AI **可自主推进**到 active + in_progress |
| `decision` | 待决策池:需要人拍板 | AI **暂停执行**,请求人类输入 |
| `active` | 执行中:正在推进 | AI **按状态机推进** |
| `done` | 已完成 | 终态 |
#### queue 与 status 的关系
两个正交维度,各管各的:
```
queue(管理维度):backlog → todo → active → done
↑
decision(临时池)
status(执行维度):todo → in_progress → in_review → testing → done
↑
blocked / cancelled
```
**一致性约束**(IPC 层校验,不进状态机):
- queue=done 时 status 必须=done
- queue=backlog 时 status 必须=todo
- queue=active 时 status ∈ {in_progress, in_review, testing}
- status=blocked 时 queue 可为 decision 或 active
#### parent_id 纵向关联
- **限制 1 级嵌套**:parent 有 parent_id 时拒绝再创建子任务
- **父任务=容器模型**:父任务 status 不走状态机(不能 advance_task),由子任务聚合计算
- **聚合规则**:
| 子任务状态 | 父任务推导 status |
|-----------|-----------------|
| 全 todo | todo |
| 任一 in_progress | in_progress |
| 任一 blocked | blocked |
| 全 done/cancelled | done |
#### content_json 结构
```jsonc
{
"background": "当前 DevFlow 一个项目只能绑一个目录...", // 背景:AI 理解动机
"acceptance_criteria": [ // 验收标准:AI 自检清单
"看板三列拖拽",
"跨项目展示任务"
],
"scope": ["前端 Board.vue", "task_repo", "router"], // 影响范围:AI 操作边界
"technical_design": "新增 queue 字段 + task_links 表...", // 技术方案:AI 执行路径
"custom_fields": {} // 扩展字段
}
```
**定位**:不是人填的表单,是 AI 和人协作填充的结构化规格。AI 从对话中提取信息填充,人确认后 AI 执行中用 acceptance_criteria 自检。
### 2.2 task_links 表(横向关联)
```sql
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, -- depends_on / blocks / relates_to
remark TEXT, -- 可选备注
created_at TEXT NOT NULL
);
CREATE INDEX idx_task_links_source ON task_links(source_id);
CREATE INDEX idx_task_links_target ON task_links(target_id);
```
#### link_type 语义
| 类型 | 语义 | AI 用途 |
|------|------|---------|
| `depends_on` | source 依赖 target(target 完成后 source 才能开始) | **拓扑排序调度** |
| `blocks` | source 阻塞 target(source 不完成则 target 无法推进) | 依赖的反向声明 |
| `relates_to` | 弱关联,无执行约束 | 上下文提示 |
#### 为什么不用 JSON 列(仿 Idea related_ids)
Idea 的 related_ids JSON 方案是 todo.md 待修的 P1 问题(#8 关联双向同步)。JSON 列的缺陷:
- 无类型区分(不能表达 depends_on vs relates_to)
- 单向(A 关联 B 不意味着 B 知道)
- 无法高效反向查询("谁依赖了我"需全表扫描 JSON)
独立表方案:有类型、双向查询、可扩展。
#### 边界约束
| 约束 | 规则 |
|------|------|
| 循环依赖 | 创建 link 时 BFS 检测环,拒绝 A→B→A |
| 跨项目 | 允许(现实中有跨项目依赖) |
| 软删除 | Task 软删除时保留 link(恢复后关系还在) |
| 执行约束 | AI **应遵守** depends_on(检查依赖全 done 才推进),人**可绕过** |
### 2.3 project_services 表(基础设施配置)
```sql
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, -- 连接地址(localhost:3306 / URL)
config_json TEXT, -- 类型相关配置 JSON
environment TEXT NOT NULL DEFAULT 'development', -- development/staging/production
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
**定位**:AI 执行任务时的基础设施上下文。AI 知道"这个项目用了什么数据库、Redis 在哪、有没有 MQ"。
**边界**:
- ⚠️ **不存敏感凭证**(密码/密钥)—— 只存连接信息,凭证走环境变量/外部密钥管理
- 这是元数据,不是运维工具 —— 不做连接池/健康检查
### 2.4 project_events 表(统一事件流)
```sql
CREATE TABLE project_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
event_type TEXT NOT NULL,
-- idea_created / idea_promoted / idea_evaluated
-- task_created / task_advanced / task_blocked / task_completed
-- workflow_started / workflow_completed
-- knowledge_extracted / knowledge_referenced
-- service_added / service_updated
-- module_bound / module_unbound
-- decision_made
entity_type TEXT, -- idea/project/task/workflow/knowledge/module/service
entity_id TEXT, -- 对应实体 ID
from_state TEXT, -- 状态变化前
to_state TEXT, -- 状态变化后
context_json TEXT, -- 事件附加上下文
source TEXT, -- ai / human / system
conversation_id TEXT, -- 触发事件的对应对话(AI Working 溯源)
created_at TEXT NOT NULL
);
CREATE INDEX idx_project_events_project ON project_events(project_id, created_at);
CREATE INDEX idx_project_events_entity ON project_events(entity_type, entity_id);
```
**定位**:AI 精准检索的基础。回答"上周做了什么""这个任务为什么 blocked""这个决策是什么时候做的"。
**与 knowledge_events 的关系**:knowledge_events 保持不变(知识库专属,向后兼容)。project_events 是全局事件流,knowledge 事件同时写入两者。
**埋点策略**:在现有 IPC 命令(create_task / advance_task / create_project 等)的执行后追加事件写入。不侵入业务逻辑,用 hook/after 模式。
### 2.5 Inbox 项目(project_id NOT NULL 兼容)
需求池的任务可能还没决定归到哪个项目。保持 `project_id NOT NULL` 约束,系统初始化自动创建 Inbox 项目:
- name = "收件箱"
- status = planning
- 特殊标记不可删除
- 不参与 Dashboard 统计
---
## 三、完整图谱视图
```
💡 Idea
╱ │ ╲
evaluations related_ids
│
promoted_to
│
▼
┌────── 📂 Project ──────────────────────────────────┐
│ ╱ │ ╲ │
│ modules services project_events │
│ (多工程) (基础设施) (统一事件流) │
│ │
│ ╱ │ ╲ │
│ parent links workflow │
│ (纵向) (横向) (执行) │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 🔀 Task ── Task ── ⚙️ Workflow │
│ │ ╱ ╲ │
│ content_json nodes branches │
│ (执行规格) (节点执行) (分支) │
│ │
│ queue (管理池: backlog/todo/decision/active/done) │
│ │
│ ai_tool_executions ←─ conversation_id ──→ 💬 对话 │
│ (AI 操作审计) (AI Working 留痕) │
│ │
│ knowledges ←── knowledge_events │
│ (经验沉淀) (知识事件) │
└────────────────────────────────────────────────────┘
每条线都是 AI 可查询、可推理的关系。
```
---
## 四、AI 上下文注入
### 4.1 get_project_context 工具
AI "比人懂项目"的技术实现 —— 一次调用获得完整心智模型:
```jsonc
// get_project_context(project_id) →
{
"project": {
"id", "name", "status", "stack",
"created_from": { "idea_id", "ai_analysis", "score" }
},
"modules": [
{ "name", "path", "stack", "dependencies": [...] }
],
"services": [
{ "type", "endpoint", "environment" }
],
"task_summary": {
"total", "by_status", "by_queue",
"blocked_tasks": [{ "id", "title", "blocked_by" }],
"in_progress": [{ "id", "title", "progress" }]
},
"task_graph": {
"parent_children": { "parent_id": ["child_id", ...] },
"dependencies": [{ "from", "to", "type" }]
},
"recent_events": [/* 最近 20 条事件 */],
"key_decisions": [/* 重要决策记录 */],
"known_pitfalls": [/* knowledges kind=pitfall */]
}
```
### 4.2 自动注入策略
此上下文可自动注入到每次 AI 对话的 system prompt(类似现有 useAiContext),让 AI 在每轮交互中都带着完整的项目认知。
**Token 预算**:完整上下文可能较大,需分级注入:
- **L0(始终注入)**:project 基础信息 + task_summary(~500 token)
- **L1(对话开始注入)**:task_graph + recent_events(~2000 token)
- **L2(按需调用)**:full context(get_project_context 工具)
---
## 五、AI 工具注册表
| 工具 | 用途 | 风险等级 |
|------|------|---------|
| `create_task` | 创建任务(支持 queue/parent_id/content_json) | low |
| `list_tasks` | 查询(支持 queue/parent_id/status 筛选) | low(只读) |
| `move_task_queue` | 跨池移动(backlog→todo→active→done) | medium |
| `create_task_link` | 建立任务关联(depends_on/blocks/relates_to) | medium |
| `remove_task_link` | 解除关联 | medium |
| `list_task_links` | 查询任务关联(AI 编排调度用) | low(只读) |
| `get_task_tree` | 获取父子任务树(含子任务进度聚合) | low(只读) |
| `update_content` | 更新 content_json 结构化规格 | medium |
| `get_project_context` | 获取项目完整图谱投影 | low(只读) |
| `get_project_timeline` | 查询项目事件流 | low(只读) |
| `add_project_service` | 添加基础设施配置 | medium |
| `list_project_services` | 查询基础设施 | low(只读) |
已有工具(扩展参数):`advance_task`、`update_task`、`list_tasks`。
---
## 六、AI Working 完整工作流
```
人在 AI Chat 说:"我想给 DevFlow 加队列看板功能"
│
▼ ① AI 创建需求
create_task(
title="队列看板功能",
queue="backlog",
content_json={ background, acceptance_criteria, scope, technical_design }
)
│
▼ ② 人确认需求(AI Chat 内审批)
人修改 content_json → AI 执行 move_task_queue(backlog → todo)
│
▼ ③ AI 自主分解
AI 创建子任务(parent_id 指向父任务):
child-1: "V28 迁移 + queue 列 + task_links 表"
child-2: "TaskRecord + Repo 扩展"
child-3: "Board.vue 看板视图"
child-4: "AI 工具注册"
│
▼ ④ AI 建立依赖
create_task_link(child-3, depends_on, child-1)
create_task_link(child-3, depends_on, child-2)
create_task_link(child-4, depends_on, child-2)
│
▼ ⑤ AI 按拓扑序执行
拓扑排序:child-1 → child-2 → child-3 ∥ child-4
AI 逐个 advance_task(todo → in_progress → ... → done)
每完成一个检查后继解锁
每步操作写入 project_events
│
▼ ⑥ AI 自检
对照 content_json.acceptance_criteria 逐项检查
│
▼ ⑦ 父任务聚合
全部子任务 done → 父任务自动聚合 done
AI 报告人类:"队列看板功能已完成"
```
---
## 七、改动影响评估
| 层 | 改动 | 量级 | 风险 |
|---|------|------|------|
| df-storage V28 | queue 列 + parent_id 列 + content_json 列 + task_links 表 + project_services 表 + project_events 表 | 中 | 低(纯加列加表) |
| df-types | TaskRecord 加 3 字段 + TaskLink / ProjectService / ProjectEvent 类型 | 小 | 低 |
| task_repo | insert/update 补字段 + TaskLinkRepo / ProjectServiceRepo / ProjectEventRepo | 中 | 低 |
| task_state_machine | **不改** | 无 | 无 |
| 父任务聚合逻辑 | 新增(子任务 status 变化时重算父 status) | 中 | 中(需定义聚合规则) |
| 事件埋点 | 在现有 IPC 命令后追加事件写入 | 中 | 低(hook 模式,不侵入) |
| IPC | create_task 加可选参数 + link/service/event CRUD + move_task_queue | 中 | 低 |
| AI 工具 | 新增 12 个工具注册 + create_task 参数扩展 | 中 | 低 |
| 前端 Board 看板 | 新增视图 | 中 | 低 |
| 前端 Task 详情 | 结构化渲染 content_json + 子任务列表 + 关联面板 | 中 | 低 |
| 前端项目详情 | 基础设施面板 + 时间线面板 | 中 | 低 |
| Inbox 项目 | 系统初始化创建 | 小 | 低 |
---
## 八、实现路径(按 AI 价值排序)
### Phase 1 — 任务网络基础(AI 编排的地基)
| 任务 | 内容 | 依赖 |
|------|------|------|
| V28 迁移 | queue + parent_id + content_json 列 + task_links 表 | 无 |
| TaskRecord 扩展 | df-types + df-storage models 补字段 | V28 |
| TaskLinkRepo | CRUD + 按 source/target 查询 + 循环依赖检测 | V28 |
| task_repo 扩展 | insert/update 补 queue/parent_id/content_json | TaskRecord |
| IPC: create_task 扩展 | 加 queue/parent_id/content_json 可选参数 | task_repo |
| IPC: task_link CRUD | create/remove/list_task_links | TaskLinkRepo |
| IPC: move_task_queue | 跨池移动 + 一致性约束校验 | task_repo |
| 父任务聚合 | 子任务 status 变化时重算父 status | task_repo |
| AI 工具注册 | create_task_link / remove_task_link / list_task_links / get_task_tree / move_task_queue / update_content | IPC |
### Phase 2 — 统一事件流(AI 精准检索的地基)
| 任务 | 内容 | 依赖 |
|------|------|------|
| project_events 表 | V28(或 V29 独立迁移) | 无 |
| ProjectEventRepo | CRUD + 按 project_id 查询 + 按 entity 查询 | project_events |
| 事件埋点 | create_task / advance_task / create_project / idea_promote 等追加事件写入 | ProjectEventRepo |
| IPC: get_project_timeline | 事件流查询(支持时间范围/事件类型筛选) | ProjectEventRepo |
| AI 工具注册 | get_project_timeline | IPC |
### Phase 3 — 项目基础设施(AI 上下文补全)
| 任务 | 内容 | 依赖 |
|------|------|------|
| project_services 表 | V28(或 V30 独立迁移) | 无 |
| ProjectServiceRepo | CRUD | project_services |
| IPC: service CRUD | add/update/remove/list_project_services | ProjectServiceRepo |
| modules 表 | 复用多工程方案子1 | 无 |
| AI 工具注册 | add_project_service / list_project_services | IPC |
### Phase 4 — AI 上下文注入(AI 使用知识图谱的接口)
| 任务 | 内容 | 依赖 |
|------|------|------|
| get_project_context 工具 | 聚合查询返回完整图谱投影 | Phase 1-3 |
| 分级注入策略 | L0 始终注入 / L1 对话开始 / L2 按需调用 | get_project_context |
| useAiContext 扩展 | 对接现有上下文注入机制 | 分级注入策略 |
### Phase 5 — 前端可视化(人的查看层)
| 任务 | 内容 | 依赖 |
|------|------|------|
| Board 看板 | 三列拖拽 + 跨项目展示 + 快速录入 | Phase 1 |
| Task 详情增强 | content_json 结构化渲染 + 子任务列表 + 关联面板 | Phase 1 |
| 项目时间线 | 事件流可视化 | Phase 2 |
| 基础设施面板 | 服务列表 + 增删改 | Phase 3 |
| 项目图谱视图 | 全景关系图(可选,优先级最低) | Phase 1-3 |
---
## 九、关键设计决策记录
| # | 决策点 | 选择 | 理由 |
|---|--------|------|------|
| D1 | queue vs status 扩展 | 新增 queue 字段 | 正交维度,状态机不动,职责分离 |
| D2 | parent_id 嵌套深度 | 限制 1 级 | 避免递归复杂度和进度幻觉 |
| D3 | 父任务语义 | 容器模型,status 聚合计算 | 父任务不执行工作流 |
| D4 | 横向关联 | 独立 task_links 表 | 有类型、可双向查询,避免 JSON 方案已知缺陷 |
| D5 | 需求结构化 | content_json + description 并存 | 结构化渲染 + 自由描述互补 |
| D6 | project_id 约束 | 保持 NOT NULL + Inbox 项目 | 最小改动,GTD 收件箱模式 |
| D7 | depends_on 执行约束 | AI 应遵守,人可绕过 | 过强约束会被绕过 |
| D8 | 循环依赖检测 | 创建 link 时 BFS 检测 | task_links 数据量小,检测成本低 |
| D9 | 事件流与 knowledge_events | 并存不替代 | knowledge_events 向后兼容 |
| D10 | 敏感凭证 | 不存入 project_services | 安全边界,凭证走环境变量 |
| D11 | 数据结构定位 | 为 AI 消费设计,非为人填表单 | AI Working 核心原则 |
| D12 | 实现优先级 | 按 AI 价值排序非按 UI 需求 | AI Working 核心原则 |
---
## 十、任务全景视图设计(Mission Control)
> **核心目标**:一个页面,看清全部项目的任务推进过程、状态分布、依赖关系、时间维度、卡点阻塞。
### 10.1 信息架构
用户要的是五个维度的清晰呈现,对应五种视图模式:
| 维度 | 视图 | 核心问题 | 数据来源 |
|------|------|---------|---------|
| **推进过程** | 看板视图 | 任务在 backlog→todo→active→done 各池的分布和流动 | tasks.queue |
| **状态分布** | 热力矩阵 | 每个项目 × 每个状态的交叉分布,一眼看全局 | tasks GROUP BY project, status |
| **依赖关系** | DAG 图 | 任务间依赖链、拓扑序、关键路径 | task_links + parent_id |
| **时间维度** | 时间线 | 任务的创建/推进/完成时间线,最近活跃度 | tasks.created_at/updated_at + project_events |
| **卡点阻塞** | 阻塞面板 | 哪些任务 blocked、为什么 blocked、阻塞了谁 | tasks.status=blocked + task_links(blocks) |
### 10.2 路由 & 页面结构
```
/mission-control → MissionControl.vue(壳页面,顶部 Tab 切换五种视图)
├── Tab 1: 看板(KanbanView.vue) — 默认视图
├── Tab 2: 矩阵(MatrixView.vue)
├── Tab 3: 依赖图(DagView.vue)
├── Tab 4: 时间线(TimelineView.vue)
├── Tab 5: 阻塞(BlockedView.vue)
└── 公共侧栏: 筛选器(项目/优先级/负责人)
```
侧栏新增导航项:**🚀 任务全景**(Mission Control),位于"任务"和"知识库"之间。
### 10.3 各视图详细设计
#### 10.3.1 看板视图(KanbanView)— 推进过程
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 💡 需求池(3) │ 📋 待办(5) │ 🔨 执行中(2) │ ✅ 完成(12) │
│ │ │ │ │
│ ┌───────────────┐ │ ┌────────────────┐ │ ┌──────────────┐ │ ┌──────────┐ │
│ │ 队列看板功能 │ │ │ V28 迁移+表结构 │ │ │ TaskRecord扩 │ │ │ 软删除 │ │
│ │ DevFlow · P1 │ │ │ DevFlow · P0 │ │ │ DevFlow·P1 │ │ │ B-13 │ │
│ │ 🔗 4 子任务 │ │ │ 🔗 → Board.vue │ │ │ ▓▓▓▓▓░░ 70% │ │ │ 06-16 │ │
│ └───────────────┘ │ └────────────────┘ │ └──────────────┘ │ └──────────┘ │
│ ┌───────────────┐ │ ┌────────────────┐ │ │
│ │ 插件机制 │ │ │ DagView 依赖图 │ │ │
│ │ DevFlow · P2 │ │ │ DevFlow · P1 │ │ │
│ └───────────────┘ │ └────────────────┘ │ │
│ │ │ │
│ ⚠️ 待决策(1) │ │ 🚫 阻塞(1) │
│ ┌───────────────┐ │ │ ┌──────────────┐ │
│ │ API 协议选型 │ │ │ │ IPC 层封装 │ │
│ │ ❓需确认 │ │ │ │ 被 V28 阻塞 │ │
│ └───────────────┘ │ │ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
**列定义**(对应 queue 值):
| 列 | queue | 子列 | 说明 |
|----|-------|------|------|
| 需求池 | backlog | — | 确认有价值,未排期 |
| 待决策 | decision | — | 需要人拍板,**红色高亮** |
| 待办 | todo | — | 已排期,可执行 |
| 执行中 | active | 正常 | 按状态机推进中 |
| 执行中 | active | 阻塞 | status=blocked,**红色边框 + 阻塞原因** |
| 完成 | done | — | 终态 |
**卡片信息密度**:
```
┌──────────────────────────┐
│ 任务标题(1 行截断) │ ← title
│ 项目名 · 优先级标签 │ ← project.name + priority badge
│ 🔗 3 子任务 / 🔗→ 依赖任务 │ ← parent_id 子任务计数 / depends_on 链
│ ▓▓▓▓░░ 60% │ ← 子任务完成率(父任务)/ 状态机进度(叶子)
│ 🚫 被 xxx 阻塞 │ ← blocked 时显示阻塞源(仅红色卡片)
│ 06-26 │ ← updated_at(相对时间)
└──────────────────────────┘
```
**交互**:
- 点击卡片 → 跳转 TaskDetail
- 卡片左滑/右滑 → 快捷 move_task_queue(需确认)
- 列头显示计数 + 拖拽提示
- 顶部全局筛选:项目(多选)/ 优先级 / 负责人
**跨项目展示**:卡片左上角的项目色条(3px)区分项目归属,悬停显示项目全名。
#### 10.3.2 热力矩阵(MatrixView)— 状态分布
```
todo in_progress in_review testing blocked done 总计
DevFlow 3 2 1 0 2 12 20
u-ask 1 0 0 0 0 5 6
u-img 0 1 0 0 0 3 4
workpod 2 0 0 0 0 1 3
─────────────────────────────────────────────────────────────────────────
总计 6 3 1 0 2 21 33
```
**视觉**:每个格子是一个色块,数字越大颜色越深(热力图)。blocked 列**红色系**,done 列**绿色系**,其他列**蓝色系**。
**交互**:
- 点击格子 → 展开该项目该状态的任务列表(inline accordion 或侧抽屉)
- 行/列可排序(按总计降序)
- 顶部摘要条:`33 任务 · 2 阻塞 · 3 执行中 · 完成率 64%`
**AI 消费价值**:get_project_context 的 task_summary.by_status / by_queue 就是这个矩阵的数据源。AI 读到 `blocked: 2` 就知道要主动排查阻塞。
#### 10.3.3 依赖图(DagView)— 依赖关系
```
┌──────────┐
│ V28 迁移 │ ← 无依赖,可立即执行
│ (done) │
└────┬─────┘
│ depends_on
▼
┌──────────┐
│ TaskRepo │
│ 扩展(todo) │
└──┬────┬──┘
│ │
▼ ▼
┌─────────┐ ┌──────────┐
│ IPC 封装 │ │ AI 工具 │
│(blocked)│ │ 注册(todo) │ ← 被 V28 阻塞(虚线 blocks)
└────┬────┘ └──────────┘
│
▼
┌─────────┐
│Board.vue │
│ (todo) │
└─────────┘
```
**节点视觉**:
- 状态颜色:todo=灰 / in_progress=蓝 / in_review=黄 / testing=橙 / done=绿 / blocked=红
- 父任务=大圆角矩形,子任务=小圆角矩形,用虚线框分组
- depends_on=实线箭头 → / blocks=红色虚线箭头 ⇢ / relates_to=灰色点线 ⋯
**布局算法**:
- 拓扑排序分层(Sugiyama / 简化版:按依赖深度分层)
- 同层节点水平排列
- 父子关系用虚线框圈住
**交互**:
- 点击节点 → 高亮该节点的所有依赖链(上游+下游路径加粗)
- 双击节点 → 跳转 TaskDetail
- 悬停节点 → 显示 tooltip(标题/状态/阻塞原因)
- 右键节点 → 快捷操作(推进/解除阻塞/编辑依赖)
- 顶部工具栏:仅看阻塞链 / 仅看关键路径 / 按项目着色
**关键路径标注**:拓扑排序后,从入度=0 的节点到出度=0 的节点的最长路径,用粗线标注。
**数据量控制**:
- 默认只渲染 active + todo + blocked 的任务(隐藏 done/cancelled)
- 超过 30 个节点时提示"节点过多,请按项目筛选"
- 支持按项目过滤
#### 10.3.4 时间线(TimelineView)— 时间维度
两种模式:
**模式 A:甘特图(横向时间轴)**
```
06-24 06-25 06-26 06-27 06-28 06-29 06-30
V28迁移 ████████←done
TaskRepo ████████←in_progress 70%
IPC封装 ██████←blocked(等V28)
Board.vue ░░░░░░←todo(未开始)
AI工具注册 ░░░░░░←todo
```
- 横轴=日期(created_at → 预估完成 / 实际 updated_at)
- 每行一个任务,条形颜色=状态色
- blocked 段叠加红色斜纹
- 父任务行展开显示子任务
**模式 B:事件流(纵向时间轴)**
```
今天 06-26
│
├─ 14:30 ✅ V28 迁移完成 DevFlow
├─ 13:15 🔨 TaskRepo 扩展开始 DevFlow
├─ 11:00 🚫 IPC 封装被阻塞 DevFlow ← 红色高亮
│ 原因:等待 V28 迁移完成
├─ 09:45 💡 创建需求"队列看板" DevFlow
│
昨天 06-25
│
├─ 22:10 ✅ B-27 审批状态统一 DevFlow
├─ 18:30 🔨 V28 迁移开始 DevFlow
...
```
- 来源:project_events 表
- 事件类型图标 + 颜色区分
- blocked/decision 事件**红色高亮**
- 按天分组,可折叠
- 支持按项目/事件类型筛选
- 无限滚动加载
**切换**:顶部 Toggle 切换甘特/事件流两种模式。
#### 10.3.5 阻塞面板(BlockedView)— 卡点
**这是最重要的视图** —— 回答"哪里卡住了、为什么、阻塞了谁"。
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🚫 阻塞任务 (2) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 🔴 IPC 层封装 DevFlow · P1 │
│ 状态: blocked ← 3 天 │
│ 阻塞原因: 等待 V28 迁移完成 │
│ 阻塞了谁: ↓ │
│ └─ Board.vue 看板视图 (todo,无法开始) │
│ └─ AI 工具注册 (todo,无法开始) │
│ 连锁影响: 2 个下游任务被阻塞 │
│ [解除阻塞] [查看详情] │
│ │
│ 🔴 API 协议选型 u-ask · P0 │
│ 状态: blocked ← 7 天 ⚠️ 长时间阻塞 │
│ 阻塞原因: 需要确认 REST vs GraphQL │
│ 阻塞了谁: ↓ │
│ └─ 后端 API 重构 (todo) │
│ └─ 前端适配 (todo) │
│ 连锁影响: 3 个下游任务被阻塞 │
│ [解除阻塞] [查看详情] │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ ⚠️ 待决策 (1) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❓ 缓存策略选型 workpod · P2 │
│ 队列: decision ← 2 天 │
│ 决策内容: Redis vs 本地缓存 │
│ 阻塞了谁: ↓ │
│ └─ 缓存模块实现 (backlog) │
│ [做出决策] [查看详情] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
**数据来源**:
- `tasks WHERE status = 'blocked'` → 阻塞任务列表
- `task_links WHERE target_id = :blocked_task_id AND link_type = 'depends_on'` → 查谁被它阻塞
- `tasks WHERE queue = 'decision'` → 待决策列表
- BFS 遍历依赖链计算"连锁影响数"(下游被间接阻塞的任务总数)
**关键视觉**:
- 阻塞时间 > 3 天 → 黄色警告标记
- 阻塞时间 > 7 天 → 红色闪烁标记 + 置顶
- 连锁影响 > 3 → 加粗影响数
- 每个阻塞项可展开"阻塞链"树形图
**排序策略**:
1. 连锁影响数降序(阻塞越多越优先处理)
2. 阻塞时间降序(越久越紧急)
3. 优先级降序
### 10.4 公共筛选器(侧栏)
所有视图共享的筛选维度:
```
┌──────────────────────┐
│ 🔍 筛选 │
│ │
│ 项目 │
│ ☑ DevFlow │
│ ☑ u-ask │
│ ☐ u-img │
│ ☑ workpod │
│ │
│ 优先级 │
│ ☑ P0 紧急 │
│ ☑ P1 高 │
│ ☑ P2 中 │
│ ☐ P3 低 │
│ │
│ 负责人 │
│ [全部 ▾] │
│ │
│ 隐藏已完成 │
│ [开关] │
│ │
│ 仅看阻塞链 │
│ [开关] │
└──────────────────────┘
```
### 10.5 后端 API 设计
| IPC Command | 用途 | 参数 |
|-------------|------|------|
| `get_mission_kanban` | 看板视图数据(按 queue 分组) | project_ids?, priority? |
| `get_mission_matrix` | 热力矩阵数据(project × status 交叉统计) | project_ids? |
| `get_mission_dag` | DAG 依赖图数据(节点+边) | project_ids?, include_done? |
| `get_mission_timeline` | 时间线数据(事件流 or 甘特图) | project_ids?, date_from?, date_to?, mode |
| `get_mission_blocked` | 阻塞面板数据(含连锁影响计算) | project_ids? |
**性能考虑**:
- 任务量级 ~50-200(桌面应用单用户),全量查询无压力
- DAG 连锁影响用 Rust BFS 遍历,不走 SQL 递归
- 时间线分页加载(每次 50 条事件)
- 矩阵统计用 SQL `GROUP BY project_id, status` 一次查询
### 10.6 AI 消费场景
这些视图的数据结构同时也是 AI 的上下文:
| AI 场景 | 数据来源 | 对应视图 |
|---------|---------|---------|
| "项目进展如何?" | get_mission_matrix | 热力矩阵 |
| "有什么卡住了?" | get_mission_blocked | 阻塞面板 |
| "下一步做什么?" | get_mission_dag(拓扑排序入度=0 的 todo 任务) | 依赖图 |
| "上周做了什么?" | get_mission_timeline(事件流) | 时间线 |
| "这个需求拆得对不对?" | get_task_tree + get_mission_dag | 看板 + 依赖图 |
**AI 自动排查阻塞**:当 get_project_context 返回 blocked_tasks 非空时,AI 可主动在对话中提示"检测到 2 个任务被阻塞,是否需要排查?"并附带阻塞链摘要。
### 10.7 实现优先级
| 优先级 | 视图 | 理由 |
|--------|------|------|
| **P0** | 阻塞面板 | 最高价值:一眼看卡点,当前完全缺失 |
| **P0** | 看板视图 | 核心交互:queue 流动可视化 |
| **P1** | 热力矩阵 | 全局分布:跨项目状态一览 |
| **P1** | 时间线(事件流模式) | 追溯能力:做了什么/何时做的 |
| **P2** | 依赖图(DAG) | 编排可视化:需要 task_links 先落地 |
| **P2** | 时间线(甘特图模式) | 锦上添花:需要预估完成时间字段 |
**P0 可独立先做**:阻塞面板 + 看板视图只需要 tasks 表的 queue/status 字段(Phase 1),不依赖 task_links(依赖图才需要)。
---
## 十一、边界与风险
### 10.1 已识别风险
| 风险 | 影响 | 缓解 |
|------|------|------|
| queue 与 status 一致性 | 两个字段可能不同步 | IPC 层强制校验约束规则 |
| 父任务聚合性能 | 子任务变化触发父重算 | 项目内任务量小(~50),实时计算无压力 |
| 事件流写入失败 | 埋点失败影响事件完整性 | 事件写入失败不阻断主操作(best-effort) |
| get_project_context 体积 | 大项目上下文可能超 token 预算 | 分级注入 L0/L1/L2 |
| task_links 循环依赖 | 死锁 | BFS 检测 + 拒绝创建 |
### 10.2 不做的事
- ❌ 不做无限嵌套任务树(限制 1 级)
- ❌ 不做凭证存储/管理(安全边界外)
- ❌ 不做事件流回填(历史数据不补事件,从上线时间点开始记录)
- ❌ 不做前端拖拽排序的 sort_order(queue 内排序按 priority + created_at)
- ❌ 不做看板的 WIP 限制(列宽限制,过度设计)
@@ -37,6 +37,7 @@
- **影响**:1.32M 接近模型上下文上限,长会话必爆;成本几乎全烧在重复搬运文件内容。 - **影响**:1.32M 接近模型上下文上限,长会话必爆;成本几乎全烧在重复搬运文件内容。
- **待核验源码点**:compress 模块(`src-tauri/src/commands/ai/` 下 compress 相关)——压缩后重组 messages 时是否真替换为摘要,还是仅打标记原样回灌。 - **待核验源码点**:compress 模块(`src-tauri/src/commands/ai/` 下 compress 相关)——压缩后重组 messages 时是否真替换为摘要,还是仅打标记原样回灌。
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] 3-compress 单元的 2 个 smell。 - **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] 3-compress 单元的 2 个 smell。
- **[2026-06-24 走查结论:已隐式修复,非当前缺陷]** 源码逐环核验:`build_for_request`(context.rs:147)两分支(未超预算 :161 / 超预算裁剪 :200)都走 `sanitize_messages`,step0(:231-234) `filter is_active()` 把 `status=compressed` 滤出发送 prompt;`is_active` 白名单(provider.rs:97-99 `None|Some("active")`)使 compressed→false;`compress_old_messages`(:820-831)标 compressed+扣 token,`push`(:76)仅 active 计 history_tokens → **compressed 真不进 LLM prompt**。is_active 白名单提交 `8a142c2`(2026-06-17)在 P0-1 实测会话(84dc36c8 ~06-22)之前。报告所见「66 compressed 但 prompt 1.32M」真因 = F-15 白名单前的老数据残留 / 或落库全量消息数 vs 送 LLM prompt token 测量混淆。**compress_with_focus 不必要**。
### [P0-2] 工具返回值协议不一致:`patch_file` / `read_file` 部分路径裸吐文件全文 ### [P0-2] 工具返回值协议不一致:`patch_file` / `read_file` 部分路径裸吐文件全文
+140 -5
View File
@@ -8,6 +8,77 @@
--- ---
## 🎯 统一规划(2026-06-26 全景)
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅落地待实测) + 多工程方案。
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
### 主线脉络
```
G1目标钉扎(✅落地,待实测) → 知识图谱Phase1(任务网络,AI编排地基)
↓ ↓
实测验证 Phase4注入(复用G1的system_prompt注入基建)
↓ ↑
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
```
### 任务图谱(DAG · 有方向有状态)
> 节点=任务(着色=状态),边=依赖方向(前置 → 后继)。**推进时更新状态**(CLI/edit 同步此表 + Mermaid)。
> 图例:✅完成 / 🔨进行中 / 📋待办 / 🚫阻塞
```mermaid
graph TD
classDef done fill:#bfb,stroke:#393,stroke-width:2px
classDef doing fill:#ffd,stroke:#993,stroke-width:2px
classDef todo fill:#eef,stroke:#99c
classDef blocked fill:#fcc,stroke:#c33
G1["G1 目标钉扎<br/>✅代码 8ce18cb / 🔨待实测"]:::doing
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
P3["父③ AI对话体验<br/>📋待办"]:::todo
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
P6["父⑥ Phase2-5<br/>🔨Phase2-3✅·4-5待办"]:::doing
P7["父⑦ 技术债<br/>📋待办"]:::todo
P1 -.先行.-> P2
G1 ==基建==> P6
P2 ==> P6
P4 ==> P6
P2 -.task_links.-> P5
```
| 任务 | 状态 | 子项 | 依赖(前置) |
|---|---|---|---|
| **G1** 目标钉扎 | 🔨 代码✅`8ce18cb` 待实测 | G1/G2/G4 落地 | — |
| **父①** 小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(弱) |
| **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — |
| **父④** F-09 per-conv | ✅ 前端per-conv | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
| **父⑥** Phase2-5 | 🔨 Phase2-3✅ | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31 project_services+D10凭证审查+IPC+AI工具,基线41) / ⑥.3注入(依赖全就绪) / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 |
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
### 协同点(跨主题复用)
1. **IDEA-FIX-07 关联单向 ↔ 知识图谱 `task_links`**:并入 Phase 1 用独立表(§2.2 论证 JSON 缺陷),别补 JSON 双向同步
2. **G1 system_prompt 注入基建 ↔ Phase 4 L0 注入**:复用 `pinned_goal` 拼接链路
3. **F-09 per-conv ↔ Phase 4 多会话注入**:F-09 前置
4. **REFACTOR-260619-04 审批状态机 ↔ `queue=decision` 待决策池**:审批对齐
### 待决策
| 决策点 | 选项 |
|---|---|
| 多工程合并 | 方案 A 合并 V28(前提:确认子1 表设计,find 未找到独立文档)/ 知识图谱独立 Phase 1 |
| conditions 启用 | 启用求值 / 保留观察 |
| BUG-260620-05 层2 | workspace_root 外默认放行(黑名单制)?— 详见 `## 🔴 2026-06-20 BUG-260620-05` |
---
## 交接状态(2026-06-14) ## 交接状态(2026-06-14)
**代码健康度**:`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error(主代理独立验证,非 mission 自报)。 **代码健康度**:`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error(主代理独立验证,非 mission 自报)。
@@ -40,10 +111,12 @@
- [x] TD-260621-02 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·pendingMaxRounds ref<string|null> 挂起 convId + 守卫比对 activeConversationId·DirAuthDialog visibleDirAuths filter conversationId·2026-06-21;🟡D pendingDirAuths 全清→per-conv filter 由 wqnd4axf8 补) — **pendingDirAuth/pendingMaxRounds 全局单 ref 非 per-conv**。`useAiEvents.ts:99/113` 模块级单实例;DirAuthDialog:60/MaxRoundsCard:54 守卫仅判 isViewingGenerating 未比对 conversationId(pendingMaxRounds 纯 boolean 无 convId 字段)。真并发下挂起弹窗错显于非挂起会话。修:pendingMaxRounds 改 ref<string|null>(convId)+ DirAuth 守卫加 convId 比对。归 F-09 B 路线。 - [x] TD-260621-02 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·pendingMaxRounds ref<string|null> 挂起 convId + 守卫比对 activeConversationId·DirAuthDialog visibleDirAuths filter conversationId·2026-06-21;🟡D pendingDirAuths 全清→per-conv filter 由 wqnd4axf8 补) — **pendingDirAuth/pendingMaxRounds 全局单 ref 非 per-conv**。`useAiEvents.ts:99/113` 模块级单实例;DirAuthDialog:60/MaxRoundsCard:54 守卫仅判 isViewingGenerating 未比对 conversationId(pendingMaxRounds 纯 boolean 无 convId 字段)。真并发下挂起弹窗错显于非挂起会话。修:pendingMaxRounds 改 ref<string|null>(convId)+ DirAuth 守卫加 convId 比对。归 F-09 B 路线。
- [x] TD-260621-03 [P1·patch_file 落地债 F-260615] ✅(读改写整体移入 _patch_guard 锁内串行化防 lost update + 删 entry().or_insert() 内存泄漏 + drop 写序列后释放·2026-06-21) — **patch_file 读改写在 FILE_LOCKS 外,lost update**。`tool_registry.rs:1247` 指纹校验(1176)+old_text CAS(1209)+new_content 计算(1197-1240)全在无锁纯读段;FILE_LOCKS(1242-1269)仅序列化 backup→tmp→rename 不防读写交错。两并发 patch 同文件:B 用基于旧内容算的 new_content 覆盖 A。write_file 不取 FILE_LOCKS 同根因。修:读+校验+new_content 整体移入锁内,或 per-path Mutex。 - [x] TD-260621-03 [P1·patch_file 落地债 F-260615] ✅(读改写整体移入 _patch_guard 锁内串行化防 lost update + 删 entry().or_insert() 内存泄漏 + drop 写序列后释放·2026-06-21) — **patch_file 读改写在 FILE_LOCKS 外,lost update**。`tool_registry.rs:1247` 指纹校验(1176)+old_text CAS(1209)+new_content 计算(1197-1240)全在无锁纯读段;FILE_LOCKS(1242-1269)仅序列化 backup→tmp→rename 不防读写交错。两并发 patch 同文件:B 用基于旧内容算的 new_content 覆盖 A。write_file 不取 FILE_LOCKS 同根因。修:读+校验+new_content 整体移入锁内,或 per-path Mutex。
- [x] TD-260621-04 [P1·patch_file 落地债 F-260615] ✅(read_file 三返回点返 file_hash 闭环 + 抽 compute_file_hash helper read_file/patch_file 共用消格式漂移·2026-06-21) — **expected_hash 契约破裂,指纹防并发形同虚设**。`tool_registry.rs:1112` schema 描述"read_file 返回的 file_hash"但 read_file handler(892-976)从不返回该字段。expected_hash 实由 patch_file 内部 format!("{}_{}",modified_secs,len)(1180)即造即比,LLM 拿不到前置值只能瞎传/省略→常 mismatch bail 或被跳过。L3 防行号漂移设计在实际 LLM 调用链落空。修:read_file 返回 file_hash 同格式闭环回传。 - [x] TD-260621-04 [P1·patch_file 落地债 F-260615] ✅(read_file 三返回点返 file_hash 闭环 + 抽 compute_file_hash helper read_file/patch_file 共用消格式漂移·2026-06-21) — **expected_hash 契约破裂,指纹防并发形同虚设**。`tool_registry.rs:1112` schema 描述"read_file 返回的 file_hash"但 read_file handler(892-976)从不返回该字段。expected_hash 实由 patch_file 内部 format!("{}_{}",modified_secs,len)(1180)即造即比,LLM 拿不到前置值只能瞎传/省略→常 mismatch bail 或被跳过。L3 防行号漂移设计在实际 LLM 调用链落空。修:read_file 返回 file_hash 同格式闭环回传。
- [ ] TD-260621-05 [P1·归 SMELL-P1-6] — **审批状态字符串双轨**。chat.rs:466/668 写 "executed" vs audit/mod.rs:490/545 + cache.rs:113 写 "completed",DTO 文档(models.rs:221)只列 completed。审计表混存两种"成功"态。归 SMELL-P1-6(String→enum)统一,行为变更需评估 AuditLog 显示 + find_cached 透传。 - [x] ✅(2026-06-24·ba811ea chat.rs executed→completed 6处:460幂等读/584 ai_approve写/634判定/668 IPC返回/885 authorize_dir日志/889 authorize_dir写 + df-storage V27 迁移 UPDATE 存量 executed→completed + v27 两测试 unify/idempotent·cargo check EXIT 0 + df-storage 78 passed) **TD-260621-05** [P1·归 SMELL-P1-6] — **审批状态字符串双轨**(已统一)。核验全绿:前端无硬编码 'executed'(grep 全 src 零命中,AuditLog:84 是 executed_at 字段名/settings:120 是文案)+ 无 WHERE status='completed'/'executed' 统计查询(仅 pending 查询)+ find_cached SW-16 已透传真实值兼容双值 + ai_authorize_dir 无第二处判定。治 AuditLog executed 记录显示错位(蓝pending标签+raw"executed")+ DTO audit/mod.rs:53 契约对齐。**注:仅统一状态值(String 仍 String),SMELL-P1-6 String→newtype/enum 强类型化整体仍待决策(独立项,本修复不涉)**。
**P2 精选(25 项·详单见报告)**:try_continue pending_conv_id 跨会话泄漏(agentic:1314)/ patch_file FILE_LOCKS 永不清理内存泄漏(tool_registry:1248)/ auto-compress 成功未即时 save(agentic:778)/ MidStream 保文 chunk.error 未回填 err(stream_recv:275)/ audit_tool_call `let _ =` 吞 insert 错(finalize:35)/ switch+delete retain 未配套 finalize 占位终态化(conversation:317)/ PerConvState.created_at 全死字段(mod:520)/ PendingApproval.diff 写而不读(mod:570)。 **P2 精选(25 项·详单见报告)**:try_continue pending_conv_id 跨会话泄漏(agentic:1314)/ patch_file FILE_LOCKS 永不清理内存泄漏(tool_registry:1248)/ auto-compress 成功未即时 save(agentic:778)/ MidStream 保文 chunk.error 未回填 err(stream_recv:275)/ audit_tool_call `let _ =` 吞 insert 错(finalize:35)/ switch+delete retain 未配套 finalize 占位终态化(conversation:317)/ PerConvState.created_at 全死字段(mod:520)/ PendingApproval.diff 写而不读(mod:570)。
**[2026-06-24 核验销账]**:audit_tool_call 吞错 ✅已修(commit a7dbd50,finalize:35 `let _ =`→match Err tracing::error,对齐 audit_finalize)+ switch+delete retain 占位终态化 ✅已修(a7dbd50,conversation/chat finalize_pending_placeholders pub(crate))+ patch_file FILE_LOCKS 永不清理 ✅已修(TD-260621-03 06-21,_patch_guard 串行化 + 删 entry().or_insert())+ PerConvState.created_at ✅已是正确态(mod.rs:772 #[allow(dead_code)]+预留注释「待 upsert 接入会话创建时间」)+ PendingApproval.diff ✅已是正确态(阶段3a 下沉 enum ApprovalKind::Risk{diff} 活调用链 audit/remote_bridge/useAiEvents,误删断前端 diff 预览);剩 3 项登记不动 —— try_continue pending_conv_id 🟡语义复杂(R-PD-6 优先审批 conv_id 设计+try_continue 回退,非确定性低风险)/ MidStream chunk.error 🟡UX 结构变更(Partial 保文加 err 字段+前端渲染,非 bug)/ auto-compress save 🟡需理解 loop save 时机判真缺口。
**销账核对(已登记 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 完成 + 用户实测后可清。
@@ -440,7 +513,7 @@
**P1** **P1**
- [ ] **REFACTOR-260619-04 [P1]** — **ToolCard.vue(1527 行)拆 5 子组件+composable+util**。先 `useToolFormat.ts`(纯函数 ~280,零风险)+ `ToolResultBody.vue`(420 最大块);再 ToolCardHeader/ToolApproval/useToolApproval。风险:折叠态 shouldKeepOpen 三处共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue` - [ ] **REFACTOR-260619-04 [P1·部分已做]** — **ToolCard.vue 拆分**。**2026-06-24 核验**:ToolCard 已从 1527→**428 行**,已抽 `ToolResultBody.vue` + `useToolCardHeader.ts` + `useToolCardRender.ts`(主体瘦身完成);但原计划的 `useToolFormat.ts`/`useToolApproval.ts`/`ToolCardHeader.vue`/`ToolApproval.vue` 未独立(当前 useToolCard/useToolCardHeader/useToolCardRender 三模块)。剩余:审批状态机独立(useToolApproval)+ 头部组件(ToolCardHeader)。风险:折叠态 shouldKeepOpen 共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue`
- [x] ✅(2026-06-20 核验已拆分·`agentic.rs` 已删除→`src-tauri/src/commands/ai/agentic/` 目录:mod.rs(83390 字节·run_agentic_loop 主 loop)/guard.rs(2894·GeneratingGuard+ContinueSnapshot 抽出对齐 A+E+F `agentic_runtime`)·stream_recv.rs(独立文件,StreamOutcome+stream_one_provider 对齐 B+C `agentic_stream`)) **REFACTOR-260619-05 [P1]** — **agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**。主 loop 是**单函数 720 行不可按函数拆**(工具执行/审批是 loop 内 if 分支;process_tool_calls 在 audit.rs)。先抽 A+E+F `agentic_runtime.rs`(GeneratingGuard+try_continue_agent_loop+ContinueSnapshot,~195,最高收益最低风险)+ B+C `agentic_stream.rs`(StreamOutcome+stream_one_provider,~165)。**D run_agentic_loop 等 F-09 B 批4 落地再评估**(避免 per_conv 双线作战)。— `src-tauri/src/commands/ai/agentic.rs` - [x] ✅(2026-06-20 核验已拆分·`agentic.rs` 已删除→`src-tauri/src/commands/ai/agentic/` 目录:mod.rs(83390 字节·run_agentic_loop 主 loop)/guard.rs(2894·GeneratingGuard+ContinueSnapshot 抽出对齐 A+E+F `agentic_runtime`)·stream_recv.rs(独立文件,StreamOutcome+stream_one_provider 对齐 B+C `agentic_stream`)) **REFACTOR-260619-05 [P1]** — **agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**。主 loop 是**单函数 720 行不可按函数拆**(工具执行/审批是 loop 内 if 分支;process_tool_calls 在 audit.rs)。先抽 A+E+F `agentic_runtime.rs`(GeneratingGuard+try_continue_agent_loop+ContinueSnapshot,~195,最高收益最低风险)+ B+C `agentic_stream.rs`(StreamOutcome+stream_one_provider,~165)。**D run_agentic_loop 等 F-09 B 批4 落地再评估**(避免 per_conv 双线作战)。— `src-tauri/src/commands/ai/agentic.rs`
- [x] ✅(2026-06-20 核验·已拆:ai_node.rs 423 + ai_self_review_node.rs 447 + ai_helpers.rs 298,测试分块清晰) **REFACTOR-260619-06 [P1]** — **ai_node.rs(1107 行)拆 3 模块**。`ai/{mod,params,ai_node,self_review}.rs`。params.rs(provider 解析 helper ~270)+ ai_node.rs(~130)+ self_review.rs(~300)。测试分块清晰(752/939/1069 三段),低风险。fixture provider_stub/config_with 留 params.rs `pub(super)`。— `crates/df-nodes/src/ai_node.rs` - [x] ✅(2026-06-20 核验·已拆:ai_node.rs 423 + ai_self_review_node.rs 447 + ai_helpers.rs 298,测试分块清晰) **REFACTOR-260619-06 [P1]** — **ai_node.rs(1107 行)拆 3 模块**。`ai/{mod,params,ai_node,self_review}.rs`。params.rs(provider 解析 helper ~270)+ ai_node.rs(~130)+ self_review.rs(~300)。测试分块清晰(752/939/1069 三段),低风险。fixture provider_stub/config_with 留 params.rs `pub(super)`。— `crates/df-nodes/src/ai_node.rs`
- [x] ✅(2026-06-20 核验已拆分·`src/components/ai/` 子目录已存在子组件:ConversationSidebar.vue + ChatInput.vue + MessageList.vue + TopBar.vue + DirAuthDialog.vue + MaxRoundsCard.vue·AiChat.vue 从 4075 行缩到 750 行·状态/composable 已外移,模板拆分完成) **REFACTOR-260619-07 [P1]** — **(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。方案已定,状态/composable 已外移,拆 template+局部 script。前置:先提交工作区未提交改动(本会话 shouldRenderMsg/scroll/1214 预检等)。— `src/components/AiChat.vue` - [x] ✅(2026-06-20 核验已拆分·`src/components/ai/` 子目录已存在子组件:ConversationSidebar.vue + ChatInput.vue + MessageList.vue + TopBar.vue + DirAuthDialog.vue + MaxRoundsCard.vue·AiChat.vue 从 4075 行缩到 750 行·状态/composable 已外移,模板拆分完成) **REFACTOR-260619-07 [P1]** — **(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。方案已定,状态/composable 已外移,拆 template+局部 script。前置:先提交工作区未提交改动(本会话 shouldRenderMsg/scroll/1214 预检等)。— `src/components/AiChat.vue`
@@ -579,6 +652,7 @@
> **与 MCP Server(F-260619-02)无关**:本需求是 DevFlow 应用内部 AI 工具(read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。 > **与 MCP Server(F-260619-02)无关**:本需求是 DevFlow 应用内部 AI 工具(read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。
- [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A 持久白名单 + Phase B 会话临时/弹窗 + Phase C 黑名单 + workspace_root 去固定根完整,CR-09 ✅ + b22e9ae reload 尊重用户 persistent) - [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A 持久白名单 + Phase B 会话临时/弹窗 + Phase C 黑名单 + workspace_root 去固定根完整,CR-09 ✅ + b22e9ae reload 尊重用户 persistent)
- **2026-06-24 三档授权演进**:Phase B 弹窗从 once/always 两档 → **once/session/always 三档**(本次单次执行后清 / 当前会话切会话清 / 始终落KV)。修 once 原写 session 致"本次"实为"本会话"名实不符(确定性 bug)。AllowedDirs 加 `once` 层(state.rs)+ ai_authorize_dir decision 改 match 四分支(chat.rs)+ 执行后 clear_once + tracing 审计 + 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval/api/types.ts/i18n 联合类型扩 session。cargo check 0 + vue-tsc 0。审查见待审查 CR-260624-01。
**核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。 **核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。
@@ -945,7 +1019,7 @@
--- ---
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 · 诊断workflow→论证workflow) ### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
> 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。 > 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。
> >
@@ -959,9 +1033,70 @@
> >
> **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。 > **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。
> >
> **BUG-260624-03 工具执行心跳误报根治**(用户 b1f644a7「一边 aichat 回复着内容,一边抛"工具已执行完成,后续回复中断"」):
> - 根因(论证 workflow 4 维度·7 条源码证据闭环):前端 watchdog 130s 无活跃事件→判断断流→抛错;后端 AiHeartbeat 30s 静默期 emit 让前端 reset,但**心跳只在 `stream_llm`(provider 读 chunk 的 select! 循环,stream_recv.rs:333)内,工具执行在 audit/mod.rs(stream_llm 外)无心跳**。单次工具执行>130s(bash 跑 cargo/测试、read 大文件、全盘 search)→ watchdog 到期→该 conv 仍 generatingConvs→守卫放行→前轮有 completed 工具即命中"工具已执行完成后续中断"文案;前一轮 delta 文本在屏 = "一边流一边抛"。**反复修未根治因都在前端守卫打补丁(B-260615-03 文案/BUG-260623-06 MaxRounds clear/TD-260621-01 残留 timer),没人补工具执行阶段的心跳缺口**。
> - 修复(A 根治 + B 兜底,论证质量 A 可发布):
> - **A 后端根治**(audit/mod.rs):新增 `execute_with_heartbeat` helper(execute 期间 30s interval emit AiHeartbeat,弃首 tick 对齐 stream_recv:174;**RAII HeartbeatGuard** drop 自动 stop+abort,panic unwind 也不泄漏 task),替换 trust_hits/low_risk 两处 `tools.execute()`。补工具执行阶段心跳缺口。
> - **B 前端兜底**(useAiStream onStreamTimeout):stillGenerating 守卫后扫 messages running 工具卡→resetStreamWatchdog 续等不抛错(防心跳丢失/老后端/审批后路径误杀)。审批路径(chat.rs:575/877)非盲区——已包 60s timeout<130s watchdog 互补治理(论证勘误维度3 误判)。
> - 验证:cargo check exit 0(RAII 编译过)+ vue-tsc 0。B 维度因网络错未跑,已自核验 ToolCard approving 计时器仅复位按钮 loading 非 running(running 由后端 AiToolCallCompleted 驱动),真死锁续等优于误报(用户可 stop),A 心跳覆盖正常执行。
>
> **BUG-260624-04 搜索工具误授权根治**(用户 b1f644a7「搜索代码也要授权,检查这样的会话搜索为什么需要授权」):
> - 根因:`grep` 工具被标 `RiskLevel::Medium`(tool_registry.rs:1682),而同类只读 `read_file`/`search_files`/`read_symbol` 均 Low。注释 line 1661 "risk:Med(读文件内容,授权目录内放行/外申请)" 把"读内容需 path_auth 授权"误塞 risk_level —— path_auth 是独立安全层(read_file Low 也走),与 risk_level(只读/写/删)混淆。默认 low 模式下 Medium 需审批 → 每次搜索弹审批。叠加 `tool_display_hint`(tool_registry:2116)无 grep 条目 → reason.rs:158 fallback default `"创建操作,请确认是否执行"` → 只读搜索显示"创建操作"误导。
> - 修复:`grep` Medium→**Low**(对齐只读同类)+ tool_display_hint 加 `"grep" => Some("搜索代码内容")`(文案准确)+ 注释更正。cargo check exit 0。
> - 全量核对:28 工具 risk_level 分级,**除 `restore_project`: High 过保守(恢复非破坏,对齐 create/update 该 Medium;但 High 安全方向不算错,未改)** 外全合理。
>
> **BUG-260624-05 压缩后每轮停止根治**(用户「上下文压缩后,后续所有对话都提示已压缩然后停止」):
> - 根因(诊断 workflow 4 维度·DB 铁证 top.1216.devflow 库 565 compressed/4246 active,ead0f7e5 seq=0 是 active 兜底摘要,12 会话前7条有 active system):
> - **#1(主因·无限循环)**:压缩摘要 `insert_at(0, ChatMessage::system(summary))`(agentic/mod.rs:923)→ status=None → is_active=true,落在可压缩区 [0..protect_start) → 每轮 `has_compressible_messages` 恒 true → 无限循环压缩。topic-switch system(DB 3eafdb85 三条堆 seq 0/1/2)同理。
> - **#2(次因·前端停止)**:压缩失败 emit AiError(useAiEvents:648 无条件 setStreaming(false))→ 前端误判生成结束(后端 loop 实际继续)。
> - 修复(P0 根治循环 + P1 前端停止):
> - **P0**(context.rs:803 `has_compressible_messages` 排除 system 角色,用 `!matches!(role, System)` 因 MessageRole 无 derive PartialEq):摘要/话题锚点是上下文锚点非压缩目标,排除后不再因 system 触发。compress_old_messages 不改(被调用时仍标旧 system 摘要 compressed 被新摘要替代,防堆积)。DB 自愈:排除 system 不限 status,旧 active 摘要也被排除,**无需 migration**。
> - **P1**(agentic/mod.rs:985 压缩失败不发 AiError):前端 AiError 无条件停流,降级 warn + build_for_request 裁剪兜底,loop 继续 stream_llm。
> - **纠正 workflow 方案 A 矛盾**:workflow 建议 is_active 排除 summarized,但会致 build_for_request 不送摘要进 LLM 丢历史;改用 has_compressible 排除 system(摘要仍 active 进 LLM)。
>
> **BUG-260625-01 grep 传单文件路径报「目录名称无效」根治**(用户「grep path 需要传目录不能传单文件·怎么总是失败」):
> - 根因(DB 铁证 ai_tool_executions fb549a0b):grep handler 直接 `grep_recursive(root)` → 内部 `read_dir(root)`,root 是单文件时报 os error 267「目录名称无效」。grep 命令行原生支持单文件,LLM 常传文件路径(64 次 grep 调用多次传文件),工具只认目录 →「总是失败」。
> - 修复(tool_registry.rs):提取 `grep_one_file`(从 grep_recursive 循环体搬移,continue→return Ok),grep_recursive 改调它(DRY 复用),handler 判 `root_meta.is_file()` → 单文件走 grep_one_file,is_dir → 递归。描述加「path 文件或目录均可」。
> - 验证:cargo test grep **13 passed**(9 个 grep_recursive 测试全过 = 提取零回归)+ audit 3 grep 测试。
>
> **BUG-260625-02 read_file search 模式显示「0 行」根治**(用户「读取 MessageList.vue 0 行·65.2KB·怎么总是失败」):
> - 根因(DB 铁证 ai_tool_executions cad747e9):read_file 带 `search` 参数返 `{matches:[{line,content}],total,has_more}` **无 content/lines/returned_lines**。ToolResultBody.vue read_file 分支只认 content/lines → 显示「0 行 · 65.2KB」+ 空 content 区。LLM 拿到 matches 能继续,但**用户看 UI 以为读取失败/空**。
> - 修复(ToolResultBody.vue + useToolCard.ts):read_file 分支识别 search 模式(parsed.search),header 显「『search』 命中 N 处」(复用 grepHitsN)+ 命中行渲染(复用 grep content 样式,read_file matches 仅 {line,content} 无 file);ToolResult 加 `search?: string` 字段。
> - 验证:vue-tsc EXIT 0。
> - 附:state.rs/tool_registry.rs test 的 AllowedDirs once 字段初始化是工作区被改掉的预存状态,本次恢复到 HEAD(once 功能早已提交),**未引入新改动**。
>
> **剩余低优 [ ](非阻断)**: > **剩余低优 [ ](非阻断)**:
> - [ ] BUG-260624-01 残留:scrollToFirstPending 去抖(同 toolCallId 不重复 scroll,防多 pending 连发滚动打断)+ 全实例广播过滤(props.toolCalls.some 跳过无 pending 实例,大列表性能) > - [x] ✅(2026-06-24·ebba973) BUG-260624-01 残留:① MessageList:503 scrollToFirstPending return 过早修复(遍历全实例调,各内部 querySelector 无 pending 则 no-op,治 pending 落后续实例滚不到)+ ② ToolCardList listener 加 props.toolCalls.some(pending) 过滤短路(降大列表 N 实例×M emit 的 DOM 查询)。**scrollToFirstPending 时间窗去抖评估后不做**:同 id 连发被上游守卫拦截(AiDirAuthRequired/AiApprovalRequired useAiEvents:359/472 some 去重 + AiToolCallStarted:427 findToolCall 守卫),不同 id 连发是多 pending 合理场景每次滚到首个合理,vue-tsc EXIT 0。
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到) > - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到)
> - [ ] MessageList:503 badge scrollToFirstPending return 过早(既有 bug,遍历实例应找含卡的那个,不首个 return) > - [x] ✅(2026-06-24·ebba973) MessageList:503 scrollToFirstPending return 过早已修(遍历全实例调,各内部 querySelector 无 pending 则 no-op,治 pending 落后续实例滚不到)
> - [ ] F-09 根因:streaming/currentText 全局单例改 per-conv(根治多会话串扰,见 docs/待决策.md) > - [ ] F-09 根因:streaming/currentText 全局单例改 per-conv(根治多会话串扰,见 docs/待决策.md)
> - [x] ✅(2026-06-24 核验伪问题销账) CR-260622-01-P2-1 — **ChatMessage.timestamp 打戳"致时序错乱"核验为伪**。grep 全 src `.sort(` 零消息按 timestamp 排序:ai.ts:188 会话列表 updated_at / ConversationSidebar pinned / TopBar weight / MessageList:231 mention span start。**消息渲染顺序由 renderItems(store.state.messages 数组顺序)= DB list_by_conversation ORDER BY seq,不依赖 ChatMessage.timestamp**。timestamp 仅 formatRelative 展示(system 摘要显示插入时刻=压缩时刻,语义合理)。agent A 假设"前端按 timestamp 排序"不成立,非真实缺陷。
> - [ ] 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 后不重读)更合适,工具侧完整缓存低优暂缓。
---
### ✅ 2026-06-25 generating 状态机双轨收口 + 自动压缩路径残留修复
- [x] ✅(2026-06-25) **Task#1 自动压缩成功路径残留**(BUG-260624-05 衍生)— **AiCompressed 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话**。治法:`AiCompressed` 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 isCompressing 防按钮卡死,miniapp 仍插摘要气泡)。按语义拆事件而非按字段。— `src-tauri/src/commands/ai/mod.rs:207/212`
- [x] ✅(2026-06-25) **Task#2 双轨状态机收口** — `generating: bool` 字段(顶层单例 + PerConvState)与 `CONV_STATE_ENABLED` 灰度开关双轨全部删除,`ConvState` enum(`conv_state.rs`,5 态)成唯一真相源:写侧经 `transition_to` 守卫(非法转换拒绝),`GeneratingGuard` new/reset/drop 无条件迁移 + emit;读侧 `can_accept_request()` 接入 chat 域 4 入口(ai_chat_send/ai_regenerate/ai_chat_edit/ai_is_generating),比 `is_active()` 更严谨(覆盖 Stopping 态竞态)。开关常量已删,代码内 `CONV_STATE_ENABLED off 回退分支` 注释为说明性遗留(实际不可触发,待清理)。详案见 [generating状态机加固-2026-06-15.md §收口记录](./02-架构设计/专项设计/generating状态机加固-2026-06-15.md)。— `src-tauri/src/commands/ai/agentic/{conv_state.rs,guard.rs}` + `commands/ai/{mod.rs,commands/chat.rs}`
---
### 🔍 2026-06-26 灵感模块诊断待办(合并推进·单任务)
> 全面审查灵感模块(df-storage / df-ideas / commands/idea.rs / Ideas.vue / IdeaDetail.vue)后发现的 P0-P2 问题。
> 排除项:~~#3 status 无状态机~~(暂保留状态可跳跃迁移,不处理)。
> 合并为单个任务推进,不拆子任务。
- [ ] **IDEA-FIX-01 [P0🔴]** — **ideas 表无软删除**。`delete_idea` 是硬删除(直接 `delete()`),无 `deleted_at` 列、无 restore API。误删不可恢复。需对标 tasks/projects 的软删除全套模板(`deleted_at` 列 + V28 迁移 + `list_active` 过滤 + restore IPC)。— `commands/idea.rs:104` + `df-storage/src/crud/idea_repo.rs` + `migrations.rs`
- [ ] **IDEA-FIX-02 [P0🔴]** — **ideas 白名单含 id/created_at**。`settings.rs:122` ideas 白名单包含 `id`、`created_at`,可通过 `update_idea` 篡改主键或创建时间。需移除(对标 B-260616-16 tasks 白名单修复)。— `commands/settings.rs:122`
- [ ] **IDEA-FIX-03 [P0🔴]** — **priority 无值域校验**。`create_idea` / `update_idea` 不校验 priority 范围,传入 -5 或 999 静默降级为 Medium。需加 `parse::<i32>() ∈ 0..=3` 校验(对标 B-260615-15 tasks priority 修复)。— `commands/idea.rs:73,88`
- [ ] **IDEA-FIX-04 [P1🟡]** — **record_to_idea 有损转换**。`record_to_idea()` 硬编码 `status=Draft`(丢弃 DB 真实 status)+ `related_ids=空 Vec`(丢弃已保存关联)。评估时上下文不完整。需从 IdeaRecord 读真实值。— `commands/idea.rs:298-312`
- [ ] **IDEA-FIX-05 [P1🟡]** — **评分关键词硬编码**。`scoring.rs` 关键词全写死中文("复用"/"重构"/"迁移"…),不可配置,英文/非技术项目评分失真。需提取为配置文件或 DB 表。— `df-ideas/src/scoring.rs`
- [ ] **IDEA-FIX-06 [P1🟡]** — **promote 补偿用 purge(不可恢复)**。`promote_idea` 回写失败时 `purge_with_descendants`(永久删除项目)。应改为 `delete`(软删除,可恢复)。— `commands/idea.rs:179`
- [ ] **IDEA-FIX-07 [P1🟡]** — **关联单向**。A 关联 B 只写 A 的 `related_ids`,B 不自动补 A。需双向同步(关联时补入对方,解绑时同步移除)。— `commands/idea.rs` + 前端 `IdeaDetail.vue`
- [ ] **IDEA-FIX-08 [P1🟡]** — **创建表单无 tags 输入**。后端 `CreateIdeaInput` 支持 tags,但前端捕捉表单(Ideas.vue capture modal)只有 title + description,tags 恒为 None。需加 tags 输入(逗号分隔 → JSON 数组)。— `Ideas.vue:108-119` + `confirmCapture()`
- [ ] **IDEA-FIX-09 [P2🟠]** — **创建表单缺 priority/source**。捕捉模态框无 priority 下拉和 source 输入。需补全。— `Ideas.vue:108-119`
- [ ] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。`hot`(score≥80)和 `pending`(多状态 OR)在前端 filter,但后端已分页返回子集,可能漏掉符合条件的灵感。需下沉后端 IdeaQuery。— `Ideas.vue:221-226`
- [ ] **IDEA-FIX-11 [P2🟠]** — **假雷达图**。i18n key 和 CSS class 叫 "radar",但实际是水平进度条。需正名为 "score-bar" 或引入真正的雷达图组件。— `IdeaDetail.vue` + `i18n/zh-CN/ideas.ts` + `i18n/en/ideas.ts`
+57
View File
@@ -24,6 +24,61 @@
--- ---
## 当前队列 ## 当前队列
### ✅ 2026-06-26 任务图谱推进待决策(已按推荐方向全部决策)
> 来源:用户 2026-06-26 多角度论证(基于代码实况) + 确认「按推荐方向决策」,7 DEC + 3 专题全部拍板。
> **关键修正**:DEC-01 V28 已被灵感软删除(`ideas.deleted_at`,commit 005079f)占用 → 知识图谱用 **V29**;DEC-03 推荐由 a 修正为 **c**(启发式是过渡,接 LLM 评分后按需升 a)。
#### ✅ DEC-260626-01 父② 知识图谱 Phase 1 多工程合并
- **决策**:**b** — 知识图谱独立 Phase 1(**V29**,V28 已被灵感软删除占用)。多工程代码零行(grep 全空),合并无理由,多工程后续 V30+。
- **关联**:todo 父②
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-02 ⑤.2 #7 promote 补偿删除方式
- **决策**:**b** — 保留 purge。补偿是内部回滚建错的 project(非用户删除),purge 干净不污染回收站。
- **关联**:todo ⑤.2 #7(标"保留 purge 不改")
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-03 ⑤.2 #6 评分关键词配置形式
- **决策**:**c** — 先拆 const 到独立文件(中/英两套,最小改动)。原推荐 a 修正:启发式是过渡(scoring.rs TODO 接 LLM 语义评分),接 LLM 后按需升 a(JSON 文件)。
- **关联**:todo ⑤.2 #6
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-04 ⑤.2 #9/#10 表单补全交互
- **决策**:**a** — tags 逗号分隔输入框(对标 Knowledge.vue 先例)+ priority 下拉 + source 输入。chip 组件留 UX 升级。
- **关联**:todo ⑤.2 #9/#10
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-05 BUG-260620-05 层2 授权政策
- **决策**:**a** — 维持方案①(白名单+弹窗三档)。层1(工程内免授权,F-260619-03 方案①)已解痛点,层2 黑名单制安全风险 + 分发冲突。
- **关联**:todo BUG-260620-05 层2(标"维持①不做")
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-06 父③ ToolCard 跨轮合并 + 审批状态机排期
- **决策**:**b** — 等 G1 实测 + 父② 后再做。审批是高危路径,AiChat.vue 仍在频繁改动,择稳定窗口专项。
- **关联**:todo 父③(排父②后)
- **状态**:✅ 已决(2026-06-26)
#### ✅ DEC-260626-07 父④ F-09 per-conv 真多会话排期
- **决策**:**a** — 父② Phase 1 后启动。F-09 是 Phase 4 前置,大改不与父② 并行(回归交叉)。
- **关联**:todo 父④(排父②后)
- **状态**:✅ 已决(2026-06-26)
#### ✅ 专题-1 workspace_root 分发适配
- **决策**:**① 先行**(去默认白名单,dev 自用 projects.path 自动授权够),**分发前定②**(去相对锚)。
- **状态**:✅ 已决(2026-06-26,① 立即/② 分发前)
#### ✅ 专题-2 BUG-260623-03 审批 pending 超时兜底
- **决策**:**c 无超时**(不做超时兜底)。用户:等待审批就是等待,一直等待,超时概念多余。审批是用户主动行为,不审批自然挂起等待,无需超时强制 reject/倒计时。
- **状态**:✅ 已决(2026-06-26,不做超时,关闭 BUG-260623-03)
#### ✅ 专题-3 F-09 streaming/currentText per-conv
- **决策**:**暂缓**(随 B 路线多会话并发同批做,即 DEC-07 父④)。
- **状态**:✅ 已决(2026-06-26,归父④)
---
#### T-260614-11 条件表达式引擎升级 #### T-260614-11 条件表达式引擎升级
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。 - **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景) - **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
@@ -296,6 +351,8 @@
**关联**:F-260619-03 去固定根(已删 is_authorized 硬编码放行)/ projects.path 自动授权(reload_allowed_dirs)/ memory [[devflow-project-path-binding]] **关联**:F-260619-03 去固定根(已删 is_authorized 硬编码放行)/ projects.path 自动授权(reload_allowed_dirs)/ memory [[devflow-project-path-binding]]
**状态**:🟡 待决策(workspace_root 运行期动态方案 a/b/c/d)。**分发前必须解决**(否则分发后 AI 文件工具全部失效)。 **状态**:🟡 待决策(workspace_root 运行期动态方案 a/b/c/d)。**分发前必须解决**(否则分发后 AI 文件工具全部失效)。
**注**:授权弹窗侧三档(once 本次单次 / session 当前会话 / always 始终)已于 2026-06-24 落地(F-260619-03 Phase B 演进,AllowedDirs 加 once 层 + 弹窗加"当前会话"按钮,详见 todo + 待审查 CR-260624-01)——**授权机制侧就绪**。本节待决策的是**相对路径锚点**(固定编译期 root vs 活跃项目),非授权机制。
**2026-06-25 弱化决策(用户)**:方向**去掉"活跃项目/根据地"概念抽象**——active_workspace 全局缓存动态锚曾试加(state.rs OnceLock+SyncRwLock + reload_active_workspace KV active_project_id)后撤回,用户明确不要此层。workspace_root 变量**暂留**(不删,resolve 相对锚/trust_key/run_command working_dir 仍引用),**弱化其默认依赖**。实质方案二选一分发前定:① 去默认白名单(default_with_root 不硬塞 workspace_root,工程根靠 projects.path 自动授权)② 去相对锚(AI 改绝对路径,相对路径不 join)。当前:逻辑不动,变量保留。
**2026-06-23 进展(BUG-260623-01)**:实测会话 6acb7f9b 暴露 write_file/append_file/rename_file 父目录校验**误用 workspace_root**(tool_registry.rs:1112/1418/1555 原用 `starts_with(workspace_root())`)→ 用户授权的其他项目目录(ai-news 等)parent 不 starts_with devflow 源码目录 → 误拒「禁止在项目目录之外创建目录」(用户授权了仍拒)。**已修**:三处改用 `allowed_dirs.is_authorized(parent)` 脱离 workspace_root。**仍写死残留**(待本决策 a/b/c/d 根治):delete_file:1482 trash 目录 / resolve_workspace_path_impl:380 相对路径 join / run_command:1791 working_dir 默认。 **2026-06-23 进展(BUG-260623-01)**:实测会话 6acb7f9b 暴露 write_file/append_file/rename_file 父目录校验**误用 workspace_root**(tool_registry.rs:1112/1418/1555 原用 `starts_with(workspace_root())`)→ 用户授权的其他项目目录(ai-news 等)parent 不 starts_with devflow 源码目录 → 误拒「禁止在项目目录之外创建目录」(用户授权了仍拒)。**已修**:三处改用 `allowed_dirs.is_authorized(parent)` 脱离 workspace_root。**仍写死残留**(待本决策 a/b/c/d 根治):delete_file:1482 trash 目录 / resolve_workspace_path_impl:380 相对路径 join / run_command:1791 working_dir 默认。
+24 -1
View File
@@ -23,7 +23,19 @@
## 当前队列 ## 当前队列
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— 🟡 待审 ### CR-260624-01 AI 路径授权三档化(once/session/always) — ✅ 已审(PASS·单 agent + 6-agent 对抗深审 0 finding)
- **范围**:AllowedDirs 加 `once` 层(state.rs:444)+ ai_authorize_dir decision 改 match 四分支 once/session/always(chat.rs:846)+ 执行后 clear_once(chat.rs:924)+ 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval.ts/api/ai.ts/api/types.ts decision 联合类型扩 'session' + i18n zh/en 加 dirAuthSession。
- **维度**:
- (a) **once 清空时机**:clear_once 在 execute 后无条件清(state.rs:924 附近 chat.rs)。竞态:同 once 路径并发工具调用?现状工具串行(per_conv 单 generating),无并发,OK——但核验确认。
- (b) **session vs once 语义边界**:session 切会话清(conversation.rs clear_session)/ once 执行后清 / reload_allowed_dirs 保留 both(state.rs:810 take once + session)。核 reload 是否漏保留 once。
- (c) **is_authorized 读 once**:state.rs is_authorized 三层 `persistent || session || once` 任一命中。核 once 命中分支存在。
- (d) **前端全链路**:DirAuthDialog handleAuthorize / useAiApproval approveToolCall / api.authorizeDir 三处 decision 联合类型均含 'session',types.ts decision: string 不受限(无类型保护,但 IPC 接受任意字符串,match _ 兜底)。
- **核验方式**:grep `once|session` 贯穿 state.rs/chat.rs/前端 + 核 clear_once 调用点 + deny 分支(早 return,once 未写,不需清,核确认)。
- **关注点**:批量 approve 仍用 once(单卡语义,不批量 always,useAiApproval:110);always 降级写 session(原降级 add_session,语义对齐)。
- **审查结果**(2026-06-25):单 agent 审查(a2b2b35,6 维度全过)+ workflow 对抗深审(w9waeyg36,6 agent 并行 review + 每条 finding 独立 verify,**0 confirmed finding**)。once 清空时机(串行无并发误清)/ session-once 边界 / is_authorized 三层读 / deny 不触发 clear_once / 前端类型一致 全过。
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— ✅ 已审(ISSUES·低优残留2项·P0清零)
- **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。 - **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。
- **维度**:(a) 独立核验 db 实测数据(token 比/重复 tool 结果/system 污染/时序);(b) 核验源码根因(报告 finding 均为"假设"待证)。 - **维度**:(a) 独立核验 db 实测数据(token 比/重复 tool 结果/system 污染/时序);(b) 核验源码根因(报告 finding 均为"假设"待证)。
@@ -42,6 +54,17 @@
--- ---
**复审结论(2026-06-24·3 agent 独立 grep/read 核验当前源码形态,防上下文污染)**: ✅ **ISSUES(低优残留,P0 清零可归档)**
- **P0-1/P0-2/P0-3 已销账**(报告 06-23/06-24 修正段:压缩 is_active 白名单隐式修复 / truncate 破坏 JSON 修 / 重复 tool_result 实测 tool_call_id 全不同证伪)
- **P1-2 末尾中断 🟡缓解**:`guard.rs` GeneratingGuard RAII(正常 reset + Drop 兜底)+ ConvState 5态 enum 状态机已系统性覆盖"generating 散布复位 + 无 panic 兜底"原根因;报告 06-23 修正推翻原假设(84dc36c8 seq85 后疑 max_iterations 收敛/自然停,非 generating 卡死)。归 BUG-260623-03 审批 pending 无超时(待决策,推荐前端倒计时)。
- **P2-1 system 时序 🟡部分缓解**:DB created_at 全消息统一 conv_created_at 不乱;但 `ChatMessage.timestamp`(provider.rs:44 system() 用插入时刻)system 摘要打戳不可靠 → 前端排序应弃 timestamp 改 seq/created_at。role 污染数据级偶发(压缩摘要 LLM 输出含 assistant 语义,非结构 bug)。
- **P2-2 非代码文件缓存 🔴仍存**:read_symbol(7c2e3b2/7f6aa1e)治 .rs/.ts/.vue 代码文件(降 24.4x);HTML/markdown/JSON 等非代码文件无 session 级缓存,LLM 仍 read_file 全文回灌。
**待修项回流 todo**:🟡 2 项低优(CR-260622-01-P2-1 前端排序弃 timestamp / CR-260622-01-P2-2 非代码文件 session 缓存,归 B 路线 prompt 策略或工具侧缓存)。
---
### CR-260620-04 Agent 最大轮次支持「不限」(working tree 未提交:0=不限 约定·config.rs clamp 0-50 + mod.rs effective_max 0→usize::MAX + GeneralPanel checkbox + App.vue clamp + i18n labelUnlimited) — ✅ 已审(PASS·⚪2 WATCH·巡检 2026-06-20 独立 grep/read 核验控制流) ### CR-260620-04 Agent 最大轮次支持「不限」(working tree 未提交:0=不限 约定·config.rs clamp 0-50 + mod.rs effective_max 0→usize::MAX + GeneralPanel checkbox + App.vue clamp + i18n labelUnlimited) — ✅ 已审(PASS·⚪2 WATCH·巡检 2026-06-20 独立 grep/read 核验控制流)
- **范围**:新功能。约定 0=不限:后端 config.rs:69 clamp(1,50)→(0,50);mod.rs:542 loop 上界算 effective_max(0→usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出,达上限暂停 AiMaxRoundsReached 不触发);前端 GeneralPanel 加「不限」checkbox(绑 agentMaxIterations===0)+ input :disabled + clamp 支持 0 + computed unlimited;App.vue:107 clamp 跟进(iter===0?0:...);i18n zh/en desc 加 0=不限 + labelUnlimited。 - **范围**:新功能。约定 0=不限:后端 config.rs:69 clamp(1,50)→(0,50);mod.rs:542 loop 上界算 effective_max(0→usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出,达上限暂停 AiMaxRoundsReached 不触发);前端 GeneralPanel 加「不限」checkbox(绑 agentMaxIterations===0)+ input :disabled + clamp 支持 0 + computed unlimited;App.vue:107 clamp 跟进(iter===0?0:...);i18n zh/en desc 加 0=不限 + labelUnlimited。
+1 -1
View File
@@ -10,7 +10,7 @@
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"build:dev": "cd src-tauri && cargo build" "build:dev": "tauri build --debug --no-bundle"
}, },
"dependencies": { "dependencies": {
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",
+4
View File
@@ -66,6 +66,10 @@ tree-sitter = "0.25"
tree-sitter-rust = "0.24.2" tree-sitter-rust = "0.24.2"
tree-sitter-typescript = "0.23.2" tree-sitter-typescript = "0.23.2"
tree-sitter-javascript = "0.25.0" tree-sitter-javascript = "0.25.0"
# Phase1 扩展:Go / Java / Python(静态编译,表驱动加表项,零架构改动)
tree-sitter-go = "0.23"
tree-sitter-java = "0.23"
tree-sitter-python = "0.23"
# 阶段3a/3b 统一审批模型开关(path_auth 审批链扎实重构 plan 阶段3)。 # 阶段3a/3b 统一审批模型开关(path_auth 审批链扎实重构 plan 阶段3)。
# 3a 后端合并(单 HashMap + kind 字段)为编译期结构性变更,本 flag 用于文档标记 + # 3a 后端合并(单 HashMap + kind 字段)为编译期结构性变更,本 flag 用于文档标记 +
+17 -24
View File
@@ -15,33 +15,26 @@
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等 //! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。 //! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
//! //!
//! # 渐进第一步(本批范围) //! # 收口后(批3+)
//! //!
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等 //! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool
//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState` //! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点
//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html) //! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。
//! 默认 on,off 降级纯旧 bool 行为(可回退)。
//! //!
//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到 //! 散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)已逐个迁到读
//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。 //! `ConvState`(`is_active()` / `can_accept_request()`),不再回退旧 bool。
//!
//! # 与目标钉扎(G1)的衔接(2026-06-26)
//!
//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边);
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`),
//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成
//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`,
//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================
// 开关
// ============================================================
/// L2 状态机总开关(默认 on)。
///
/// - **on(默认)**:`GeneratingGuard` set/reset 时同步迁移 [`ConvState`](enum.ConvState.html)
/// 作视图层,前后端可读统一状态;guard 失败兜底仍走旧 bool 复位(`generating=false`),
/// 不影响语义(状态机层失败不阻断核心复位)。
/// - **off(降级)**:guard 不写 `ConvState`,纯旧 `generating` bool 行为(排障 / 对比 / 临时
/// 回退用)。设为 false 即可观察无状态机接入的效果,语义等价改动前。
///
/// 机制优先 prompt 说教:每改进配开关 + 兜底,分阶段可回退(对齐 AI 改进方案三原则)。
pub const CONV_STATE_ENABLED: bool = true;
// ============================================================ // ============================================================
// ConvState enum // ConvState enum
// ============================================================ // ============================================================
@@ -169,8 +162,8 @@ impl ConvState {
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断 /// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。 /// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
/// ///
/// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。 /// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send /
#[allow(dead_code)] /// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。
pub fn can_accept_request(self) -> bool { pub fn can_accept_request(self) -> bool {
matches!(self, ConvState::Idle | ConvState::Error) matches!(self, ConvState::Idle | ConvState::Error)
} }
+71 -76
View File
@@ -10,7 +10,8 @@ use tokio::sync::Mutex;
use crate::commands::ai::{AiChatEvent, AiSession}; use crate::commands::ai::{AiChatEvent, AiSession};
use super::conv_state::{self, ConvState}; // 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。
use super::conv_state::ConvState;
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。 /// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
/// ///
@@ -39,14 +40,15 @@ use super::conv_state::{self, ConvState};
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。 /// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。 /// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
/// ///
/// 开关 `CONV_STATE_ENABLED`(默认 on)门控:off 时跳过 enum 迁移与 emit,纯旧 bool 行为(可回退)。 /// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit
/// 无条件执行(单一真相源,无 off 降级分支)。
pub(super) struct GeneratingGuard { pub(super) struct GeneratingGuard {
session: Arc<Mutex<AiSession>>, session: Arc<Mutex<AiSession>>,
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。 /// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
conv_id: String, conv_id: String,
done: bool, done: bool,
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。 /// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
/// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。 /// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。
app_handle: AppHandle, app_handle: AppHandle,
} }
@@ -59,71 +61,68 @@ impl GeneratingGuard {
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经 // 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。 // tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。 // spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
if conv_state::CONV_STATE_ENABLED { // 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。
let session = session.clone(); // 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。
let app_handle = app_handle.clone(); let session_for_spawn = session.clone();
let cid = conv_id.clone(); let app_handle_for_spawn = app_handle.clone();
tauri::async_runtime::spawn(async move { let cid = conv_id.clone();
let new_state = { tauri::async_runtime::spawn(async move {
let mut session = session.lock().await; let new_state = {
let conv = session.conv(&cid); let mut session = session_for_spawn.lock().await;
match conv.conv_state.transition_to(ConvState::Generating) { let conv = session.conv(&cid);
Ok(ns) => { match conv.conv_state.transition_to(ConvState::Generating) {
conv.conv_state = ns; Ok(ns) => {
ns conv.conv_state = ns;
} ns
Err(e) => {
tracing::warn!(
conv_id = %cid,
error = %e,
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
);
return;
}
} }
}; Err(e) => {
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。 tracing::warn!(
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 conv_id = %cid,
let ev = AiChatEvent::AiConvStateChanged { error = %e,
conv_state: new_state, "[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
conversation_id: Some(cid), );
}; return;
let _ = app_handle.emit("ai-chat-event", ev.clone()); }
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); }
}); };
} // 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let ev = AiChatEvent::AiConvStateChanged {
conv_state: new_state,
conversation_id: Some(cid),
};
let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone());
let _ = app_handle_for_spawn.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
});
Self { session, conv_id, done: false, app_handle } Self { session, conv_id, done: false, app_handle }
} }
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。 /// 显式复位生成态。emit 前调用保证顺序。幂等。
/// ///
/// 仅写 per_conv.conv_id.generating(唯一真相源)。 /// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,CONV_STATE_ENABLED 门控)。 /// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。
pub(super) async fn reset(&mut self) { pub(super) async fn reset(&mut self) {
if !self.done { if !self.done {
let mut session = self.session.lock().await; let mut session = self.session.lock().await;
let conv = session.conv(&self.conv_id); let conv = session.conv(&self.conv_id);
conv.generating = false; // L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。
// L2 批2:ConvState 持久化(Generating→Idle 写收敛,CONV_STATE_ENABLED 门控)。 match conv.conv_state.transition_to(ConvState::Idle) {
if conv_state::CONV_STATE_ENABLED { Ok(ns) => {
match conv.conv_state.transition_to(ConvState::Idle) { conv.conv_state = ns;
Ok(ns) => { // L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
conv.conv_state = ns; // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。 let ev = AiChatEvent::AiConvStateChanged {
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 conv_state: ns,
let ev = AiChatEvent::AiConvStateChanged { conversation_id: Some(self.conv_id.clone()),
conv_state: ns, };
conversation_id: Some(self.conv_id.clone()), let _ = self.app_handle.emit("ai-chat-event", ev.clone());
}; let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
Err(e) => tracing::warn!(
conv_id = %self.conv_id,
error = %e,
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
),
} }
Err(e) => tracing::warn!(
conv_id = %self.conv_id,
error = %e,
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
),
} }
self.done = true; self.done = true;
} }
@@ -142,9 +141,9 @@ impl GeneratingGuard {
impl Drop for GeneratingGuard { impl Drop for GeneratingGuard {
fn drop(&mut self) { fn drop(&mut self) {
// 未 done 即异常退出(panic/未走正常 return):核心 generating 经 spawn 复位, // 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位,
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。 // ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
// 开关 off / done=true(正常 reset/disarm 已走) → 跳过。 // done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。
if !self.done { if !self.done {
let session = self.session.clone(); let session = self.session.clone();
let conv_id = self.conv_id.clone(); let conv_id = self.conv_id.clone();
@@ -154,20 +153,17 @@ impl Drop for GeneratingGuard {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut s = session.lock().await; let mut s = session.lock().await;
let conv = s.conv(&conv_id); let conv = s.conv(&conv_id);
conv.generating = false; // L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。
// L2 批2:ConvState 持久化(异常退出兜底→Idle,CONV_STATE_ENABLED 门控)。 if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
if conv_state::CONV_STATE_ENABLED { conv.conv_state = ns;
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) { // 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
conv.conv_state = ns; // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。 let ev = AiChatEvent::AiConvStateChanged {
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 conv_state: ns,
let ev = AiChatEvent::AiConvStateChanged { conversation_id: Some(conv_id.clone()),
conv_state: ns, };
conversation_id: Some(conv_id.clone()), let _ = app_handle.emit("ai-chat-event", ev.clone());
}; let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
} }
}); });
} }
@@ -188,8 +184,7 @@ mod tests {
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。 // guard 内部 transition_to 是纯函数,直接断言逻辑路径。
#[test] #[test]
fn test_guard_new_migrates_to_generating() { fn test_guard_new_migrates_to_generating() {
// CONV_STATE_ENABLED on → Idle → Generating(读侧持久化字段迁移路径)。 // 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。
assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设");
let mut s = ConvState::Idle; let mut s = ConvState::Idle;
s = s.transition_to(ConvState::Generating).unwrap(); s = s.transition_to(ConvState::Generating).unwrap();
assert_eq!(s, ConvState::Generating); assert_eq!(s, ConvState::Generating);
+369 -56
View File
@@ -48,6 +48,7 @@ use super::stream_recv::{stream_llm, StreamResult};
use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::{AiChatEvent, AiSession, ErrorType, SessionState}; use super::{AiChatEvent, AiSession, ErrorType, SessionState};
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。 /// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
/// ///
@@ -106,6 +107,90 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。 /// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
pub const TOPIC_MARKER_ENABLED: bool = true; pub const TOPIC_MARKER_ENABLED: bool = true;
// ============================================================
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
//
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
// 1214/首位锚点稀释/小预算被裁三重风险。
// ============================================================
/// G1 总开关:目标钉扎是否启用(默认 true)。
///
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
pub const GOAL_PIN_ENABLED: bool = true;
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
///
/// true(默认):目标文本前置 `## 当前目标(全程锚定,所有动作须服务于它)` 标题 + 目标内容,
/// 显式分隔防与行为准则混淆,提升 LLM 注意力分配。false(裸拼):目标文本直接 append 到
/// system_prompt,无标题分隔(紧凑,排障/对比用)。
pub const GOAL_INJECT_BANNER: bool = true;
/// G1 截断长度:目标文本截断上限(默认 500 字符)。
///
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
pub const GOAL_MAX_CHARS: usize = 500;
/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。
///
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
/// pinned_goal 存在时 take_topic_marker 后丢弃跳过 insert(防 marker 累积 + 消双锚点稀释);
/// false(回退)= 无视 pinned_goal 照常 insert,完全退回原双锚点行为(排障/对比用)。
///
/// 与 TOPIC_MARKER_ENABLED 独立:TOPIC_MARKER_ENABLED 是整段总开关(false=整段跳过),
/// TOPIC_MARKER_GOAL_AWARE 是 goal 感知前置 guard(仅 GOAL_AWARE=true 且 pinned_goal
/// 存在时跳过 insert)。双关默认 true,各自单点回退不耦合(G1 关闭时 GOAL_AWARE 仍生效,
/// 但 pinned_goal 永远 None → 不跳过 → 退原 topic 行为,双层兜底)。
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
// ============================================================
// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26)
//
// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) →
// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会,
// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词),
// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰。
// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。
// ============================================================
/// G2 总开关:探索熔断是否启用(默认 true)。
///
/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count,
/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0,
/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
pub const STALL_BREAKER_ENABLED: bool = true;
/// G2 连续空结果阈值(默认 3)。
///
/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽,
/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词
/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
///
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标,
/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示,
/// 确认无误杀场景用。
pub const STALL_BREAKER_WARN_FIRST: bool = true;
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
///
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。 /// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
/// ///
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试, /// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
@@ -156,18 +241,16 @@ mod guard;
use guard::GeneratingGuard; use guard::GeneratingGuard;
// ============================================================ // ============================================================
// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。 // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。 // 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
// //
// 本批范围(渐进): // 收口后(批3+,2026-06-25):
// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。 // - conv_state.rs:纯逻辑 enum + 守卫 + 单测。
// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState // - run_agentic_loop 入口桥接:迁移 ConvState(Idle→Generating / Error→Generating)。
// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。 // - guard.reset/drop 同步 ConvState→Idle(正常退出 + 异常兜底)。
// - guard.reset 同步 ConvState→Idle(正常退出路径)。 // - 批3 双轨收口:CONV_STATE_ENABLED 开关与 PerConvState.generating bool 已退役,
// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。 // ConvState 成为生成态唯一真相源(无条件迁移 + emit,无 off 降级分支)。
// // - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。
// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。
// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。
// ============================================================ // ============================================================
pub mod conv_state; pub mod conv_state;
@@ -419,25 +502,21 @@ pub(crate) async fn run_agentic_loop(
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢 // 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。 // per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
// //
// L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建, // L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。 // new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。
// 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum // 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源),
// 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。 // emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。
{ {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.generating = true; // L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。
// L2 批2:ConvState 持久化(Idle→Generating 写收敛,CONV_STATE_ENABLED 门控)。 match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
// enum 与 generating bool 双轨(bool 核心复位,enum 供读侧/前端);off 降级跳过。 Ok(ns) => conv.conv_state = ns,
if conv_state::CONV_STATE_ENABLED { Err(e) => tracing::warn!(
match conv.conv_state.transition_to(conv_state::ConvState::Generating) { conv_id = %conv_id,
Ok(ns) => conv.conv_state = ns, error = %e,
Err(e) => tracing::warn!( "[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
conv_id = %conv_id, ),
error = %e,
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
),
}
} }
} }
@@ -680,9 +759,61 @@ pub(crate) async fn run_agentic_loop(
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 // loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new(); let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
let mut stall_count: u32 = 0;
let mut stall_warned: bool = false;
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传 // BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None; let mut last_reasoning_content: Option<String> = None;
// G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
//
// 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request
// 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非
// messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。
//
// 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 →
// 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过,
// pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt)。
//
// 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入
// 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。
let mut system_prompt = system_prompt;
if GOAL_PIN_ENABLED {
let goal_opt: Option<String> = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| c.pinned_goal.clone())
};
if let Some(goal) = goal_opt {
let goal_text = goal.trim();
if !goal_text.is_empty() {
let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect();
let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS {
format!("{}…", goal_text)
} else {
goal_text
};
system_prompt = if GOAL_INJECT_BANNER {
format!(
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}",
system_prompt, goal_text
)
} else {
format!("{}\n\n{}", system_prompt, goal_text)
};
tracing::info!(
conv_id = %conv_id,
chars = goal_text.chars().count(),
"[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt"
);
}
}
}
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
@@ -748,7 +879,27 @@ pub(crate) async fn run_agentic_loop(
return; return;
} }
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.messages.take_topic_marker() // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
// 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
// user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。
let goal_active = TOPIC_MARKER_GOAL_AWARE
&& conv
.pinned_goal
.as_deref()
.map(|g| !g.trim().is_empty())
.unwrap_or(false);
let marker = conv.messages.take_topic_marker();
if goal_active && marker.is_some() {
tracing::info!(
conv_id = %conv_id,
iteration,
"[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释"
);
None
} else {
marker
}
}; };
if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| { if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| {
// 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。 // 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。
@@ -929,7 +1080,10 @@ pub(crate) async fn run_agentic_loop(
pre_tokens = pre_compress_tokens, pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功,摘要已插首位" "[ai] 自动压缩成功,摘要已插首位"
); );
let ev = AiChatEvent::AiCompressed { // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.clone()), conversation_id: Some(conv_id.clone()),
summary, summary,
}; };
@@ -982,14 +1136,10 @@ pub(crate) async fn run_agentic_loop(
); );
} }
} }
let ev = AiChatEvent::AiError { // BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e), // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
error_type: Some(ErrorType::Unknown), // + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
conversation_id: Some(conv_id.clone()), // 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
} }
} }
} }
@@ -1463,6 +1613,116 @@ pub(crate) async fn run_agentic_loop(
} }
} }
// G2 探索熔断:连续空结果无进展检查(治 R4 游荡死循环 + token 失控,2026-06-26)。
//
// STALL_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
// 仅检查已回填 Tool 消息(同 L1):pending_count>0(待审批)交给上方审批分支,此处看已回填结果。
// 取末尾连续 Tool 消息(倒序 take_while role==Tool),逐条 is_empty_tool_result:
// - 任一非空 → stall_count=0 重置 + stall_warned=false(有进展,清警示态);
// - 全空 → stall_count+=1。
// 两段式(STALL_BREAKER_WARN_FIRST):stall_count == THRESHOLD-1 且未警示过 → 置 warned 标志,
// 警示文本拼进 system_prompt(本轮 LLM 已 stream 完,实际下轮生效——因 system_prompt 不变量,
// 下轮重新 stream 时会读最新 system_prompt;此处通过 insert 一条一次性软提示消息保本轮自纠)。
// stall_count >= THRESHOLD → guard.reset + AiHelpRequired + return 强制熔断。
// 与 L1 独立:is_empty_tool_result(空成功)不与 L1 is_failure(失败关键词)重叠。
if STALL_BREAKER_ENABLED {
let all_empty = {
let session = session_arc.lock().await;
let messages = match session.conv_read(&conv_id) {
Some(conv) => conv.messages.all_messages_clone(),
None => Vec::new(),
};
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果)。
let recent_tool_results: Vec<&ChatMessage> = messages
.iter()
.rev()
.take_while(|m| matches!(m.role, MessageRole::Tool))
.collect();
if recent_tool_results.is_empty() {
// 本轮无工具回填(非工具轮)→ 不计入也不重置,保持 stall_count(不干扰已有计数)。
None::<bool>
} else {
// 任一非空 → 重置;全空 → Some(true)。
let any_non_empty = recent_tool_results
.iter()
.any(|m| !is_empty_tool_result(&m.content));
Some(!any_non_empty)
}
};
// 锁已 drop,可安全 await/emit。
if let Some(all_empty_flag) = all_empty {
if all_empty_flag {
stall_count += 1;
} else {
stall_count = 0;
stall_warned = false;
}
// 两段式警示:THRESHOLD-1 次(如 THRESHOLD=3 即第 2 次)首次警示。
if STALL_BREAKER_WARN_FIRST
&& stall_count == STALL_BREAKER_THRESHOLD.saturating_sub(1)
&& !stall_warned
{
stall_warned = true;
let goal_text = if STALL_BREAKER_GOAL_REMIND {
let g = session_arc
.lock()
.await
.conv_read(&conv_id)
.and_then(|c| c.pinned_goal.clone())
.unwrap_or_default();
if g.trim().is_empty() {
String::new()
} else {
format!("(当前目标: {})", g.trim())
}
} else {
String::new()
};
let warn_text = format!(
"⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。",
stall_count, goal_text
);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
let conv = session.conv(&conv_id);
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
tracing::info!(
conv_id = %conv_id,
iteration,
stall_count,
"[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)"
);
}
}
// 熔断:达 THRESHOLD 强制停止。
if stall_count >= STALL_BREAKER_THRESHOLD {
tracing::warn!(
conv_id = %conv_id,
stall_count,
"[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", stall_count
);
guard.reset().await;
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiHelpRequired {
reason: format!(
"连续 {} 次工具返回空结果无进展,疑似目标漂移已停止",
stall_count
),
context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(),
options: vec![
"回顾目标".into(),
"换思路".into(),
"停止".into(),
],
conversation_id: Some(conv_id.clone()),
},
);
return;
}
}
}
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复 // 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
if pending_count > 0 { if pending_count > 0 {
let usage = df_ai::provider::TokenUsage { let usage = df_ai::provider::TokenUsage {
@@ -1556,6 +1816,60 @@ pub(crate) async fn run_agentic_loop(
}); });
} }
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
///
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
/// 序列化为紧凑 JSON,无空格):
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
/// - read_symbol 未找到 → 含「未找到」回退提示。
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
///
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() {
return true;
}
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
const EMPTY_MARKERS: &[&str] = &[
"\"total\":0",
"\"entries\":[]",
"\"matches\":[]",
"\"results\":[]",
"\"files\":[]",
];
for marker in EMPTY_MARKERS {
if trimmed.contains(marker) {
return true;
}
}
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
const EMPTY_TEXT: &[&str] = &[
"未找到",
"没有找到",
"无匹配",
"没有匹配",
"未匹配",
"未发现",
"无记录",
"No matches",
"no matches",
"0 results",
"0 matches",
"没有数据",
"没有符合",
];
for marker in EMPTY_TEXT {
if trimmed.contains(marker) {
return true;
}
}
false
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环 /// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
/// ///
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。 /// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
@@ -1610,15 +1924,10 @@ pub(crate) async fn try_continue_agent_loop(
.find_map(|a| a.conversation_id.clone()); .find_map(|a| a.conversation_id.clone());
let conv = session.conv_read(conv_id); let conv = session.conv_read(conv_id);
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。 // F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed), // L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
// off 回退 generating bool。双轨过渡:off 等价旧行为。 // CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
let is_generating = conv.map(|c| { // is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
if conv_state::CONV_STATE_ENABLED { let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false);
let agent_language = conv.and_then(|c| c.agent_language.clone()); let agent_language = conv.and_then(|c| c.agent_language.clone());
let model_override = conv.and_then(|c| c.model_override.clone()); let model_override = conv.and_then(|c| c.model_override.clone());
ContinueSnapshot { ContinueSnapshot {
@@ -1667,11 +1976,19 @@ pub(crate) async fn try_continue_agent_loop(
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。 // 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内 // 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。 // build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保 generating=true(审批等待态), // 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。 // 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
// F-09 B 批4:per_conv 唯一真相源,删顶层双写复位。 // 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(conv_id).generating = false; // 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
Ok(ns) => session.conv(conv_id).conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %conv_id,
error = %e,
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
),
}
drop(session); drop(session);
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider"); tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
let _ = app.emit("ai-chat-event", AiChatEvent::AiError { let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
@@ -1721,16 +2038,12 @@ pub(crate) async fn try_continue_agent_loop(
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。 // spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。) // (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。 // F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool。 // L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
// is_active() 与原 generating 语义一致。
let still_generating = { let still_generating = {
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
session.conv_read(conv_id).map(|c| { session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
if conv_state::CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false)
}; };
if !still_generating { if !still_generating {
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted"); tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
+11 -2
View File
@@ -32,7 +32,7 @@ pub(crate) async fn audit_tool_call(
message_id: Option<&str>, message_id: Option<&str>,
) { ) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
let _ = repo if let Err(e) = repo
.insert(AiToolExecutionRecord { .insert(AiToolExecutionRecord {
id: new_id(), id: new_id(),
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
@@ -51,7 +51,16 @@ pub(crate) async fn audit_tool_call(
executed_at, executed_at,
decided_by: decided_by.map(|s| s.to_string()), decided_by: decided_by.map(|s| s.to_string()),
}) })
.await; .await
{
tracing::error!(
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
conv_id,
tool_call_id,
tool_name,
e
);
}
} }
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result) /// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
+56 -2
View File
@@ -248,6 +248,60 @@ async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u3
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成) /// 返回待审批的工具数量(0 = 全部自动执行完成)
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
///
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
async fn execute_with_heartbeat(
tools: &AiToolRegistry,
name: &str,
args: serde_json::Value,
app: &AppHandle,
conv_id: &str,
) -> anyhow::Result<serde_json::Value> {
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::Duration;
// RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。
struct HeartbeatGuard {
stop: Arc<AtomicBool>,
handle: tokio::task::JoinHandle<()>,
}
impl Drop for HeartbeatGuard {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
self.handle.abort();
}
}
let stop = Arc::new(AtomicBool::new(false));
let app_h = app.clone();
let conv = conv_id.to_string();
let stop_c = stop.clone();
// 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174),
// 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。
let heartbeat = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await;
loop {
interval.tick().await;
if stop_c.load(Ordering::SeqCst) {
break;
}
let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) };
let _ = app_h.emit("ai-chat-event", ev.clone());
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
});
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
let _guard = HeartbeatGuard { stop, handle: heartbeat };
tools.execute(name, args).await
}
pub(crate) async fn process_tool_calls( pub(crate) async fn process_tool_calls(
session: &mut AiSession, session: &mut AiSession,
tool_calls_acc: HashMap<u32, ToolCallDraft>, tool_calls_acc: HashMap<u32, ToolCallDraft>,
@@ -601,7 +655,7 @@ pub(crate) async fn process_tool_calls(
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
let exec_result = tools.execute(&draft.name, args).await; let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
match exec_result { match exec_result {
Ok(val) => { Ok(val) => {
let content = val.to_string(); let content = val.to_string();
@@ -658,7 +712,7 @@ pub(crate) async fn process_tool_calls(
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
let result = tools.execute(&draft.name, args).await; let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
match result { match result {
Ok(val) => { Ok(val) => {
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
+125 -32
View File
@@ -39,16 +39,29 @@ const DEFINITION_KINDS: &[&str] = &[
"interface_declaration", "interface_declaration",
"enum_declaration", "enum_declaration",
"method_definition", "method_definition",
// Go
"method_declaration", // Go method(receiver func)
"type_declaration", // Go type Xxx struct/interface/func
// Java
"constructor_declaration", // Java constructor
"annotation_type_declaration", // @interface
"record_declaration", // Java record(Java 16+)
// Python(tree-sitter-python 用 _definition 后缀)
"function_definition",
"class_definition",
]; ];
/// 文件后缀 → tree-sitter grammar 集中 lookup(静态编译,不动态加载 / 不抽 trait)。 /// 文件后缀 → tree-sitter grammar 集中 lookup(静态编译,不动态加载 / 不抽 trait)。
/// ///
/// 设计(grammar 策略 2026-06-24 定稿,详见插件机制设计文档): /// 设计(grammar 策略 2026-06-24 定稿,详见插件机制设计文档):
/// **3 个 grammar 覆盖 4 类文件**,架构成本最低。 /// **6 个 grammar 覆盖 9 类文件**,架构成本最低。
/// - `.rs` → tree-sitter-rust /// - `.rs` → tree-sitter-rust
/// - `.ts` → tree-sitter-typescript(language_typescript) /// - `.ts` → tree-sitter-typescript(language_typescript)
/// - `.tsx` → tree-sitter-typescript(language_tsx) /// - `.tsx` → tree-sitter-typescript(language_tsx)
/// - `.js` / `.jsx` → tree-sitter-javascript /// - `.js` / `.jsx` → tree-sitter-javascript
/// - `.go` → tree-sitter-go
/// - `.java` → tree-sitter-java
/// - `.py` → tree-sitter-python
/// - `.vue` → 返回 None 标记"需切 script 段"(Vue 借 TS,逻辑在 script,read_symbol 内 extract_vue_script 切段喂 ts) /// - `.vue` → 返回 None 标记"需切 script 段"(Vue 借 TS,逻辑在 script,read_symbol 内 extract_vue_script 切段喂 ts)
/// ///
/// 未来加语言 = 此处加一行 + 可能补 DEFINITION_KINDS(表驱动,~几行),不返工调用方。 /// 未来加语言 = 此处加一行 + 可能补 DEFINITION_KINDS(表驱动,~几行),不返工调用方。
@@ -59,19 +72,22 @@ pub fn grammar_for(ext: &str) -> Option<Language> {
"ts" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT)), "ts" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT)),
"tsx" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TSX)), "tsx" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TSX)),
"js" | "jsx" => Some(Language::from(tree_sitter_javascript::LANGUAGE)), "js" | "jsx" => Some(Language::from(tree_sitter_javascript::LANGUAGE)),
"go" => Some(Language::from(tree_sitter_go::LANGUAGE)),
"java" => Some(Language::from(tree_sitter_java::LANGUAGE)),
"py" => Some(Language::from(tree_sitter_python::LANGUAGE)),
// Vue 借 TS:返 None 标记需切 script 段(read_symbol 内 extract_vue_script + 喂 ts) // Vue 借 TS:返 None 标记需切 script 段(read_symbol 内 extract_vue_script + 喂 ts)
"vue" => None, "vue" => None,
_ => None, _ => None,
} }
} }
/// 判断 ext 是否为 Phase1 支持语言(含 Vue 借 TS)。 /// 判断 ext 是否为支持语言(含 Vue 借 TS)。
/// ///
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来 /// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。 /// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
#[allow(dead_code)] #[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") matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
} }
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。 /// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
@@ -106,6 +122,9 @@ fn extract_vue_script(source: &str) -> Option<(String, usize)> {
/// - `symbol`:目标符号名(精确匹配定义节点的 name field) /// - `symbol`:目标符号名(精确匹配定义节点的 name field)
/// - `full`:true=完整定义体,false=骨架 /// - `full`:true=完整定义体,false=骨架
/// - `kind_hint`:可选 kind 过滤(如 "function"/"struct"),None 匹配任意定义 kind /// - `kind_hint`:可选 kind 过滤(如 "function"/"struct"),None 匹配任意定义 kind
/// - `drill`:可选子定义名(如 "parse");Some(name)=在目标符号节点子树内递归找名为 name 的
/// 嵌套定义(局部函数/impl 方法),命中即返该子定义的 full 态;未命中走兜底(reason=drill_not_found)。
/// None=不下钻,走原 full/kind_hint 三态逻辑。
pub fn read_symbol( pub fn read_symbol(
source: &str, source: &str,
file_hash: &str, file_hash: &str,
@@ -113,6 +132,7 @@ pub fn read_symbol(
symbol: &str, symbol: &str,
full: bool, full: bool,
kind_hint: Option<&str>, kind_hint: Option<&str>,
drill: Option<&str>,
) -> serde_json::Value { ) -> serde_json::Value {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
let is_vue = ext == "vue"; let is_vue = ext == "vue";
@@ -136,7 +156,7 @@ pub fn read_symbol(
Some(g) => g, Some(g) => g,
None => { None => {
return fallback(symbol, path, file_hash, "no_grammar", return fallback(symbol, path, file_hash, "no_grammar",
&format!("不支持 {} 文件符号解析(Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue)", ext)); &format!("不支持 {} 文件符号解析(支持 .rs/.ts/.tsx/.js/.jsx/.vue/.go/.java/.py)", ext));
} }
}; };
@@ -154,6 +174,21 @@ pub fn read_symbol(
let mut found: Option<Node> = None; let mut found: Option<Node> = None;
find_definition(&root, symbol, kind_hint, parse_source.as_bytes(), &mut found); find_definition(&root, symbol, kind_hint, parse_source.as_bytes(), &mut found);
// drill:在目标符号节点子树内递归找名为 drill 的嵌套定义(局部函数/impl 方法)。
// 命中即返该子定义 full 态;未命中走兜底(reason=drill_not_found,不 panic)。
if let Some(drill_name) = drill {
if let Some(sym_node) = found {
let mut drill_found: Option<Node> = None;
find_definition(&sym_node, drill_name, None, parse_source.as_bytes(), &mut drill_found);
return match drill_found {
Some(dn) => extract_symbol(dn, parse_source.as_bytes(), source, path, file_hash, true, line_offset),
None => fallback(drill_name, path, file_hash, "drill_not_found",
&format!("符号「{}」内未找到下钻子定义「{}」(可能非嵌套定义/作用域差异)", symbol, drill_name)),
};
}
// found 本身为 None 时 drill 无意义,落入下方 not_found 兜底
}
match found { match found {
Some(node) => extract_symbol(node, parse_source.as_bytes(), source, path, file_hash, full, line_offset), Some(node) => extract_symbol(node, parse_source.as_bytes(), source, path, file_hash, full, line_offset),
None => fallback(symbol, path, file_hash, "not_found", None => fallback(symbol, path, file_hash, "not_found",
@@ -237,7 +272,7 @@ fn extract_symbol(
let node_src = node_text(node, source); let node_src = node_text(node, source);
let signature = extract_signature(&node_src); let signature = extract_signature(&node_src);
let calls = collect_calls(node, source); let calls = collect_calls(node, source, line_offset);
if full { if full {
serde_json::json!({ serde_json::json!({
@@ -265,7 +300,7 @@ fn extract_symbol(
"signature": signature, "signature": signature,
"calls": calls, "calls": calls,
"mode": "skeleton", "mode": "skeleton",
"hint": "骨架态:传 full=true 取完整定义体(语法边界精准);下钻局部分支 drill 待 Phase2", "hint": "骨架态:传 full=true 取完整定义体(语法边界精准);传 drill=\"子定义名\" 下钻符号内嵌套定义(局部函数/impl 方法)",
}) })
} }
} }
@@ -289,25 +324,34 @@ fn extract_signature(node_src: &str) -> String {
/// ///
/// Phase1:扁平收集去重(Phase2 按分支结构组织调用图)。 /// Phase1:扁平收集去重(Phase2 按分支结构组织调用图)。
/// Rust/TS/JS 的 call_expression 都有 "function" field(callee),统一提取。 /// Rust/TS/JS 的 call_expression 都有 "function" field(callee),统一提取。
fn collect_calls(node: Node, source: &[u8]) -> Vec<String> { /// Phase2:每项带行号(call_expression 所在行,1-based + Vue 偏移,与 start_row 同口径)。
fn collect_calls(node: Node, source: &[u8], line_offset: usize) -> Vec<serde_json::Value> {
let mut calls = Vec::new(); let mut calls = Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
collect_calls_inner(node, source, &mut calls, &mut seen); collect_calls_inner(node, source, line_offset, &mut calls, &mut seen);
calls calls
} }
fn collect_calls_inner(node: Node, source: &[u8], out: &mut Vec<String>, seen: &mut HashSet<String>) { fn collect_calls_inner(
node: Node,
source: &[u8],
line_offset: usize,
out: &mut Vec<serde_json::Value>,
seen: &mut HashSet<String>,
) {
let mut cursor = node.walk(); let mut cursor = node.walk();
for child in node.children(&mut cursor) { for child in node.children(&mut cursor) {
if child.kind() == "call_expression" { if child.kind() == "call_expression" {
if let Some(func) = child.child_by_field_name("function") { if let Some(func) = child.child_by_field_name("function") {
let name = node_text(func, source).to_string(); let name = node_text(func, source).to_string();
if !name.is_empty() && seen.insert(name.clone()) { if !name.is_empty() && seen.insert(name.clone()) {
out.push(name); // 行号:调用语句所在行(call_expression 节点本身的 start_position),1-based + Vue 偏移
let line = child.start_position().row + 1 + line_offset;
out.push(serde_json::json!({ "name": name, "line": line }));
} }
} }
} }
collect_calls_inner(child, source, out, seen); collect_calls_inner(child, source, line_offset, out, seen);
} }
} }
@@ -359,23 +403,31 @@ struct Config {
assert!(grammar_for("tsx").is_some()); assert!(grammar_for("tsx").is_some());
assert!(grammar_for("js").is_some()); assert!(grammar_for("js").is_some());
assert!(grammar_for("jsx").is_some()); assert!(grammar_for("jsx").is_some());
// Phase1 扩展:Go / Java / Python(静态编译,表驱动加表项)
assert!(grammar_for("go").is_some());
assert!(grammar_for("java").is_some());
assert!(grammar_for("py").is_some());
// Vue 借 TS:grammar 本身返 None(切段后用 ts) // Vue 借 TS:grammar 本身返 None(切段后用 ts)
assert!(grammar_for("vue").is_none()); assert!(grammar_for("vue").is_none());
// 不支持语言 // 不支持语言
assert!(grammar_for("py").is_none());
assert!(grammar_for("").is_none()); assert!(grammar_for("").is_none());
} }
#[test] #[test]
fn test_is_supported_ext() { fn test_is_supported_ext() {
assert!(is_supported_ext("rs")); assert!(is_supported_ext("rs"));
assert!(is_supported_ext("go"));
assert!(is_supported_ext("java"));
assert!(is_supported_ext("py"));
assert!(is_supported_ext("vue")); assert!(is_supported_ext("vue"));
assert!(!is_supported_ext("py")); // 不支持语言
assert!(!is_supported_ext("rb"));
assert!(!is_supported_ext("md"));
} }
#[test] #[test]
fn test_read_symbol_rust_skeleton() { fn test_read_symbol_rust_skeleton() {
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", false, None); let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", false, None, None);
assert_eq!(r["fallback"], serde_json::Value::Null); assert_eq!(r["fallback"], serde_json::Value::Null);
assert_eq!(r["symbol"], "read_symbol"); assert_eq!(r["symbol"], "read_symbol");
assert_eq!(r["kind"], "function_item"); assert_eq!(r["kind"], "function_item");
@@ -384,15 +436,16 @@ struct Config {
assert!(r["content"].is_null()); assert!(r["content"].is_null());
assert!(r["signature"].as_str().unwrap().contains("read_symbol")); assert!(r["signature"].as_str().unwrap().contains("read_symbol"));
// calls 含同文件静态调用(parse/validate)+ load(跨文件也命中名,Phase1 扁平) // calls 含同文件静态调用(parse/validate)+ load(跨文件也命中名,Phase1 扁平)
// Phase2:每项是 {name, line} 对象
let calls = r["calls"].as_array().unwrap(); let calls = r["calls"].as_array().unwrap();
assert!(calls.iter().any(|c| c == "parse")); assert!(calls.iter().any(|c| c["name"] == "parse"));
assert!(calls.iter().any(|c| c == "validate")); assert!(calls.iter().any(|c| c["name"] == "validate"));
assert_eq!(r["body_lines"].as_u64().unwrap(), 6); assert_eq!(r["body_lines"].as_u64().unwrap(), 6);
} }
#[test] #[test]
fn test_read_symbol_rust_full() { fn test_read_symbol_rust_full() {
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", true, None); let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", true, None, None);
assert_eq!(r["mode"], "full"); assert_eq!(r["mode"], "full");
let content = r["content"].as_str().unwrap(); let content = r["content"].as_str().unwrap();
// full 态返完整定义体(含函数体 {}) // full 态返完整定义体(含函数体 {})
@@ -403,7 +456,7 @@ struct Config {
#[test] #[test]
fn test_read_symbol_struct() { fn test_read_symbol_struct() {
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, None); let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, None, None);
assert_eq!(r["symbol"], "Config"); assert_eq!(r["symbol"], "Config");
assert_eq!(r["kind"], "struct_item"); assert_eq!(r["kind"], "struct_item");
} }
@@ -411,14 +464,14 @@ struct Config {
#[test] #[test]
fn test_read_symbol_kind_hint_filter() { fn test_read_symbol_kind_hint_filter() {
// kind_hint="struct" 时,"read_symbol"(function)不应被误匹配为 struct // kind_hint="struct" 时,"read_symbol"(function)不应被误匹配为 struct
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, Some("struct")); let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, Some("struct"), None);
assert_eq!(r["symbol"], "Config"); assert_eq!(r["symbol"], "Config");
assert_eq!(r["kind"], "struct_item"); assert_eq!(r["kind"], "struct_item");
} }
#[test] #[test]
fn test_read_symbol_not_found_fallback() { fn test_read_symbol_not_found_fallback() {
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "nonexistent_fn", false, None); let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "nonexistent_fn", false, None, None);
assert_eq!(r["fallback"], true); assert_eq!(r["fallback"], true);
assert_eq!(r["reason"], "not_found"); assert_eq!(r["reason"], "not_found");
assert!(r["suggestion"].as_str().unwrap().contains("grep")); assert!(r["suggestion"].as_str().unwrap().contains("grep"));
@@ -426,8 +479,10 @@ struct Config {
#[test] #[test]
fn test_read_symbol_no_grammar_fallback() { fn test_read_symbol_no_grammar_fallback() {
let src = "def hello():\n print('hi')\n"; // Ruby 无 grammar(.rb 未集成 tree-sitter-ruby),走兜底。
let r = read_symbol(src, "h", "test.py", "hello", false, None); // 注:原用 .py 做"无 grammar"样例,Phase1 扩展加 Python 支持后失效。
let src = "def hello\n puts 'hi'\nend\n";
let r = read_symbol(src, "h", "test.rb", "hello", false, None, None);
assert_eq!(r["fallback"], true); assert_eq!(r["fallback"], true);
assert_eq!(r["reason"], "no_grammar"); assert_eq!(r["reason"], "no_grammar");
assert!(r["suggestion"].as_str().unwrap().contains("不支持")); assert!(r["suggestion"].as_str().unwrap().contains("不支持"));
@@ -448,7 +503,7 @@ function helper() {}
<style scoped></style> <style scoped></style>
"#; "#;
let r = read_symbol(vue, "h", "App.vue", "greet", false, None); let r = read_symbol(vue, "h", "App.vue", "greet", false, None, None);
assert_eq!(r["fallback"], serde_json::Value::Null); assert_eq!(r["fallback"], serde_json::Value::Null);
assert_eq!(r["symbol"], "greet"); assert_eq!(r["symbol"], "greet");
assert_eq!(r["kind"], "function_declaration"); assert_eq!(r["kind"], "function_declaration");
@@ -456,17 +511,53 @@ function helper() {}
let start = r["line_start"].as_u64().unwrap(); let start = r["line_start"].as_u64().unwrap();
assert!(start >= 4, "Vue 行号应映射回原文件(script 段偏移后),实际 {}", start); assert!(start >= 4, "Vue 行号应映射回原文件(script 段偏移后),实际 {}", start);
let calls = r["calls"].as_array().unwrap(); let calls = r["calls"].as_array().unwrap();
assert!(calls.iter().any(|c| c == "helper")); // Phase2:calls 每项是 {name, line} 对象
assert!(calls.iter().any(|c| c["name"] == "helper"));
} }
#[test] #[test]
fn test_read_symbol_vue_no_script_fallback() { fn test_read_symbol_vue_no_script_fallback() {
let vue = "<template><div>hi</div></template>\n<style></style>"; let vue = "<template><div>hi</div></template>\n<style></style>";
let r = read_symbol(vue, "h", "App.vue", "greet", false, None); let r = read_symbol(vue, "h", "App.vue", "greet", false, None, None);
assert_eq!(r["fallback"], true); assert_eq!(r["fallback"], true);
assert_eq!(r["reason"], "vue_no_script"); assert_eq!(r["reason"], "vue_no_script");
} }
/// Phase2 drill:下钻目标符号(read_symbol)内的嵌套子定义(parse)。
/// RUST_FIXTURE 中 read_symbol 函数体内调用了 parse,但 parse 是顶层函数(非 read_symbol 子树内),
/// 故用带嵌套局部函数的 fixture 验证:outer 内定义 inner,drill="inner" 应命中并返 full 态。
#[test]
fn test_read_symbol_drill_hits_nested() {
let src = r#"
fn outer() {
fn inner(x: i32) -> i32 {
x + 1
}
inner(42)
}
"#;
// 外层骨架态,确认 outer 能解析到
let skeleton = read_symbol(src, "h", "t.rs", "outer", false, None, None);
assert_eq!(skeleton["symbol"], "outer");
// 下钻 inner:应在 outer 子树内命中,返 full 态
let drill = read_symbol(src, "h", "t.rs", "outer", false, None, Some("inner"));
assert_eq!(drill["fallback"], serde_json::Value::Null, "drill 命中应返符号非兜底");
assert_eq!(drill["symbol"], "inner");
assert_eq!(drill["mode"], "full", "drill 命中走 full 态(完整定义体)");
let content = drill["content"].as_str().unwrap();
assert!(content.contains("fn inner"), "drill content 应含 inner 定义体");
}
/// Phase2 drill 未命中兜底:下钻外层符号内不存在的子定义名 → fallback reason=drill_not_found。
#[test]
fn test_read_symbol_drill_not_found_fallback() {
let src = "fn outer() {\n fn inner() {}\n}\n";
let drill = read_symbol(src, "h", "t.rs", "outer", false, None, Some("nonexistent"));
assert_eq!(drill["fallback"], true);
assert_eq!(drill["reason"], "drill_not_found");
assert!(drill["suggestion"].as_str().unwrap().contains("nonexistent"));
}
#[test] #[test]
fn test_extract_vue_script() { fn test_extract_vue_script() {
let vue = "a\nb\n<script>let x = 1;</script>"; let vue = "a\nb\n<script>let x = 1;</script>";
@@ -495,11 +586,13 @@ function helper() {}
let mut parser = Parser::new(); let mut parser = Parser::new();
parser.set_language(&Language::from(tree_sitter_rust::LANGUAGE)).unwrap(); parser.set_language(&Language::from(tree_sitter_rust::LANGUAGE)).unwrap();
let tree = parser.parse(src, None).unwrap(); let tree = parser.parse(src, None).unwrap();
let calls = collect_calls(tree.root_node(), src.as_bytes()); let calls = collect_calls(tree.root_node(), src.as_bytes(), 0);
// b 去重只出现一次 // b 去重只出现一次(Phase2:每项是 {name, line} 对象)
assert!(calls.iter().any(|c| c == "b")); assert!(calls.iter().any(|c| c["name"] == "b"));
assert_eq!(calls.iter().filter(|c| *c == "b").count(), 1); assert_eq!(calls.iter().filter(|c| c["name"] == "b").count(), 1);
assert!(calls.iter().any(|c| c == "c")); assert!(calls.iter().any(|c| c["name"] == "c"));
// line 应为调用所在行(src 单行,a 在第 1 行)
assert_eq!(calls[0]["line"].as_u64().unwrap(), 1);
} }
/// 多角度验证(用户要求:实现后看效果 + 多角度测试): /// 多角度验证(用户要求:实现后看效果 + 多角度测试):
@@ -514,7 +607,7 @@ function helper() {}
let full_bytes = source.len(); let full_bytes = source.len();
// 骨架态:解析自身 read_symbol 函数(只给该符号,非整个文件) // 骨架态:解析自身 read_symbol 函数(只给该符号,非整个文件)
let skeleton = read_symbol(&source, "h0", "code_intel.rs", "read_symbol", false, None); let skeleton = read_symbol(&source, "h0", "code_intel.rs", "read_symbol", false, None, None);
assert_ne!(skeleton["fallback"], true, "真实文件应解析出 read_symbol 符号"); assert_ne!(skeleton["fallback"], true, "真实文件应解析出 read_symbol 符号");
assert_eq!(skeleton["symbol"], "read_symbol"); assert_eq!(skeleton["symbol"], "read_symbol");
assert_eq!(skeleton["kind"], "function_item"); assert_eq!(skeleton["kind"], "function_item");
@@ -531,7 +624,7 @@ function helper() {}
assert!(ratio > 5.0, "骨架密度应显著高于全文(降 >5x),实际 {:.1}x", ratio); assert!(ratio > 5.0, "骨架密度应显著高于全文(降 >5x),实际 {:.1}x", ratio);
// full 态:完整定义体(语法边界 100% 准) // full 态:完整定义体(语法边界 100% 准)
let full_sym = read_symbol(&source, "h1", "code_intel.rs", "read_symbol", true, None); let full_sym = read_symbol(&source, "h1", "code_intel.rs", "read_symbol", true, None, None);
let content = full_sym["content"].as_str().expect("full 态应返 content"); let content = full_sym["content"].as_str().expect("full 态应返 content");
assert!(content.contains("pub fn read_symbol")); assert!(content.contains("pub fn read_symbol"));
// calls 应含 read_symbol 内部调用的函数(grammar_for/extract_symbol/fallback 等) // calls 应含 read_symbol 内部调用的函数(grammar_for/extract_symbol/fallback 等)
+238 -89
View File
@@ -24,7 +24,9 @@ use crate::state::AppState;
use crate::commands::{err_str, now_millis}; use crate::commands::{err_str, now_millis};
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED};
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
use super::super::agentic::conv_state::ConvState;
use super::super::audit::{audit_finalize, emit_data_changed}; use super::super::audit::{audit_finalize, emit_data_changed};
use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize; use super::super::augmentation::sanitize;
@@ -51,7 +53,7 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages; /// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv, /// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。 /// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
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。 // SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传 // 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。 // &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
@@ -70,6 +72,78 @@ fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id:
} }
} }
/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。
///
/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active),
/// 提取后存 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。
///
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标
/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。
/// 2. trim 空白。
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
///
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
pub(crate) fn extract_pinned_goal(text: &str) -> String {
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < n {
if chars[i] == '[' {
// 探测 [kind:...],命中则跳过整段(含 ])。
if let Some(close) = find_mention_close(&chars, i, KINDS) {
i = close + 1;
continue;
}
}
out.push(chars[i]);
i += 1;
}
let cleaned = out.trim();
// 截断到 GOAL_MAX_CHARS(字符级,非字节级,中文友好)
cleaned.chars().take(GOAL_MAX_CHARS).collect()
}
/// 探测 `chars[open]=='['` 起的 mention 段 `[kind:...]`,返回匹配 `]` 的索引。
///
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
let after = open + 1;
for k in kinds {
let kc: Vec<char> = k.chars().collect();
if chars.len() < after + kc.len() {
continue;
}
let slice = &chars[after..after + kc.len()];
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
continue;
}
let mut j = after + kc.len();
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') {
continue;
}
j += 1;
while j < chars.len() {
if chars[j] == ']' {
return Some(j);
}
j += 1;
}
return None; // 未闭合 ] → 不当 mention
}
None
}
/// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。 /// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。
/// ///
/// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此, /// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此,
@@ -187,13 +261,16 @@ pub async fn ai_regenerate(
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。 // (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
{ {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// generating 拦截:仅查目标 conv 的 per_conv.generating。 // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 仅拦
if is_gen { // Generating/Compressed(漏拦 Stopping 态),can_accept_request 更严谨(防停止中接新请求竞态)。
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
if !cs.can_accept_request() {
return Err("AI 正在生成中,请等待完成".to_string()); return Err("AI 正在生成中,请等待完成".to_string());
} }
let conv = session.conv(&conversation_id); let conv = session.conv(&conversation_id);
conv.generating = true; // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
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 从头计数。 // F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
@@ -212,7 +289,8 @@ pub async fn ai_regenerate(
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User)) .map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.unwrap_or(false); .unwrap_or(false);
if !last_is_user { if !last_is_user {
conv.generating = false; // 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
return Err("没有可重新生成的回复".to_string()); return Err("没有可重新生成的回复".to_string());
} }
} }
@@ -282,15 +360,10 @@ pub async fn ai_is_generating(
// 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。 // 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。
return Ok(false); return Ok(false);
} }
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state==Generating(含 Compressed 派生态, // L2 读侧(双轨收口批1):统一读 conv_state.is_active(),删除 CONV_STATE_ENABLED off 回退分支
// 压缩期间 loop 仍活跃),off 回退 generating bool(可回退)。双轨过渡:开关 off 等价旧行为零破坏。 // (enum 单路径)。is_active() 拦 Generating+Compressed(压缩期间 loop 仍活跃),语义与
Ok(session.conv_read(&target).map(|c| { // 原 c.generating 一致(此读点用于判断「是否在生成中」,应保留拦 Compressed)。
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED { Ok(session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false))
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false))
} }
/// 发送消息并获取流式 AI 响应 /// 发送消息并获取流式 AI 响应
@@ -351,13 +424,15 @@ pub async fn ai_chat_send(
session.active_conv_created_at = Some(now_millis()); session.active_conv_created_at = Some(now_millis());
} }
} }
// generating 拦截:仅查目标 conv 的 per_conv.generating(真并发下各 conv 独立)。 // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
let is_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false); // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态,
if is_gen { // can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。
let cs = session.conv_read(&target).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
if !cs.can_accept_request() {
return Err("AI 正在生成中,请等待完成".to_string()); return Err("AI 正在生成中,请等待完成".to_string());
} }
let conv = session.conv(&target); let conv = session.conv(&target);
conv.generating = true; // 批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 从头计数(累计计数器复位)。 // F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
@@ -388,6 +463,14 @@ pub async fn ai_chat_send(
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
}
}
(target, user_msg_id) (target, user_msg_id)
}; };
@@ -457,7 +540,7 @@ pub async fn ai_approve(
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。 // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await { match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
Ok(Some(rec)) => { Ok(Some(rec)) => {
if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" { if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" {
return Ok(format!("已处理({})", rec.status)); return Ok(format!("已处理({})", rec.status));
} }
} }
@@ -534,7 +617,7 @@ pub async fn ai_approve(
// session_trust —— 下次同会话同类操作(同工具+同目录 TrustKey)自动放行,跳过 pending + 二次确认。 // session_trust —— 下次同会话同类操作(同工具+同目录 TrustKey)自动放行,跳过 pending + 二次确认。
// 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。 // 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。
// F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。 // F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。
// 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 executed)。 // 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 completed)。
if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) { if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) {
let inserted = if let Some(ref cid) = conv_id { let inserted = if let Some(ref cid) = conv_id {
session.conv(cid).session_trust.insert(key.clone()) session.conv(cid).session_trust.insert(key.clone())
@@ -581,7 +664,7 @@ pub async fn ai_approve(
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。 // 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。 // 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
let (audit_status, result_val) = match &exec_result { let (audit_status, result_val) = match &exec_result {
Ok(val) => ("executed", val.clone()), Ok(val) => ("completed", val.clone()),
Err(e) => ("failed", serde_json::Value::String(e.to_string())), Err(e) => ("failed", serde_json::Value::String(e.to_string())),
}; };
// 审计:人工审批后无论成败回填(决策者=human) // 审计:人工审批后无论成败回填(决策者=human)
@@ -601,7 +684,7 @@ pub async fn ai_approve(
// 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步) // 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步)
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。 // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
// 无 conv_id 的无主审批不入 messages(审计仍记 executed/failed)。 // 无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id { if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string()); session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
@@ -627,11 +710,11 @@ pub async fn ai_approve(
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。 // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
// create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行, // create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行,
// 故补全点放此(工具已 executed,可拿到 result.id + args.source)。 // 故补全点放此(工具已 completed,可拿到 result.id + args.source)。
// message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义); // message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义);
// assistant_with_tools 消息持久驻留 messages,审批时仍可读。 // assistant_with_tools 消息持久驻留 messages,审批时仍可读。
// 仅 executed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。 // 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
if audit_status == "executed" { if audit_status == "completed" {
let message_id = { let message_id = {
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
conv_id.as_deref().and_then(|cid| { conv_id.as_deref().and_then(|cid| {
@@ -665,15 +748,15 @@ pub async fn ai_approve(
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续) // 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
Ok("executed".to_string()) Ok("completed".to_string())
} }
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。 /// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
fn authz_debug(msg: &str) { fn authz_debug(msg: &str) {
use std::io::Write; use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true) let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
.open("E:/wk-lab/devflow/authz-debug.log") if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
{
let _ = writeln!(f, "{}", msg); let _ = writeln!(f, "{}", msg);
} }
} }
@@ -691,17 +774,10 @@ fn drive_letter(path: &str) -> Option<String> {
None None
} }
/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。 /// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
/// /// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。
/// 失败回退 None(对比退化,不报跨盘提示)。
fn workspace_drive() -> Option<String> { fn workspace_drive() -> Option<String> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
.parent()
.and_then(|p| p.parent())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("."));
drive_letter(&root.to_string_lossy())
} }
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。 /// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
@@ -751,9 +827,10 @@ fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json:
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending /// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。 /// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
/// ///
/// `decision`: /// `decision`(三授权档 + 拒):
/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop /// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复 /// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop /// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
/// ///
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result → /// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
@@ -843,23 +920,33 @@ pub async fn ai_authorize_dir(
return Ok("rejected".to_string()); return Ok("rejected".to_string());
} }
// ── "once":写会话级临时授权(随会话销毁,不落库) ── // ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项)。 // L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
if decision == "once" { // deny 已在上方早 return,此处仅 once/session/always。
for d in &dir_strs { match decision.as_str() {
state.add_session_allowed_dir(d.clone()).await; "once" => {
for d in &dir_strs {
state.add_once_allowed_dir(d.clone()).await;
}
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
} }
} "session" => {
for d in &dir_strs {
// ── "always":写持久化授权(Settings KV + 同步内存 persistent) ──
if decision == "always" {
for d in &dir_strs {
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): dir={} err={}", d, e);
// 降级:写入会话临时授权,保本路径本会话可用
state.add_session_allowed_dir(d.clone()).await; state.add_session_allowed_dir(d.clone()).await;
} }
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
} }
"always" => {
for d in &dir_strs {
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
// 降级:写入会话临时授权,保本路径本会话可用
state.add_session_allowed_dir(d.clone()).await;
}
}
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
}
_ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
} }
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ── // ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
@@ -882,11 +969,11 @@ pub async fn ai_authorize_dir(
}; };
tracing::debug!( tracing::debug!(
ok = exec_result.is_ok(), ok = exec_result.is_ok(),
status = if exec_result.is_ok() { "executed" } else { "failed" }, status = if exec_result.is_ok() { "completed" } else { "failed" },
"[AI-AUTHZ] execute 后" "[AI-AUTHZ] execute 后"
); );
let (audit_status, mut result_val) = match &exec_result { let (audit_status, mut result_val) = match &exec_result {
Ok(val) => ("executed", val.clone()), Ok(val) => ("completed", val.clone()),
Err(e) => ("failed", serde_json::Value::String(e.to_string())), Err(e) => ("failed", serde_json::Value::String(e.to_string())),
}; };
// 阶段4(容错/恢复):跨盘预检 → tool_result 提示。 // 阶段4(容错/恢复):跨盘预检 → tool_result 提示。
@@ -908,6 +995,10 @@ pub async fn ai_authorize_dir(
} }
} }
// once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
// 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
state.clear_once_allowed_dirs().await;
// 替换占位 tool_result 为真实结果 // 替换占位 tool_result 为真实结果
{ {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
@@ -1118,7 +1209,8 @@ pub async fn ai_chat_compress_context(
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
let cid = session.active_conversation_id.clone(); let cid = session.active_conversation_id.clone();
drop(session); drop(session);
let ev = AiChatEvent::AiCompressed { // 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: cid, conversation_id: cid,
summary: String::new(), summary: String::new(),
}; };
@@ -1144,7 +1236,8 @@ pub async fn ai_chat_compress_context(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false); session.conv(&conv_id).messages.set_compressing(false);
drop(session); drop(session);
let ev = AiChatEvent::AiCompressed { // 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
summary: String::new(), summary: String::new(),
}; };
@@ -1209,7 +1302,8 @@ pub async fn ai_chat_compress_context(
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
} }
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await; save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
let ev = AiChatEvent::AiCompressed { // 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
summary, summary,
}; };
@@ -1243,8 +1337,10 @@ pub async fn ai_chat_edit(
// (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。 // (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。
{ {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
if is_gen { // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态。
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
if !cs.can_accept_request() {
return Err("AI 正在生成中,请等待完成".to_string()); return Err("AI 正在生成中,请等待完成".to_string());
} }
let conv = session.conv(&conversation_id); let conv = session.conv(&conversation_id);
@@ -1255,14 +1351,21 @@ pub async fn ai_chat_edit(
// ② 其后所有消息标 truncated(无后续也 OK,返回 0) // ② 其后所有消息标 truncated(无后续也 OK,返回 0)
let _ = conv.messages.truncate_after_user_message(&new_message) let _ = conv.messages.truncate_after_user_message(&new_message)
.map_err(|_| "定位被编辑消息失败".to_string())?; .map_err(|_| "定位被编辑消息失败".to_string())?;
// 占用 generating + stop_flag // 占用生成态 + stop_flag
conv.generating = true; // 批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 从头计数。 // F-260616-11: 编辑重生成 = 新生命周期起点,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();
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&new_message);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
}
}
} }
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
@@ -1379,7 +1482,9 @@ pub async fn ai_chat_force_send(
} }
} }
// ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。 // ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。
let was_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false); // was_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。is_active() 与原 generating 语义一致。
let was_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages // SW-260618-02:强制发送丢弃目标 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, "已取消");
@@ -1387,13 +1492,22 @@ pub async fn ai_chat_force_send(
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()));
{ {
let conv = session.conv(&target); let conv = session.conv(&target);
conv.generating = false; // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle 收敛)。
// was_gen=false 时仍 Idle(幂等);非活跃态→Idle 非法仅 warn(不阻断强制发送语义)。
match conv.conv_state.transition_to(ConvState::Idle) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %target,
error = %e,
"[ai] 强制发送 ConvState→Idle 非法(不阻断强制发送)"
),
}
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
} }
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无 generating 拦截—— // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位) // 这是"强制"语义本身,前面已主动复位)
let conv = session.conv(&target); let conv = session.conv(&target);
conv.generating = true; // 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。
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 从头计数。 // F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。
@@ -1417,6 +1531,15 @@ pub async fn ai_chat_force_send(
} }
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
// pinned_goal 已更新,续跑用新目标)。
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
}
}
(was_gen.then_some(target.clone()), target, user_msg_id) (was_gen.then_some(target.clone()), target, user_msg_id)
}; };
@@ -1495,15 +1618,9 @@ pub async fn ai_chat_stop(
return Ok(()); return Ok(());
} }
// 仅查目标 conv 的 generating(真并发下各 conv 独立)。 // 仅查目标 conv 的 generating(真并发下各 conv 独立)。
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed), // L2 读侧(双轨收口批1):统一读 conv_state.is_active()(Generating+Compressed),删除
// off 回退 generating bool。双轨过渡:off 等价旧行为。 // CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于判断停止按钮是否可点的预判断。
let is_gen = session.conv_read(&target).map(|c| { let is_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false);
if !is_gen { if !is_gen {
return Ok(()); return Ok(());
} }
@@ -1522,7 +1639,15 @@ pub async fn ai_chat_stop(
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()));
let conv = session.conv(&target); let conv = session.conv(&target);
conv.generating = false; // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
match conv.conv_state.transition_to(ConvState::Idle) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %target,
error = %e,
"[ai] ai_chat_stop 审批分支 ConvState→Idle 非法(不阻断 emit AiCompleted)"
),
}
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
drop(session); drop(session);
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
@@ -1557,13 +1682,25 @@ pub async fn ai_chat_stop(
tokio::time::sleep(std::time::Duration::from_secs(3)).await; tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
let target_owned = conv_id.clone().unwrap_or_default(); let target_owned = conv_id.clone().unwrap_or_default();
// still_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
// 3秒后重检是否仍在生成。is_active() 与原 generating 语义一致。
let still_gen = if target_owned.is_empty() { let still_gen = if target_owned.is_empty() {
false false
} else { } else {
session.conv_read(&target_owned).map(|c| c.generating).unwrap_or(false) session.conv_read(&target_owned).map(|c| c.conv_state.is_active()).unwrap_or(false)
}; };
if still_gen { if still_gen {
session.conv(&target_owned).generating = false; // 3秒超时强制收尾:generating bool 已退役,改 ConvState 迁移(Generating→Idle)收敛生成态。
// 正常路径由 guard.drop 复位,此处仅超时兜底(loop 未及时退出场景)。
let conv = session.conv(&target_owned);
match conv.conv_state.transition_to(ConvState::Idle) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %target_owned,
error = %e,
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
),
}
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, total_tokens: 0,
@@ -1602,7 +1739,9 @@ pub async fn ai_continue_loop(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作 // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。 // (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); // 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。达 max 时
// generating=true(conv_state=Generating),is_active() 返回 true 符合预期。
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
if !is_gen { if !is_gen {
return Err("AI 未在暂停态,无需继续".to_string()); return Err("AI 未在暂停态,无需继续".to_string());
} }
@@ -1622,11 +1761,11 @@ pub async fn ai_continue_loop(
/// 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态用户点「停止」) /// 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态用户点「停止」)
/// ///
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点停止调本命令 → 复位 generating + emit AiCompleted(标收敛)。 /// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。
/// ///
/// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库(agentic/mod.rs /// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库(agentic/mod.rs
/// 达上限分支),此处仅复位 generating + emit AiCompleted 通知前端收尾。校验复用 ai_approve /// 达上限分支),此处仅复位生成态 + emit AiCompleted 通知前端收尾。校验复用 ai_approve
/// 模式(generating + active_conversation_id 一致性)。 /// 模式(生成态 + active_conversation_id 一致性)。
#[tauri::command] #[tauri::command]
pub async fn ai_stop_loop( pub async fn ai_stop_loop(
app: AppHandle, app: AppHandle,
@@ -1637,14 +1776,24 @@ pub async fn ai_stop_loop(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可停止)。 // (真并发下后台 conv 也应可停止)。
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); // 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。停止按钮判断
// 是否在生成中(与 ai_continue_loop 同场景)。is_active() 符合语义。
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
if !is_gen { if !is_gen {
return Err("AI 未在暂停态,无需停止".to_string()); return Err("AI 未在暂停态,无需停止".to_string());
} }
let conv = session.conv(&conversation_id); let conv = session.conv(&conversation_id);
// 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致) // 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致)
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
conv.generating = false; // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
match conv.conv_state.transition_to(ConvState::Idle) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %conversation_id,
error = %e,
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
),
}
} }
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
@@ -297,7 +297,10 @@ pub async fn ai_conversation_switch(
drop(session); drop(session);
state.clear_session_allowed_dirs().await; state.clear_session_allowed_dirs().await;
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); // 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断目标会话是否
// 已在生成中(是否需要从 DB reload)。is_active() 与原 generating 语义一致(压缩期间后台 loop
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload)。
let already_live = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
if !already_live { if !already_live {
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。 // 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
@@ -312,6 +315,10 @@ pub async fn ai_conversation_switch(
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空, // 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的) // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
// → ai_pending_tool_calls 查询 → ai_approve 落库)。 // → ai_pending_tool_calls 查询 → ai_approve 落库)。
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):切走目标 conv 前,
// 先把其 pending 审批占位 tool_result 终态化为"会话已切换",防占位残留下次发送喂给 LLM。
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。 // 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB) // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
@@ -357,6 +364,10 @@ pub async fn ai_conversation_delete(
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap, // 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理, // 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。 // 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):删除目标 conv 前,
// 先把其 pending 审批占位 tool_result 终态化为"会话已删除",防占位残留下次发送喂给 LLM。
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。 // 阶段3a 单真相源合并:单表 retain(kind 不区分)。
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));
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此, // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
+48 -13
View File
@@ -199,8 +199,17 @@ pub enum AiChatEvent {
AiContextCleared { conversation_id: Option<String> }, AiContextCleared { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。 /// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
AiCompressing { conversation_id: Option<String> }, AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。 /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。
AiCompressed { conversation_id: Option<String>, summary: String }, ///
/// 治 Task#1(BUG-260624-05):自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷,
/// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。
/// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。
AiManualCompressed { conversation_id: Option<String>, summary: String },
/// F-15 阶段2 自动 LLM 压缩完成(agentic loop 触发,治 Task#1):对桌面端静默——
/// 桌面 useAiContext 仅复位 isCompressing(防按钮卡死兜底),不弹 toast 不 switchConversation。
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
AiAutoCompressed { conversation_id: Option<String>, summary: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): /// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
/// ///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI /// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
@@ -257,8 +266,8 @@ pub enum AiChatEvent {
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态 /// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。 /// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
/// ///
/// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生, /// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 generating bool 已退役,emit 无条件执行
/// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。 /// (enum 单一真相源,无 off 降级分支)。前端按 conv_state 事件流派生展示态。
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed), /// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
/// 供前端 union discriminator 直接使用。 /// 供前端 union discriminator 直接使用。
AiConvStateChanged { AiConvStateChanged {
@@ -393,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
"run_command" => { "run_command" => {
let dir = match args.get("working_dir").and_then(|v| v.as_str()) { let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => normalize_dir_key(d), Some(d) => normalize_dir_key(d),
None => workspace_root_str(), None => String::new(),
}; };
Some(TrustKey::Execute { dir }) Some(TrustKey::Execute { dir })
} }
@@ -414,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String {
.unwrap_or_else(|| std::path::PathBuf::from(".")); .unwrap_or_else(|| std::path::PathBuf::from("."));
normalize_dir_key(&parent.to_string_lossy()) normalize_dir_key(&parent.to_string_lossy())
} }
Err(_) => workspace_root_str(), Err(_) => String::new(),
} }
} }
@@ -514,7 +523,9 @@ impl AiSession {
.any(|a| a.conversation_id.as_deref() == Some(conv_id)); .any(|a| a.conversation_id.as_deref() == Some(conv_id));
if has_pending { if has_pending {
SessionState::AwaitingApproval SessionState::AwaitingApproval
} else if self.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) { } else if self.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) {
// 读侧(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断是否在
// 活跃生成。is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为 Streaming)。
SessionState::Streaming SessionState::Streaming
} else { } else {
SessionState::Idle SessionState::Idle
@@ -557,7 +568,8 @@ mod tests_f09_per_conv {
#[test] #[test]
fn test_per_conv_state_new() { fn test_per_conv_state_new() {
let s = PerConvState::new(); let s = PerConvState::new();
assert!(!s.generating, "generating 初值应为 false"); // 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。
assert_eq!(s.conv_state, ConvState::Idle, "conv_state 初值应为 Idle");
assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0"); assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0");
assert!(s.agent_language.is_none(), "agent_language 初值应为 None"); assert!(s.agent_language.is_none(), "agent_language 初值应为 None");
assert!(s.model_override.is_none(), "model_override 初值应为 None"); assert!(s.model_override.is_none(), "model_override 初值应为 None");
@@ -587,7 +599,8 @@ mod tests_f09_per_conv {
// 首次 conv() 创建 // 首次 conv() 创建
let ptr_first = { let ptr_first = {
let s = session.conv("conv-a"); let s = session.conv("conv-a");
assert!(!s.generating); // 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。
assert_eq!(s.conv_state, ConvState::Idle);
assert_eq!(s.iteration_used, 0); assert_eq!(s.iteration_used, 0);
s as *const PerConvState s as *const PerConvState
}; };
@@ -744,15 +757,36 @@ mod tests_f09_per_conv {
/// 读写,AiSession 顶层单例字段已全部删除。 /// 读写,AiSession 顶层单例字段已全部删除。
/// ///
/// 字段初值逐字对齐 [`AiSession::new`](#method.new)。 /// 字段初值逐字对齐 [`AiSession::new`](#method.new)。
///
/// # 字段正交性(G1 目标钉扎 / G3 状态机收敛衔接,2026-06-26)
///
/// 本 struct 同时持有两类正交状态:
/// - **生命周期态**(`conv_state: ConvState`):loop 是否在跑(Idle/Generating/Stopping/...),
/// 归 `ConvState` enum 管(写收敛见 [`ConvState::transition_to`])。
/// - **内容态**(`pinned_goal` 等):用户想达成什么 / 会话进度等内容信息,挂本 struct 兄弟字段。
///
/// 两者**正交共存**于同一 struct,不互相进变体:目标**绝不**塞进 `ConvState` enum(否则
/// 5 态会膨胀成 `GeneratingWithGoalA`/`GeneratingWithGoalB` 爆炸组合,违反 conv-state-convergence
/// 分支「轻量状态机不引入框架」原则,见 agentic/conv_state.rs 文件头)。目标钉扎字段随会话
/// 销毁不落库(对齐 `knowledge_extracted` 语义,PerConvState 无 serde derive)。
pub struct PerConvState { pub struct PerConvState {
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图) /// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
pub messages: ContextManager, pub messages: ContextManager,
/// 是否正在生成
pub generating: bool,
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。 /// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
/// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。 /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
pub conv_state: ConvState, pub conv_state: ConvState,
/// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。
///
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
///
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
pub pinned_goal: Option<String>,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>, pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
@@ -799,11 +833,12 @@ impl PerConvState {
/// - session_trust: HashSet::new() /// - session_trust: HashSet::new()
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义) /// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志) /// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
/// - pinned_goal: None(G1 目标钉扎,新会话未提取目标)
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
messages: ContextManager::new(ContextConfig::default()), messages: ContextManager::new(ContextConfig::default()),
generating: false,
conv_state: ConvState::Idle, conv_state: ConvState::Idle,
pinned_goal: None,
stop_flag: Arc::new(AtomicBool::new(false)), stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()), notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0, iteration_used: 0,
+2
View File
@@ -69,6 +69,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- Create/query projects, tasks, and ideas\n\ - Create/query projects, tasks, and ideas\n\
- Run workflows\n\ - Run workflows\n\
- Read file contents, list directories, create/write files\n\ - Read file contents, list directories, create/write files\n\
- To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found\n\
- Get file metadata (size, line count, binary detection)\n\ - Get file metadata (size, line count, binary detection)\n\
- Append content to files, search files by name pattern\n\n\ - Append content to files, search files by name pattern\n\n\
## Guidelines\n\ ## Guidelines\n\
@@ -92,6 +93,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 创建/查询项目、任务、灵感\n\ - 创建/查询项目、任务、灵感\n\
- 运行工作流\n\ - 运行工作流\n\
- 读取文件内容、列出目录、创建/写入文件\n\ - 读取文件内容、列出目录、创建/写入文件\n\
- 理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file\n\
- 获取文件元信息(大小、行数、二进制检测)\n\ - 获取文件元信息(大小、行数、二进制检测)\n\
- 追加写入文件、按名称模式搜索文件\n\n\ - 追加写入文件、按名称模式搜索文件\n\n\
## 行为准则\n\ ## 行为准则\n\
+43 -70
View File
@@ -30,9 +30,9 @@
//! //!
//! ## R1 兜底(硬性,不可省,§3.3 + D6) //! ## R1 兜底(硬性,不可省,§3.3 + D6)
//! //!
//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 双轨口径: //! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 口径:
//! CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool)。 //! 批3 收口后读 conv_state.is_active() 单一 enum 路径,开关 + generating bool 已退役)。
//! generating=true 则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」, //! 生成中(Generating/Compressed)则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」,
//! 不调 ai_chat_send。这是 miniapp 与桌面同时给同 conv 发消息的双保险, //! 不调 ai_chat_send。这是 miniapp 与桌面同时给同 conv 发消息的双保险,
//! 无论 ai_chat_send 内部是否已有 generating guard 都加(防御性编程)。 //! 无论 ai_chat_send 内部是否已有 generating guard 都加(防御性编程)。
//! //!
@@ -75,8 +75,6 @@ use crate::commands::ai::skills::SkillInfo;
use df_types::augmentation::MentionSpanDto; use df_types::augmentation::MentionSpanDto;
// F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。 // F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
// CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。
use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED;
// ============================================================ // ============================================================
// MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93) // MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93)
@@ -637,11 +635,10 @@ async fn route_list_entities(state: &State<'_, AppState>) {
/// ai_chat_send 内部虽有 generating guard,但桥接层再加一层前置检查作为双保险 /// ai_chat_send 内部虽有 generating guard,但桥接层再加一层前置检查作为双保险
/// (防御性编程,对齐设计文档 D6「桥接层 send 路由硬性加,无论 ai_chat_send 内部是否有 guard」)。 /// (防御性编程,对齐设计文档 D6「桥接层 send 路由硬性加,无论 ai_chat_send 内部是否有 guard」)。
/// ///
/// 检查口径与 `ai_is_generating` 完全一致(双轨): /// 检查口径与 `ai_is_generating` 完全一致:
/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(含 Generating / Compressed 派生态) /// - 读 `conv_state.is_active()`(含 Generating / Compressed 派生态,批3 收口后单一 enum 路径)
/// - off → 回退 `generating` bool
/// ///
/// generating=true 则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。 /// 生成中则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。
/// (事件透传阶段2 已建通路,miniapp handleEvent 能渲染 AiError,用户可见反馈)。 /// (事件透传阶段2 已建通路,miniapp handleEvent 能渲染 AiError,用户可见反馈)。
/// ///
/// message 必填,缺失跳过(ai_chat_send 无 message 无意义,日志告警)。 /// message 必填,缺失跳过(ai_chat_send 无 message 无意义,日志告警)。
@@ -738,9 +735,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
/// 返 `None` 表示放行(可发)。 /// 返 `None` 表示放行(可发)。
/// ///
/// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293): /// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293):
/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃, /// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃;
/// 压缩期间 loop 仍活跃) /// 批3 收口后单一 enum 路径,开关 + generating bool 已退役)
/// - off → 回退 `generating` bool
/// ///
/// 目标 conv 解析(对齐 ai_is_generating:278-284): /// 目标 conv 解析(对齐 ai_is_generating:278-284):
/// - 入参 conversation_id 非空优先 /// - 入参 conversation_id 非空优先
@@ -762,16 +758,11 @@ async fn check_generating_reject(
Some(id) => id, Some(id) => id,
None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行 None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行
}; };
// 双轨读 generating(对齐 ai_is_generating,无 conv_read 视为 false 放行)。 // 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。对齐 ai_is_generating,无 conv_read 视为 false 放行。
let is_gen = session let is_gen = session
.conv_read(&target) .conv_read(&target)
.map(|c| { .map(|c| c.conv_state.is_active())
if CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
})
.unwrap_or(false); .unwrap_or(false);
if is_gen { if is_gen {
Some(target) Some(target)
@@ -932,8 +923,9 @@ mod tests {
// ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ── // ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ──
// //
// check_generating_reject 需 `State<AppState>`,单元测试难构造。 // check_generating_reject 需 `State<AppState>`,单元测试难构造。
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟 generating 状态, // 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态,
// 复核 conv_read + CONV_STATE_ENABLED 口径对齐 ai_is_generating。 // 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。
// (批3 收口后单一 enum 路径,开关 + generating bool 已退役。)
/// R1 判定:无目标 conv 且无 active → 放行(返 None)。 /// R1 判定:无目标 conv 且无 active → 放行(返 None)。
#[test] #[test]
@@ -950,108 +942,89 @@ mod tests {
); );
} }
/// R1 判定:目标 conv 未在跑 → 放行(generating=false)。 /// R1 判定:目标 conv 未在跑 → 放行(conv_state=Idle,is_active()=false)。
#[test] #[test]
fn test_r1_target_idle_pass() { fn test_r1_target_idle_pass() {
use crate::commands::ai::agentic::conv_state::ConvState;
let mut session = AiSession::new(); let mut session = AiSession::new();
let conv = session.conv("conv-idle"); let conv = session.conv("conv-idle");
conv.generating = false; conv.conv_state = ConvState::Idle;
// 读 generating 口径(对齐 check_generating_reject / ai_is_generating) // 批3 收口:读 conv_state.is_active() 口径(对齐 check_generating_reject / ai_is_generating)。
// 开关已退役,直接读 enum 单一路径。
let is_gen = session let is_gen = session
.conv_read("conv-idle") .conv_read("conv-idle")
.map(|c| { .map(|c| c.conv_state.is_active())
if CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
})
.unwrap_or(false); .unwrap_or(false);
assert!(!is_gen, "conv-idle 未在跑,is_gen 应为 false(放行)"); assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)");
} }
/// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。 /// R1 判定:目标 conv 在跑 → 拒绝(conv_state=Generating,is_active()=true)。
#[test] #[test]
fn test_r1_target_generating_reject() { fn test_r1_target_generating_reject() {
use crate::commands::ai::agentic::conv_state::ConvState; use crate::commands::ai::agentic::conv_state::ConvState;
let mut session = AiSession::new(); let mut session = AiSession::new();
let conv = session.conv("conv-busy"); let conv = session.conv("conv-busy");
// 双轨一致:CONV_STATE_ENABLED on 时 R1 读 conv_state.is_active()(非 generating bool), // 批3 收口:generating bool 已退役,生成态经 conv_state=Generating 表达(单一真相源)。
// 须同步设 conv_state=Generating(对齐 test_r1_conv_state_active_reject 设态范式)
conv.conv_state = ConvState::Generating; conv.conv_state = ConvState::Generating;
conv.generating = true;
let is_gen = session let is_gen = session
.conv_read("conv-busy") .conv_read("conv-busy")
.map(|c| { .map(|c| c.conv_state.is_active())
if CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
})
.unwrap_or(false); .unwrap_or(false);
assert!(is_gen, "conv-busy generating=true 应拒绝"); assert!(is_gen, "conv-busy Generating 应拒绝");
} }
/// R1 判定:conv_state 派生态(Generating)is_active()=true(双轨 on 路径)。 /// R1 判定:conv_state 派生态(Generating)is_active()=true(单一 enum 路径)。
/// ///
/// 验证 CONV_STATE_ENABLED on 时读 conv_state.is_active() 而非 generating bool —— /// 验证批3 收口后读 conv_state.is_active() 判定活跃拒绝(enum 唯一真相源)。
/// 即便 generating bool 误为 false 但 conv_state=Generating,仍应判定活跃拒绝。
#[test] #[test]
fn test_r1_conv_state_active_reject() { fn test_r1_conv_state_active_reject() {
use crate::commands::ai::agentic::conv_state::ConvState; use crate::commands::ai::agentic::conv_state::ConvState;
// CONV_STATE_ENABLED 是编译期 const(true),本测试假定 on 路径。 // 批3 收口:开关已退役,enum 单一真相源,无条件 on 路径。
// 若未来开关改 runtime,此测试需条件跳过 on 时才跑。
let mut session = AiSession::new(); let mut session = AiSession::new();
let conv = session.conv("conv-state-busy"); let conv = session.conv("conv-state-busy");
// 模拟状态机写收敛路径:conv_state=Generating,generating bool 同步 true(双轨一致) // 模拟状态机写收敛路径:conv_state=Generating(单一真相源,无 generating bool 双轨)。
conv.conv_state = ConvState::Generating; conv.conv_state = ConvState::Generating;
conv.generating = true;
let is_gen = session let is_gen = session
.conv_read("conv-state-busy") .conv_read("conv-state-busy")
.map(|c| { .map(|c| c.conv_state.is_active())
if CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
})
.unwrap_or(false); .unwrap_or(false);
assert!( assert!(
is_gen, is_gen,
"conv_state=Generating is_active() 应为 true(双轨 on 路径拒绝)" "conv_state=Generating is_active() 应为 true(单一 enum 路径拒绝)"
); );
} }
/// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。 /// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。
#[test] #[test]
fn test_r1_target_input_priority() { fn test_r1_target_input_priority() {
use crate::commands::ai::agentic::conv_state::ConvState;
let mut session = AiSession::new(); let mut session = AiSession::new();
session.active_conversation_id = Some("conv-active".to_string()); session.active_conversation_id = Some("conv-active".to_string());
// active 在跑,但入参 conv-other 未跑 → 入参优先,应放行 // active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行
session.conv("conv-active").generating = true; session.conv("conv-active").conv_state = ConvState::Generating;
let _ = session.conv("conv-other"); // 创建但不 generating let _ = session.conv("conv-other"); // 创建但 Idle(默认)
let active_gen = session let active_gen = session
.conv_read("conv-active") .conv_read("conv-active")
.map(|c| c.generating) .map(|c| c.conv_state.is_active())
.unwrap_or(false); .unwrap_or(false);
let other_gen = session let other_gen = session
.conv_read("conv-other") .conv_read("conv-other")
.map(|c| c.generating) .map(|c| c.conv_state.is_active())
.unwrap_or(false); .unwrap_or(false);
assert!(active_gen, "conv-active 在跑"); assert!(active_gen, "conv-active 在跑");
assert!(!other_gen, "conv-other 未跑"); assert!(!other_gen, "conv-other 未跑");
// 入参 conv-other 优先 → 判定 conv-other.generating=false → 放行 // 入参 conv-other 优先 → 判定 conv-other Idle → 放行
// (与 check_generating_reject 入参优先逻辑一致) // (与 check_generating_reject 入参优先逻辑一致)
} }
// ── PerConvState 初值对齐(确保 R1 测试基线 generating=false) ── // ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ──
/// PerConvState::new() generating 初值 false(R1 放行基线)。 /// PerConvState::new() conv_state 初值 Idle(R1 放行基线)。
#[test] #[test]
fn test_per_conv_state_generating_default_false() { fn test_per_conv_state_conv_state_default_idle() {
use crate::commands::ai::agentic::conv_state::ConvState;
let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused) let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused)
let s = PerConvState::new(); let s = PerConvState::new();
assert!(!s.generating, "PerConvState::new generating 初值应为 false"); assert_eq!(s.conv_state, ConvState::Idle, "PerConvState::new conv_state 初值应为 Idle");
} }
} }
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
//! 项目事件流相关命令(知识图谱 Phase 2 V30,对标设计 §2.4 + §五 get_project_timeline)
//!
//! 统一事件流 project_events 的查询入口。供 AI / Dashboard 项目时间线视图使用
//! (设计 §10.3.4 模式 B 事件流 + §五 get_project_timeline)。
//!
//! 筛选策略:Repo::get_by_project 取最近 N 条(上限 200,对标设计 §10.5 单用户桌面应用
//! 全量查询无压力),应用层按 event_type / 时间范围过滤。复杂多维筛选不进 SQL(数据量小,
//! 应用层过滤足够 + 保持 Repo 专用方法精简,对标 TaskLinkRepo 全专用路径)。
use serde::{Deserialize, Serialize};
use tauri::State;
use df_storage::models::ProjectEventRecord;
use crate::state::AppState;
use super::err_str;
/// get_project_timeline 查询入参(对标设计 §五 get_project_timeline:time 范围/event_type 筛选)。
///
/// 所有字段可选:
/// - `project_id`:必填(查询某项目的事件流)。
/// - `event_type`:可选,按事件类型精确过滤(如 "task_advanced");空/None = 不过滤。
/// - `limit`:可选,返回上限(默认 50,Repo 内部钳制最大 200)。
#[derive(Debug, Deserialize)]
pub struct GetTimelineInput {
pub project_id: String,
/// 事件类型过滤(精确匹配,如 "task_created" / "idea_promoted")。None/空串 = 不过滤。
pub event_type: Option<String>,
/// 返回条数上限(默认 50)。Repo 内部钳制 ≤ 200。
#[serde(default = "default_limit")]
pub limit: u32,
}
fn default_limit() -> u32 {
50
}
/// 时间线条目(对标设计 §10.3.4 模式 B 事件流)。直接回 ProjectEventRecord(Serialize 已派生),
/// 外层补 total 便于前端分页/计数。
#[derive(Debug, Serialize)]
pub struct TimelineResult {
/// 过滤后的事件列表(时间倒序,Repo 已排)
pub items: Vec<ProjectEventRecord>,
/// 过滤后总数(≤ limit)
pub total: usize,
/// 查询的项目 ID(回显)
pub project_id: String,
}
/// 查询项目事件流(对标设计 §五 get_project_timeline + §2.4)。
///
/// 支持按 event_type 过滤 + limit 上限。时间范围过滤暂未实现(设计 §2.4 列出 time 范围筛选,
/// 但当前 get_by_project 仅返回最近 N 条倒序,时间范围筛选用例可后续按需加 Repo 方法;
/// 单用户桌面应用事件量小,最近 N 条 + event_type 过滤已覆盖主要场景)。
#[tauri::command]
pub async fn get_project_timeline(
state: State<'_, AppState>,
input: GetTimelineInput,
) -> Result<TimelineResult, String> {
let project_id = input.project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
// event_type 过滤值规范化(None/空串 = 不过滤)
let event_filter = input
.event_type
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
// Repo 取最近 limit 条(内部钳制 ≤ 200),时间倒序。
let mut events = state
.project_events
.get_by_project(&project_id, input.limit)
.await
.map_err(err_str)?;
// 应用层按 event_type 过滤(SQL 下推不值得——单用户事件量小,保持 Repo 方法精简)。
if let Some(et) = &event_filter {
events.retain(|e| e.event_type == *et);
}
let total = events.len();
Ok(TimelineResult {
items: events,
total,
project_id,
})
}
+112 -13
View File
@@ -9,7 +9,7 @@ use df_ai::provider::LlmProvider;
use df_types::types::{new_id, Priority}; use df_types::types::{new_id, Priority};
use df_ideas::capture::Idea; use df_ideas::capture::Idea;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery}; use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectRecord}; use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
use crate::state::AppState; use crate::state::AppState;
@@ -73,6 +73,15 @@ pub async fn create_idea(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateIdeaInput, input: CreateIdeaInput,
) -> Result<IdeaRecord, String> { ) -> Result<IdeaRecord, String> {
// IDEA-FIX-03: priority 值域校验 ∈ 0..=3 (对标 tasks B-260615-15)。
// priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值,
// 防 LLM/前端传 99 等被静默吞为 Critical。
if !(0..=3).contains(&input.priority) {
return Err(format!(
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low,对齐前端约定 api/types.ts:141),收到 {}",
input.priority
));
}
let now = now_millis(); let now = now_millis();
let record = IdeaRecord { let record = IdeaRecord {
id: new_id(), id: new_id(),
@@ -95,6 +104,12 @@ pub async fn create_idea(
.insert(record.clone()) .insert(record.clone())
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 知识图谱 Phase 2(对标设计 §2.4):idea_created 事件**暂不埋点**。
// 原因:project_events.project_id 是 NOT NULL + FK(PRAGMA foreign_keys=ON),
// 而 idea 无 project_id(立项前不属于任何项目)。设计 §2.5 计划用系统初始化创建的
// Inbox 项目作为无主 idea 的归属,但 Inbox 项目尚未实现(独立任务)。
// 写 NULL 会违反 NOT NULL,写不存在的 project_id 会违反 FK——两者都会让 best-effort
// 退化成「写失败 warn」,无实际价值且噪音。Inbox 项目落地后此处补 idea_created 埋点。
Ok(record) Ok(record)
} }
@@ -113,10 +128,37 @@ pub async fn update_idea(
.map_err(err_str) .map_err(err_str)
} }
/// 删除灵感 /// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
#[tauri::command] #[tauri::command]
pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> { pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.delete(&id).await.map_err(err_str) state.ideas.soft_delete(&id).await.map_err(err_str)
}
/// 恢复灵感(从回收站还原,清 deleted_at)。对标 restore_task / restore_project。
#[tauri::command]
pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.restore(&id).await.map_err(err_str)
}
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
#[tauri::command]
pub async fn relate_ideas(
state: State<'_, AppState>,
subject_id: String,
target_ids: Vec<String>,
) -> Result<(), String> {
state
.ideas
.sync_related_ids(&subject_id, &target_ids)
.await
.map_err(err_str)
}
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
#[tauri::command]
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
state.ideas.list_deleted().await.map_err(err_str)
} }
/// 将灵感晋升为项目 — 复用 df-project 领域逻辑创建项目,回写灵感 status=promoted/promoted_to /// 将灵感晋升为项目 — 复用 df-project 领域逻辑创建项目,回写灵感 status=promoted/promoted_to
@@ -172,14 +214,39 @@ pub async fn promote_idea(
..record ..record
}; };
if let Err(e) = state.ideas.update_full(&updated).await { if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项) // 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}"); tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await { if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}"); tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
} }
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e)); return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
} }
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
// 灵感此时已立项为新项目(project_id 存在,FK 满足),事件挂在 project_id 下,
// entity 指向 idea,from_state=draft→to_state=promoted。
let event = ProjectEventRecord {
id: new_id(),
project_id: project_id.clone(),
event_type: "idea_promoted".to_string(),
entity_type: Some("idea".to_string()),
entity_id: Some(id.clone()),
from_state: Some("draft".to_string()),
to_state: Some("promoted".to_string()),
context_json: None,
source: Some("human".to_string()),
conversation_id: None,
created_at: now_millis(),
};
if let Err(e) = state.project_events.insert(event).await {
tracing::warn!(
idea_id = %id,
project_id = %project_id,
error = %e,
"[事件流] idea_promoted 埋点写入失败(不阻断立项)"
);
}
Ok(df_ideas::promotion::PromotionResult { Ok(df_ideas::promotion::PromotionResult {
idea_id: id, idea_id: id,
project_id: project_id, project_id: project_id,
@@ -377,28 +444,60 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
}, },
None => Vec::new(), None => Vec::new(),
}; };
// IDEA-FIX-05: 读真实 related_ids(原硬编码 Vec::new() 丢关联上下文)。解析同 tags 模式。
let related_ids: Vec<String> = match record.related_ids.as_deref() {
Some(r) => match serde_json::from_str::<Vec<String>>(r) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, idea_id = %record.id, "[ideas] related_ids JSON 解析失败,降级空");
Vec::new()
}
},
None => Vec::new(),
};
Idea { Idea {
id: record.id.clone(), id: record.id.clone(),
title: record.title.clone(), title: record.title.clone(),
description: record.description.clone(), description: record.description.clone(),
status: df_types::types::IdeaStatus::Draft, // IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
status: status_from_str(&record.status),
priority: priority_from_i32(record.priority), priority: priority_from_i32(record.priority),
scores: None, scores: None,
tags, tags,
source: record.source.clone(), source: record.source.clone(),
related_ids: Vec::new(), related_ids,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(), updated_at: chrono::Utc::now(),
} }
} }
/// i32 优先级 → Priority 枚举(与 df-types 枚举值一致:Low=0/Medium=1/High=2/Critical=3) /// i32 优先级 → Priority 枚举(对齐**前端约定** 0=critical/1=high/2=medium/3=low,
/// 见 api/types.ts:141 + Tasks.vue option + constants/project.ts PRIORITY_CLASS)。
/// 注:df-types Priority 枚举 discriminant 是 Low=0/Critical=3(历史定义,与前端相反),
/// 故本函数手动映射对齐前端语义,不靠 discriminant。Priority as_str 序列化("low"/"critical")
/// 不受影响。verify agent 发现的预存跨层 bug(父⑤⑤.2 收尾揪出):原 0=>Low 致 Ideas
/// 表单选 Critical(P0) 存 Low,标签与实际存储/评分相反。
fn priority_from_i32(p: i32) -> Priority { fn priority_from_i32(p: i32) -> Priority {
match p { match p {
0 => Priority::Low, 0 => Priority::Critical,
2 => Priority::High, 1 => Priority::High,
x if x >= 3 => Priority::Critical, 2 => Priority::Medium,
_ => Priority::Medium, _ => Priority::Low,
}
}
/// IDEA-FIX-05: idea status 字符串(DB snake_case)→ IdeaStatus 枚举。
/// record_to_idea 读真实 status 用(原硬编码 Draft 丢真实状态)。未知值 fallback Draft(同原行为)。
fn status_from_str(s: &str) -> df_types::types::IdeaStatus {
use df_types::types::IdeaStatus;
match s {
"draft" => IdeaStatus::Draft,
"pending_review" => IdeaStatus::PendingReview,
"approved" => IdeaStatus::Approved,
"rejected" => IdeaStatus::Rejected,
"promoted" => IdeaStatus::Promoted,
"archived" => IdeaStatus::Archived,
_ => IdeaStatus::Draft,
} }
} }
+2
View File
@@ -3,10 +3,12 @@
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。 //! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
pub mod ai; pub mod ai;
pub mod events;
pub mod idea; pub mod idea;
pub mod knowledge; pub mod knowledge;
pub mod knowledge_timeline; pub mod knowledge_timeline;
pub mod project; pub mod project;
pub mod services;
pub mod settings; pub mod settings;
pub mod task; pub mod task;
pub mod workflow; pub mod workflow;
+54 -1
View File
@@ -18,12 +18,49 @@ use df_project::scan::{
normalize_path, DiscoveredProject, normalize_path, DiscoveredProject,
}; };
use df_storage::crud::ProjectQuery; use df_storage::crud::ProjectQuery;
use df_storage::models::ProjectRecord; use df_storage::models::{ProjectEventRecord, ProjectRecord};
use crate::state::AppState; use crate::state::AppState;
use super::{err_str, now_millis}; use super::{err_str, now_millis};
// ============================================================
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
// ============================================================
/// 追加一条项目事件到 project_events(best-effort)。失败仅 tracing::warn 不阻断主 IPC。
///
/// `source` 标 `"human"`(IPC 层)。AI 工具路径在 tool_registry 内自行标 `"ai"`。
async fn emit_project_event(
state: &AppState,
project_id: &str,
event_type: &str,
entity_type: Option<&str>,
entity_id: Option<&str>,
) {
let record = ProjectEventRecord {
id: new_id(),
project_id: project_id.to_string(),
event_type: event_type.to_string(),
entity_type: entity_type.map(|s| s.to_string()),
entity_id: entity_id.map(|s| s.to_string()),
from_state: None,
to_state: Some("planning".to_string()),
context_json: None,
source: Some("human".to_string()),
conversation_id: None,
created_at: now_millis(),
};
if let Err(e) = state.project_events.insert(record).await {
tracing::warn!(
event_type = event_type,
project_id = project_id,
error = %e,
"[事件流] 埋点写入失败(不阻断主操作)"
);
}
}
/// 创建项目入参 /// 创建项目入参
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateProjectInput { pub struct CreateProjectInput {
@@ -132,6 +169,22 @@ async fn create_with_binding(
if record.path.is_some() { if record.path.is_some() {
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
} }
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):项目创建事件。best-effort 不阻断。
//
// 设计 §2.4 event_type 枚举未列 project_created(枚举非穷尽,Repo 不做白名单校验——
// 见 project_event_repo.rs 注释「埋点层是唯一生产者,字符串字面量自带约束」)。此处沿用
// task_created/idea_created 命名约定新增 project_created,语义自洽。entity_type=project。
//
// 注:灵感晋升(create_from_idea)在 promote_idea 单独记 idea_promoted 事件(更精确),
// 此处一律记 project_created(项目实体自身视角),两事件互补不冲突。
emit_project_event(
state,
&record.id,
"project_created",
Some("project"),
Some(&record.id),
)
.await;
Ok(record) Ok(record)
} }
+285
View File
@@ -0,0 +1,285 @@
//! 项目基础设施配置相关命令(知识图谱 Phase 3 V31,对标设计 §2.3 + §五 + D10 安全边界)
//!
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
//!
//! 关键设计(对标 events.rs Phase 2 模式 + project.rs IPC 模式):
//! - **service_type 白名单**:mysql/postgresql/sqlite/redis/mongodb/mq/api/other,
//! 校验下沉 ProjectServiceRepo::insert_validated / update_full_validated(单一真相源)。
//! - **D10 安全边界**:不存敏感凭证。ProjectServiceRepo 在 insert/update 时审查
//! config_json / endpoint / remark 是否含 password/secret/token/api_key 子串,命中即拒。
//! IPC 入参 schema 在此注明,config_json 不含密码 —— 凭证走环境变量。
//! - create/update IPC 返回完整记录(对标 create_project),前端拿回 id 直接用。
use serde::{Deserialize, Serialize};
use tauri::State;
use df_storage::crud::PROJECT_SERVICE_TYPES;
use df_storage::models::ProjectServiceRecord;
use df_types::types::new_id;
use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 入参 / 出参结构
// ============================================================
/// 新增基础设施配置入参(对标设计 §五 add_project_service + D10 边界)。
///
/// ⚠️ **D10 安全边界**:不存敏感凭证(密码/密钥/Token)。config_json / endpoint / remark
/// 含 password/secret/token/api_key 子串会被 Repo 层审查拒绝。凭证走环境变量,此处只存
/// 连接信息(host/port/数据库名/连接池配置等元数据)。
#[derive(Debug, Deserialize)]
pub struct AddProjectServiceInput {
pub project_id: String,
pub name: String,
/// 基础设施类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other
pub service_type: String,
/// 连接地址(localhost:3306 / URL),可选。不含凭证(query 带 token= 会被拒)
#[serde(default)]
pub endpoint: Option<String>,
/// 类型相关配置 JSON 字符串(如 {"pool_size":10}),可选。不含密码字段
#[serde(default)]
pub config_json: Option<String>,
/// 环境 development/staging/production,默认 development
#[serde(default = "default_environment")]
pub environment: String,
#[serde(default)]
pub remark: Option<String>,
}
/// 整体更新入参(对标 Repo::update_full_validated,保留 id 与 created_at)。
/// 全字段必填(整体替换语义,与 IdeaRepo::update_full 一致)。
#[derive(Debug, Deserialize)]
pub struct UpdateProjectServiceInput {
pub id: String,
pub project_id: String,
pub name: String,
pub service_type: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub config_json: Option<String>,
#[serde(default = "default_environment")]
pub environment: String,
#[serde(default)]
pub remark: Option<String>,
}
/// 列表查询入参(对标设计 §五 list_project_services:按项目过滤,可选按环境过滤)。
#[derive(Debug, Deserialize)]
pub struct ListProjectServicesInput {
pub project_id: String,
/// 可选,按环境过滤(development/staging/production)。空/None = 返回所有环境。
#[serde(default)]
pub environment: Option<String>,
}
/// 列表返回(对标 events.rs TimelineResult,外层补 total 便于前端计数)。
#[derive(Debug, Serialize)]
pub struct ListProjectServicesResult {
pub items: Vec<ProjectServiceRecord>,
pub total: usize,
pub project_id: String,
}
fn default_environment() -> String {
"development".to_string()
}
// ============================================================
// IPC 命令
// ============================================================
/// 新增基础设施配置(对标设计 §五 add_project_service)。
///
/// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 AI 工具
/// add_project_service 同源)。返回完整记录。
#[tauri::command]
pub async fn add_project_service(
state: State<'_, AppState>,
input: AddProjectServiceInput,
) -> Result<ProjectServiceRecord, String> {
let project_id = input.project_id.trim().to_string();
let name = input.name.trim().to_string();
let service_type = input.service_type.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
if name.is_empty() {
return Err("name 不能为空".to_string());
}
if service_type.is_empty() {
return Err("service_type 不能为空".to_string());
}
// service_type 白名单 fail-fast(给清晰错误,虽 Repo 层也会校验)
if !PROJECT_SERVICE_TYPES.contains(&service_type.as_str()) {
return Err(format!(
"非法 service_type: {service_type},合法值: {:?}",
PROJECT_SERVICE_TYPES
));
}
let record = ProjectServiceRecord {
id: new_id(),
project_id,
name,
service_type,
endpoint: input
.endpoint
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
config_json: input
.config_json
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
environment: input.environment.trim().to_string(),
remark: input
.remark
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// created_at/updated_at 由 Repo 内部覆盖,此处占位
created_at: now_millis(),
updated_at: now_millis(),
};
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
let id = state
.project_services
.insert_validated(record)
.await
.map_err(err_str)?;
state
.project_services
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| "插入后回读失败".to_string())
}
/// 整体更新基础设施配置(保留 id 与 created_at,updated_at 由 Repo 内部覆盖)。
///
/// service_type 变更须走此整体更新路径(对标 Repo 注释:update_field 白名单不含 service_type,
/// 防旁路 update_field 改类型)。D10 凭证审查下沉 update_full_validated。
#[tauri::command]
pub async fn update_project_service(
state: State<'_, AppState>,
input: UpdateProjectServiceInput,
) -> Result<ProjectServiceRecord, String> {
let id = input.id.trim().to_string();
let project_id = input.project_id.trim().to_string();
let name = input.name.trim().to_string();
let service_type = input.service_type.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
if project_id.is_empty() || name.is_empty() || service_type.is_empty() {
return Err("project_id / name / service_type 不能为空".to_string());
}
if !PROJECT_SERVICE_TYPES.contains(&service_type.as_str()) {
return Err(format!(
"非法 service_type: {service_type},合法值: {:?}",
PROJECT_SERVICE_TYPES
));
}
// 先校验存在(404 友好错误),再整体更新
let existing = state
.project_services
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("基础设施配置 {id} 不存在"))?;
let record = ProjectServiceRecord {
id: id.clone(),
project_id,
name,
service_type,
endpoint: input
.endpoint
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
config_json: input
.config_json
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
environment: input.environment.trim().to_string(),
remark: input
.remark
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// 保留原 created_at(update_full_validated 不覆盖 created_at)
created_at: existing.created_at,
updated_at: now_millis(),
};
let hit = state
.project_services
.update_full_validated(&record)
.await
.map_err(err_str)?;
if !hit {
return Err(format!("基础设施配置 {id} 不存在(更新未命中)"));
}
state
.project_services
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| "更新后回读失败".to_string())
}
/// 删除基础设施配置(按 id 物理删,对标 Repo::delete)。
#[tauri::command]
pub async fn remove_project_service(
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let id = id.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
state.project_services.delete(&id).await.map_err(err_str)
}
/// 查询项目基础设施配置(对标设计 §五 list_project_services)。
///
/// 按项目过滤,可选按环境过滤(production 部署任务只取 production 服务,开发调试取 development,
/// 环境隔离避免误连生产库)。返回时间倒序列表。
#[tauri::command]
pub async fn list_project_services(
state: State<'_, AppState>,
input: ListProjectServicesInput,
) -> Result<ListProjectServicesResult, String> {
let project_id = input.project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
let env_filter = input
.environment
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let items = if let Some(env) = &env_filter {
state
.project_services
.list_by_project_env(&project_id, env)
.await
.map_err(err_str)?
} else {
state
.project_services
.list_by_project(&project_id)
.await
.map_err(err_str)?
};
let total = items.len();
Ok(ListProjectServicesResult {
items,
total,
project_id,
})
}
+6
View File
@@ -47,3 +47,9 @@ pub async fn settings_delete(
) -> Result<bool, String> { ) -> Result<bool, String> {
state.settings.delete(&key).await.map_err(err_str) state.settings.delete(&key).await.map_err(err_str)
} }
/// 获取 DevFlow 数据目录路径(运行期确定,跨平台)
#[tauri::command]
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string())
}
+572 -3
View File
@@ -1,16 +1,60 @@
//! 任务相关命令 //! 任务相关命令
use serde::Deserialize; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
use df_types::types::{new_id, TaskStatus}; use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery; use df_storage::crud::TaskQuery;
use df_storage::models::TaskRecord; use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
use crate::state::AppState; use crate::state::AppState;
use super::{err_str, now_millis}; use super::{err_str, now_millis};
// ============================================================
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
// ============================================================
/// 追加一条项目事件到 project_events(best-effort)。
///
/// 埋点策略(设计 §2.4):事件写入失败**不阻断主操作**,仅 `tracing::warn` 记录。
/// 设计 §10.1「事件流写入失败 — 影响事件完整性 — 事件写入失败不阻断主操作(best-effort)」。
///
/// `source` 语义:本辅助仅由 IPC 层调用,标 `"human"`(AI 工具路径在 tool_registry 内自行标
/// `"ai"`,不经本 IPC)。`project_id` / `entity_type` / `entity_id` / `event_type` 由调用方
/// 提供。`from_state` / `to_state` / `context_json` 可选。
async fn emit_event(
state: &State<'_, AppState>,
project_id: &str,
event_type: &str,
entity_type: Option<&str>,
entity_id: Option<&str>,
from_state: Option<&str>,
to_state: Option<&str>,
) {
let record = ProjectEventRecord {
id: new_id(),
project_id: project_id.to_string(),
event_type: event_type.to_string(),
entity_type: entity_type.map(|s| s.to_string()),
entity_id: entity_id.map(|s| s.to_string()),
from_state: from_state.map(|s| s.to_string()),
to_state: to_state.map(|s| s.to_string()),
context_json: None,
source: Some("human".to_string()),
conversation_id: None,
created_at: now_millis(),
};
if let Err(e) = state.project_events.insert(record).await {
tracing::warn!(
event_type = event_type,
project_id = project_id,
error = %e,
"[事件流] 埋点写入失败(不阻断主操作)"
);
}
}
/// 创建任务入参 /// 创建任务入参
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateTaskInput { pub struct CreateTaskInput {
@@ -25,12 +69,94 @@ pub struct CreateTaskInput {
/// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。 /// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。
/// 空字符串视为不关联(与 AI 工具层一致)。 /// 空字符串视为不关联(与 AI 工具层一致)。
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID(知识图谱 Phase 1 V29)。默认 None = 叶子任务。
/// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// None = 无结构化规格(纯文本 description)。
#[serde(default)]
pub content_json: Option<String>,
} }
fn default_priority() -> i32 { fn default_priority() -> i32 {
2 // medium — 新任务默认中优先级(非 high),符合常识 2 // medium — 新任务默认中优先级(非 high),符合常识
} }
/// queue 默认值(serde default,对标 DB DEFAULT 'todo')
fn default_queue() -> String {
"todo".to_string()
}
// ============================================================
// 知识图谱 Phase 1:queue 白名单 + queue/status 一致性约束(IPC 层校验,对标设计 §2.1)
// ============================================================
/// queue 合法值白名单(对标设计 §2.1 queue 字段语义)。
/// 不走 TaskStatus 枚举(queue 是独立的管理维度,与 status 执行维度正交)。
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
/// queue 执行中池(active 时 status 必须属于执行中三态之一,对标设计 §2.1 一致性约束)
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
/// 校验 queue 值在白名单内,否则返回 Err(防拼写漂移/非法值进库)。
fn validate_queue(queue: &str) -> Result<(), String> {
if TASK_QUEUE_VALUES.contains(&queue) {
Ok(())
} else {
Err(format!(
"非法 queue 值 {:?},合法值: {:?}",
queue, TASK_QUEUE_VALUES
))
}
}
/// queue/status 一致性约束校验(对标设计 §2.1,IPC 层校验不进状态机)。
///
/// 规则(设计 §2.1「queue 与 status 的关系」一致性约束):
/// - queue=done 时 status 必须=done
/// - queue=backlog 时 status 必须=todo
/// - queue=active 时 status ∈ {in_progress, in_review, testing}
/// - status=blocked 时 queue 可为 decision 或 active(本规则约束 queue 赋值场景,不在此单独拦)
/// - queue=todo 时 status=todo(默认);queue=decision 时无 status 强约束(待决策池可任意 status)
///
/// 注:create_task 仅校验 queue(新建任务 status 恒 todo),完整约束在 move_task_queue 落实。
fn assert_queue_status_consistent(queue: &str, status: &str) -> Result<(), String> {
match queue {
"done" => {
if status != "done" {
return Err(format!(
"一致性约束违反:queue=done 要求 status=done,当前 status={status:?}"
));
}
}
"backlog" => {
if status != "todo" {
return Err(format!(
"一致性约束违反:queue=backlog 要求 status=todo,当前 status={status:?}"
));
}
}
"active" => {
if !ACTIVE_OK_STATUSES.contains(&status) {
return Err(format!(
"一致性约束违反:queue=active 要求 status ∈ {:?},当前 status={status:?}",
ACTIVE_OK_STATUSES
));
}
}
_ => {} // todo / decision 无 status 强约束
}
Ok(())
}
/// 列出未删除任务(deleted_at IS NULL)。 /// 列出未删除任务(deleted_at IS NULL)。
/// ///
/// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id) /// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id)
@@ -92,11 +218,66 @@ pub async fn get_task_by_id(
} }
/// 创建任务,返回完整记录 /// 创建任务,返回完整记录
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1):扩展 queue/parent_id/content_json 可选参数(向后兼容,
/// 旧调用方不传等价改造前行为)。三个新参数的 IPC 层校验:
/// - `queue`:白名单校验(validate_queue)+ queue/status 一致性(create_task 时 status 恒 todo,
/// 仅 backlog/todo/decision 合法;active/done 需经 move_task_queue 或 advance_task 流转)。
/// - `parent_id`:1 级嵌套铁律(对标设计 §2.1 D2)。父任务自身不能有 parent_id(拒绝创建孙任务);
/// 父任务不存在则拒(防悬空 parent_id)。空字符串视为 None(向后兼容)。
/// - `content_json`:仅做轻量 JSON 合法性校验(非空时必须是合法 JSON),结构细节由 AI 消费层负责。
#[tauri::command] #[tauri::command]
pub async fn create_task( pub async fn create_task(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateTaskInput, input: CreateTaskInput,
) -> Result<TaskRecord, String> { ) -> Result<TaskRecord, String> {
// ── queue 校验(白名单 + 空串默认 todo)──
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
let queue = if input.queue.trim().is_empty() {
"todo".to_string()
} else {
let q = input.queue.trim();
validate_queue(q)?;
q.to_string()
};
// queue/status 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法。
// active/done 必须经 move_task_queue 流转,新建直接落 active/done 违反一致性约束。
assert_queue_status_consistent(&queue, "todo")?;
// ── parent_id 校验(1 级嵌套铁律,对标设计 §2.1 D2)──
// 空字符串/纯空白视为 None(向后兼容)
let parent_id = input
.parent_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(pid) = &parent_id {
let parent = state
.tasks
.get_by_id(pid)
.await
.map_err(err_str)?
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
// 1 级嵌套铁律:父任务自身有 parent_id → 它是子任务 → 拒绝在其下创建孙任务。
if parent.parent_id.is_some() {
return Err(format!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
parent.parent_id
));
}
}
// ── content_json 轻量校验(非空时须是合法 JSON)──
let content_json = match &input.content_json {
Some(c) if !c.trim().is_empty() => {
// 校验合法 JSON(防脏数据/截断串进库);结构细节由 AI 消费层负责
serde_json::from_str::<serde_json::Value>(c)
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
Some(c.clone())
}
_ => None, // 空串/None 视为无结构化规格
};
let now = now_millis(); let now = now_millis();
let record = TaskRecord { let record = TaskRecord {
id: new_id(), id: new_id(),
@@ -113,6 +294,10 @@ pub async fn create_task(
output_json: None, output_json: None,
// F-260619-01:空字符串视为不关联(与 AI 工具层一致) // F-260619-01:空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()), idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
queue,
parent_id,
content_json,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
@@ -121,6 +306,17 @@ pub async fn create_task(
.insert(record.clone()) .insert(record.clone())
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_created 事件。best-effort 不阻断。
emit_event(
&state,
&record.project_id,
"task_created",
Some("task"),
Some(&record.id),
None,
Some(&record.status),
)
.await;
Ok(record) Ok(record)
} }
@@ -168,6 +364,30 @@ pub async fn update_task(
return Err(format!("非法 project_id 值 {:?},目标项目不存在", value)); return Err(format!("非法 project_id 值 {:?},目标项目不存在", value));
} }
} }
// parent_id 1 级嵌套铁律(对标 create_task L203-221,verify agent 发现的 P1:update_task 绕过)。
// parent_id 在 tasks 白名单(设计 §五 move/update 路径),但 1 级铁律须 IPC 层校验,否则
// update_task(id,"parent_id",X) 可创建孙任务(X 自身有 parent_id)或悬空(X 不存在)或自环。
if field == "parent_id" {
let new_pid = value.trim();
if !new_pid.is_empty() {
if new_pid == id {
return Err("非法 parent_id:不能将任务设为自身的父任务(自环)".to_string());
}
let parent = state
.tasks
.get_by_id(new_pid)
.await
.map_err(err_str)?
.ok_or_else(|| format!("非法 parent_id 值 {:?},父任务不存在", value))?;
if parent.parent_id.is_some() {
return Err(format!(
"违反 1 级嵌套铁律:父任务 {new_pid} 自身是子任务(parent_id={:?}),不允许挂孙任务",
parent.parent_id
));
}
}
// 空字符串 = None(解除父),合法放行
}
state state
.tasks .tasks
.update_field(&id, &field, &value) .update_field(&id, &field, &value)
@@ -195,6 +415,11 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected` /// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。 /// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
/// ///
/// **知识图谱 Phase 1 V29 父聚合(对标设计 §2.1)**:推进完成后,若推进的任务有 parent_id,
/// 触发父任务 status 重算(recompute_parent_status)。父任务=容器模型,status 不走状态机,
/// 由子任务聚合计算(聚合规则见 recompute_parent_status)。聚合失败不阻断推进(best-effort,
/// 对标设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
///
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。 /// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
#[tauri::command] #[tauri::command]
pub async fn advance_task( pub async fn advance_task(
@@ -202,7 +427,351 @@ pub async fn advance_task(
id: String, id: String,
target_status: String, target_status: String,
) -> Result<TaskRecord, String> { ) -> Result<TaskRecord, String> {
df_nodes::task_advance_node::advance_task_atomic(&state.tasks, &id, &target_status) // 知识图谱 Phase 2:推进前读当前态,作 task_advanced 事件 from_state(仅一次轻量读,
// advance 低频无压力)。失败(任务不存在)不阻断——后续 atomic 会用 NotFound 拒绝,from 留空。
let from_state = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.map(|t| t.status);
let updated = df_nodes::task_advance_node::advance_task_atomic(
&state.tasks,
&id,
&target_status,
)
.await
.map_err(err_str)?;
// 父聚合(知识图谱 Phase 1 V29):推进的子任务有 parent_id → 重算父 status。
// best-effort:聚合失败不阻断推进(子任务已成功推进是主结果,父 status 漂移可后续修正),
// 仅 warn 日志记录。父任务 status 不走状态机,经专用方法 set_status_for_aggregation
// 直接写(绕过 D-260616-04 status 收口:父任务=容器模型,聚合规则是唯一非状态机写入路径)。
if let Some(pid) = &updated.parent_id {
if let Err(e) = recompute_parent_status(&state, pid).await {
tracing::warn!(
task_id = %id,
parent_id = %pid,
error = %e,
"[父聚合] 重算父任务 status 失败(不阻断子任务推进)"
);
}
}
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
emit_event(
&state,
&updated.project_id,
"task_advanced",
Some("task"),
Some(&updated.id),
from_state.as_deref(),
Some(&updated.status),
)
.await;
Ok(updated)
}
// ============================================================
// 知识图谱 Phase 1:父任务聚合(对标设计 §2.1 父聚合规则)
// ============================================================
/// 父任务 status 重算(对标设计 §2.1 聚合规则,D3 父任务=容器模型)。
///
/// 聚合规则(设计 §2.1「父任务推导 status」表,优先级从高到低):
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
/// 2. 任一子 in_progress → 父 in_progress(执行中)
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
/// 4. 全子 todo → 父 todo(尚未开始)
/// 5. 其他混合态(如 todo+done) → 父 in_progress(进行中,有进展未全完)
///
/// 触发时机:advance_task 子任务推进成功后,若子任务有 parent_id 则调本函数。
/// 数据源:count_children_by_status(一次 GROUP BY 查询,数据量小无压力)。
/// 写入:set_status_for_aggregation(父任务 status 唯一非状态机写入路径)。
///
/// 返回:重算后的父任务最新 status(若与当前相同则不写,返当前值)。
async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -> Result<String, String> {
let counts = state
.tasks
.count_children_by_status(parent_id)
.await
.map_err(err_str)?;
// 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status
if counts.is_empty() {
return state
.tasks
.get_by_id(parent_id)
.await
.map_err(err_str)?
.map(|t| t.status)
.ok_or_else(|| format!("父任务 {parent_id} 不存在"));
}
// 转 HashMap<status, count> 便于按规则判定
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
let total: i64 = map.values().sum();
let blocked = map.get("blocked").copied().unwrap_or(0);
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 抖动)
let current = state
.tasks
.get_by_id(parent_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
if current.status == new_status {
return Ok(new_status);
}
state
.tasks
.set_status_for_aggregation(parent_id, &new_status)
.await
.map_err(err_str)?;
Ok(new_status)
}
// ============================================================
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
// ============================================================
/// 创建任务横向关联(对标设计 §2.2,AI 拓扑排序编排调度的基础)。
///
/// 调 TaskLinkRepo::create_link(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。
/// 循环依赖 / 自环 / 非法 link_type 在 repo 层拒绝(对标 D8)。
///
/// 参数:
/// - `task_id`:source 任务 ID(关联发起方)
/// - `target_id`:target 任务 ID(关联指向方)
/// - `link_type`:depends_on / blocks / relates_to(白名单校验)
/// - `remark`:可选备注
///
/// 返回:插入的 link id。
#[tauri::command]
pub async fn create_task_link(
state: State<'_, AppState>,
task_id: String,
target_id: String,
link_type: String,
remark: Option<String>,
) -> Result<String, String> {
let id = new_id();
state
.task_links
.create_link(&id, &task_id, &target_id, &link_type, remark.as_deref())
.await .await
.map_err(err_str) .map_err(err_str)
} }
/// 删除任务关联(按 link id,对标设计 §2.2)。返回是否命中。
#[tauri::command]
pub async fn remove_task_link(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.task_links.delete(&id).await.map_err(err_str)
}
/// 任务关联查询入参方向(对标设计 §2.2 + §五 list_task_links)。
///
/// - `Outgoing`:task_id 作为 source 查其声明的全部关联(我依赖谁/我阻塞谁)
/// - `Incoming`:task_id 作为 target 查谁指向它(谁依赖我/谁被我阻塞,反向查询)
/// - `Both`:双向合并(去重),全量关联视图
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkDirection {
Outgoing,
Incoming,
Both,
}
impl Default for LinkDirection {
fn default() -> Self {
LinkDirection::Both
}
}
/// 查询任务关联(AI 编排调度用,对标设计 §2.2 + §五 list_task_links)。
///
/// 参数:
/// - `task_id`:目标任务 ID
/// - `direction`:查询方向(Outgoing=作为 source / Incoming=作为 target / Both=双向合并),
/// 默认 Both(全量关联视图)。空字符串/未传走默认 Both(向后兼容)。
///
/// 返回:关联记录列表(TaskLinkRecord,按 created_at 升序)。
#[tauri::command]
pub async fn list_task_links(
state: State<'_, AppState>,
task_id: String,
direction: Option<LinkDirection>,
) -> Result<Vec<TaskLinkRecord>, String> {
let dir = direction.unwrap_or_default();
match dir {
LinkDirection::Outgoing => state.task_links.get_by_source(&task_id).await.map_err(err_str),
LinkDirection::Incoming => state.task_links.get_by_target(&task_id).await.map_err(err_str),
// 双向合并:取 Outgoing + Incoming 后按 id 去重(同一 link 在双向查询中可能出现两次)
LinkDirection::Both => {
let mut outgoing = state.task_links.get_by_source(&task_id).await.map_err(err_str)?;
let incoming = state.task_links.get_by_target(&task_id).await.map_err(err_str)?;
// 按 id 去重(Outgoing 优先保留,incoming 中 id 未出现的才追加)
let mut seen: std::collections::HashSet<String> =
outgoing.iter().map(|l| l.id.clone()).collect();
for l in incoming {
if seen.insert(l.id.clone()) {
outgoing.push(l);
}
}
// 按 created_at 升序(合并后重排,与单向查询排序一致)
outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(outgoing)
}
}
}
// ============================================================
// 知识图谱 Phase 1:move_task_queue(跨池移动 + 一致性约束,对标设计 §2.1 + §五)
// ============================================================
/// 跨池移动任务(对标设计 §2.1 queue 字段语义 + §五 move_task_queue)。
///
/// queue 是管理维度池(backlog/todo/decision/active/done),与 status(执行维度)正交。
/// move_task_queue 改 queue,同时按一致性约束联动调整 status(对标设计 §2.1):
/// - queue=done → status 强制=done(池完成即任务完成)
/// - queue=backlog → status 强制=todo(需求池任务尚未开始)
/// - queue=active → status 若不在 {in_progress,in_review,testing} 则强制=in_progress
/// - queue=todo → status 若非 todo 则强制=todo(待办池任务尚未开始)
/// - queue=decision → status 不变(待决策池可保留任意执行态,暂停推进但执行态保留)
///
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
///
/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口,
/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。
#[tauri::command]
pub async fn move_task_queue(
state: State<'_, AppState>,
id: String,
new_queue: String,
) -> Result<TaskRecord, String> {
let new_queue = new_queue.trim().to_string();
validate_queue(&new_queue)?;
// 读当前任务(取当前 status 做一致性联动决策)
let current = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在"))?;
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
let new_status = match new_queue.as_str() {
"done" => "done".to_string(),
"backlog" => "todo".to_string(),
"active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.clone() // 已在执行中三态,保留
} else {
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
}
}
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态)
_ => unreachable!("validate_queue 已收口"),
};
// 写 queue:走通用 update_field(queue 已在 tasks 白名单登记,知识图谱 Phase 1 V29 新增)。
// 与 current.queue 不同才写(避免无谓 updated_at 抖动)。
if current.queue != new_queue {
state
.tasks
.update_field(&id, "queue", &new_queue)
.await
.map_err(err_str)?;
}
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
if current.status != new_status {
state
.tasks
.set_status_for_aggregation(&id, &new_status)
.await
.map_err(err_str)?;
}
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。from/to 用 queue 值。
if current.queue != new_queue {
emit_event(
&state,
&current.project_id,
"task_advanced",
Some("task"),
Some(&current.id),
Some(&current.queue),
Some(&new_queue),
)
.await;
}
// 回读最新记录返回
state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)"))
}
// ============================================================
// 知识图谱 Phase 1:get_task_tree(父子任务树,对标设计 §2.1 + §五)
// ============================================================
/// 任务树节点(父 + 子任务列表,对标设计 §2.1 限 1 级嵌套)。
///
/// 设计限 1 级嵌套(无孙任务,D2),故树结构是扁平的「父 + 直接子任务列表」,无需递归。
#[derive(Debug, Serialize)]
pub struct TaskTreeNode {
/// 父任务(根节点,可能是叶子任务无子)
pub parent: TaskRecord,
/// 直接子任务列表(parent_id 指向 parent 的任务,按 created_at 升序)。叶子任务时为空。
pub children: Vec<TaskRecord>,
}
/// 获取任务父子树(对标设计 §2.1 + §五 get_task_tree)。
///
/// 限 1 级嵌套(设计 D2:无孙任务),故本方法取「指定任务 + 其直接子任务」,不递归。
///
/// 参数 `parent_id`:任务 ID(无论它是叶子还是父任务,都返回其自身 + 子任务列表)。
/// AI 用途:查看需求分解结构、子任务进度聚合(设计 §六 ⑥⑦ AI 自检 + 父聚合)。
#[tauri::command]
pub async fn get_task_tree(
state: State<'_, AppState>,
parent_id: String,
) -> Result<TaskTreeNode, String> {
let parent = state
.tasks
.get_by_id(&parent_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {parent_id} 不存在"))?;
let children = state.tasks.get_children(&parent_id).await.map_err(err_str)?;
Ok(TaskTreeNode { parent, children })
}
+32 -5
View File
@@ -23,8 +23,8 @@ pub fn run() {
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join(db_name); let db_path = data_dir.join(db_name);
// 初始化全局状态(打开数据库 + 执行迁移) // 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?; let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断) // FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
if let Err(e) = tauri::async_runtime::block_on( if let Err(e) = tauri::async_runtime::block_on(
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers), commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
@@ -45,17 +45,27 @@ pub fn run() {
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。 // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
// 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。 // 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原
// generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。
let dirty_convs: Vec<String> = session let dirty_convs: Vec<String> = session
.per_conv .per_conv
.iter() .iter()
.filter(|(_, c)| c.generating) .filter(|(_, c)| c.conv_state.is_active())
.map(|(id, _)| id.clone()) .map(|(id, _)| id.clone())
.collect(); .collect();
let was_generating = !dirty_convs.is_empty(); let was_generating = !dirty_convs.is_empty();
// 复位每个残留 generating 的 conv(批8 多 conv 全覆盖)。 // 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。
for cid in &dirty_convs { for cid in &dirty_convs {
if let Some(conv) = session.per_conv.get_mut(cid) { if let Some(conv) = session.per_conv.get_mut(cid) {
conv.generating = false; match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %cid,
error = %e,
"[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)"
),
}
} }
} }
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
@@ -245,11 +255,27 @@ pub fn run() {
commands::task::restore_task, commands::task::restore_task,
commands::task::get_task_by_id, commands::task::get_task_by_id,
commands::task::advance_task, commands::task::advance_task,
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
commands::task::create_task_link,
commands::task::remove_task_link,
commands::task::list_task_links,
commands::task::move_task_queue,
commands::task::get_task_tree,
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
commands::events::get_project_timeline,
// 知识图谱 Phase 3(对标设计 §五 + §2.3 + D10):项目基础设施配置 CRUD
commands::services::add_project_service,
commands::services::update_project_service,
commands::services::remove_project_service,
commands::services::list_project_services,
// 灵感 // 灵感
commands::idea::list_ideas, commands::idea::list_ideas,
commands::idea::create_idea, commands::idea::create_idea,
commands::idea::update_idea, commands::idea::update_idea,
commands::idea::relate_ideas,
commands::idea::delete_idea, commands::idea::delete_idea,
commands::idea::restore_idea,
commands::idea::list_deleted_ideas,
commands::idea::evaluate_idea, commands::idea::evaluate_idea,
commands::idea::list_idea_evaluations, commands::idea::list_idea_evaluations,
commands::idea::promote_idea, commands::idea::promote_idea,
@@ -327,6 +353,7 @@ pub fn run() {
commands::settings::settings_set, commands::settings::settings_set,
commands::settings::settings_get_all, commands::settings::settings_get_all,
commands::settings::settings_delete, commands::settings::settings_delete,
commands::settings::get_data_dir,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+127 -46
View File
@@ -12,8 +12,8 @@ use tokio::sync::{Mutex, RwLock, Semaphore};
use df_ai::ai_tools::AiToolRegistry; use df_ai::ai_tools::AiToolRegistry;
use df_storage::crud::{ use df_storage::crud::{
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo, AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
SettingsRepo, TaskRepo, WorkflowRepo, ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
}; };
use df_storage::db::Database; use df_storage::db::Database;
use df_workflow::eventbus::EventBus; use df_workflow::eventbus::EventBus;
@@ -337,6 +337,16 @@ pub struct AppState {
pub projects: ProjectRepo, pub projects: ProjectRepo,
/// 任务表 Repo /// 任务表 Repo
pub tasks: TaskRepo, pub tasks: TaskRepo,
/// 任务横向关联表 Repo(知识图谱 Phase 1 V29,AI 拓扑排序编排调度的基础)
pub task_links: TaskLinkRepo,
/// 项目统一事件流表 Repo(知识图谱 Phase 2 V30,project_events 追加型审计表)。
/// 埋点 best-effort:事件写入失败不阻断主操作(设计 §10.1),commands 层 hook/after 追加。
pub project_events: ProjectEventRepo,
/// 项目基础设施配置表 Repo(知识图谱 Phase 3 V31,project_services)。
/// 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
pub project_services: ProjectServiceRepo,
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留) /// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)] #[allow(dead_code)]
pub releases: ReleaseRepo, pub releases: ReleaseRepo,
@@ -383,6 +393,8 @@ pub struct AppState {
pub knowledge_events: KnowledgeEventsRepo, pub knowledge_events: KnowledgeEventsRepo,
/// 知识库行为配置(提取 + 注入) /// 知识库行为配置(提取 + 注入)
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>, pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
/// 应用数据目录(运行期确定:Windows $APPDATA/devflow, macOS ~/Library/Application Support/devflow)
pub data_dir: PathBuf,
/// 通用应用设置 KV Repo(前端 localStorage 迁移目标) /// 通用应用设置 KV Repo(前端 localStorage 迁移目标)
pub settings: SettingsRepo, pub settings: SettingsRepo,
// ── LLM 并发控制 ── // ── LLM 并发控制 ──
@@ -436,29 +448,39 @@ pub struct AllowedDirs {
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。 /// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。 /// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
pub persistent: HashSet<PathBuf>, pub persistent: HashSet<PathBuf>,
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。 /// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。 /// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。 /// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
pub session: HashSet<PathBuf>, pub session: HashSet<PathBuf>,
/// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs
/// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。
/// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。
pub once: HashSet<PathBuf>,
} }
impl AllowedDirs { impl AllowedDirs {
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组) /// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
pub const SETTINGS_KEY: &'static str = "allowed_dirs"; pub const SETTINGS_KEY: &'static str = "allowed_dirs";
/// 仅含 workspace_root 的默认白名单(向后兼容:无 allowed_dirs 时行为不变)。 /// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。
/// 用于 mod.rs trust_key_for 等无白名单上下文的旧路径(零回归)。 /// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单;用户首次访问任意目录
/// 走弹窗三档授权(once/session/always),对齐产品定位(不预设源码目录)。仅作 init
/// 占位(reload_allowed_dirs 从 KV 覆盖)+ 测试构造基线。
pub fn default_with_root() -> Self { pub fn default_with_root() -> Self {
let mut set = HashSet::new(); Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
set.insert(workspace_root_path()); }
Self { persistent: set, session: HashSet::new() }
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
self.persistent.iter().next()
} }
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。 /// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
/// ///
/// **workspace_root 默认在 persistent**(default_with_root 初始插入,reload_allowed_dirs /// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
/// KV 未配时保持),故首次访问工程根免授权(向后兼容)。用户从白名单删除工程根后, /// KV 未配时也不塞),首次访问工程根走弹窗三档授权。用户授权(always/session/once)后
/// 工程根也需授权(动态白名单完整语义,F-260619-03 收尾:去掉代码硬编码固定放行)。 /// 落白名单,后续命中放行(动态白名单完整语义,F-260619-03 收尾)。
/// ///
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows /// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot /// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
@@ -484,6 +506,7 @@ impl AllowedDirs {
let cand = strip_verbatim(best_effort_canonicalize(candidate)); let cand = strip_verbatim(best_effort_canonicalize(candidate));
self.persistent.iter().any(|d| cand.starts_with(d)) self.persistent.iter().any(|d| cand.starts_with(d))
|| self.session.iter().any(|d| cand.starts_with(d)) || self.session.iter().any(|d| cand.starts_with(d))
|| self.once.iter().any(|d| cand.starts_with(d))
} }
} }
@@ -562,11 +585,16 @@ pub fn check_path_authorization(
raw_path: &str, raw_path: &str,
allowed: &AllowedDirs, allowed: &AllowedDirs,
) -> PathAuthDecision { ) -> PathAuthDecision {
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致) // 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
let resolved = if Path::new(raw_path).is_absolute() { let resolved = if Path::new(raw_path).is_absolute() {
PathBuf::from(raw_path) PathBuf::from(raw_path)
} else if let Some(root) = allowed.first_persistent_dir() {
root.join(raw_path)
} else { } else {
workspace_root_path().join(raw_path) // 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。
return PathAuthDecision::NeedsAuthorization {
dir: PathBuf::from("."),
};
}; };
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
@@ -582,7 +610,7 @@ pub fn check_path_authorization(
let dir = resolved let dir = resolved
.parent() .parent()
.map(|p| p.to_path_buf()) .map(|p| p.to_path_buf())
.unwrap_or_else(|| workspace_root_path()); .unwrap_or_else(|| PathBuf::from("."));
PathAuthDecision::NeedsAuthorization { dir } PathAuthDecision::NeedsAuthorization { dir }
} }
@@ -623,11 +651,15 @@ fn best_effort_canonicalize(p: &Path) -> PathBuf {
p.to_path_buf() p.to_path_buf()
} }
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。 /// workspace 根目录(当前:编译期 CARGO_MANIFEST_DIR 上两级,仅开发机有效)。
/// ///
/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器 /// ⚠️ 分发限制:env!("CARGO_MANIFEST_DIR") 是编译期常量,打包后指向编译机路径,用户机器无效。
/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md /// 目标方案(D 混合策略):workspace_root 改为运行期动态确定,从已绑定项目的
/// (workspace_root 分发适配)。当前仅开发机自用有效。 /// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
/// 已修复:
/// - authz_debug → std::env::temp_dir() (chat.rs)
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
fn workspace_root_path() -> PathBuf { fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
@@ -636,15 +668,25 @@ fn workspace_root_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
} }
/// 应用数据目录(运行期确定,跨平台)。
/// 不依赖编译期常量,打包分发后仍有效。
/// 用于 DevFlow 自身数据存储(.trash / logs 等),非用户项目目录。
/// 取 AppState.data_dir,不持 state 时回退 workspace_root_path()(开发期兼容)。
pub fn data_dir_path(state: &AppState) -> &Path {
&state.data_dir
}
impl AppState { impl AppState {
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表 /// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
pub async fn init(db_path: &Path) -> Result<Self> { pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
let db = Arc::new(Database::open(db_path).await?); let db = Arc::new(Database::open(db_path).await?);
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc, // F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。 // 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。 // 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default()));
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs)); let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(
&db, &allowed_dirs, data_dir.clone(),
));
// Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>), // resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。 // 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
@@ -657,6 +699,9 @@ impl AppState {
idea_evaluations: IdeaEvalRepo::new(&db), idea_evaluations: IdeaEvalRepo::new(&db),
projects: ProjectRepo::new(&db), projects: ProjectRepo::new(&db),
tasks: TaskRepo::new(&db), tasks: TaskRepo::new(&db),
task_links: TaskLinkRepo::new(&db),
project_events: ProjectEventRepo::new(&db),
project_services: ProjectServiceRepo::new(&db),
releases: ReleaseRepo::new(&db), releases: ReleaseRepo::new(&db),
workflows: WorkflowRepo::new(&db), workflows: WorkflowRepo::new(&db),
node_executions: NodeExecutionRepo::new(&db), node_executions: NodeExecutionRepo::new(&db),
@@ -672,6 +717,7 @@ impl AppState {
knowledge: KnowledgeRepo::new(&db), knowledge: KnowledgeRepo::new(&db),
knowledge_events: KnowledgeEventsRepo::new(&db), knowledge_events: KnowledgeEventsRepo::new(&db),
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
data_dir: data_dir.clone(),
settings: SettingsRepo::new(&db), settings: SettingsRepo::new(&db),
llm_concurrency: LlmConcurrency::new(3, 2), llm_concurrency: LlmConcurrency::new(3, 2),
agent_max_iterations: Arc::new(AtomicUsize::new( agent_max_iterations: Arc::new(AtomicUsize::new(
@@ -700,10 +746,23 @@ impl AppState {
// 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。 // 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。
state.reload_provider_caps().await; state.reload_provider_caps().await;
// F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。 // F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。
// 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(仅 workspace_root)。 // 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(方案①后为空)。
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。 // P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
state.reload_knowledge_config().await; state.reload_knowledge_config().await;
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
let old_trash = workspace_root_path().join(".trash");
let new_trash = data_dir.join(".trash");
if old_trash.exists() && !new_trash.exists() {
if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
tracing::warn!(
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
old_trash, new_trash, e,
);
}
}
Ok(state) Ok(state)
} }
@@ -750,7 +809,7 @@ impl AppState {
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。 /// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
/// ///
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持 /// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
/// default_with_root(仅 workspace_root,向后兼容:is_authorized 始终放行 workspace_root)。 /// default_with_root(方案①后为空,首次访问走弹窗授权)。
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败 /// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。 /// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
pub async fn reload_allowed_dirs(&self) { pub async fn reload_allowed_dirs(&self) {
@@ -784,10 +843,8 @@ impl AppState {
all_dirs.sort(); all_dirs.sort();
all_dirs.dedup(); all_dirs.dedup();
let mut set = HashSet::new(); let mut set = HashSet::new();
// KV 未配(空)时保留 workspace_root(向后兼容:默认根免授权) // 方案①:KV + 项目绑定均空时不再硬塞 workspace_root(分发后该路径无效)。
if all_dirs.is_empty() { // 用户首次访问任意目录走弹窗三档授权,授权后落 KV 持久。
set.insert(workspace_root_path());
}
for d in all_dirs { for d in all_dirs {
let d = d.trim(); let d = d.trim();
if d.is_empty() { if d.is_empty() {
@@ -806,7 +863,8 @@ impl AppState {
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。 // 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
let mut guard = self.allowed_dirs.write().await; let mut guard = self.allowed_dirs.write().await;
let preserved_session = std::mem::take(&mut guard.session); let preserved_session = std::mem::take(&mut guard.session);
*guard = AllowedDirs { persistent: set, session: preserved_session }; let preserved_once = std::mem::take(&mut guard.once);
*guard = AllowedDirs { persistent: set, session: preserved_session, once: preserved_once };
} }
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。 /// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
@@ -836,14 +894,10 @@ impl AppState {
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。 /// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
pub async fn get_allowed_dirs(&self) -> Vec<String> { pub async fn get_allowed_dirs(&self) -> Vec<String> {
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
let root = workspace_root_path();
let guard = self.allowed_dirs.read().await; let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard let mut out: Vec<String> = guard
.persistent .persistent
.iter() .iter()
.filter(|p| **p != root)
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
.collect(); .collect();
out.sort(); out.sort();
@@ -890,6 +944,28 @@ impl AppState {
pub async fn clear_session_allowed_dirs(&self) { pub async fn clear_session_allowed_dirs(&self) {
self.allowed_dirs.write().await.session.clear(); self.allowed_dirs.write().await.session.clear();
} }
/// F-260619-03 Phase B: 追加本次单次授权目录(弹窗"本次"选项)。
///
/// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock
/// 取快照读 once 放行,执行后由 clear_once_allowed_dirs 清空(本次放行即失效,
/// 下次同路径仍弹窗)。规范化与 add_session_allowed_dir 一致(canonicalize 失败回退 trim)。
pub async fn add_once_allowed_dir(&self, dir: String) {
let d = dir.trim();
if d.is_empty() {
return;
}
let p = PathBuf::from(d);
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
PathBuf::from(d.trim_end_matches(['/', '\\']))
}));
self.allowed_dirs.write().await.once.insert(normalized);
}
/// F-260619-03 Phase B: 清空本次单次授权(工具执行后调,本次放行即失效)。
pub async fn clear_once_allowed_dirs(&self) {
self.allowed_dirs.write().await.once.clear();
}
} }
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。 /// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
@@ -950,12 +1026,14 @@ mod tests {
// 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。 // 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。
// ============================================================ // ============================================================
/// workspace_root 路径在 default_with_root 白名单内授权通过 /// 显式授权 workspace_root 后命中(方案①:default_with_root 不再预设,需显式授权)
#[test] #[test]
fn test_allowed_dirs_workspace_root_authorized() { fn test_allowed_dirs_workspace_root_authorized() {
let allowed = AllowedDirs::default_with_root(); let mut set = HashSet::new();
set.insert(workspace_root_path());
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
let root = workspace_root_path(); let root = workspace_root_path();
assert!(allowed.is_authorized(&root), "workspace_root 应被授权"); assert!(allowed.is_authorized(&root), "显式授权的 workspace_root 应命中");
// workspace 内子路径也应授权(starts_with workspace_root) // workspace 内子路径也应授权(starts_with workspace_root)
let child = root.join("src").join("main.rs"); let child = root.join("src").join("main.rs");
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权"); assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
@@ -967,7 +1045,7 @@ mod tests {
let mut set = HashSet::new(); let mut set = HashSet::new();
let custom = PathBuf::from("E:/wk-test-external-dir"); let custom = PathBuf::from("E:/wk-test-external-dir");
set.insert(custom.clone()); set.insert(custom.clone());
let allowed = AllowedDirs { persistent: set, session: HashSet::new() }; let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
// 精确命中 + 子路径 starts_with 命中 // 精确命中 + 子路径 starts_with 命中
assert!(allowed.is_authorized(&custom)); assert!(allowed.is_authorized(&custom));
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt"))); assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
@@ -978,7 +1056,7 @@ mod tests {
fn test_allowed_dirs_unauthorized_rejected() { fn test_allowed_dirs_unauthorized_rejected() {
let mut set = HashSet::new(); let mut set = HashSet::new();
set.insert(PathBuf::from("E:/wk-test-authorized")); set.insert(PathBuf::from("E:/wk-test-authorized"));
let allowed = AllowedDirs { persistent: set, session: HashSet::new() }; let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt"); let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝"); assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
} }
@@ -1007,7 +1085,7 @@ mod tests {
fn test_allowed_dirs_session_authorized() { fn test_allowed_dirs_session_authorized() {
let mut session = HashSet::new(); let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-temp-session")); session.insert(PathBuf::from("E:/wk-temp-session"));
let allowed = AllowedDirs { persistent: HashSet::new(), session }; let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt"))); assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
} }
@@ -1018,7 +1096,7 @@ mod tests {
persistent.insert(PathBuf::from("E:/wk-persist")); persistent.insert(PathBuf::from("E:/wk-persist"));
let mut session = HashSet::new(); let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-session")); session.insert(PathBuf::from("E:/wk-session"));
let allowed = AllowedDirs { persistent, session }; let allowed = AllowedDirs { persistent, session, once: HashSet::new() };
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a"))); assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b"))); assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c"))); assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
@@ -1034,7 +1112,7 @@ mod tests {
let mut persistent = HashSet::new(); let mut persistent = HashSet::new();
// 用户误把整个 C:\ 加入白名单 // 用户误把整个 C:\ 加入白名单
persistent.insert(PathBuf::from("C:\\")); persistent.insert(PathBuf::from("C:\\"));
let allowed = AllowedDirs { persistent, session: HashSet::new() }; let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
// System32 应被黑名单拒(尽管 C:\ 在白名单) // System32 应被黑名单拒(尽管 C:\ 在白名单)
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam"))); assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
} }
@@ -1044,7 +1122,7 @@ mod tests {
fn test_blacklist_windows_program_files() { fn test_blacklist_windows_program_files() {
let mut persistent = HashSet::new(); let mut persistent = HashSet::new();
persistent.insert(PathBuf::from("C:\\")); persistent.insert(PathBuf::from("C:\\"));
let allowed = AllowedDirs { persistent, session: HashSet::new() }; let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe"))); assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
} }
@@ -1072,10 +1150,12 @@ mod tests {
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试 // F-260619-03 Phase B/C: check_path_authorization 三态决策测试
// ============================================================ // ============================================================
/// Phase B/C: workspace_root 路径 → Authorized /// Phase B/C: 显式授权 workspace_root 后,内路径 → Authorized(方案①:不再默认)
#[test] #[test]
fn test_check_path_authorized_workspace() { fn test_check_path_authorized_workspace() {
let allowed = AllowedDirs::default_with_root(); let mut set = HashSet::new();
set.insert(workspace_root_path());
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
let rel = "src/main.rs"; let rel = "src/main.rs";
match check_path_authorization(rel, &allowed) { match check_path_authorization(rel, &allowed) {
PathAuthDecision::Authorized => {} PathAuthDecision::Authorized => {}
@@ -1100,7 +1180,7 @@ mod tests {
fn test_check_path_session_hit() { fn test_check_path_session_hit() {
let mut session = HashSet::new(); let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-session")); session.insert(PathBuf::from("E:/wk-session"));
let allowed = AllowedDirs { persistent: HashSet::new(), session }; let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) { match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
PathAuthDecision::Authorized => {} PathAuthDecision::Authorized => {}
other => panic!("session 命中应 Authorized, got {:?}", other), other => panic!("session 命中应 Authorized, got {:?}", other),
@@ -1127,7 +1207,9 @@ mod tests {
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。 /// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
#[test] #[test]
fn test_is_authorized_strips_verbatim_prefix() { fn test_is_authorized_strips_verbatim_prefix() {
let allowed = AllowedDirs::default_with_root(); let mut set = HashSet::new();
set.insert(workspace_root_path());
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
let child = workspace_root_path().join("src").join("main.rs"); let child = workspace_root_path().join("src").join("main.rs");
// candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单 // candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单
let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy())); let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy()));
@@ -1291,4 +1373,3 @@ mod tests {
assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个"); assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个");
} }
} }
+3 -3
View File
@@ -77,10 +77,10 @@ export const aiApi = {
/** /**
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。 * F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。 * decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。 * 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
*/ */
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> { authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
return invoke('ai_authorize_dir', { toolCallId, decision }) return invoke('ai_authorize_dir', { toolCallId, decision })
}, },
+5
View File
@@ -40,6 +40,11 @@ export const ideaApi = {
return invoke('promote_idea', { id }) return invoke('promote_idea', { id })
}, },
/** 双向同步关联关系:原子地设置主体灵感的目标关联列表,自动添加/移除反向关联。 */
relateIdeas(subjectId: string, targetIds: string[]): Promise<void> {
return invoke('relate_ideas', { subjectId, targetIds })
},
/** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */ /** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> { listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
return invoke('list_idea_evaluations', { ideaId }) return invoke('list_idea_evaluations', { ideaId })
+5
View File
@@ -16,6 +16,11 @@ export const settingsApi = {
return invoke<boolean>('settings_set', { key, value }) return invoke<boolean>('settings_set', { key, value })
}, },
/** 获取 DevFlow 数据目录路径(运行期确定,跨平台) */
getDataDir(): Promise<string> {
return invoke<string>('get_data_dir')
},
/** 取全部 key/value(启动时一次性拉回恢复偏好) */ /** 取全部 key/value(启动时一次性拉回恢复偏好) */
getAll(): Promise<Record<string, string>> { getAll(): Promise<Record<string, string>> {
return invoke<Record<string, string>>('settings_get_all') return invoke<Record<string, string>>('settings_get_all')
+12 -9
View File
@@ -339,9 +339,11 @@ export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | '
* - error:出错(用户可重试回 generating) * - error:出错(用户可重试回 generating)
* - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态) * - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态)
* *
* 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发 * 批4 双轨收口后(2026-06-25):convStates(enum Map)是「会话是否生成中」的唯一真相源,
* AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为 * 旧 generatingConvs bool 轨已退役。CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged 时,
* 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。 * useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为 enum 写入口
* (替代旧 bool 轨 add),消费方不再回退 bool 判断。streaming 单值是另一条过渡态
* (F-09 per-conv streaming 收口前保留),不在本状态机口径内。
*/ */
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed' export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
@@ -358,7 +360,7 @@ export type AiChatEvent = ({
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
} | { } | {
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分 // path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/always/deny)vs 'risk'(普通 RiskLevel 审批, // 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立 // 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk', // AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。 // path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
@@ -387,7 +389,7 @@ export type AiChatEvent = ({
type: 'AiMaxRoundsReached' type: 'AiMaxRoundsReached'
} | { } | {
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。 // F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。 // 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
} | { } | {
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): // L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
@@ -400,8 +402,9 @@ export type AiChatEvent = ({
// L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。 // L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。 // conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
// 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识 // 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。CONV_STATE_ENABLED 门控:off 时 // 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。批4 双轨收口后:CONV_STATE_ENABLED=off
// 后端不 emit,前端 conv_state 收不到 → 消费方须回退旧 streaming/generating bool 判断(双轨过渡)。 // 或老后端不发本事件时,useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为
// enum 写入口(替代旧 bool 轨 add),消费方不再回退 streaming/generating bool 判断。
type: 'AiConvStateChanged'; conv_state: ConvState type: 'AiConvStateChanged'; conv_state: ConvState
} | { } | {
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」) // F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
@@ -544,8 +547,8 @@ export interface AiToolCallInfo {
diff?: string diff?: string
/** /**
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。 * path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 三选项, * - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项,
* 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。 * 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项, * - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
* 调 aiApi.approve(一次性)。 * 调 aiApi.approve(一次性)。
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。 * undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
+2
View File
@@ -597,6 +597,8 @@ async function initContextEventListeners(): Promise<void> {
void store.switchConversation(store.state.activeConversationId) void store.switchConversation(store.state.activeConversationId)
} }
}, },
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
// 自动压缩(AiAutoCompressed)静默不进此回调,避免每次发送误弹 toast + 误刷新打断流。
onCompressed: (_convId, _summary) => { onCompressed: (_convId, _summary) => {
showToast(t('aiChat.compressSuccess'), 'info') showToast(t('aiChat.compressSuccess'), 'info')
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图) // 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
+5 -3
View File
@@ -313,9 +313,11 @@ let _unlistenPending: UnlistenFn | null = null
let _disposed = false let _disposed = false
onMounted(async () => { onMounted(async () => {
const fn = await listen('ai-pending-arrived', () => { const fn = await listen('ai-pending-arrived', () => {
// nextTick 等 pending_approval 类挂到 DOM 后再查(scrollToFirstPending 查 .ai-tool-card--pending_approval)。 // BUG-260624-01 残留优化:本实例无 pending 卡时短路返回,避免大列表 N 个 ToolCardList 实例
// 多个 ToolCardList 实例(每条 AI 消息一个)各自 listen,querySelector 只命中自身 root 内的 // 每次广播都跑 querySelector(N 实例 × M 次 emit = N×M 次 DOM 查询)。原靠 scrollToFirstPending
// pending 卡,非含 pending 卡的实例查 null no-op,天然过滤不串扰。 // 内 querySelector 返 null no-op 天然过滤(功能正确),此处显式 props 过滤把无 pending 实例挡在
// 进入函数前。含 pending 的实例再走 nextTick + scrollToFirstPending(展开折叠组 + scroll)。
if (!props.toolCalls.some(tc => tc.status === 'pending_approval')) return
nextTick(() => scrollToFirstPending()) nextTick(() => scrollToFirstPending())
}) })
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。 // 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
+41 -14
View File
@@ -2,19 +2,34 @@
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。 <!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 --> 分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 -->
<!-- read_file 结果:代码预览 --> <!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content"> <div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar"> <div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span> <span class="ai-tool-file-icon" v-html="fileIcon"></span>
<span class="ai-tool-file-path">{{ parsed?.path }}</span> <span class="ai-tool-file-path">{{ parsed?.path }}</span>
<span class="ai-tool-file-meta">{{ parsed?.has_more <!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
<span v-if="parsed?.search" class="ai-tool-file-meta">「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 }) ? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span> : `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)"> <button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }} {{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
</button> </button>
</div> </div>
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre> <!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
<!-- BUG-260626-01:行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-main">
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
<code class="ai-tool-grep-content">{{ m.content }}</code>
</div>
</div>
</div>
<!-- 默认模式:全文预览 -->
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
</div> </div>
<!-- list_directory 结果:文件树 --> <!-- list_directory 结果:文件树 -->
@@ -82,14 +97,15 @@
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span> <span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
</div> </div>
</div> </div>
<!-- content 模式:命中行(文件:行:内容),支持上下文折叠 --> <!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
<!-- BUG-260626-01:文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
<div v-else class="ai-tool-grep-matches"> <div v-else class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match"> <div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-head"> <div class="ai-tool-grep-match-main">
<span class="ai-tool-grep-file">{{ m.file }}</span> <span class="ai-tool-grep-file">{{ m.file }}</span>
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">:{{ m.line }}</span> <span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
<code class="ai-tool-grep-content">{{ m.content }}</code>
</div> </div>
<code class="ai-tool-grep-content">{{ m.content }}</code>
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre> <pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
</div> </div>
</div> </div>
@@ -336,26 +352,37 @@ watch(() => props.tc.status, (s) => {
.ai-tool-grep-match:last-child { .ai-tool-grep-match:last-child {
border-bottom: none; border-bottom: none;
} }
.ai-tool-grep-match-head { /* BUG-260626-01:命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
.ai-tool-grep-match-main {
display: flex; display: flex;
align-items: baseline; align-items: flex-start;
gap: 1px; gap: 10px;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 10px; font-size: 11px;
margin-bottom: 1px;
} }
.ai-tool-grep-file { .ai-tool-grep-file {
color: var(--df-accent); color: var(--df-accent);
flex-shrink: 0;
max-width: 40%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 10px;
padding-top: 1px;
} }
.ai-tool-grep-line { .ai-tool-grep-line {
color: var(--df-text-dim); color: var(--df-text-dim);
flex-shrink: 0; flex-shrink: 0;
min-width: 2em;
text-align: right;
font-size: 10px;
user-select: none;
padding-top: 1px;
} }
.ai-tool-grep-content { .ai-tool-grep-content {
display: block; flex: 1;
min-width: 0;
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
font-size: 11px; font-size: 11px;
color: var(--df-text); color: var(--df-text);
+15 -3
View File
@@ -3,8 +3,9 @@
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同 后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。 未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。 条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
每项独立三按钮:点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。 每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。
点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。 点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。
点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。 点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。 store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
toast 经 emit 转父(保持单一 toast 源)。 --> toast 经 emit 转父(保持单一 toast 源)。 -->
@@ -19,6 +20,11 @@
:disabled="actingIds.has(item.id)" :disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'once')" @click="handleAuthorize(item.id, 'once')"
>{{ $t('aiChat.dirAuthOnce') }}</button> >{{ $t('aiChat.dirAuthOnce') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--session"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'session')"
>{{ $t('aiChat.dirAuthSession') }}</button>
<button <button
class="ai-dir-auth-btn ai-dir-auth-btn--always" class="ai-dir-auth-btn ai-dir-auth-btn--always"
:disabled="actingIds.has(item.id)" :disabled="actingIds.has(item.id)"
@@ -74,7 +80,7 @@ const actingIds = reactive(new Set<string>())
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 → /** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError * execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */ * 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> { async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
if (actingIds.has(id)) return if (actingIds.has(id)) return
actingIds.add(id) actingIds.add(id)
try { try {
@@ -148,6 +154,12 @@ watch(() => pendingDirAuths.value, (items) => {
color: #fff; color: #fff;
} }
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); } .ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--session {
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
color: var(--df-accent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
}
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--always { .ai-dir-auth-btn--always {
background: var(--df-warning); background: var(--df-warning);
color: #000; color: #000;
+5 -42
View File
@@ -503,10 +503,12 @@ function collapseAllToolLists(active: Set<string>): void {
function scrollToFirstPending(): void { function scrollToFirstPending(): void {
const tcl = toolCardListRef.value as unknown const tcl = toolCardListRef.value as unknown
const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : []) const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : [])
// BUG-260624-01 残留修复:遍历所有实例调 scrollToFirstPending,各实例内部 querySelector 无
// pending 卡则 no-op,含 pending 的实例滚到其首张待审批卡。原 `return` 首个有方法的实例即停,
// pending 若落在后续实例(头部徽标点击场景)滚不到。移除 return 让所有实例尝试,no-op 实例零成本。
for (const r of refs) { for (const r of refs) {
if (typeof (r as any)?.scrollToFirstPending === 'function') { if (typeof (r as any)?.scrollToFirstPending === 'function') {
;(r as any).scrollToFirstPending() ;(r as any).scrollToFirstPending()
return
} }
} }
} }
@@ -720,20 +722,6 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
}) })
} }
// ── UX-02: 重新生成最后一条 AI 回复 ──
// 复用 store.regenerate(后端 ai_regenerate:弹末尾 AI 回复→重跑 loop)。
// 失败 toast 提示,不影响其他状态(后端已复位 generating)。
async function handleRegenerate(): Promise<void> {
try {
await store.regenerate()
await nextTick()
scrollToBottom()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.regenerateFailed', { msg }), type: 'error' })
}
}
// ── UX-03: 错误气泡操作入口 ── // ── UX-03: 错误气泡操作入口 ──
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段, // errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。 // 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
@@ -1057,33 +1045,8 @@ defineExpose({
<span class="ai-cursor"></span> <span class="ai-cursor"></span>
</div> </div>
<!-- UX-02:AI 消息操作栏(hover 显示:重新生成)。 <!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置) -->
复制走气泡内右下角图标(ai-copy-btn--ai),本栏仅留重新生成,故只在 <div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error">
末条 AI 消息(重生成语义)、非错误、有内容、非流式中显示。 -->
<div
v-if="isLastAi(item.msg) && item.msg.content && !item.msg.isError && !store.state.streaming"
class="ai-msg-actions"
>
<button
class="ai-msg-act"
:disabled="store.state.streaming"
:title="$t('aiChat.regenerate')"
@click.stop="handleRegenerate"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('aiChat.regenerate') }}</span>
</button>
</div>
<!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置)。
仅 isError 消息显示;「重试」仅可重试错误(network/timeout/unknown)显示,
Fatal 类(auth/provider_config — 鉴权/4xx/参数错)重试必败故隐藏;
「去设置」仅 error_type ∈ {auth, provider_config} 显示;
Fatal 时附 notRetryable 提示,说明为何无重试按钮。 -->
<div
v-if="item.msg.isError && !store.state.streaming"
class="ai-msg-actions ai-msg-actions--error"
>
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span> <span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
<button <button
v-if="canRetry(item.msg)" v-if="canRetry(item.msg)"
+14 -14
View File
@@ -162,16 +162,16 @@
</template> </template>
</div> </div>
<!-- 传统评分雷达图 --> <!-- 传统评分条(score-bar) -->
<div class="detail-section"> <div class="detail-section">
<h3>{{ $t('ideas.multiScoreTitle') }}</h3> <h3>{{ $t('ideas.multiScoreTitle') }}</h3>
<div class="radar-chart" v-if="parseScores(idea.scores).length > 0"> <div class="score-bar-chart" v-if="parseScores(idea.scores).length > 0">
<div class="radar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name"> <div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
<span class="radar-label">{{ dim.name }}</span> <span class="score-bar-label">{{ dim.name }}</span>
<div class="radar-bar-track"> <div class="score-bar-track">
<div class="radar-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div> <div class="score-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
</div> </div>
<span class="radar-value">{{ dim.score }}</span> <span class="score-bar-value">{{ dim.score }}</span>
</div> </div>
</div> </div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div> <div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div>
@@ -774,31 +774,31 @@ watch(() => props.idea.id, loadHistory)
margin: 6px 0 0; margin: 6px 0 0;
} }
/* ===== 雷达图(div 模拟) ===== */ /* ===== 多维评分条(div 模拟) ===== */
.radar-chart { .score-bar-chart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
.radar-row { .score-bar-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.radar-label { .score-bar-label {
font-size: 12px; font-size: 12px;
color: var(--df-text-secondary); color: var(--df-text-secondary);
min-width: 72px; min-width: 72px;
text-align: right; text-align: right;
} }
.radar-bar-track { .score-bar-track {
flex: 1; flex: 1;
height: 8px; height: 8px;
background: var(--df-border); background: var(--df-border);
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
overflow: hidden; overflow: hidden;
} }
.radar-bar-fill { .score-bar-fill {
height: 100%; height: 100%;
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
transition: width 0.4s; transition: width 0.4s;
@@ -806,7 +806,7 @@ watch(() => props.idea.id, loadHistory)
.fill-high { background: var(--df-success); } .fill-high { background: var(--df-success); }
.fill-mid { background: var(--df-warning); } .fill-mid { background: var(--df-warning); }
.fill-low { background: var(--df-danger); } .fill-low { background: var(--df-danger); }
.radar-value { .score-bar-value {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
min-width: 28px; min-width: 28px;
+20 -1
View File
@@ -5,6 +5,11 @@
<h2>{{ $t('settings.sectionAdvanced') }}</h2> <h2>{{ $t('settings.sectionAdvanced') }}</h2>
</div> </div>
<div class="general-settings"> <div class="general-settings">
<!-- ═══ 应用数据目录(运行期确定,跨平台) ═══ -->
<SettingRow :label="$t('settings.labelDataDir')" :desc="$t('settings.descDataDir')">
<span class="data-dir-path" :title="dataDir">{{ dataDir || $t('settings.loading') }}</span>
</SettingRow>
<!-- F-#97:三档 low/medium/all。low=仅低风险自动(等价旧 autoExecute=false); <!-- F-#97:三档 low/medium/all。low=仅低风险自动(等价旧 autoExecute=false);
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) --> medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')"> <SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
@@ -40,10 +45,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch, useTemplateRef } from 'vue' import { ref, reactive, watch, useTemplateRef, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { settingsApi } from '@/api'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
// ============================================================ // ============================================================
@@ -56,6 +62,14 @@ import SettingRow from './SettingRow.vue'
// ============================================================ // ============================================================
const { t } = useI18n() const { t } = useI18n()
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
// 应用数据目录(运行期确定,跨平台)
const dataDir = ref('')
onMounted(async () => {
try {
dataDir.value = await settingsApi.getDataDir()
} catch { /* 静默 */ }
})
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) } // useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) }
// confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText // confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText
// 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。 // 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。
@@ -125,6 +139,11 @@ function markSaved() {
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/ F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */ actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.data-dir-path {
font-size: 12px;
color: var(--df-text-dim);
word-break: break-all;
}
.confirm-mask { .confirm-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
+140 -1
View File
@@ -10,11 +10,12 @@
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers): //! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层 //! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
import { reactive } from 'vue'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo, ConvState } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -124,3 +125,141 @@ export function clearAllApprovalTimers(): void {
for (const timer of _approvalTimers.values()) clearTimeout(timer) for (const timer of _approvalTimers.values()) clearTimeout(timer)
_approvalTimers.clear() _approvalTimers.clear()
} }
// ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ──
//
// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/
// useAiStream.ts(onStreamTimeout stillGenerating)/useAiConversations.ts(switchConversation 重算)
// 等读点均需 import getConvState。若 getConvState 留在 useAiEvents,这些模块反向 import
// useAiEvents 会与 useAiEvents→useAiStream/useAiEvents←stores 的现有依赖构成环。
// 故将 convStates Map + getConvState + setConvState 全部下沉到本共享层(与 nextMsgId/findToolCall/
// 审批计时器同位置,沿用既有破环模式)。useAiEvents 改为从本模块 import 它们。
/**
* per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。
*
* 模块级 reactive Map(convId → ConvState),不进 store.state(与生成生命周期语义独立于
* 消息/会话列表,同 pendingMaxRounds/pendingHelp 模式)。批4 收口后,本 Map 是「会话是否生成中」
* 的唯一真相源,generatingConvs bool 轨已退役。
*/
export const convStates = reactive(new Map<string, ConvState>())
/**
* 取某 conv 的 conv_state;未追踪过返回 null。
*
* 桥接语义(批4 收口):bool 轨 generatingConvs.has(id) 等价于本函数返回值 ∈
* {'generating','stopping','compressed'}(非终止三态);idle/error/null 对应 bool 轨 has()=false。
* 依据:handleConvStateEvent 原同步逻辑将三态都 add,终止态 delete。
*/
export function getConvState(convId: string | null | undefined): ConvState | null {
if (!convId) return null
return convStates.get(convId) ?? null
}
/**
* 写入 conv_state(useAiEvents.handleConvStateEvent 的 AiConvStateChanged case 唯一写入口,
* 及活跃事件兜底写 generating 态)。
*
* idle 为终态收敛:写后删 Map 项(与后端 generating=false 语义对齐,Map 不持陈旧 idle 项)。
*/
export function setConvState(convId: string | null | undefined, s: ConvState): void {
if (!convId) return
if (s === 'idle') {
convStates.delete(convId)
} else {
convStates.set(convId, s)
}
}
// ── F-09 B 路线 per-conv 流式态(streaming/currentText) ──
//
// 背景(DEC-07a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例,
// F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming &&
// currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话
// 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。
//
// 铁律:单会话回归零变化。per-conv Map 在单会话场景只有 activeConversationId 一项,等价原全局
// 单例。state.streaming/currentText 改为委派本 Map 的 accessor(stores/ai.ts),所有现有消费方
// (streamingGuard/useAiEvents/useAiSend/useAiConversations/useAiWindow/useAiPanel/MessageList/
// ChatInput/TopBar/AiChat)读写 `store.state.streaming` / `store.state.currentText` 零改动透明继承。
//
// 下沉到 aiShared.ts(与 convStates 同位置)的原因:stores/ai.ts 已 import aiShared(getConvState),
// 把 per-conv Map 放这里不新增依赖环;且与 convStates 同款 per-conv 模式集中管理,语义聚合。
//
// 类型:streaming=boolean(该会话是否正在流式输出),currentText=string(该会话累积的流式文本)。
export interface ConvStreamState {
streaming: boolean
currentText: string
}
/**
* per-conv 流式态真相源:convId → {streaming, currentText}。
*
* 模块级 reactive Map(对齐 convStates 模式)。stores/ai.ts 的 state.streaming/currentText
* accessor 委派本 Map 按 activeConversationId 索引读写,响应式依赖经 Vue reactive Map proxy 跟踪。
*
* 收敛:streaming=false 时删 Map 项(对齐 convStates idle 收敛,Map 不持陈旧 false 项);
* 写 true 时按需 set。currentText 写空串等同收敛(下次写非空重新 set)。
*/
export const convStreamStates = reactive(new Map<string, ConvStreamState>())
/**
* 取某 conv 的 stream state;未追踪过返回 null。
*
* 单会话场景:activeConversationId 恒定,Map 仅此一项,等价原全局单例。
*/
export function getConvStreamState(convId: string | null | undefined): ConvStreamState | null {
if (!convId) return null
return convStreamStates.get(convId) ?? null
}
/**
* 取某 conv 的 stream state,不存在则惰性创建(供 streaming/currentText 写入路径建项)。
* 惰性建对齐 per-conv「写时建」语义——读路径(get)不建,仅写入路径(set)建。
*/
function ensureConvStreamState(convId: string): ConvStreamState {
let s = convStreamStates.get(convId)
if (!s) {
s = { streaming: false, currentText: '' }
convStreamStates.set(convId, s)
}
return s
}
/**
* 写某 conv 的 streaming 态(false 时收敛删 Map 项,对齐 convStates idle 语义)。
*
* @param convId 目标会话 id
* @param value streaming 目标值
*/
export function setConvStreaming(convId: string | null | undefined, value: boolean): void {
if (!convId) return
if (value) {
ensureConvStreamState(convId).streaming = value
} else {
// false 收敛:若该 conv 已无任何活跃态(streaming=false 且 currentText 空),删 Map 项避免陈旧。
const cur = convStreamStates.get(convId)
if (cur) {
cur.streaming = false
if (!cur.currentText) convStreamStates.delete(convId)
}
}
}
/**
* 写某 conv 的 currentText(空串时仅清字段,streaming 仍 true 则保留项)。
*
* @param convId 目标会话 id
* @param value currentText 目标值(空串=清流式累积,但会话可能仍在 streaming)
*/
export function setConvCurrentText(convId: string | null | undefined, value: string): void {
if (!convId) return
ensureConvStreamState(convId).currentText = value
}
/** 清某 conv 的 stream state(会话删除/收尾彻底清,删整个 Map 项)。
* 注:日常 streaming=false/currentText='' 收敛走 setConvStreaming 内联删,本函数用于会话级删除。 */
export function clearConvStreamState(convId: string | null | undefined): void {
if (!convId) return
convStreamStates.delete(convId)
}
+18 -29
View File
@@ -5,17 +5,19 @@
//! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 → //! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 →
//! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。 //! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。
//! //!
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做三件事: //! 本模块把写侧收敛到单一 `setStreaming` 入口,做两件事:
//! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝—— //! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝——
//! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗) //! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗)
//! 记 warn 日志供排查卡死。 //! 记 warn 日志供排查卡死。
//! 2. 联动 generatingConvs:value=true 且带 convId → add(convId);value=false 且带 convId //! 2. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
//! → delete(convId)。不带 convId 的复位(全局兜底)不动 generatingConvs,因其由 //! 不校验,回退原散布语义(灰度/回退开关)。
//! AiCompleted/AiError 按 conversation_id 精确管理(避免误删并发会话生成态)。
//! 3. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
//! 不校验不联动,回退原散布语义(灰度/回退开关)。
//! //!
//! 注:本 guard 是「写收敛 + 可观测 + 联动」三合一,不是拒绝式状态机—— //! 批4 双轨收口后:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs
//! (bool 轨已退役)。streaming 单值(state.streaming)是全局当前视图流式态,另一条过渡态,
//! 归 F-09 B 路线 per-conv streaming 收口,本批暂留。doSend setStreaming(true,{convId}) 后,
//! 生成态由 useAiEvents handleEvent 活跃事件兜底写 setConvState('generating') 标记。
//!
//! 注:本 guard 是「写收敛 + 可观测」二合一,不是拒绝式状态机——
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。 //! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
@@ -29,8 +31,7 @@ const appSettings = useAppSettingsStore()
/// guard 是否启用(读 appSettings,默认开=true)。 /// guard 是否启用(读 appSettings,默认开=true)。
/// ///
/// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更, /// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更,
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现联动 generatingConvs 引入回归, /// 默认开启直接收效;flag 仅作紧急回退通道(若发现回归,用户可在设置里关掉回退原散布语义)。
/// 用户可在设置里关掉回退原散布语义)。
function isGuardEnabled(): boolean { function isGuardEnabled(): boolean {
return appSettings.get<boolean>(STREAMING_GUARD_FLAG, true) return appSettings.get<boolean>(STREAMING_GUARD_FLAG, true)
} }
@@ -38,14 +39,13 @@ function isGuardEnabled(): boolean {
/** /**
* streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。 * streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。
* @param value 目标流式态 * @param value 目标流式态
* @param opts.convId 关联会话 id。value=true 时 add 进 generatingConvs; * @param opts.convId 关联会话 id(批4 收口后仅用于日志可观测,不再联动 generatingConvs;
* value=false 时 delete 出 generatingConvs。省略则不动 generatingConvs * 生成态真相归 convStates,由 useAiEvents 兜底写 setConvState 标记)。
* (全局兜底复位场景,generatingConvs 由 AiCompleted/AiError 精确管理)。
* @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。 * @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。
* *
* 行为: * 行为:
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不联动不校验)。 * - flag 关 → 直接 `state.streaming = value`(回退散布语义,不校验)。
* - flag 开 → 幂等检测(value===当前值记 debug)、联动 generatingConvs、写日志。 * - flag 开 → 幂等检测(value===当前值记 debug)、写日志。
*/ */
export function setStreaming( export function setStreaming(
value: boolean, value: boolean,
@@ -73,18 +73,8 @@ export function setStreaming(
) )
} }
// 联动 generatingConvs:仅在有 convId 时联动。 // 批4 双轨收口:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs
// value=true:convId 入 Set(标记该会话生成中,侧栏/分离窗口据此显示)。 // (bool 轨已退役)。streaming 单值是另一条过渡态(F-09 per-conv streaming 收口前保留)。
// value=false:convId 出 Set(该会话收尾)。无 convId 的全局复位不动 Set
// (并发会话生成态由各自 AiCompleted/AiError 精确 delete 管理,全局清会误杀)。
if (convId) {
if (value) {
state.generatingConvs.add(convId)
} else {
state.generatingConvs.delete(convId)
}
}
state.streaming = value state.streaming = value
} }
@@ -94,9 +84,8 @@ export function setStreaming(
* 与 setStreaming(false) 区别:本函数额外清 currentText + queue, * 与 setStreaming(false) 区别:本函数额外清 currentText + queue,
* 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。 * 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。
* *
* TD-260621-01 per-conv:看门狗超时携带 convId 时,仅清该 conv 的 generatingConvs * TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态;
* (经 setStreaming 联动 delete),不再全清误杀并发会话生成态。不带 convId(全局兜底, * 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。
* 如 stopListener 卸载场景)时 generatingConvs 不动,由各自会话收尾事件精确管理。
* *
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下 * 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积), * 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
+7 -7
View File
@@ -31,8 +31,8 @@ const _pendingApprovalIds = new Set<string>()
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 * 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
* *
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC—— * path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。 * - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。 * once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。 * - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
* approved=true→放行执行,approved=false→拒绝。 * approved=true→放行执行,approved=false→拒绝。
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。 * 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
@@ -40,16 +40,16 @@ const _pendingApprovalIds = new Set<string>()
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。 * 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
* *
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。 * @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。 * @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
*/ */
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') { async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试) // F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
if (_pendingApprovalIds.has(toolCallId)) return if (_pendingApprovalIds.has(toolCallId)) return
_pendingApprovalIds.add(toolCallId) _pendingApprovalIds.add(toolCallId)
try { try {
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚 // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
const tc = findToolCall(toolCallId) const tc = findToolCall(toolCallId)
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/always/deny); // path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny);
// 其余(risk/缺省)走 approve(approve/reject)。 // 其余(risk/缺省)走 approve(approve/reject)。
const isPathKind = !!tc && tc.kind === 'path' const isPathKind = !!tc && tc.kind === 'path'
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear, // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
@@ -95,7 +95,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
* *
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。 * path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
* 批量操作语义: * 批量操作语义:
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权仅本次, * - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。 * 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。 * - decision='reject':risk 类 approved=false;path 类 decision='deny'。
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。 * 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
@@ -106,7 +106,7 @@ async function batchApprove(decision: 'approve' | 'reject') {
for (const p of items) { for (const p of items) {
try { try {
if (p.kind === 'path') { if (p.kind === 'path') {
// path 类:approve→once(仅本次,不批量写持久白名单), reject→deny // path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny') await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
} else { } else {
// risk 类:approve/reject boolean // risk 类:approve/reject boolean
+17 -7
View File
@@ -7,14 +7,15 @@
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase, //! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase,
//! 对齐 useAiEvents.ts 既有 9 事件): //! 对齐 useAiEvents.ts 既有 9 事件):
//! AiCompressing → isCompressing=true //! AiCompressing → isCompressing=true
//! AiCompressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里) //! AiManualCompressed → isCompressing=false + (手动 IPC,成功 toast 由调用方弹,摘要已在事件里)
//! AiAutoCompressed → isCompressing=false + (loop 自动压缩,静默仅复位,治 Task#1 不弹 toast)
//! AiContextCleared → (刷新提示由调用方弹) //! AiContextCleared → (刷新提示由调用方弹)
//! AiError → isCompressing=false + (错误 toast 由调用方弹) //! AiError → isCompressing=false + (错误 toast 由调用方弹)
//! //!
//! 设计: //! 设计:
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含 //! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
//! AiCompressing/AiCompressed/AiContextCleared,因 types.ts 不在本任务白名单), //! AiCompressing/AiManualCompressed|AiAutoCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。 //! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 5 类事件。
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。 //! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref), //! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
//! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用, //! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用,
@@ -32,7 +33,7 @@ import { aiApi } from '@/api'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { resolveAiLang } from './aiShared' import { resolveAiLang } from './aiShared'
/// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false) /// 压缩中 loading(防重复点击;AiCompressing→true / AiManualCompressed|AiAutoCompressed|AiError→false)
const isCompressing = ref(false) const isCompressing = ref(false)
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等 /// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
@@ -43,7 +44,8 @@ let _unlistenContext: UnlistenFn | null = null
interface ContextCallbacks { interface ContextCallbacks {
/** 清空完成(AiContextCleared):toast + 刷新消息列表 */ /** 清空完成(AiContextCleared):toast + 刷新消息列表 */
onCleared?: (conversationId: string) => void onCleared?: (conversationId: string) => void
/** 压缩成功(AiCompressed):toast + 渲染摘要 system 消息 */ /** 手动压缩成功(AiManualCompressed):toast + 渲染摘要 system 消息。
* 注:仅 AiManualCompressed 触发;AiAutoCompressed(loop 自动)静默不进此回调(治 Task#1)。 */
onCompressed?: (conversationId: string, summary: string) => void onCompressed?: (conversationId: string, summary: string) => void
/** 压缩失败 / 错误(AiError with conversation_id):toast */ /** 压缩失败 / 错误(AiError with conversation_id):toast */
onError?: (conversationId: string, message: string) => void onError?: (conversationId: string, message: string) => void
@@ -76,10 +78,18 @@ async function initContextListener(callbacks: ContextCallbacks = {}): Promise<vo
case 'AiCompressing': case 'AiCompressing':
isCompressing.value = true isCompressing.value = true
break break
case 'AiCompressed': case 'AiManualCompressed':
// 手动 IPC 压缩(用户点压缩按钮):复位 isCompressing + 弹 toast + 刷新视图回填摘要。
isCompressing.value = false isCompressing.value = false
_callbacks.onCompressed?.(convId || '', payload.summary || '') _callbacks.onCompressed?.(convId || '', payload.summary || '')
break break
case 'AiAutoCompressed':
// 治 Task#1:loop 自动压缩对桌面静默——仅复位 isCompressing(冗余兜底,Auto 路径
// 前端 isCompressing 本就为 false,只有手动 compressContext 才置 true),不调
// onCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。
// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
isCompressing.value = false
break
case 'AiContextCleared': case 'AiContextCleared':
_callbacks.onCleared?.(convId || '') _callbacks.onCleared?.(convId || '')
break break
@@ -122,7 +132,7 @@ async function compressContext(): Promise<void> {
try { try {
await aiApi.compressContext(convId, resolveAiLang()) await aiApi.compressContext(convId, resolveAiLang())
// 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing; // 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing;
// case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用) // case 'AiManualCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
isCompressing.value = false isCompressing.value = false
} catch (e) { } catch (e) {
isCompressing.value = false isCompressing.value = false
+9 -3
View File
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
import { notifyConversationChanged } from './useAiEvents' import { notifyConversationChanged } from './useAiEvents'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { nextMsgId } from './aiShared' import { nextMsgId, getConvState, clearConvStreamState } from './aiShared'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiMessage, AiToolCallInfo } from '@/api/types' import type { AiMessage, AiToolCallInfo } from '@/api/types'
@@ -101,8 +101,11 @@ export async function switchConversation(id: string) {
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId // 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。 // 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。 // 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
// 走 setStreaming guard 收敛写入口(convId=id 联动幂等——has(id) 决定值,add/delete 幂等无副作用)。 // 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
setStreaming(state.generatingConvs.has(id), { convId: id, reason: 'switchConversation-recompute' }) // 桥接语义:has(id)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
const targetCs = getConvState(id)
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
try { try {
const rawMsgs = typeof detail.messages === 'string' const rawMsgs = typeof detail.messages === 'string'
@@ -238,6 +241,9 @@ export async function switchConversation(id: string) {
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */ /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
async function deleteConversation(id: string) { async function deleteConversation(id: string) {
await aiApi.deleteConversation(id) await aiApi.deleteConversation(id)
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
clearConvStreamState(id)
if (state.activeConversationId === id) { if (state.activeConversationId === id) {
state.activeConversationId = null state.activeConversationId = null
void appSettings.remove('df-ai-active-conv') void appSettings.remove('df-ai-active-conv')
+38 -69
View File
@@ -20,16 +20,19 @@
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出) //! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event' import { listen, emit } from '@tauri-apps/api/event'
import { ref, reactive } from 'vue' import { ref } from 'vue'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared' import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState } from './aiShared'
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
export { getConvState } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations' import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo, ConvState } from '@/api/types' import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
let _unlistenAiEvent: (() => void) | null = null let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null let _unlistenConvChanged: (() => void) | null = null
@@ -138,35 +141,12 @@ export interface PendingHelp {
} }
export const pendingHelp = ref<PendingHelp | null>(null) export const pendingHelp = ref<PendingHelp | null>(null)
// L2 统一状态机(批2 1c):per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。 // L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
// // stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
// 模块级 reactive Map(convId → ConvState)直接 import 读(同 pendingMaxRounds/pendingHelp 模式, // useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
// 不进 store.state,与生成生命周期语义独立于消息/会话列表)。停止按钮/MaxRoundsCard 读之 // 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役,
// 派生三态(generating/stopping/error),旧 streaming/generatingConvs bool 双轨过渡保留—— // 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed
// conv_state 未收到时(CONV_STATE_ENABLED=off 或后端老版)消费方回退旧 bool 判断(兜底)。 // add 的兜底分支已删,enum 单写 setConvState 即收敛)。
//
// 写收敛:仅本模块 handleEvent 的 AiConvStateChanged case 写(setConvState 辅助),其他地方只读。
// 兜底同步:idle/error/compressed 在 case 内同步做旧 generatingConvs 收敛(AiCompleted/AiError 同义),
// 保持双轨真相一致——后端 emit 可能与 AiCompleted/AiError 不严格按序到达,任一来源触发收敛均可。
export const convStates = reactive(new Map<string, ConvState>())
/** 取某 conv 的 conv_state;未追踪过返回 null(消费方据此回退旧 bool 判断) */
export function getConvState(convId: string | null | undefined): ConvState | null {
if (!convId) return null
return convStates.get(convId) ?? null
}
/** 写入 conv_state(本模块内 AiConvStateChanged case 唯一写入口) */
function setConvState(convId: string | null | undefined, s: ConvState): void {
if (!convId) return
// idle 为终态收敛:写后可保留也可删,这里删以让 getConvState 回退旧 bool(语义对齐:
// 后端 generating=false 即 Idle,前端旧 bool 也是 false,删 Map 项后消费方回退 bool 即得正确态)。
if (s === 'idle') {
convStates.delete(convId)
} else {
convStates.set(convId, s)
}
}
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */ /** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() { export function notifyConversationChanged() {
@@ -521,31 +501,17 @@ function handleToolEvent(event: AiChatEvent): boolean {
} }
} }
/** L2 状态机域:消费 AiConvStateChanged,维护 per-conv conv_state 真相源。 /** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
* *
* 非终止态(generating/stopping/compressed):仅写 convStates,不动看门狗/旧 bool * 批4 双轨收口后:enum(convStates)是唯一真相源,setConvState 单写即收敛。
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。 * 原 idle/error 同步 delete generatingConvs、非终止态同步 add 的双轨兜底分支已删
* 终止态(idle/error): * (bool 轨 generatingConvs 已退役)。看门狗由活跃 delta/工具事件重置,这里不干预。
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
* 返回值:true=已处理(命中 case);false=未命中。 */ * 返回值:true=已处理(命中 case);false=未命中。 */
function handleConvStateEvent(event: AiChatEvent): boolean { function handleConvStateEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiConvStateChanged') return false if (event.type !== 'AiConvStateChanged') return false
const convId = event.conversation_id || state.activeConversationId const convId = event.conversation_id || state.activeConversationId
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态) if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
const cs = event.conv_state setConvState(convId, event.conv_state)
setConvState(convId, cs)
// 终止态同步收敛旧 generatingConvs(双轨过渡,与 AiCompleted/AiError 同义)。
// compressed 非终止(派生态,压缩完回原态),不收敛。
if (cs === 'idle' || cs === 'error') {
state.generatingConvs.delete(convId)
} else if (cs === 'generating' || cs === 'stopping' || cs === 'compressed') {
// 非终止态同步加 generatingConvs(确保旧 bool 真相与 conv_state 一致,
// 防 AiConvStateChanged{generating} 先于首个 delta 到达时旧 bool 漏加致停止按钮/卡片错态)。
state.generatingConvs.add(convId)
}
return true return true
} }
@@ -585,10 +551,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
flushCurrentText() flushCurrentText()
state.currentText = '' state.currentText = ''
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' }) setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
state.generatingConvs.delete(event.conversation_id || '') // 批4 双轨收口:AiCompleted 收尾收敛 convStates→idle(删 Map 项,enum 单一真相源)。
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op; // 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)。 // off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。
convStates.delete(event.conversation_id || '') convStates.delete(event.conversation_id || '')
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
@@ -653,10 +618,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。 // 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
flushCurrentText() flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' }) setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
state.generatingConvs.delete(event.conversation_id || '') // 批4 双轨收口:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。 // 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。 // error 态在新会话发送(首活跃事件到达兜底写 generating)或下次 AiCompleted 时自然收敛。
if (event.conversation_id) convStates.set(event.conversation_id, 'error') if (event.conversation_id) convStates.set(event.conversation_id, 'error')
state.currentText = '' state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次 state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
@@ -706,7 +670,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
clearAllToolSlowTimers() clearAllToolSlowTimers()
flushCurrentText() flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' }) setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
state.generatingConvs.delete(event.conversation_id || '') // 批4 双轨收口:求助即终止 loop,收敛 convStates(删 Map 项回 null/不在生成)。
// HelpRequiredCard 守卫仅判 pendingHelp.conversationId===active,不读 conv_state,删项安全。
convStates.delete(event.conversation_id || '')
state.currentText = '' state.currentText = ''
state.agentRound = 0 state.agentRound = 0
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。 // 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
@@ -756,32 +722,35 @@ export function handleEvent(event: AiChatEvent) {
} }
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话), // L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。 // 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。 // 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。
if (handleConvStateEvent(event)) return if (handleConvStateEvent(event)) return
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡 // F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/ // 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id // AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡, // 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
// 不影响其他态(handleUserMessageEvent 去重幂等)。 // 不影响其他态(handleUserMessageEvent 去重幂等)。
if (handleUserMessageEvent(event)) return if (handleUserMessageEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏 // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。 // L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需收敛生成态。
const isCurrent = !convId || convId === state.activeConversationId const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) { if (!isCurrent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空 // 批4 双轨收口:非当前会话终止事件,收敛 convStates(删 Map 项回 idle/不在生成),
state.generatingConvs.delete(convId || '') // 对齐 :592/:660/:711 当前会话分支语义。非当前会话的 error 态无消费方(MaxRoundsCard/
// ChatInput 均读 activeConversationId),回退 null 与停止按钮 streaming 回退行为等价。
convStates.delete(convId || '')
void loadConversations() void loadConversations()
} }
return return
} }
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop) // 批4 双轨收口:活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中。
// 完成后端 CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged{generating} 时,此处作为
// enum 兜底写入口(替代原 generatingConvs.add + 清残留 error 双操作,合并为 setConvState('generating') 一步)。
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已先到(handleConvStateEvent 在
// isCurrent 守卫前处理),此处 setConvState 同值 set 幂等覆盖,无副作用。完成/错误/求助事件除外
// (三者后端均已终止 loop,在各自 case 内由 convStates.delete/set('error') 收敛)。
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') { if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
state.generatingConvs.add(convId) setConvState(convId, 'generating')
// L2 双轨过渡:会话进入生成(活跃事件到达)时,清 convStates 中可能残留的 error 项,
// 避免上一轮错误态 conv_state 未收敛致停止按钮错显"重试"(此时实际已在生成)。
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已更新;此处兜底 off/老后端。
convStates.delete(convId)
} }
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。 // TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
+30 -6
View File
@@ -13,7 +13,7 @@
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { nextMsgId } from './aiShared' import { nextMsgId, getConvState } from './aiShared'
import { forceResetStreaming } from './streamingGuard' import { forceResetStreaming } from './streamingGuard'
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
@@ -42,9 +42,10 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位, * 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位,
* 对齐 [[devflow-generating-statemachine]])。 * 对齐 [[devflow-generating-statemachine]])。
* *
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的 * TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming
* generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空 * 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。 * 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空
* (legacy fallback 路径)时全局兜底,行为对齐旧版。
*/ */
export function onStreamTimeout(convId?: string) { export function onStreamTimeout(convId?: string) {
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear // 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
@@ -54,14 +55,37 @@ export function onStreamTimeout(convId?: string) {
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。 // 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。 // 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
const stillGenerating = convId const stillGenerating = convId
? state.generatingConvs.has(convId) ? (() => {
// 批4 双轨收口:读 convStates(enum 真相源)派生,替代旧 generatingConvs.has。
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
const cs = getConvState(convId)
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
})()
: state.streaming : state.streaming
if (!stillGenerating) { if (!stillGenerating) {
clearStreamWatchdog(convId) clearStreamWatchdog(convId)
return return
} }
// 兜底守卫(BUG-260624-03):工具执行中(running 卡)不计整流超时。
// 后端 execute_with_heartbeat(audit/mod.rs)已为工具执行补 30s AiHeartbeat,正常执行
// 不会到 130s 超时;到此且仍有 running 卡 = 心跳也丢失(老后端/provider 双重异常),
// 静默续等(reset 重计 130s)给工具完成/心跳恢复机会,不抛"工具已执行完成后续中断"误报。
// 注:真死锁(running 永驻)时续等会持续,但优于误报流中断(forceReset 致状态不一致);
// running 状态由后端 AiToolCallCompleted 驱动(非 ToolCard approving 计时器,那仅复位按钮 loading),
// 用户可手动 stop,且 A 心跳已覆盖正常执行,真死锁罕见。
for (let i = state.messages.length - 1; i >= 0; i--) {
const toolCalls = state.messages[i].toolCalls
if (!toolCalls) continue
for (let j = 0; j < toolCalls.length; j++) {
if (toolCalls[j].status === 'running') {
resetStreamWatchdog(convId)
return
}
}
}
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。 // 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。 // 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由
// 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。
forceResetStreaming('onStreamTimeout(130s 无数据)', convId) forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
clearStreamWatchdog(convId) clearStreamWatchdog(convId)
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。 // AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
+16 -8
View File
@@ -10,7 +10,7 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { nextMsgId } from './aiShared' import { nextMsgId, getConvState, convStates } from './aiShared'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
@@ -52,18 +52,24 @@ async function detachPanel(convId?: string) {
} }
// 快照当前生成态,供分离窗口接管(保持正在进行的对话) // 快照当前生成态,供分离窗口接管(保持正在进行的对话)
// F-09:用 per-conv key 写入(多会话各自快照互不覆盖) // F-09:用 per-conv key 写入(多会话各自快照互不覆盖)
if (state.streaming && targetConv && state.generatingConvs.has(targetConv)) { // 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
localStorage.setItem(genKey(targetConv), targetConv) // 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
localStorage.setItem(textKey(targetConv), state.currentText) if (state.streaming && targetConv) {
const cs = getConvState(targetConv)
const gen = cs === 'generating' || cs === 'stopping' || cs === 'compressed'
if (gen) {
localStorage.setItem(genKey(targetConv), targetConv)
localStorage.setItem(textKey(targetConv), state.currentText)
}
} }
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context, // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。 // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 // 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
// F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。 // F-09:仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清, // watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。 // 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' }) setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
if (targetConv) state.generatingConvs.delete(targetConv) if (targetConv) convStates.delete(targetConv)
const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : '' const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : ''
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
const win = new WebviewWindow(label, { const win = new WebviewWindow(label, {
@@ -135,7 +141,7 @@ function cleanupAllDetachedSnapshots(): void {
* 1. state.currentText = df-ai-text-${convId}(已生成文本) * 1. state.currentText = df-ai-text-${convId}(已生成文本)
* 2. push 占位 assistant 气泡(content='') * 2. push 占位 assistant 气泡(content='')
* 3. state.streaming = true * 3. state.streaming = true
* 4. state.generatingConvs.add(df-ai-gen) * 4. convStates.set(df-ai-gen, 'generating')(批4 双轨收口:替代旧 generatingConvs.add)
* *
* 幂等:若 state.streaming 已是 true(已恢复过/正在生成),直接返回不重复 push。 * 幂等:若 state.streaming 已是 true(已恢复过/正在生成),直接返回不重复 push。
* 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false * 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false
@@ -194,7 +200,9 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
timestamp: Date.now(), timestamp: Date.now(),
}) })
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' }) setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
state.generatingConvs.add(gen) // 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。
// setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。
convStates.set(gen, 'generating')
return true return true
} }
+2
View File
@@ -85,6 +85,8 @@ export interface ToolResult {
body?: string body?: string
body_bytes?: number body_bytes?: number
elapsed_ms?: number elapsed_ms?: number
// read_file search 模式(BUG-260625-02):传 search 参数返命中行 matches[{line,content}] 非全文 content
search?: string
// grep 跨文件内容搜索结果字段(F-260621): // grep 跨文件内容搜索结果字段(F-260621):
// - output_mode: content(默认)/ files_with_matches / count // - output_mode: content(默认)/ files_with_matches / count
// - matches: content 模式命中行 [{file,line,content,context?}] // - matches: content 模式命中行 [{file,line,content,context?}]
+2 -1
View File
@@ -155,7 +155,8 @@ export default {
dirAuthRequired: 'Path authorization required', dirAuthRequired: 'Path authorization required',
dirAuthHint: '{tool} wants to access: {path}', dirAuthHint: '{tool} wants to access: {path}',
dirAuthOnce: 'This time', dirAuthOnce: 'This time',
dirAuthAlways: 'Always allow', dirAuthSession: 'This session',
dirAuthAlways: 'Always',
dirAuthDeny: 'Deny', dirAuthDeny: 'Deny',
dirAuthFailed: 'Authorization failed: {msg}', dirAuthFailed: 'Authorization failed: {msg}',
+17
View File
@@ -22,6 +22,14 @@ export default {
deleting: 'Deleting…', deleting: 'Deleting…',
promoting: 'Promoting…', promoting: 'Promoting…',
// Errors
err: {
loadFailed: 'Failed to load ideas',
createFailed: 'Failed to create idea',
deleteFailed: 'Failed to delete idea',
relateFailed: 'Failed to update relations',
},
// Adversarial evaluation // Adversarial evaluation
adversarialTitle: '⚖️ Adversarial Evaluation', adversarialTitle: '⚖️ Adversarial Evaluation',
evalModeHeuristic: 'Heuristic', evalModeHeuristic: 'Heuristic',
@@ -112,8 +120,17 @@ export default {
captureTitle: '✨ Capture New Idea', captureTitle: '✨ Capture New Idea',
fieldTitle: 'Title', fieldTitle: 'Title',
fieldDesc: 'Description', fieldDesc: 'Description',
fieldTags: 'Tags',
fieldPriority: 'Priority',
fieldSource: 'Source',
titlePlaceholder: 'Describe your idea in one sentence...', titlePlaceholder: 'Describe your idea in one sentence...',
descPlaceholder: 'More details (optional)...', descPlaceholder: 'More details (optional)...',
tagsPlaceholder: 'Comma-separated, e.g. tools, efficiency, automation',
sourcePlaceholder: 'Source (optional, e.g. user feedback, competitor analysis)',
priorityCritical: 'Critical',
priorityHigh: 'High',
priorityMedium: 'Medium',
priorityLow: 'Low',
// Deep link not found // Deep link not found
notFound: 'Idea not found or deleted', notFound: 'Idea not found or deleted',
+3
View File
@@ -111,6 +111,9 @@ export default {
aiLanguageZh: '🇨🇳 简体中文', aiLanguageZh: '🇨🇳 简体中文',
aiLanguageEn: '🇺🇸 English', aiLanguageEn: '🇺🇸 English',
labelDataDir: 'App Data Directory',
descDataDir: 'Where DevFlow stores .trash (recycle bin), database, logs and other internal data',
labelAutoExecute: 'AI auto-execute scope', labelAutoExecute: 'AI auto-execute scope',
descAutoExecute: 'Control risk level AI auto-executes (low/medium/all-takeover)', descAutoExecute: 'Control risk level AI auto-executes (low/medium/all-takeover)',
autoModeLow: 'Low risk (default)', autoModeLow: 'Low risk (default)',
+3 -2
View File
@@ -155,8 +155,9 @@ export default {
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ── // ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
dirAuthRequired: '需要路径授权', dirAuthRequired: '需要路径授权',
dirAuthHint: '{tool} 想访问:{path}', dirAuthHint: '{tool} 想访问:{path}',
dirAuthOnce: '仅本次', dirAuthOnce: '本次',
dirAuthAlways: '未来都允许', dirAuthSession: '当前会话',
dirAuthAlways: '始终放行',
dirAuthDeny: '拒绝', dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}', dirAuthFailed: '授权失败:{msg}',
+17
View File
@@ -22,6 +22,14 @@ export default {
deleting: '删除中…', deleting: '删除中…',
promoting: '立项中…', promoting: '立项中…',
// 错误
err: {
loadFailed: '加载灵感失败',
createFailed: '创建灵感失败',
deleteFailed: '删除灵感失败',
relateFailed: '更新关联关系失败',
},
// 对抗式评估 // 对抗式评估
adversarialTitle: '⚖️ 对抗式评估', adversarialTitle: '⚖️ 对抗式评估',
evalModeHeuristic: '启发式', evalModeHeuristic: '启发式',
@@ -112,8 +120,17 @@ export default {
captureTitle: '✨ 捕捉新灵感', captureTitle: '✨ 捕捉新灵感',
fieldTitle: '标题', fieldTitle: '标题',
fieldDesc: '描述', fieldDesc: '描述',
fieldTags: '标签',
fieldPriority: '优先级',
fieldSource: '来源',
titlePlaceholder: '一句话描述你的灵感...', titlePlaceholder: '一句话描述你的灵感...',
descPlaceholder: '详细说明(可选)...', descPlaceholder: '详细说明(可选)...',
tagsPlaceholder: '用逗号分隔,如: 工具,效率,自动化',
sourcePlaceholder: '来源(可选,如:用户反馈、竞品分析)',
priorityCritical: '紧急',
priorityHigh: '高',
priorityMedium: '中',
priorityLow: '低',
// 深链不存在提示 // 深链不存在提示
notFound: '灵感不存在或已删除', notFound: '灵感不存在或已删除',
+3
View File
@@ -111,6 +111,9 @@ export default {
aiLanguageZh: '🇨🇳 简体中文', aiLanguageZh: '🇨🇳 简体中文',
aiLanguageEn: '🇺🇸 English', aiLanguageEn: '🇺🇸 English',
labelDataDir: '应用数据目录',
descDataDir: 'DevFlow 存储 .trash(回收站)、数据库、日志等内部数据的目录路径',
labelAutoExecute: 'AI 自动执行范围', labelAutoExecute: 'AI 自动执行范围',
descAutoExecute: '控制 AI 自动执行操作的风险等级(低/中/全部接管)', descAutoExecute: '控制 AI 自动执行操作的风险等级(低/中/全部接管)',
autoModeLow: '低风险(默认)', autoModeLow: '低风险(默认)',
+120 -33
View File
@@ -50,6 +50,9 @@ const MESSAGE_CAP = 200
const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计) const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计)
import { useAiEvents } from '@/composables/ai/useAiEvents' import { useAiEvents } from '@/composables/ai/useAiEvents'
import { useAiStream } from '@/composables/ai/useAiStream' import { useAiStream } from '@/composables/ai/useAiStream'
// 批4 双轨收口:getConvState 从 aiShared 取(下沉破环,避免 stores/ai↔useAiEvents 反向环)。
// F-09 per-conv streaming/currentText(DEC-07a 父④):convStreamStates Map + 辅助函数亦从 aiShared 取。
import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText } from '@/composables/ai/aiShared'
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend' import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
import { useAiApproval } from '@/composables/ai/useAiApproval' import { useAiApproval } from '@/composables/ai/useAiApproval'
import { useAiConversations } from '@/composables/ai/useAiConversations' import { useAiConversations } from '@/composables/ai/useAiConversations'
@@ -58,24 +61,67 @@ import { useAiPanel } from '@/composables/ai/useAiPanel'
import { useAiContext } from '@/composables/ai/useAiContext' import { useAiContext } from '@/composables/ai/useAiContext'
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用) /// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
export const state = reactive({ //
messages: [] as AiMessage[], // F-09 B 路线 per-conv streaming/currentText(DEC-07a 父④):
streaming: false, // `streaming`/`currentText` 不再是全局单例字段,改为 accessor 委派 aiShared.convStreamStates
currentText: '', // (per-conv Map,定义于 composables/ai/aiShared.ts)按 activeConversationId 索引读写。
pendingApprovals: [] as AiToolCallInfo[], //
// 正在生成的会话集合(F-09 多会话并发):后端 per-conv 已支持多会话同时生成, // 单会话回归铁律:单会话场景 activeConversationId 恒定,Map 仅此一项,accessor 行为等价原全局
// 前端用 Set 表达"多会话同时生成"态。生成中切走时用于路由,后台事件不污染当前视图。 // 单例(读返回该项值,写改该项值)。所有现有消费方 `store.state.streaming` / `store.state.currentText`
// isGenerating(convId)/addGenerating/removeGenerating helper 经 useAiStore 暴露。 // 零改动透明继承(read = get active 项,write = set active 项)。
// 单会话场景行为不变(Set 含 0 或 1 元素)。 //
generatingConvs: new Set<string>(), // 多会话收益:切会话(activeConversationId 变)即切到该会话的 stream state,A 后台生成中切到 B,
providers: [] as AiProviderConfig[], // B 的 streaming/currentText 读各自 Map 项,A 残留 currentText 不串到 B 视图(BUG-260624-01 根因清除)。
activeProvider: null as string | null, //
// accessor 实现要点:
// - get:读 activeConversationId → 取 convStreamStates 项;无 active/无项 → streaming 返回 false、
// currentText 返回 ''(与原全局单例初值一致,空安全)。
// - set:读 activeConversationId → 写 convStreamStates 对应项;无 active 则 no-op(无目标 conv
// 无法路由,原全局单例此场景也写无意义的全局值,no-op 更干净)。
// - reactive 追踪:Vue 3 reactive 包装带 accessor 描述符的对象时,会保留 get/set 并经
// Reflect.get/set 调用,track/trigger 正常工作。`state.streaming` 读取建立对 activeConversationId
// + 对应 Map 项的响应式依赖,任一变化触发更新。
// - 闭包引用 state 本身:get/set 需读 state.activeConversationId,用下方 `stateRef` 闭包变量指向
// reactive 包装后的对象(先声明后赋值,accessor 闭包延迟读 stateRef,避免 TDZ)。
const _stateBase: {
messages: AiMessage[]
// F-09 per-conv:streaming/currentText 是 accessor(下方 Object.defineProperty 定义),
// 委派 aiShared.convStreamStates 按 activeConversationId 索引读写。streaming/currentText
// 不在字面量显式赋值(由 accessor 提供),故 _stateBase 类型不含此两键;
// 通过下方 StateWithStream 接口扩展导出 state 的完整类型(消费方编译可见)。
pendingApprovals: AiToolCallInfo[]
providers: AiProviderConfig[]
activeProvider: string | null
panelOpen: boolean
maximized: boolean
conversations: AiConversationSummary[]
activeConversationId: string | null
sidebarOpen: boolean
sidebarWidth: number
detached: boolean
docked: boolean
skills: SkillInfo[]
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[]
agentRound: number
archivedCollapsed: boolean
foldedGroups: Record<string, boolean>
searchQuery: string
lastTokenUsage: { prompt: number; completion: number; total: number } | null
convTokenTotal: { prompt: number; completion: number; total: number } | null
} = {
messages: [],
pendingApprovals: [],
// 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates
// (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。
// 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。
providers: [],
activeProvider: null,
panelOpen: true, panelOpen: true,
// 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽) // 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
maximized: true, maximized: true,
// 对话管理 // 对话管理
conversations: [] as AiConversationSummary[], conversations: [],
activeConversationId: null as string | null, activeConversationId: null,
sidebarOpen: false, sidebarOpen: false,
// 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16) // 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16)
sidebarWidth: 200, sidebarWidth: 200,
@@ -84,24 +130,66 @@ export const state = reactive({
// 吸附跟随中 // 吸附跟随中
docked: false, docked: false,
// 本机技能(`/` 联想) // 本机技能(`/` 联想)
skills: [] as SkillInfo[], skills: [],
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测) // 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测)
// F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图) // F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图)
queue: [] as { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[], queue: [],
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round) // Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0 // AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
agentRound: 0, agentRound: 0,
// 归档分组折叠态(默认折叠) // 归档分组折叠态(默认折叠)
archivedCollapsed: true, archivedCollapsed: true,
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui) // 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
foldedGroups: { today: false, yesterday: false, earlier: true } as Record<string, boolean>, foldedGroups: { today: false, yesterday: false, earlier: true },
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。 // 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。 // 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
searchQuery: '', searchQuery: '',
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计) // token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
lastTokenUsage: null as { prompt: number; completion: number; total: number } | null, lastTokenUsage: null,
convTokenTotal: null as { prompt: number; completion: number; total: number } | null, convTokenTotal: null,
}
// F-09 per-conv streaming/currentText accessor 委派层:先声明 stateRef 占位,
// reactive 包装后回填,accessor 闭包经 stateRef 读 activeConversationId。
let stateRef: typeof _stateBase | null = null
// 在 _stateBase 上定义 streaming/currentText accessor(委派 convStreamStates per-conv Map)。
// 注:用 Object.defineProperty 而非字面量 getter,避免与上方类型声明冲突;reactive 会保留此描述符。
Object.defineProperty(_stateBase, 'streaming', {
enumerable: true,
configurable: true,
get(): boolean {
const s = stateRef
if (!s) return false
return getConvStreamState(s.activeConversationId)?.streaming ?? false
},
set(value: boolean) {
const s = stateRef
if (!s) return
setConvStreaming(s.activeConversationId, value)
},
}) })
Object.defineProperty(_stateBase, 'currentText', {
enumerable: true,
configurable: true,
get(): string {
const s = stateRef
if (!s) return ''
return getConvStreamState(s.activeConversationId)?.currentText ?? ''
},
set(value: string) {
const s = stateRef
if (!s) return
setConvCurrentText(s.activeConversationId, value)
},
})
export const state = reactive(_stateBase) as typeof _stateBase & {
streaming: boolean
currentText: string
}
// 回填 stateRef:accessor 闭包读此引用(经 reactive 包装后的代理对象,activeConversationId 变化可追踪)。
stateRef = state
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。 // 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底: // 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
@@ -189,20 +277,20 @@ const filteredConversations = computed<AiConversationSummary[] | null>(() => {
}) })
/** /**
* F-09 多会话并发:生成态 Set helper。 * F-09 多会话并发:生成态 helper(批4 双轨收口后改为派生)。
* *
* Vue 3 reactive 对 Set 的 has/add/delete 均有 proxy 跟踪,在 computed/template * 批4 收口:generatingConvs(Set,bool 轨)已退役,生成态真相归 convStates(enum Map,定义于
* 内调用 isGenerating(convId) 会建立响应式依赖,Set 变更时自动触发更新。 * aiShared.ts)。本 helper 改派生自 getConvState:conv_state∈{generating,stopping,compressed}
* 单会话场景:Set 含 0 或 1 元素,行为与原单值 generatingConvId 等价。 * (非终止三态)视为生成中,idle/error/null 视为不在生成。桥接语义对齐原 bool 轨 has() 判定。
*
* Vue 3 reactive 对 Map 的 get 有 proxy 跟踪,在 computed/template 内调用 isGenerating(convId)
* 仍建立响应式依赖,convStates 变更时自动触发更新(等价原 Set 行为)。
* addGenerating/removeGenerating 写 helper 已删(写收敛到 useAiEvents setConvState 唯一入口)。
*/ */
function addGenerating(convId: string): void {
state.generatingConvs.add(convId)
}
function removeGenerating(convId: string): void {
state.generatingConvs.delete(convId)
}
function isGenerating(convId: string | null | undefined): boolean { function isGenerating(convId: string | null | undefined): boolean {
return !!convId && state.generatingConvs.has(convId) if (!convId) return false
const cs = getConvState(convId)
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
} }
/** /**
@@ -217,9 +305,8 @@ export function useAiStore() {
return { return {
state, state,
filteredConversations, filteredConversations,
// F-09: 生成态 Set helper(替代单值 generatingConvId 的读写) // F-09: 生成态 helper(批4 双轨收口:仅读 isGenerating 派生自 getConvState;
addGenerating, // addGenerating/removeGenerating 写 helper 已删,写收敛到 useAiEvents setConvState)
removeGenerating,
isGenerating, isGenerating,
...useAiEvents(), ...useAiEvents(),
...useAiStream(), ...useAiStream(),
+10
View File
@@ -61,6 +61,15 @@ export function createIdeasStore() {
return res return res
} }
async function relateIdeas(subjectId: string, targetIds: string[]) {
try {
await ideaApi.relateIdeas(subjectId, targetIds)
await loadIdeas() // 后端已原子更新全部受影响灵感,刷新列表保持 store 一致
} catch (e: any) {
state.error = e?.toString() ?? t('ideas.err.relateFailed')
}
}
return { return {
loadIdeas, loadIdeas,
createIdea, createIdea,
@@ -68,5 +77,6 @@ export function createIdeasStore() {
deleteIdea, deleteIdea,
evaluateIdea, evaluateIdea,
promoteIdea, promoteIdea,
relateIdeas,
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@
- 仅提取 ≥2 处逐字相同的规则(已 grep 比对一致) - 仅提取 ≥2 处逐字相同的规则(已 grep 比对一致)
- scoped 故意 override 全局值的(如 .empty-hint padding 24px vs 全局 12px) - scoped 故意 override 全局值的(如 .empty-hint padding 24px vs 全局 12px)
不提取,保留各组件 scoped 覆盖(零视觉变化) 不提取,保留各组件 scoped 覆盖(零视觉变化)
- global.css 已有的 .btn/.modal-*/.page-header/.empty-hint/.empty-state - global.css 已有的 .btn / .modal 系列 / .page-header / .empty-hint / .empty-state
不在此重复,仅补充尚未全局化的类 不在此重复,仅补充尚未全局化的类
落地批:DRY 收口(B-260619) 落地批:DRY 收口(B-260619)
+25 -2
View File
@@ -112,6 +112,17 @@
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" /> <input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label> <label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea> <textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTags') }}</label>
<input v-model="newIdeaTags" :placeholder="$t('ideas.tagsPlaceholder')" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldPriority') }}</label>
<select v-model.number="newIdeaPriority" class="modal-select">
<option :value="0">{{ $t('ideas.priorityCritical') }} (P0)</option>
<option :value="1">{{ $t('ideas.priorityHigh') }} (P1)</option>
<option :value="2">{{ $t('ideas.priorityMedium') }} (P2)</option>
<option :value="3">{{ $t('ideas.priorityLow') }} (P3)</option>
</select>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldSource') }}</label>
<input v-model="newIdeaSource" :placeholder="$t('ideas.sourcePlaceholder')" />
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button> <button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" :disabled="creating" @click="confirmCapture"> <button class="btn-confirm" :disabled="creating" @click="confirmCapture">
@@ -164,6 +175,9 @@ const pageSize = ref(0)
const showCaptureModal = ref(false) const showCaptureModal = ref(false)
const newIdeaTitle = ref('') const newIdeaTitle = ref('')
const newIdeaDesc = ref('') const newIdeaDesc = ref('')
const newIdeaTags = ref('') // 逗号分隔输入(对标 KnowledgeDetail.vue tagsInput)
const newIdeaPriority = ref(2) // 默认 medium,对标 CreateIdeaInput 0-3
const newIdeaSource = ref('')
// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ── // ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ──
const creating = ref(false) const creating = ref(false)
@@ -274,6 +288,9 @@ function scoreClass(score: number | null) {
function openCaptureModal() { function openCaptureModal() {
newIdeaTitle.value = '' newIdeaTitle.value = ''
newIdeaDesc.value = '' newIdeaDesc.value = ''
newIdeaTags.value = ''
newIdeaPriority.value = 2
newIdeaSource.value = ''
showCaptureModal.value = true showCaptureModal.value = true
} }
@@ -281,9 +298,14 @@ async function confirmCapture() {
if (!newIdeaTitle.value.trim() || creating.value) return if (!newIdeaTitle.value.trim() || creating.value) return
creating.value = true creating.value = true
try { try {
// tags 逗号分隔 → JSON 字符串(对标 KnowledgeDetail.vue saveEdit split+trim+filter 模式)
const tags = newIdeaTags.value.split(',').map(t => t.trim()).filter(Boolean)
await store.createIdea({ await store.createIdea({
title: newIdeaTitle.value.trim(), title: newIdeaTitle.value.trim(),
description: newIdeaDesc.value.trim() || undefined, description: newIdeaDesc.value.trim() || undefined,
priority: newIdeaPriority.value,
tags: JSON.stringify(tags),
source: newIdeaSource.value.trim() || undefined,
}) })
showCaptureModal.value = false showCaptureModal.value = false
} finally { } finally {
@@ -351,10 +373,10 @@ async function onUpdateDesc(desc: string) {
await store.updateIdea(currentIdea.value.id, 'description', desc) await store.updateIdea(currentIdea.value.id, 'description', desc)
} }
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存) // 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存,双向同步)
async function onUpdateRelated(ids: string[]) { async function onUpdateRelated(ids: string[]) {
if (!currentIdea.value) return if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids)) await store.relateIdeas(currentIdea.value.id, ids)
} }
onMounted(async () => { onMounted(async () => {
@@ -566,6 +588,7 @@ watch(() => route.params.id, (id) => {
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; } .modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
.modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); } .modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); }
.modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; } .modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
.modal-box .modal-select { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; } .modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; } .modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; } .modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
+11 -11
View File
@@ -154,16 +154,16 @@
<p v-if="sourceAiAnalysis.summary" class="source-summary">{{ sourceAiAnalysis.summary }}</p> <p v-if="sourceAiAnalysis.summary" class="source-summary">{{ sourceAiAnalysis.summary }}</p>
<!-- 多维评分条(借鉴 IdeaDetail parseScores 渲染) --> <!-- 多维评分条(借鉴 IdeaDetail parseScores 渲染) -->
<div v-if="sourceScores.length" class="source-scores"> <div v-if="sourceScores.length" class="source-scores">
<div class="radar-row" v-for="dim in sourceScores" :key="dim.name"> <div class="score-bar-row" v-for="dim in sourceScores" :key="dim.name">
<span class="radar-label">{{ dim.name }}</span> <span class="score-bar-label">{{ dim.name }}</span>
<div class="radar-bar-track"> <div class="score-bar-track">
<div <div
class="radar-bar-fill" class="score-bar-fill"
:class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"
:style="{ width: dim.score + '%' }" :style="{ width: dim.score + '%' }"
></div> ></div>
</div> </div>
<span class="radar-value">{{ dim.score }}</span> <span class="score-bar-value">{{ dim.score }}</span>
</div> </div>
</div> </div>
</template> </template>
@@ -634,24 +634,24 @@ onUnmounted(() => {
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); } .assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); } .assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
/* 多维评分条(复用 IdeaDetail radar-* 样式定义) */ /* 多维评分条(复用 IdeaDetail score-bar-* 样式定义) */
.source-scores { .source-scores {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
} }
.radar-row { display: flex; align-items: center; gap: 10px; } .score-bar-row { display: flex; align-items: center; gap: 10px; }
.radar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; } .score-bar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; }
.radar-bar-track { .score-bar-track {
flex: 1; height: 8px; background: var(--df-border); flex: 1; height: 8px; background: var(--df-border);
border-radius: var(--df-radius-xs); overflow: hidden; border-radius: var(--df-radius-xs); overflow: hidden;
} }
.radar-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; } .score-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; }
.fill-high { background: var(--df-success); } .fill-high { background: var(--df-success); }
.fill-mid { background: var(--df-warning); } .fill-mid { background: var(--df-warning); }
.fill-low { background: var(--df-danger); } .fill-low { background: var(--df-danger); }
.radar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); } .score-bar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }
</style> </style>
+59
View File
@@ -0,0 +1,59 @@
# DevFlow 待办
## 灵感模块功能完善(合并推进)
> 诊断发现的 P0-P2 问题合并为单次任务,逐项推进。
> 排除项:#3 status 无状态机(暂保留状态可跳跃迁移,不处理)。
### P0 — 数据安全 / 正确性
- [x] **#1 软删除机制**:ideas 表加 `deleted_at` 列 + 软删除(对标 tasks/projects 的 `delete` + `restore` 全套模板)。当前 `delete_idea` 是硬删除,误删不可恢复。
- [x] **#2 白名单收紧**:`settings.rs` ideas 白名单移除 `id` / `created_at`,防止主键和创建时间被篡改。(对标 B-260616-16 tasks 白名单修复)
- [x] **#4 priority 值域校验**:`create_idea` / `update_idea` 加 priority 校验(`parse::<i32>() ∈ 0..=3`,非法返 Err)。(对标 B-260615-15 tasks priority 修复)
### P1 — 架构 / 一致性
- [x] **#5 有损转换修复**:`record_to_idea()` 不再硬编码 `status=Draft` / `related_ids=空`,从 IdeaRecord 读取真实值,评估时不丢失上下文。
- [x] **#6 评分关键词拆文件**:将 `scoring.rs` 硬编码关键词拆到独立 const 文件(最小改动)。启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。**不做 DB 表配置化**(过度工程)。
- [x] **#7 promote 补偿改软删除**:`promote_idea` 回写失败时的补偿删除从 `purge_with_descendants`(不可恢复)改为 `soft_delete`(软删除,可恢复)。
- [x] **#8 关联双向同步**:A 关联 B 时自动在 B 的 `related_ids` 中补入 A,解绑时同步移除,保持关联关系对称。
- [x] **#9 创建表单补 tags 输入**:捕捉灵感表单增加 tags 输入(逗号分隔或 chip 组件),`confirmCapture` 传 tags JSON 字符串给后端。
### P2 — 前端 / UX
- [x] **#10 创建表单补全 priority/source**:捕捉模态框增加 priority 下拉(低/中/高/紧急)和 source 输入框。
- [~] **#12 前端 filter 改后端查询**:`hot`(score≥80)和 `pending`(多状态 OR)仍保留前端 filter(团队决策:扩多值属过度设计),keyword/order_by 已下沉后端。
- [x] **#14 雷达图正名/替换**:将 i18n key 和 CSS class 中的 "radar" 改为 "score-bar"(或引入真正的雷达图组件如 ECharts radar)。
## AI 对话目标丢失(诊断待办)
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
> 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。
- [~] **G1 目标钉扎** ✅代码落地(编译过):`PerConvState.pinned_goal` + chat.rs 两处提取 + `run_agentic_loop` 入口拼 system_prompt。核心治 R1。**待实测**。
- [~] **G2 探索熔断** ✅代码落地(编译过):`STALL_BREAKER_*` 四开关 + stall_count + `is_empty_tool_result`(对齐 serde_json 紧凑格式)。警示 `insert_at(0,system)` **经核验安全**(convert_request:101/117 把 system 抽顶层合并,无连续 System 1214 风险;G1 评审该论据不成立)。**待实测**。
- [~] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum。
- [~] **G4 话题标记修正** ✅代码落地(编译过):`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goal 存在时 take 后丢弃跳过 insert。**待实测**。
- [x] **G5 附 bug** ✅已排查(不改代码):dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。
- [ ] **G1 目标刷新策略**(openQuestion):首版 None 才写,同对话改目标保留旧目标。待观察是否需 drift 刷新。
> 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。
> 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。
## 对抗式评估接入(引擎已实现 → IPC + 前端打通)
> 引擎代码已就绪(`df-ideas/src/adversarial.rs` + `adversarial_helpers.rs`),
> 但 IPC 层和前端零引用,用户无法使用。需补接入层。
### 后端 — IPC 接入
- [ ] **A1 evaluate_idea 命令**:新增 IPC command,按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`。
- [ ] **A2 批量评估命令**:`evaluate_ideas_batch`,遍历多个 idea 调同一 engine 实例(复用 provider 连接)。
- [ ] **A3 评估结果持久化**:评估结果写回 `ideas` 表(scores + evaluated_by + evaluated_at),避免每次重新评估。
- [ ] **A4 类型导出**:`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)。
### 前端 — 结果展示
- [ ] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分(LLM / 启发式兜底 / 纯启发式)。
- [ ] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPC,loading 状态 + 错误提示。
- [ ] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar)。