Compare commits

...

43 Commits

Author SHA1 Message Date
40a97a7655 文档: 插件机制设计(grammar静态/集成切入点/通用远期)
复杂度=架构成本框架评估可扩展性(持续+复利负债)。
决策: grammar 静态编译+集中 lookup(当前,红利未兑现不承担动态加载成本)/
外部 API 集成(云效禅道)是插件正确切入点(窄契约窄插件,真实高价值)/
通用插件系统(VSCode式)远期(无生态=过早=负债)。
渐进路径: 静态grammar→云效集成直接实现→抽 IntegrationProvider trait→禅道印证抽象→插件化。
单向同步起步(双向冲突是分布式级难题,远期)。
2026-06-24 02:50:01 +08:00
7c2e3b23c1 新增: AST 代码智能 read_symbol 三态(治 prompt 爆,信息密度驱动)
tree-sitter 语法层符号解析,治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。
核心思想:信息密度≠压缩,read_symbol 按语义层级返符号骨架/全文,非物理读全文件。
- code_intel.rs: grammar_for 集中 lookup(Rust/TS/JS/Vue借TS,3 grammar 4 类)+ read_symbol 三态(骨架默认/全文)+ 不报错兜底(无grammar/解析失败/未找到→grep提示)
- tool_registry 注册 read_symbol(对齐 read_file handler 闭包模式)
- Cargo.toml 加 tree-sitter 0.25 + rust/typescript/javascript grammar
- AST 设计文档 grammar 策略章节定稿(静态编译+lookup,不动态不trait)
实测: code_intel.rs 全文 21851B vs read_symbol 骨架 897B,降 24.4x(达设计目标一个量级+)。单测 14 全过。
2026-06-24 02:49:48 +08:00
1ffa023f3d 修复: 消息重叠与审批卡死 2026-06-24 01:44:46 +08:00
61bad4fa28 新增: AST 符号解析设计(信息密度三态供给+主题压缩) 2026-06-24 01:43:12 +08:00
bdf3f96491 文档: 待办与决策更新 2026-06-24 00:29:02 +08:00
bb8b3c6c59 优化: 工程配置清理 2026-06-24 00:27:10 +08:00
96e554ce22 修复: 后端若干bug 2026-06-24 00:27:09 +08:00
0a8db6504a 优化: 设置页UX与AI自动执行三档 2026-06-24 00:27:08 +08:00
78b48d871b 新增: 跨端用户消息同步 2026-06-24 00:27:08 +08:00
698d981962 新增: 小程序端跨端AI对话 2026-06-24 00:19:54 +08:00
0c7c5e6068 新增: df-relay Dockerfile + .dockerignore(云端部署基建)
docker build -f crates/df-relay/Dockerfile -t df-relay .(context=repo根,multi-stage rust:slim 编译→debian 运行)。容器内 :8080(DF_RELAY_ADDR),宿主映射 :9180 避开测试机 8080 占用。
2026-06-22 03:50:37 +08:00
f4b65f6c2e 新增: df-relay binary 入口(main.rs axum WS Server :8080)
Phase3 联调链补 relay 启动入口:src/main.rs 调 DefaultRelayServer::start(listen 0.0.0.0:8080,DF_RELAY_ADDR env 覆盖)+ tracing-subscriber 日志。cargo run -p df-relay 启动,提供 /ws/device + /ws/miniapp 端点。cargo check EXIT=0。
2026-06-22 03:43:17 +08:00
59f07bf060 修复: df-miniapp 打包结构(manifest/pages/uni.scss 移 src/ + 补 vite.config)
Phase2 落地遗漏 build 验证致结构错位:manifest.json/pages.json/uni.scss 在根(uni-app 找 src/)+ 无 vite.config.ts(vite-plugin-uni 未加载,-p mp-weixin 失效走 h5)。移 src/ + 补 vite.config 后 build:mp-weixin 产出完整小程序(app.js/app.json/pages/project.config.json)。
2026-06-22 03:43:16 +08:00
a9703c3a1f 文档: Phase3 阶段2/3后端闭环销账
阶段2(EventBus Value+emit双写54处+send_raw_event+tunnel subscriber) 后端上行闭环 / 阶段3(remote_bridge+miniapp4命令+on_command) 下行闭环。剩阶段4真机联调 + relay_url/device_id UI + token生产级(Phase4)。
2026-06-22 03:33:48 +08:00
ed8e2fc155 新增: Phase3 src-tauri 集成 df-tunnel(跨端闭环)
Cargo.toml(df-tunnel+uuid workspace) + state.rs(tunnel 字段 Arc<WsTunnelClient>) + lib.rs spawn tunnel task:device_id(KV/UUID 持久) + relay_url(KV/默认 localhost:8080) + token 固定常量 + subscriber(ai_event_bus→send_raw_event) + on_command(remote_bridge 下行路由) + connect(失败非阻断)。打通桌面↔relay↔miniapp 全双工闭环。cargo EXIT=0。
2026-06-22 03:31:59 +08:00
612de4cd7c 新增: Phase3阶段2 emit双写全接入(D2全19变体)
chat.rs 19 + audit/mod.rs 12 + guard.rs 3 + stream_recv.rs 2 + agentic/mod.rs 7(emit-only 补全,原11已落地)。每处 app.emit 前端 + publish_event tunnel 双路独立(emit 不删)。D2 全 19 变体透传 EventBus 供 tunnel subscriber 透传 miniapp。cargo EXIT=0。
2026-06-22 03:31:58 +08:00
60b73377f6 文档: Phase3 阶段2/3销账
阶段2(②EventBus Value载荷+agentic 11处双写+send_raw_event) / 阶段3(remote_bridge+miniapp 4命令) 部分登记;F+G 核验项销账(guard/审批态)。剩余:tunnel subscriber + setup 集成(src-tauri 集成模块从零)。
2026-06-22 03:11:46 +08:00
030ddb668a 新增: df-tunnel send_raw_event 出站透传
Phase3 阶段2(D2 全19变体透传):trait TunnelClient 加 send_raw_event(Value) + WsTunnelClient 实现;OutFrame::Event 改持 String(send_event/send_raw_event 预序列化,run_loop 只写 socket DRY)。cargo check + 5 测试过。
2026-06-22 03:11:46 +08:00
0fb5af50c7 新增: Phase3 EventBus Value载荷+桥接层
- EventBus 载荷改 serde_json::Value(D2 全19变体透传),删 AiBusEvent 强类型子集
- 新增 publish_event(AiChatEvent) helper(内部 to_value→publish,供 emit 双写)
- 新增 remote_bridge.rs 桥接层(MiniCommand→Tauri command 路由+R1 同conv并发兜底)
- agentic/mod.rs 11处 AiBusEvent→AiChatEvent 迁移(Error/Completed/Round)
2026-06-22 03:06:28 +08:00
8ccdce282c 新增: miniapp补齐4远程命令(approve/authorize_dir/continue_loop/stop_loop 对齐Phase3 §2.2) 2026-06-22 02:53:13 +08:00
5337c7d5e3 文档: Phase3阶段2前置决策登记(EventBus AiBusEvent 8变体 vs D2全19变体透传 载荷冲突) 2026-06-22 02:46:58 +08:00
d38d1af68d 重构: df-tunnel Phase3阶段1纯透传(D1=A:CommandHandler收Value+payload透传不解析TunnelCommand,D5保留弱校验) 2026-06-22 02:45:36 +08:00
057fd0994e 文档: todo登记Phase3联调待办+跨端状态更新(Phase2三层全落+Phase3设计出) 2026-06-22 02:38:09 +08:00
da886fa1c8 文档: todo销账(TD-260621-07 ToolResult字段已落地F-260621 grep) 2026-06-22 02:32:00 +08:00
e8468c5bc6 新增: Phase3跨端联调设计文档(协议统一方案A纯透传+AiSession桥接风险+4阶段路线) 2026-06-22 02:29:13 +08:00
36a8391626 优化: 删除孤儿GeneralPanel.vue(拆3Section后零引用+plan阶段2弃用) 2026-06-22 02:29:13 +08:00
a5a4fc887a 重构: df-miniapp移入apps/(monorepo分层 crates=Rust库/apps=前端应用)
- df-miniapp/ -> apps/df-miniapp/(uni-app 工程归集前端应用层,与 crates/ Rust 库分层)
- 删冗余 apps/df-miniapp/.gitignore(根已覆盖 node_modules/dist/.DS_Store 等)
- 根 .gitignore 加 unpackage/(uni-app 编译产物,唯一根未覆盖项)
- vue-tsc 零错(移动不改代码,类型不变)
2026-06-22 02:19:23 +08:00
280baeaee0 新增: df-miniapp uni-app跨端AI Chat(WS连relay透传+18变体AiChatEvent镜像)
- 脚手架: uni-app vue3 ts + 微信小程序manifest + pages(chat/conversations)+暗色主题
- WS客户端(ws.ts): uni.connectSocket连relay /ws/miniapp + Hello握手 + 指数退避重连 + 心跳Ping + JSON透传分派
- 类型镜像: events.ts AiChatEvent 18变体(对齐mod.rs grep核验) + relay.ts协议(BroadcastMessage/Hello/ControlMessage/MiniCommand)
- useAiChat: 18变体分派状态机(TextDelta累积/AgentRound flush/Tool状态/ConvState驱动generating) + send/stop/regenerate/switch + 乐观渲染
- 页面: chat(消息气泡/流式渲染/输入栏/工具调用展示) + conversations(列表占位)
- vue-tsc零错; 核验修正注释(变体数22→18, 心跳heartbeat→ping)
- 待对齐(Phase3联调): miniapp MiniCommand{cmd,args} vs df-tunnel TunnelCommand{kind,...} 格式需统一
2026-06-22 01:59:02 +08:00
25d65652cd 新增: df-tunnel Phase2 WS隧道客户端(出站穿NAT+Hello握手+收发循环)
- WsTunnelClient: connect_async连df-relay /ws/device + Hello(HelloKind::Device)
- 握手探测(HANDSHAKE_PROBE 3s):无负帧=relay静默通过 / error帧=Auth失败 / Close=Connect失败
- 收发循环: socket入帧→parse_command_from_broadcast提payload(kind=command)→on_command回调; mpsc出帧→socket写; heartbeat 25s Ping
- cleanup_conn: Close帧+500ms等task+abort兜底; connected用Arc<AtomicBool>共享
- backoff_delay(1→2→4→8→16→30s cap)预留Phase3 supervisor自动重连
- 协议对齐 df-relay(Hello字段/方向Event↔Command/payload透传),cargo check零warning+4测试通过
2026-06-22 01:55:46 +08:00
2b8b30ef41 新增: df-relay Phase2 WS中继实现(Hello握手+鉴权+ConnRegistry配对路由+Event/Command方向分发) 2026-06-22 01:47:01 +08:00
2fe1fe57c8 优化: tool_result截断50KB→8KB治GLM1214(conversation.rs持久化视图) 2026-06-22 01:45:44 +08:00
d1d3871a88 重构: scan.rs拆4模块(stack/discover/readme/sample)共享常量提mod.rs 2026-06-22 01:45:43 +08:00
2a13f45650 文档: todo销账合并批(REFACTOR-02/08/10+ARC-e+related_ids+B-260619-02+version/ARC-d标注+Phase2评估暂缓) 2026-06-22 01:45:43 +08:00
83cee61476 新增: 列表分页UI(Paginator组件+三视图接入)
Paginator.vue复用组件(客户端切片,不发新IPC,数据量小前瞻基建)
Tasks/Ideas/Projects.vue接store列表切片(pagedProjects/pagedIdeas)
向后兼容铁律:pageSize=0整组件v-if隐藏+切片返全量,等价改造前行为
旧调用方(store.loadTasks等签名)零改动,分页状态纯前端ref
筛选/排序变化page重置1防越界空页;i18n双语pagination键齐备
2026-06-22 01:24:50 +08:00
82059bac4a 优化: tasks查询索引补全(V26 priority+assignee索引)
list_by_query已支持priority/assignee过滤下推,缺索引致全表扫描
migrate_v26 CREATE INDEX IF NOT EXISTS补建(老库幂等)
V1_SQL同步加(新库一次性建),对齐idx_tasks_status同类维度
数据量~15前瞻基建,为后续priority排序/分配人筛选铺路
2026-06-22 01:24:14 +08:00
cf7fe867ee 优化: aichat B路线Phase1规划开关启用(PLANNING_ENABLED=true)
主loop走filter_tool_defs_planned规划(agentic/mod.rs:633接入点就绪)
双层开关可回退:PLANNING_ENABLED(主loop)+PLAN_HINT_ENABLED(plan_hint函数)
任一关闭退扁平旧行为;feature_flags_default_on测试断言同步
2026-06-22 01:24:14 +08:00
5d1749a58f 新增: 跨端WS隧道脚手架(df-tunnel客户端+df-relay云中继)
df-tunnel:桌面端出站WS客户端(TunnelClient trait+骨架,穿NAT连云后端)
df-relay:axum WS中继服务(转发小程序↔桌面端,广播骨架)
双crate独立消息骨架,不依赖src-tauri/df-types避跨crate强耦合
workspace crates/* glob自动注册,Phase2填充WS握手/鉴权/重连/路由
2026-06-22 01:24:01 +08:00
566bbcb394 修复: run_workflow空dag反序列化失败(nodes/edges加serde default)
BUG-260621-01: 前端推进链传空 dag {} 时,DagDef.nodes/edges 无 serde
default 致 Tauri IPC 入口报 missing field nodes,挡在 workflow.rs:91
template_for 选模板逻辑之前。加 #[serde(default)] 后 {} 反序列化成空
DagDef(HashMap/Vec Default 均空集合)→ is_empty() 进选模板分支。非空 dag
零回归。2 单测防回归,df-workflow test 51 passed。
2026-06-22 01:08:26 +08:00
eda2a5f887 优化: aichat L2状态机双轨清理+读侧迁移+L3事件总线emit双写
L2 双轨清理:删 guard 本地 state 冗余字段(ConvState 已落 PerConvState
持久化作读侧真相源)。读侧迁移:ai_is_generating/ai_chat_stop 加
CONV_STATE_ENABLED 门控读 conv_state.is_active()。L3 emit 双写:mod.rs
关键事件 AiCompleted/AiError/AiAgentRound 经 ai_event_bus.publish 双写
(EVENT_BUS_ENABLED 门控,与 app.emit 并存非替换,高频 delta 不双写)。

双轨过渡收尾,可一键回退(CONV_STATE_ENABLED off 回退 bool 真相源)。
2026-06-22 01:03:49 +08:00
c9b6e28433 新增: 三实体列表查询维度补全(status/关键词/排序/分页下沉后端)
任务/项目/灵感三实体新增 list_by_query 动态 WHERE(累积式 where_clauses
+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制。命令层
list_{tasks,projects,ideas} 吃 Option<XxxQuery> 双参向后兼容(旧无参/单参
路径等价全量)。前端 Tasks/Ideas status/keyword 筛选下沉后端 query。

F-260621-02
2026-06-22 01:03:37 +08:00
72e57c6232 优化: L3事件总线入AppState(基建就位,emit双写留批3)
ai_event_bus字段独立于工作流event_bus,批2b仅入AppState+初始化。
emit点双写+前端订阅留批3:无后端订阅者(工作流独立域)+前端经ai-chat-event已收→全双写空转,待真实消费者(跨端/多模块)出现再针对性接入。渐进可回退。
2026-06-22 00:46:35 +08:00
cdb227f999 优化: L2统一状态机完整(ConvState持久化+事件暴露+前端status联合)
1a PerConvState.conv_state持久化(入口/reset/drop写收敛,CONV_STATE_ENABLED门控)
1b AiChatEvent加AiConvStateChanged变体,guard持app_handle在迁移点emit
1c 前端convStates真相源+停止按钮三态(status union)+MaxRoundsCard读conv_state
双轨过渡:enum视图与generating bool并行,off降级可回退
2026-06-22 00:34:43 +08:00
d2cada97cd 重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)
L1 agent 元能力层(治痛①死循环零交付):
- env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱)
- 断路器:同类工具失败≥3熔断 + guard.reset
- detect_environment 主动探测工具(python/node/shell)
- 求助协议 AiHelpRequired 事件 + 前端求助卡

L2 统一状态机后端(治痛②,前端批2):
- ConvState enum 5态 + 合法转换守卫(conv_state.rs)
- GeneratingGuard 接入视图层(guard.rs)

L3 事件总线后端骨架(治痛③④⑤,接入批2):
- EventBus pub-sub + AiBusEvent 8变体(event_bus.rs)

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
2026-06-22 00:04:14 +08:00
113 changed files with 27876 additions and 1076 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
target
node_modules
dist
dist-ssr
.git
*.db
*.log
apps/df-miniapp/node_modules
apps/df-miniapp/dist
src-tauri/target

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ node_modules/
dist/
dist-ssr/
# uni-app 编译产物(微信小程序/H5 打包输出)
unpackage/
# Editor directories and files
.vscode/*
!.vscode/extensions.json

325
Cargo.lock generated
View File

@@ -248,6 +248,64 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.24.0",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -721,6 +779,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -778,6 +842,7 @@ dependencies = [
"df-nodes",
"df-project",
"df-storage",
"df-tunnel",
"df-types",
"df-workflow",
"futures",
@@ -794,6 +859,11 @@ dependencies = [
"tauri-plugin-window-state",
"tokio",
"tracing",
"tree-sitter",
"tree-sitter-javascript",
"tree-sitter-rust",
"tree-sitter-typescript",
"uuid",
]
[[package]]
@@ -897,6 +967,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "df-relay"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"futures-util",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "df-storage"
version = "0.1.0"
@@ -912,6 +998,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "df-tunnel"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.23.1",
"tracing",
]
[[package]]
name = "df-types"
version = "0.1.0"
@@ -1842,6 +1943,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
@@ -1856,6 +1963,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -2259,6 +2367,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2382,6 +2496,21 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.1"
@@ -2521,6 +2650,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -3743,6 +3881,7 @@ version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -3750,6 +3889,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -3876,6 +4026,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3986,6 +4145,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4536,6 +4701,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -4640,6 +4814,32 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite 0.23.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.24.0",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4786,6 +4986,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -4829,6 +5030,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -4852,6 +5054,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -4876,12 +5108,99 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tree-sitter"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
dependencies = [
"cc",
"regex",
"regex-syntax",
"serde_json",
"streaming-iterator",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
dependencies = [
"cc",
"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.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-typescript"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5019,6 +5338,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
<title>DevFlow Mini</title>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
})
</script>
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
</head>
<body>
<noscript>
<strong>本应用需 JavaScript 启用。</strong>
</noscript>
<div id="app"></div>
</body>
</html>

13924
apps/df-miniapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "df-miniapp",
"version": "0.1.0",
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
"private": true,
"scripts": {
"dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"marked": "^18.0.5",
"vue": "^3.4.0",
"vue-i18n": "^9.0.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^5.2.8",
"vue-tsc": "^2.0.0"
}
}

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
const aiChat = useAiChat()
onLaunch(() => {
console.log('[DevFlow Mini] 应用启动')
aiChat.connect()
})
onShow(() => {
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
aiChat.resumeBackground()
})
onHide(() => {
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
console.log('[DevFlow Mini] 应用进入后台')
aiChat.pauseBackground()
})
</script>
<style lang="scss">
page {
background-color: $uni-bg-color;
color: $uni-text-color;
font-size: $uni-font-size-base;
}
</style>

View File

@@ -0,0 +1,586 @@
/**
* df-relay WS 客户端(uni-app 跨端适配)
*
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/miniapp`
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
*
* 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
* - H5:uni.connectSocket 封装浏览器 WebSocket
* - 统一经 uni API 封装,业务层不直接 new WebSocket
*
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
*
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
*/
import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
/** 入站事件回调(Event/Control 消息分派) */
export interface WsHandlers {
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
onEvent: (msg: BroadcastMessage) => void
/** 收到 Control 消息(心跳响应 / 在线状态等) */
onControl: (msg: ControlMessage) => void
/** 状态变更(连接 / 断开 / 重连中) */
onStatus: (status: WsStatus, detail?: string) => void
}
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
interface QueuedCmd {
cmd: MiniCommand
ts: number
}
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
const HANDSHAKE_TIMEOUT_MS = 12000
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
const CONNECT_TIMEOUT_MS = 8000
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
const PENDING_MAX = 50
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
const PENDING_TTL_MS = 60000
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
const HEARTBEAT_DEAD_FACTOR = 2
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
const MAX_RECONNECT_ATTEMPTS = 20
/**
* WS 客户端单例(miniapp 全局一个连接)
*
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
*/
class WsClient {
private socket: UniApp.SocketTask | null = null
private handlers: WsHandlers | null = null
private status: WsStatus = 'disconnected'
/** 握手是否已完成(收到首个广播或握手成功信号) */
private handshaked = false
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
private connectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
*/
private pendingQueue: QueuedCmd[] = []
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
private lastInboundAt = 0
/** 订阅(单订阅,composable 注册一次) */
subscribe(h: WsHandlers): void {
this.handlers = h
}
/** 读取当前状态 */
getStatus(): WsStatus {
return this.status
}
/** 主动连接 */
connect(): void {
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
return
}
this.manualClose = false
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow()
}
/** 主动断开(用户手动,不重连) */
disconnect(): void {
this.manualClose = true
this.reconnectAttempts = 0
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
this.cleanup()
this.setStatus('disconnected', 'manual close')
}
/**
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
*
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
* - disconnected:onClose 已触发,直接重连;
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
*
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
*/
resumeIfDisconnected(): void {
if (this.manualClose) return
if (this.status === 'disconnected' || this.status === 'reconnecting') {
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0
this.reconnectNow()
}
}
/**
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
*
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
*/
pauseBackground(): void {
this.stopHeartbeat()
}
/**
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
*/
resumeBackground(): void {
if (this.status === 'connected' && this.handshaked) {
this.startHeartbeat()
} else {
this.resumeIfDisconnected()
}
}
/**
* 发送 Command(miniapp → device)
*
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) {
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
if (this.manualClose) {
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
return false
}
this.enqueuePending(cmd)
return false
}
try {
const text = JSON.stringify(cmd)
this.socket.send({ data: text })
return true
} catch (e) {
console.error('[WsClient] send 失败', e)
return false
}
}
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
private enqueuePending(cmd: MiniCommand): void {
if (this.pendingQueue.length >= PENDING_MAX) {
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
this.pendingQueue.shift()
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
}
this.pendingQueue.push({ cmd, ts: Date.now() })
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
}
/**
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
*
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
*/
private flushPendingQueue(): void {
if (this.pendingQueue.length === 0) return
const now = Date.now()
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
while (this.pendingQueue.length > 0) {
const item = this.pendingQueue.shift()!
if (now - item.ts > PENDING_TTL_MS) {
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
continue
}
try {
if (this.socket) {
this.socket.send({ data: JSON.stringify(item.cmd) })
}
} catch (e) {
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
}
}
}
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
/**
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
*
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
* 统一经此,保证:
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
*/
private reconnectNow(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.cleanup()
this.openSocket()
}
private openSocket(): void {
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
const cfg = getConfig()
this.setStatus('connecting')
console.log('[WsClient] 连接', cfg.relayHost)
let task: UniApp.SocketTask
try {
task = uni.connectSocket({
url: cfg.relayHost,
complete: () => {
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
},
})
} catch (e) {
console.error('[WsClient] connectSocket 异常', e)
this.scheduleReconnect()
return
}
this.socket = task
task.onOpen(() => this.handleOpen())
task.onMessage((res) => this.handleMessage(res))
task.onClose((res) => this.handleClose(res))
task.onError((res) => this.handleError(res))
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.status === 'connecting') {
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, CONNECT_TIMEOUT_MS)
}
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
private handleOpen(): void {
// onOpen 到达,取消连接超时
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
this.setStatus('handshaking')
const cfg = getConfig()
const hello: Hello = {
kind: 'miniapp',
device_id: cfg.deviceId,
token: cfg.token,
}
try {
this.socket?.send({ data: JSON.stringify(hello) })
} catch (e) {
console.error('[WsClient] 发送 Hello 失败', e)
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
this.cleanup()
this.scheduleReconnect()
return
}
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (!this.handshaked) {
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, HANDSHAKE_TIMEOUT_MS)
}
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
private handleMessage(res: { data: string | ArrayBuffer }): void {
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
if (typeof res.data !== 'string') {
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
return
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
if (!this.handshaked) {
this.handleHandshakeResponse(text)
return
}
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
let msg: BroadcastMessage
try {
msg = JSON.parse(text) as BroadcastMessage
} catch (e) {
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
return
}
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
if (msg.kind === 'event') {
this.handlers?.onEvent(msg)
} else if (msg.kind === 'control') {
this.handlers?.onControl(msg.payload as ControlMessage)
}
// command 是 device→miniapp 反向(不应在此端收到,忽略)
}
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
private handleHandshakeResponse(text: string): void {
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
let parsedOk = false
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
try {
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
parsedOk = true
} catch (e) {
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
}
if (parsedOk && parsed) {
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
if (parsed.error !== undefined) {
console.error('[WsClient] 握手失败', parsed.error, text)
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
this.clearHandshakeTimer()
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.cleanup()
return
}
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
// device 离线则无响应,文案保持「已连接中继」诚实表述。
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
this.flushPendingQueue()
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
return
}
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
return
}
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
if (text.includes('"error"')) {
console.error('[WsClient] 握手失败(includes 兜底)', text)
this.setStatus('disconnected', `handshake failed: ${text}`)
this.clearHandshakeTimer()
this.cleanup()
return
}
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
this.flushPendingQueue()
this.handleMessage({ data: text })
}
private handleClose(res: { code: number; reason: string }): void {
console.log('[WsClient] 连接关闭', res.code, res.reason)
this.cleanup()
if (!this.manualClose) {
this.scheduleReconnect()
} else {
this.setStatus('disconnected', 'manual close')
}
}
private handleError(res: { errMsg: string }): void {
console.error('[WsClient] socket 错误', res.errMsg)
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
if (this.status === 'connecting' || this.status === 'handshaking') {
this.cleanup()
this.scheduleReconnect()
}
}
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
private startHeartbeat(): void {
this.stopHeartbeat()
const cfg = getConfig()
// 连接成功初始化活性时间(首次心跳检查不立即判死)
this.lastInboundAt = Date.now()
this.heartbeatTimer = setInterval(() => {
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
const now = Date.now()
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
this.cleanup()
this.scheduleReconnect()
return
}
const ping: ControlMessage = { control_kind: 'ping' }
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
try {
this.socket?.send({ data: JSON.stringify(ping) })
} catch (e) {
console.warn('[WsClient] 心跳发送失败', e)
}
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
void cfg
}, cfg.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/**
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
*
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
*
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.cleanup()
return
}
const cfg = getConfig()
const attempt = this.reconnectAttempts++
const exp = Math.min(attempt, 6)
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
const jitter = 0.5 + Math.random() * 0.5
const delay = Math.round(base * jitter)
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.reconnectNow()
}, delay)
}
/** 清握手超时定时器(握手成功/失败时调) */
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
/**
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
*
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
*/
private destroySocket(task: UniApp.SocketTask): void {
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
try {
task.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
}
/**
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
*
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
*/
private cleanup(): void {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
this.handshaked = false
this.pendingQueue = []
}
private setStatus(status: WsStatus, detail?: string): void {
this.status = status
this.handlers?.onStatus(status, detail)
}
}
/** 全局单例(miniapp 进程内一个连接) */
export const wsClient = new WsClient()

View File

@@ -0,0 +1,36 @@
<template>
<!-- rich-text 小程序原生组件(无子组件依赖,)marked mdhtml rich-text :nodes 渲染
alpha 根因已查实(uni-app 2026 alpha tap 失效),rich-text 非元凶,降级后正常用 -->
<rich-text :nodes="html" :selectable="true" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
defineOptions({ name: 'MdView' })
/**
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
*
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
* - breaks:true 聊天换行(单换行转 <br>)
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
* - parse 失败回退原文
* - 空内容返空(rich-text 不渲染)
*
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
*/
const props = defineProps<{ content: string }>()
const html = computed(() => {
const src = props.content || ''
if (!src) return ''
try {
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
} catch {
return src
}
})
</script>

View File

@@ -0,0 +1,133 @@
<template>
<!-- @/ 联想弹层(MVP)
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入
/file /web /task /memory + @file @web @task 四类技能占位
渲染对齐 chat/index.vue mp-weixin 稳定风格(原生 view/text + 绝对定位浮层) -->
<view v-if="visible" class="mention-mask" @tap="onClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
<text class="mention-hint">MVP 占位 · 点击插入</text>
</view>
<scroll-view class="mention-list" scroll-y>
<view
v-for="(item, idx) in items"
:key="idx"
class="mention-item"
@tap="onSelect(item)"
>
<text class="mention-item-label">{{ item.label }}</text>
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* MentionInput 联想弹层组件(MVP)。
*
* 设计:
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
* - 不查后端/不调路由:items 由父组件固定注入
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
*
* props:
* - visible:是否显示
* - trigger:触发类型(`/` 技能 / `@` 提及)
* - items:占位项列表 {label, insert}
* emits:
* - select(insert):插入文本
* - close():关闭弹层
*/
defineOptions({ name: 'MentionInput' })
interface MentionItem {
label: string
insert: string
}
defineProps<{
visible: boolean
trigger: '/' | '@'
items: MentionItem[]
}>()
const emit = defineEmits<{
(e: 'select', insert: string): void
(e: 'close'): void
}>()
function onSelect(item: MentionItem): void {
emit('select', item.insert)
}
function onClose(): void {
emit('close')
}
</script>
<style scoped>
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item:last-child {
border-bottom: none;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
/**
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
*
* 这些值在真实部署前是占位:
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
*/
export interface MiniappConfig {
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
relayHost: string
/** 配对绑定的设备 ID */
deviceId: string
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
token: string
/** 心跳间隔(ms) */
heartbeatInterval: number
/** 重连初始间隔(ms,指数退避) */
reconnectBaseDelay: number
/** 重连最大间隔(ms) */
reconnectMaxDelay: number
}
/**
* 默认配置(占位值,需替换)
*
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
* 这符合预期,提示用户配置)。
*/
export const defaultConfig: MiniappConfig = {
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/miniapp',
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token',
heartbeatInterval: 30000,
reconnectBaseDelay: 1000,
reconnectMaxDelay: 30000,
}
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
let _config: MiniappConfig = { ...defaultConfig }
export function getConfig(): MiniappConfig {
return _config
}
export function setConfig(patch: Partial<MiniappConfig>): void {
_config = { ..._config, ...patch }
}

View File

@@ -0,0 +1,13 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
/**
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
*/
export function createApp() {
const app = createSSRApp(App)
return {
app,
}
}

View File

@@ -0,0 +1,53 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}

View File

@@ -0,0 +1,41 @@
{
"pages": [
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "AI 对话",
"enablePullDownRefresh": false
}
},
{
"path": "pages/conversations/index",
"style": {
"navigationBarTitleText": "会话列表",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "DevFlow",
"navigationBarBackgroundColor": "#1a1a1a",
"backgroundColor": "#0f0f0f",
"backgroundTextStyle": "light"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#4a9eff",
"backgroundColor": "#1a1a1a",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/chat/index",
"text": "对话"
},
{
"pagePath": "pages/conversations/index",
"text": "会话"
}
]
}
}

View File

@@ -0,0 +1,993 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events'
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留备用(工具修复后可拆回)。
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
const mdCache = new Map<string, string>()
function renderMd(src: string): string {
if (!src) return ''
const cached = mdCache.get(src)
if (cached !== undefined) return cached
let html: string
try {
html = marked.parse(src, { breaks: true, async: false }) as string
html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
} catch {
html = src
}
mdCache.set(src, html)
return html
}
const ai = useAiChat()
const inputText = ref('')
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
const mentionVisible = ref(false)
const mentionTrigger = ref<'skill' | 'entity'>('skill')
// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
const pendingSkill = ref<SkillInfoLike | null>(null)
// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
const pendingMentionSpans = ref<MentionSpan[]>([])
// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
const scrollAnchor = ref('')
function scrollToBottom(): void {
scrollAnchor.value = ''
// setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
}
let scrollTimer: ReturnType<typeof setTimeout> | null = null
/** 流式增量高频,throttle 防滚底抖动 */
function throttledScrollToBottom(): void {
if (scrollTimer) return
scrollTimer = setTimeout(() => {
scrollTimer = null
scrollToBottom()
}, 120)
}
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
watch(() => ai.messages.length, () => scrollToBottom())
watch(() => ai.currentText.value, throttledScrollToBottom)
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
/**
* P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
* TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
*/
const MAX_RENDER_MSGS = 200
const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
/**
* 输入变化时检测联想触发条件。
*
* 两类触发:
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
* listEntities() 拉实体 + 显实体浮层。
*
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
*/
function detectTrigger(): void {
const text = inputText.value
if (!text) {
mentionVisible.value = false
return
}
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
if (text.startsWith('/') && !pendingSkill.value) {
mentionTrigger.value = 'skill'
mentionVisible.value = true
ai.listSkills()
return
}
// 实体触发:末尾连续非空白段恰好单字符 @
const match = text.match(/([^\s]+)\s*$/)
const last = match ? match[1] : ''
if (last === '@') {
mentionTrigger.value = 'entity'
mentionVisible.value = true
ai.listEntities()
return
}
mentionVisible.value = false
}
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
function onInput(): void {
detectTrigger()
}
/**
* 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
*
* 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
* 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
* 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
*/
function onSkillSelect(skill: SkillInfoLike): void {
pendingSkill.value = skill
inputText.value = ''
mentionVisible.value = false
}
/**
* 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
*
* span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
* 后端据 start/length 在原文中切出区间注入对应实体上下文。
* kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
* 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
*/
function onEntitySelect(
entity: ProjectRecord | TaskRecord | IdeaRecord,
kind: 'project' | 'task' | 'idea',
): void {
const text = inputText.value
// 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
const stripped = text.replace(/@\s*$/, '')
// 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
// 名:project 用 name,task/idea 用 title
const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
const insertText = `[${kindLabel}:${name}]`
const start = stripped.length
inputText.value = stripped + insertText
// 累积 mention span(字符偏移,后端透传不解释单位)
pendingMentionSpans.value.push({
start,
length: insertText.length,
kind,
refId: entity.id,
label: name,
})
mentionVisible.value = false
}
/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
function clearPendingSkill(): void {
pendingSkill.value = null
}
/** 关闭弹层(点遮罩) */
function onMentionClose(): void {
mentionVisible.value = false
}
/**
* 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
*
* - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
* - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
* - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
*
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/
function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
const text = inputText.value.trim()
if (!text) {
console.log('[v2:send] 空文本,忽略')
return
}
if (ai.generating.value) {
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
uni.showToast({
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
icon: 'none',
})
return
}
mentionVisible.value = false
const skill = pendingSkill.value ? pendingSkill.value.name : undefined
const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
ai.send(text, skill, spans)
inputText.value = ''
pendingSkill.value = null
pendingMentionSpans.value = []
}
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task',
'delete_project',
'restore_project',
'purge_project',
'delete_file',
'run_command',
'http_request',
])
// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
const STATUS_META: Record<string, { label: string; cls: string }> = {
running: { label: '执行中', cls: 'st-running' },
completed: { label: '已完成', cls: 'st-completed' },
failed: { label: '失败', cls: 'st-failed' },
pending_approval: { label: '待审批', cls: 'st-pending' },
rejected: { label: '已拒绝', cls: 'st-rejected' },
}
/** 截断长字符串(参数值/结果展示防撑爆气泡) */
function truncate(s: string, max = 120): string {
return s.length > max ? s.slice(0, max) + '…' : s
}
/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
function formatArgs(args: unknown): { key: string; val: string }[] {
if (!args || typeof args !== 'object') return []
const obj = args as Record<string, unknown>
return Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.slice(0, 4)
.map(([k, v]) => ({
key: k,
val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
}))
}
/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
function formatResult(result: unknown): string {
if (result === undefined || result === null) return ''
if (typeof result === 'string') return truncate(result)
try {
return truncate(JSON.stringify(result))
} catch {
return ''
}
}
/**
* 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
*
* 后端工具挂起审批时往消息流写占位 assistant message,content 形如
* 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
* miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
* 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
*/
const PENDING_MARKER = '__PENDING__:'
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
function isPendingPlaceholder(content: string | undefined | null): boolean {
if (!content) return false
if (content.includes(PENDING_MARKER)) return true
return content === PENDING_PLACEHOLDER_LEGACY
}
/**
* 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
* {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
*/
function isToolResultJson(content: string | undefined | null): boolean {
if (!content) return false
const t = content.trim()
if (!(t.startsWith('{') || t.startsWith('['))) return false
try {
JSON.parse(t)
return true
} catch {
return false
}
}
/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
function onApprove(tc: AiToolCallInfo): void {
if (HIGH_RISK_TOOLS.has(tc.name)) {
uni.showModal({
title: '高危操作确认',
content: `${tc.name}」属高危操作,确认批准执行?`,
confirmColor: '#e85a4f',
success: (r) => {
if (r.confirm) ai.approve(tc.id, true)
},
})
return
}
ai.approve(tc.id, true)
}
/** 审批拒绝(risk 类) */
function onReject(tc: AiToolCallInfo): void {
ai.approve(tc.id, false)
}
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
ai.authorizeDir(tc.id, decision)
}
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
const STATUS_TEXT: Record<string, string> = {
connecting: '连接中…',
handshaking: '握手中…',
reconnecting: '重连中…·点此立即重连',
disconnected: '已断开·点此重连',
}
const statusText = computed(() => {
if (ai.isWsConnected.value) {
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
}
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
})
const canManualReconnect = computed(
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
)
function onStatusTap(): void {
if (canManualReconnect.value) ai.resumeIfDisconnected()
}
/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
function onStop(): void {
ai.stop()
}
/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
function onContinueLoop(): void {
ai.continueLoop()
}
/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
function onStopLoop(): void {
ai.stopLoop()
}
/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
function onRegenerate(): void {
ai.regenerate()
}
/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
function onCopyMessage(m: { content?: string }): void {
if (!m.content) return
uni.setClipboardData({
data: m.content,
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
})
}
</script>
<template>
<view class="page">
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
<view class="top">
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
<view class="top-right">
<text v-if="ai.tokenUsage.value" class="token-usage">🪙 {{ ai.tokenUsage.value.total }}</text>
<view v-if="ai.pendingApprovals.length > 0" class="pending-badge">
<text> {{ ai.pendingApprovals.length }} 待审批</text>
</view>
</view>
</view>
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
<view v-if="ai.maxRoundsActive.value" class="maxrounds-panel">
<text class="maxrounds-text"> 已达最大轮次</text>
<view class="maxrounds-actions">
<button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
<button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
</view>
</view>
<!-- 待审批面板( pendingApprovals 渲染, messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
<view class="pending-panel-head">
<text> {{ ai.pendingApprovals.length }} 项待审批</text>
</view>
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
<view class="tool-head">
<text class="tool-name">🔧 {{ p.name }}</text>
<text class="tool-status st-pending">待审批</text>
</view>
<view v-if="formatArgs(p.args).length > 0" class="tool-args">
<view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
<text class="tool-arg-key">{{ a.key }}:</text>
<text class="tool-arg-val">{{ a.val }}</text>
</view>
</view>
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
<text>📁 {{ p.dir }}</text>
</view>
<view v-if="p.reason" class="tool-reason">
<text> {{ p.reason }}</text>
</view>
<!-- risk :批准/拒绝 -->
<view v-if="p.kind !== 'path'" class="approval-actions">
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
</view>
<!-- path :本次/永久/拒绝 -->
<view v-else class="approval-actions">
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
</view>
</view>
</view>
<!-- 消息列表(scroll-view 标准滚动, text 渲染) -->
<scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
<view v-if="ai.messages.length === 0" class="empty">
<text>暂无消息</text>
</view>
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
<template v-else>
<!-- 后端审批占位 message(需要__PENDING__:call_xxx)隐藏文本,审批走工具卡片 -->
<rich-text
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
:nodes="renderMd(m.content)"
selectable
/>
<!-- JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
<view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
<text>📋 {{ m.content }}</text>
</view>
<!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
<!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
<view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
<view class="tool-head">
<text class="tool-name">🔧 {{ tc.name }}</text>
<text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
{{ (STATUS_META[tc.status] || {}).label || tc.status }}
</text>
</view>
<!-- 参数 -->
<view v-if="formatArgs(tc.args).length > 0" class="tool-args">
<view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
<text class="tool-arg-key">{{ a.key }}:</text>
<text class="tool-arg-val">{{ a.val }}</text>
</view>
</view>
<!-- path 授权待授权目录 -->
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
<text>📁 {{ tc.dir }}</text>
</view>
<!-- 风险说明 -->
<view v-if="tc.reason" class="tool-reason">
<text> {{ tc.reason }}</text>
</view>
<!-- 审批按钮已移至顶部 pendingApprovals 面板( messages 解耦,req3 重连恢复) -->
<!-- 执行结果摘要(completed/failed) -->
<view
v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
class="tool-result"
>
<text>{{ formatResult(tc.result) }}</text>
</view>
</view>
</template>
</view>
<!-- 流式生成中的当前文本 -->
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
<text user-select>{{ ai.currentText.value }}</text>
</view>
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
<view id="list-bottom-anchor"></view>
</scroll-view>
<!-- 联想弹层(技能 / @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
<view v-if="mentionVisible && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
<text class="mention-hint">点击选中</text>
</view>
<scroll-view class="mention-list" scroll-y>
<!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
<template v-if="mentionTrigger === 'skill'">
<view v-if="ai.skills.length === 0" class="mention-empty">
<text>加载中或无技能...</text>
</view>
<view
v-for="(s, idx) in ai.skills"
:key="'sk-' + idx"
class="mention-item"
@tap="onSkillSelect(s)"
>
<view class="mention-item-main">
<text class="mention-item-label">/{{ s.name }}</text>
<text class="mention-item-source">{{ s.source }}</text>
</view>
<text class="mention-item-desc">{{ s.description }}</text>
<text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
</view>
</template>
<!-- 实体列表(`@` 触发, 项目/任务/灵感 分组) -->
<template v-else>
<view v-if="ai.entities.projects.length === 0 && ai.entities.tasks.length === 0 && ai.entities.ideas.length === 0" class="mention-empty">
<text>加载中或无数据...</text>
</view>
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
<text>项目</text>
</view>
<view
v-for="(p, idx) in ai.entities.projects"
:key="'p-' + idx"
class="mention-item"
@tap="onEntitySelect(p, 'project')"
>
<text class="mention-item-label">{{ p.name }}</text>
<text class="mention-item-desc">{{ p.description }}</text>
</view>
<view v-if="ai.entities.tasks.length > 0" class="mention-group-title">
<text>任务</text>
</view>
<view
v-for="(t, idx) in ai.entities.tasks"
:key="'t-' + idx"
class="mention-item"
@tap="onEntitySelect(t, 'task')"
>
<text class="mention-item-label">{{ t.title }}</text>
<text class="mention-item-desc">{{ t.description }}</text>
</view>
<view v-if="ai.entities.ideas.length > 0" class="mention-group-title">
<text>灵感</text>
</view>
<view
v-for="(i, idx) in ai.entities.ideas"
:key="'i-' + idx"
class="mention-item"
@tap="onEntitySelect(i, 'idea')"
>
<text class="mention-item-label">{{ i.title }}</text>
<text class="mention-item-desc">{{ i.description }}</text>
</view>
</template>
</scroll-view>
</view>
</view>
<!-- 输入 + 发送(原生 button @tap) -->
<view class="bar">
<!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
<view v-if="pendingSkill" class="skill-chip">
<text class="skill-chip-name">/{{ pendingSkill.name }}</text>
<text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
<text class="skill-chip-close" @tap="clearPendingSkill">×</text>
</view>
<textarea
v-model="inputText"
class="ipt"
:placeholder="ai.generating.value ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
:auto-height="true"
:adjust-position="true"
confirm-type="send"
@confirm="handleSend"
@input="onInput"
/>
<!-- 重发最后一条 user(P1-F,非生成中且有消息时显) -->
<button
v-if="!ai.generating.value && ai.messages.length > 0"
class="regen-btn"
@tap="onRegenerate"
>
重发
</button>
<!-- 生成中显停止按钮(wires F9),否则发送 -->
<button v-if="ai.generating.value" class="send-btn stop-btn" @tap="onStop">停止</button>
<button v-else class="send-btn" @tap="handleSend">发送</button>
</view>
</view>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #0f0f0f;
}
.top {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #1a1a1a;
}
.st {
color: #999999;
font-size: 12px;
}
.st-tap {
color: #4a9eff;
}
.top-right {
display: flex;
flex-direction: row;
align-items: center;
}
.token-usage {
color: #777777;
font-size: 11px;
margin-right: 8px;
}
.list {
flex: 1;
min-height: 0;
padding: 12px;
}
.empty {
text-align: center;
padding: 80px 0;
}
.empty text {
color: #666666;
font-size: 13px;
}
.msg {
margin-bottom: 12px;
padding: 10px 14px;
border-radius: 10px;
background-color: #2a2a2a;
}
.msg text {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
}
.msg.user {
background-color: #4a9eff;
}
.msg.user text {
color: #ffffff;
}
.msg.system text {
color: #999999;
font-size: 12px;
}
.bar {
flex-shrink: 0;
display: flex;
align-items: flex-end;
padding: 8px 12px;
background-color: #1a1a1a;
}
.ipt {
flex: 1;
background-color: #2a2a2a;
color: #e0e0e0;
padding: 8px 12px;
min-height: 36px;
max-height: 120px;
box-sizing: border-box;
overflow-y: auto;
border-radius: 6px;
font-size: 14px;
line-height: 20px;
}
.send-btn {
margin-left: 8px;
background-color: #4a9eff;
color: #ffffff;
font-size: 14px;
}
.send-btn.stop-btn {
background-color: #5a2a2a;
color: #e85a4f;
}
.regen-btn {
margin-left: 8px;
background-color: #2a2a2a;
color: #c0c0c0;
font-size: 13px;
}
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item-main {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-source {
color: #777777;
font-size: 10px;
margin-left: 8px;
}
.mention-item-desc {
color: #999999;
font-size: 12px;
margin-top: 2px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
.mention-empty {
padding: 24px 12px;
text-align: center;
}
.mention-empty text {
color: #666666;
font-size: 12px;
}
.mention-group-title {
padding: 6px 12px;
background-color: #232323;
border-bottom: 1px solid #2a2a2a;
}
.mention-group-title text {
color: #888888;
font-size: 11px;
font-weight: bold;
}
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
.skill-chip {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
height: 28px;
margin-right: 6px;
background-color: #1f3a5f;
border: 1px solid #4a9eff;
border-radius: 14px;
}
.skill-chip-name {
color: #4a9eff;
font-size: 12px;
}
.skill-chip-hint {
color: #7799bb;
font-size: 10px;
margin-left: 6px;
}
.skill-chip-close {
color: #4a9eff;
font-size: 16px;
margin-left: 6px;
padding: 0 2px;
}
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
.pending-badge {
flex-shrink: 0;
margin: 0 8px;
padding: 2px 8px;
background-color: #3a2f1f;
border: 1px solid #f0c75e;
border-radius: 10px;
}
.pending-badge text {
color: #f0c75e;
font-size: 11px;
}
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
.pending-panel {
flex-shrink: 0;
max-height: 40vh;
overflow-y: auto;
padding: 4px 12px 8px;
background-color: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
}
.pending-panel-head {
padding: 4px 0;
}
.pending-panel-head text {
color: #f0c75e;
font-size: 12px;
font-weight: bold;
}
/* 达最大轮次面板(P1-E:继续/停止循环) */
.maxrounds-panel {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #2a2218;
border-bottom: 1px solid #3a2f1f;
}
.maxrounds-text {
color: #f0c75e;
font-size: 13px;
}
.maxrounds-actions {
display: flex;
flex-direction: row;
}
.tool-card {
margin-top: 8px;
padding: 8px 10px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 8px;
}
.tool-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.tool-name {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.tool-status {
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
}
.tool-status.st-running {
background-color: #2a3a5a;
color: #6bb6ff;
}
.tool-status.st-completed {
background-color: #1f3a2a;
color: #6bd99a;
}
.tool-status.st-failed {
background-color: #3a1f1f;
color: #e85a4f;
}
.tool-status.st-pending {
background-color: #3a2f1f;
color: #f0c75e;
}
.tool-status.st-rejected {
background-color: #2a2a2a;
color: #999999;
}
.tool-args {
margin-top: 6px;
}
.tool-arg {
display: flex;
flex-direction: row;
margin-top: 2px;
}
.tool-arg-key {
color: #7799bb;
font-size: 12px;
margin-right: 4px;
}
.tool-arg-val {
color: #c0c0c0;
font-size: 12px;
flex: 1;
}
.tool-dir {
margin-top: 4px;
}
.tool-dir text {
color: #6bd99a;
font-size: 12px;
}
.tool-reason {
margin-top: 6px;
padding: 4px 6px;
background-color: #2a2218;
border-radius: 4px;
}
.tool-reason text {
color: #f0c75e;
font-size: 12px;
}
.approval-actions {
display: flex;
flex-direction: row;
margin-top: 8px;
}
.approval-btn {
margin-right: 6px;
padding: 0 12px;
height: 30px;
line-height: 30px;
font-size: 12px;
border-radius: 6px;
}
.approval-btn.approve {
background-color: #2a5a3a;
color: #6bd99a;
}
.approval-btn.always {
background-color: #2a3a5a;
color: #6bb6ff;
}
.approval-btn.reject {
background-color: #5a2a2a;
color: #e85a4f;
margin-right: 0;
}
.tool-result {
margin-top: 6px;
padding: 4px 6px;
background-color: #181818;
border-radius: 4px;
}
.tool-result text {
color: #999999;
font-size: 11px;
}
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
.msg-toolresult {
margin-top: 4px;
padding: 6px 8px;
background-color: #181818;
border-radius: 6px;
max-height: 80px;
overflow: hidden;
}
.msg-toolresult text {
color: #777777;
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events'
const ai = useAiChat()
/** 点击会话项切换 */
function handleSelect(convId: string): void {
ai.switchConversation(convId)
// 切换后跳回对话页
uni.switchTab({ url: '/pages/chat/index' })
}
/** 新建会话 */
function handleNew(): void {
ai.newConversation()
uni.switchTab({ url: '/pages/chat/index' })
}
/** 长按会话项重命名(对齐桌面端改会话名) */
function handleRename(conv: Conversation): void {
uni.showModal({
title: '重命名会话',
editable: true,
placeholderText: '输入新名称',
content: conv.title || '',
success: (r) => {
if (r.confirm && r.content) {
const newTitle = r.content.trim()
if (newTitle && newTitle !== conv.title) {
ai.renameConversation(conv.id, newTitle)
}
}
},
})
}
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
onPullDownRefresh(() => {
ai.refreshConversations()
uni.stopPullDownRefresh()
})
/** 格式化时间戳为可读字符串 */
function formatTime(ts?: number): string {
if (!ts) return ''
const d = new Date(ts)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${mi}`
}
</script>
<template>
<view class="conv-page">
<!-- 顶部新建按钮 -->
<view class="new-bar" @click="handleNew">
<text class="new-icon">+</text>
<text class="new-text">新建会话</text>
</view>
<!-- 会话列表 -->
<scroll-view class="conv-list" scroll-y>
<view v-if="ai.conversations.length === 0" class="empty-hint">
<text>暂无历史会话</text>
<text class="empty-sub">在桌面端创建后,此处同步</text>
</view>
<view
v-for="conv in ai.conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }"
@click="handleSelect(conv.id)"
@longpress="handleRename(conv)"
>
<view class="conv-main">
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
</view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view>
<view v-if="ai.conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text>
</view>
</scroll-view>
</view>
</template>
<style lang="scss" scoped>
.conv-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: $uni-bg-color;
}
.new-bar {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $uni-bg-color-dark;
border-bottom: 1rpx solid $uni-border-color;
}
.new-icon {
font-size: 40rpx;
color: $uni-color-primary;
margin-right: 16rpx;
}
.new-text {
font-size: $uni-font-size-base;
color: $uni-color-primary;
}
.conv-list {
flex: 1;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding: 200rpx 24rpx;
text {
color: $uni-text-color-disable;
font-size: $uni-font-size-base;
}
.empty-sub {
margin-top: 12rpx;
font-size: $uni-font-size-sm;
}
}
.conv-item {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid $uni-border-color;
&.active {
background-color: $uni-bg-color-light;
border-left: 6rpx solid $uni-color-primary;
}
}
.conv-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-title {
font-size: $uni-font-size-base;
color: $uni-text-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-last {
margin-top: 8rpx;
font-size: $uni-font-size-sm;
color: $uni-text-color-grey;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: $uni-font-size-sm;
color: $uni-text-color-disable;
margin-left: 16rpx;
}
.list-hint {
padding: 24rpx;
text-align: center;
}
.list-hint text {
color: $uni-text-color-disable;
font-size: $uni-font-size-sm;
}
</style>

7
apps/df-miniapp/src/shims-uni.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}

View File

@@ -0,0 +1,193 @@
/**
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
*
* 类型证据:
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
*
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
*/
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
import type { SkillInfo } from './relay'
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
/**
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
*
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
*
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
*/
export type AiChatEvent =
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
| {
type: 'AiApprovalRequired'
id: string
name: string
args: unknown
reason: string
diff?: string | null
conversation_id?: string | null
}
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
| {
type: 'AiCompleted'
total_tokens: number
prompt_tokens: number
completion_tokens: number
incomplete?: boolean | null
conversation_id?: string | null
}
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
| { type: 'AiHeartbeat'; conversation_id?: string | null }
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
| {
type: 'AiDirAuthRequired'
id: string
tool: string
path: string
dir: string
conversation_id?: string | null
}
| { type: 'AiContextCleared'; conversation_id?: string | null }
| { type: 'AiCompressing'; conversation_id?: string | null }
| { type: 'AiCompressed'; conversation_id?: string | null; summary: string }
| {
type: 'AiHelpRequired'
reason: string
context: string
options: string[]
conversation_id?: string | null
}
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
| { type: 'AiConversationList'; conversations: Conversation[] }
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
| { type: 'AiSkillList'; skills: SkillInfo[] }
| {
type: 'AiEntityList'
projects: ProjectRecord[]
tasks: TaskRecord[]
ideas: IdeaRecord[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type']
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
export interface Conversation {
id: string
title: string
/** 最近一条消息摘要(可选) */
lastMessage?: string
/** 最近更新时间戳(ms) */
updatedAt?: number
/** 创建时间(ms) */
createdAt?: number
}
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
isError?: boolean
/** 工具调用列表(对齐 AiToolCallInfo) */
toolCalls?: AiToolCallInfo[]
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
incomplete?: boolean
}
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
export interface AiToolCallInfo {
id: string
name: string
args: unknown
result?: unknown
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
reason?: string
diff?: string
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
kind?: 'risk' | 'path'
dir?: string
path?: string
}
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
export type PendingApproval = AiToolCallInfo
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
export interface TokenUsage {
prompt: number
completion: number
total: number
}
// ============================================================
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
// ============================================================
//
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
export interface ProjectRecord {
id: string
name: string
description: string
status: string
idea_id: string | null
/** 绑定的本地代码目录绝对路径(null=未绑定) */
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
created_at: string
updated_at: string
}
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
export interface TaskRecord {
id: string
project_id: string
title: string
description: string
status: string
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
priority: number
branch_name: string | null
assignee: string | null
created_at: string
updated_at: string
}
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
export interface IdeaRecord {
id: string
title: string
description: string
status: string
priority: number
score: number | null
tags: string | null
source: string | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,204 @@
/**
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
*
* 类型证据:
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
*
* 关键协议点(relay.rs:332-365 handle_inbound_text):
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
* 再从 payload 取 AiChatEvent。
*/
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
export type ClientKind = 'device' | 'miniapp'
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
export type MessageKind = 'event' | 'command' | 'control'
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
export type PresenceState = 'online' | 'offline'
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
export type ConnId = number
/**
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
*
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
*/
export interface BroadcastMessage {
/** 配对绑定的设备 ID */
device_id: string
/** 消息语义 */
kind: MessageKind
/** 消息来源连接 ID(relay 分配,u64) */
source: ConnId
/** 来源客户端类型 */
from: ClientKind
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
payload: unknown
/** 时间戳(ms) */
ts: number
}
/**
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
*
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
*/
export interface Hello {
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
kind: 'miniapp' | 'device'
/** 配对绑定的设备 ID(用户首次配置时生成) */
device_id: string
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
token: string
}
/**
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
*
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
*/
export type ControlMessage =
| { control_kind: 'pair'; device_id: string; token: string }
| { control_kind: 'heartbeat'; device_id: string }
| { control_kind: 'presence'; device_id: string; state: PresenceState }
| { control_kind: 'ping' }
| { control_kind: 'pong' }
/**
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
*
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
* 字段:
* - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数)
*
* 路由表全覆盖(14 条):
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
*/
export interface MiniCommand {
/** Tauri command 名 */
cmd: string
/** 命令参数(原始对象,对齐各 command 参数签名) */
args: Record<string, unknown>
}
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
export interface SendMessageArgs {
message: string
conversation_id?: string | null
model_override?: string | null
/**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
*/
skill?: string | null
/**
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
* null/undefined/空=无 mention(纯文本消息)。
*/
mention_spans?: MentionSpan[] | null
}
/**
* 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
*
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
* 前端按 {start, length} 在原文中切出区间替换为 chip。
*/
export interface MentionSpan {
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
start: number
/** 区间长度 */
length: number
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
kind: 'project' | 'task' | 'idea' | 'skill'
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
refId: string
/** chip 展示文本(项目名/任务标题/技能名) */
label: string
}
/**
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
* src-tauri/src/commands/ai/skills.rs:19-34)。
*
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
*/
export interface SkillInfo {
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
name: string
/** 技能描述(浮层副标题) */
description: string
/** 参数提示(可选,如 "<url>") */
argument_hint?: string
/** skill | command | plugin(来源分类) */
source: string
/** SKILL.md 绝对路径(后端注入时读全文) */
path: string
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
duplicates?: string[]
}
/** switch_conversation 命令参数 */
export interface SwitchConversationArgs {
conversation_id: string
}
/** stop 命令参数(对齐 ai_chat_stop) */
export interface StopArgs {
conversation_id?: string | null
}
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
export interface ApproveArgs {
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
tool_call_id: string
/** 是否批准(true=批准执行,false=拒绝) */
approved: boolean
}
/**
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
*
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
* - 'once':本次授权(下次同路径仍需问)
* - 'always':永久授权(写入会话级信任目录)
* - 'deny':拒绝(本次不授权)
*/
export interface AuthorizeDirArgs {
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
tool_call_id: string
/** 授权决策('once' / 'always' / 'deny') */
decision: 'once' | 'always' | 'deny'
}
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
export interface ContinueLoopArgs {
/** 需继续运行的会话 id */
conversation_id: string
}
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
export interface StopLoopArgs {
/** 霷停止循环的会话 id */
conversation_id: string
}

View File

@@ -0,0 +1,33 @@
/**
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
*
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
*/
/* 主色调 */
$uni-color-primary: #4a9eff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 中性色(暗色背景适配) */
$uni-bg-color: #0f0f0f;
$uni-bg-color-dark: #1a1a1a;
$uni-bg-color-light: #2a2a2a;
$uni-text-color: #e0e0e0;
$uni-text-color-grey: #999999;
$uni-text-color-disable: #666666;
$uni-border-color: #333333;
/* 字号 */
$uni-font-size-sm: 24rpx;
$uni-font-size-base: 28rpx;
$uni-font-size-lg: 32rpx;
/* 气泡角色配色 */
$uni-bubble-user-bg: #4a9eff;
$uni-bubble-user-text: #ffffff;
$uni-bubble-assistant-bg: #2a2a2a;
$uni-bubble-assistant-text: #e0e0e0;
$uni-bubble-error-bg: #3a1a1a;
$uni-bubble-error-text: #ff6b6b;

View File

@@ -0,0 +1,62 @@
/**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
*
* 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
* 版本无关,build 不依赖 renderer TS 签名。
*
* mp-weixin rich-text 限制(已核官方文档,非臆测):
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
* 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
*/
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
return html.replace(re, (m, attrs: string) => {
if (attrs && /\sstyle\s*=/.test(attrs)) {
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
const merged = prev.endsWith(';') ? prev : `${prev};`
return `style="${merged}${style}"`
})
}
return `<${tag}${attrs || ''} style="${style}">`
})
}
/**
* 对 marked.parse 产出的 HTML 注入 inline style。
*
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
*/
export function styleMarkdown(html: string): string {
let out = html
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag(
out,
'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
)
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
out = styleTag(
out,
'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
out = styleTag(
out,
'table',
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
)
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
return out
}

View File

@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"jsx": "preserve",
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM"],
"types": ["@dcloudio/types", "@types/node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "unpackage"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
export default defineConfig({
plugins: [uni()],
})

View File

@@ -286,6 +286,73 @@ impl ToolDomain {
// ---- 识别器 -----------------------------------------------------------------
/// 意图识别 L0 去噪:剥离 mention 结构化前缀(`[项目: DevFlow]` / `[任务: xxx]` 等)。
///
/// mention 前缀是系统注入的结构标签,语义已由 augmentation @ 通道单独 resolve
/// 注入 system promptcontent 里残留的前缀只污染关键词识别——实测会话
/// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`
/// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见agent 被迫反复 `list_tasks`。
///
/// 剥离规则:扫描 `[`,若紧跟已知 mention kind项目/任务/想法/技能,
/// project/task/idea/skill大小写不敏感+ 冒号(全/半角 `:`/``),则连同到
/// 首个 `]` 整段移除;未闭合或非已知 kind 的 `[...]` 原样保留(代码片段/错误码)。
///
/// kind 表与 `MentionRef`project/task/idea/skill对齐——单一真相源新增 mention
/// kind 时此处同步。不折叠空白:关键词 `contains` 匹配对空白不敏感,剥离即可。
fn strip_mention_tags(message: &str) -> String {
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
let chars: Vec<char> = message.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(message.len());
let mut i = 0;
while i < n {
if chars[i] == '[' {
if let Some(close) = try_match_mention(&chars, i, KINDS) {
i = close + 1; // 跳过整段 mention含 `]`
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
/// 探测 `chars[open] == '['` 起的 mention 段:`[kind: ...]`。
///
/// 返回匹配 `]` 的索引(`open` 指向 `[`。kind 大小写不敏感ASCII+ 中文直比;
/// 冒号接受全角 ``/半角 `:`kind 后允许空白;未闭合 `]` 返 `None`(不当 mention保留原样
fn try_match_mention(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;
}
// 未闭合 `]` → 不当 mention保留原样
return None;
}
None
}
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
#[derive(Debug, Clone, Default)]
pub struct IntentRecognizer;
@@ -299,14 +366,16 @@ impl IntentRecognizer {
///
/// 优先级SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回不累积低分组。
pub fn recognize(message: &str) -> (Intent, f32) {
// L0 去噪:先剥离 mention 结构标签(防 `[项目: x]` 把代码任务误判 Project
let cleaned = strip_mention_tags(message);
// 依次按优先级组求胜者。高分组有命中即提前返回。
if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) {
if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) {
return hit;
}
if let Some(hit) = best_in_group(message, ENTITY_GROUP) {
if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) {
return hit;
}
if let Some(hit) = best_in_group(message, GENERIC_GROUP) {
if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) {
return hit;
}
(Intent::Unknown, 0.0)
@@ -347,7 +416,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
/// 返回空 `Vec` 表示该意图**无工具收敛**Chat或**未识别**Unknown
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM
///
/// 设计Code → [file, http]File → [file]Project/Task/Idea → [data]
/// 设计Code → [file, http]File → [file]Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1
/// Http → [http]Search → [file](含 search_filesConversation → [];
/// Chat → []Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command,
@@ -357,9 +426,12 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
Intent::File => &[ToolDomain::File],
Intent::Project => &[ToolDomain::Data],
Intent::Task => &[ToolDomain::Data],
Intent::Idea => &[ToolDomain::Data],
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
// (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
// 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
Intent::Project => &[ToolDomain::Data, ToolDomain::File],
Intent::Task => &[ToolDomain::Data, ToolDomain::File],
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
Intent::Conversation => &[],
Intent::Search => &[ToolDomain::File],
Intent::Http => &[ToolDomain::Http],
@@ -608,6 +680,47 @@ mod tests {
assert_eq!(i, Intent::Search);
}
// --- strip_mention_tagsL0 去噪)---
#[test]
fn strip_removes_all_mention_kinds() {
assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码");
assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看");
assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊");
// 英文 kind + 大小写不敏感
assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this");
assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it");
// 全角冒号
assert_eq!(strip_mention_tags("[项目DevFlow] 阅读"), " 阅读");
}
#[test]
fn strip_preserves_non_mention_brackets() {
// 非 mention 的 [...] 原样保留(代码片段/错误码)
assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和");
assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind
assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号
// 未闭合的 mention-like 不剥离(防误吞)
assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合");
}
#[test]
fn recognize_mention_prefix_not_misread_as_project() {
// 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project)
// 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。
let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码");
assert_eq!(i, Intent::Code);
assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c);
}
#[test]
fn recognize_real_project_word_still_hits_project() {
// 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。
// 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。
let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
assert_eq!(i, Intent::Project);
}
#[test]
fn recognize_http_zh() {
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
@@ -713,11 +826,13 @@ mod tests {
}
#[test]
fn subset_project_is_data() {
fn subset_project_is_data_and_file() {
// L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。
// Data(create_project/bind_directory)+ File 只读(read_file)共存。
let s = tool_subset_for(&Intent::Project);
assert!(s.contains(&"create_project"));
assert!(s.contains(&"bind_directory"));
assert!(!s.contains(&"read_file"));
assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)");
}
#[test]

View File

@@ -26,13 +26,15 @@
// ---- feature flag 占位 ------------------------------------------------------
/// 规划能力总开关(Phase 1 接入用占位)。
/// 规划能力总开关(主 loop 规划开关,门控 filter_tool_defs_planned 接入)。
///
/// **当前 `false`** = 单链 ReAct 旧行为(主 loop 走 Plan 规划)。
/// Phase 1plan_hint 接入主 loop落地时翻为 `true` + 条件编译门控
/// **当前 `true`** = 主 loop 走 Plan 规划agentic/mod.rs:633 调 filter_tool_defs_planned)。
/// Phase 1plan_hint 接入主 loop落地,翻 true 启用
/// 注:`PLAN_HINT_ENABLED`(plan_hint 函数 flag,Phase0a 就绪 true)单一定义在 `plan_hint.rs`,
/// 本模块仅 `PLANNING_ENABLED`(主 loop 规划开关),消除此前双定义矛盾。
pub const PLANNING_ENABLED: bool = false;
/// 双层开关:即使 PLANNING_ENABLED=true,若 plan_hint 内部 PLAN_HINT_ENABLED 关闭,
/// filter_tool_defs_planned 仍退扁平(任一关闭退旧行为,可回退)。
pub const PLANNING_ENABLED: bool = true;
/// Plan 层级硬上限(防爆深,防规划 LLM 退化成超长链)。
///
@@ -632,10 +634,10 @@ mod tests {
}
#[test]
fn feature_flags_default_off() {
// PLANNING_ENABLED 当前(主 loop 走规划),Phase 1 接入时翻 true
fn feature_flags_default_on() {
// PLANNING_ENABLED 当前(主 loop 走 filter_tool_defs_planned 规划),Phase 1 已落地
// PLAN_HINT_ENABLED(plan_hint 函数 flag)在 plan_hint.rs 单一定义,此处不再断言。
assert!(!PLANNING_ENABLED);
assert!(PLANNING_ENABLED);
}
#[test]

View File

@@ -22,18 +22,49 @@ pub struct ShellResult {
pub enum ShellType {
/// Windows cmd.exe (默认 Windows)
Cmd,
/// PowerShell
/// PowerShell 5.x(Windows 自带,不支持 && 运算符)
PowerShell,
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
Pwsh,
/// Unix sh (默认非 Windows)
Sh,
}
impl Default for ShellType {
fn default() -> Self {
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
if cfg!(windows) {
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
} else {
ShellType::Sh
}
}
}
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
///
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
fn probe_pwsh() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| {
let mut cmd = std::process::Command::new("pwsh");
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd.status().map(|s| s.success()).unwrap_or(false)
})
}
/// Shell 命令执行请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellRequest {
@@ -65,6 +96,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
c.stdout(Stdio::piped()).stderr(Stdio::piped());
c
}
ShellType::Pwsh => {
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
let mut c = tokio::process::Command::new("pwsh");
c.arg("-NoProfile").arg("-Command").arg(&request.command);
c.stdout(Stdio::piped()).stderr(Stdio::piped());
c
}
ShellType::Cmd => {
let mut c = tokio::process::Command::new("cmd");
c.arg("/C").arg(&request.command);

View File

@@ -1,741 +0,0 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
use anyhow::{Context, Result};
#[path = "scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
use scan_helpers::{collect_images, strip_readme_noise};
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
pub fn normalize_path(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
// ============================================================
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
// ============================================================
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
"pnpm-workspace.yaml",
"lerna.json",
"turbo.json",
"nx.json",
];
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
for marker in MONOREPO_MARKERS {
if root.join(marker).is_file() {
return true;
}
}
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true;
}
}
}
}
false
}
/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
/// 项目根目录绝对路径
pub path: String,
/// 推断的项目名(目录名)
pub name: String,
/// 规则探测的技术栈(空=未识别)
pub stack: Vec<String>,
/// 是否为 monorepo 根(便于前端标记)
pub is_monorepo: bool,
}
/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = Vec::new();
let mono = is_monorepo(root);
// 1. 根目录自身是项目(有 manifest 标志)
if has_project_manifest(root) {
let stack = detect_stack(root).unwrap_or_default();
out.push(DiscoveredProject {
path: root.to_string_lossy().to_string(),
name: root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| root.to_string_lossy().to_string()),
stack,
is_monorepo: mono,
});
}
// 2. monorepo → 展开 packages/* apps/* 直接子目录
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
let scan_globs: &[&str] = if mono {
&["packages", "apps"]
} else {
&[""]
};
for glob in scan_globs {
let target = if glob.is_empty() {
root.to_path_buf()
} else {
root.join(glob)
};
if !target.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&target) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
// 必须有项目标志 + detect_stack 非空
if !has_project_manifest(&p) {
continue;
}
let stack = match detect_stack(&p) {
Ok(s) => s,
Err(_) => continue,
};
if stack.is_empty() {
continue;
}
out.push(DiscoveredProject {
path: p.to_string_lossy().to_string(),
name,
stack,
is_monorepo: false,
});
}
}
Ok(out)
}
/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
const MARKS: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
// ============================================================
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
// ============================================================
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme_raw(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 读 README 原始内容(不做处理)
fn read_readme_raw(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(content);
}
}
}
None
}
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let raw = read_readme_raw(root);
let (readme, images) = match raw {
Some(text) => {
let images = collect_images(&text);
let cleaned = strip_readme_noise(&text);
if cleaned.trim().is_empty() {
(None, images)
} else {
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
}
}
None => (None, Vec::new()),
};
Ok(ProjectSample {
readme,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
/// 按字符数截断(避免截断 UTF-8 多字节边界)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(
d.join("README.md"),
"# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.",
)
.unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
// 徽章行剥,内容图 + 正文保留
let readme = s.readme.as_deref().unwrap_or("");
assert!(readme.contains("test project"), "readme={readme}");
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
// 内容图收集(badge 跳,arch 留)
assert_eq!(s.images.len(), 1);
assert_eq!(s.images[0].src, "./docs/arch.png");
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
#[test]
fn strips_frontmatter_and_toc_and_badges() {
let d = scratch("noise");
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
fs::write(d.join("README.md"), readme).unwrap();
let s = collect_sample(&d).unwrap();
let r = s.readme.as_deref().unwrap_or("");
assert!(r.contains("# Foo"), "标题应保留: {r}");
assert!(r.contains("real intro"), "正文应保留: {r}");
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_pnpm() {
let d = scratch("mono-pnpm");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_npm_workspaces() {
let d = scratch("mono-npm");
fs::write(
d.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_non_monorepo() {
let d = scratch("nonmono");
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_monorepo_children() {
let d = scratch("discover-mono");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
fs::create_dir_all(d.join("packages/web")).unwrap();
fs::write(
d.join("packages/web/package.json"),
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
)
.unwrap();
fs::create_dir_all(d.join("packages/cli")).unwrap();
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
// 空 stack 子目录应过滤
fs::create_dir_all(d.join("packages/empty")).unwrap();
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"web"), "names={names:?}");
assert!(names.contains(&"cli"), "names={names:?}");
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_flat_children() {
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
let d = scratch("discover-flat");
fs::create_dir_all(d.join("proj-a")).unwrap();
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
fs::create_dir_all(d.join("not-a-project")).unwrap();
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"proj-a"), "names={names:?}");
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}

View File

@@ -0,0 +1,276 @@
//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。
use std::path::Path;
use anyhow::Result;
use super::stack::{detect_stack, has_file_with_ext};
use super::SAMPLE_IGNORED_DIRS;
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
"pnpm-workspace.yaml",
"lerna.json",
"turbo.json",
"nx.json",
];
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
for marker in MONOREPO_MARKERS {
if root.join(marker).is_file() {
return true;
}
}
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true;
}
}
}
}
false
}
/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
/// 项目根目录绝对路径
pub path: String,
/// 推断的项目名(目录名)
pub name: String,
/// 规则探测的技术栈(空=未识别)
pub stack: Vec<String>,
/// 是否为 monorepo 根(便于前端标记)
pub is_monorepo: bool,
}
/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = Vec::new();
let mono = is_monorepo(root);
// 1. 根目录自身是项目(有 manifest 标志)
if has_project_manifest(root) {
let stack = detect_stack(root).unwrap_or_default();
out.push(DiscoveredProject {
path: root.to_string_lossy().to_string(),
name: root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| root.to_string_lossy().to_string()),
stack,
is_monorepo: mono,
});
}
// 2. monorepo → 展开 packages/* apps/* 直接子目录
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
let scan_globs: &[&str] = if mono {
&["packages", "apps"]
} else {
&[""]
};
for glob in scan_globs {
let target = if glob.is_empty() {
root.to_path_buf()
} else {
root.join(glob)
};
if !target.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&target) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
// 必须有项目标志 + detect_stack 非空
if !has_project_manifest(&p) {
continue;
}
let stack = match detect_stack(&p) {
Ok(s) => s,
Err(_) => continue,
};
if stack.is_empty() {
continue;
}
out.push(DiscoveredProject {
path: p.to_string_lossy().to_string(),
name,
stack,
is_monorepo: false,
});
}
}
Ok(out)
}
/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
const MARKS: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn detects_monorepo_pnpm() {
let d = scratch("mono-pnpm");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_npm_workspaces() {
let d = scratch("mono-npm");
fs::write(
d.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_non_monorepo() {
let d = scratch("nonmono");
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_monorepo_children() {
let d = scratch("discover-mono");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
fs::create_dir_all(d.join("packages/web")).unwrap();
fs::write(
d.join("packages/web/package.json"),
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
)
.unwrap();
fs::create_dir_all(d.join("packages/cli")).unwrap();
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
// 空 stack 子目录应过滤
fs::create_dir_all(d.join("packages/empty")).unwrap();
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"web"), "names={names:?}");
assert!(names.contains(&"cli"), "names={names:?}");
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_flat_children() {
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
let d = scratch("discover-flat");
fs::create_dir_all(d.join("proj-a")).unwrap();
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
fs::create_dir_all(d.join("not-a-project")).unwrap();
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"proj-a"), "names={names:?}");
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}

View File

@@ -0,0 +1,114 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! ## 模块拆分(2026-06-22,REFACTOR-260619-10)
//!
//! 原 `scan.rs`(741 行)按职责拆为 4 子模块,纯文件重组、零行为变化:
//! - [`stack`]:技术栈探测(`detect_stack` + package.json 解析)
//! - [`discover`]:历史项目发现/monorepo 展开(`discover_projects`/`is_monorepo`)
//! - [`readme`]:README 读取与首段抽取(`read_readme_raw`/`extract_description`)
//! - [`sample`]:项目采样(`collect_sample` + 目录树/清单/噪音目录)
//!
//! 跨模块共享的常量/helper 留在本 `mod.rs`(`SAMPLE_IGNORED_DIRS`/`truncate_chars`),
//! 子模块经 `use super::*` 引用。`pub use` 重导出保持外部 `df_project::scan::*` 路径不变。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
#[path = "../scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
pub mod discover;
pub mod readme;
pub mod sample;
pub mod stack;
// ── 对外稳定 API 重导出(外部 `df_project::scan::*` 路径不变)──
pub use discover::{discover_projects, is_monorepo, DiscoveredProject};
pub use readme::extract_description;
pub use sample::{collect_sample, ProjectSample};
pub use stack::detect_stack;
// ============================================================
// 跨模块共享常量/helper
// ============================================================
/// 目录树/项目发现过滤的噪音目录(依赖产物/构建/缓存/IDE)。
///
/// 同时被 [`discover`] 与 [`sample`] 引用,故置于本 `mod.rs`,`pub(super)` 暴露给子模块。
pub(super) const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 按字符数截断(避免截断 UTF-8 多字节边界)。
///
/// 被 [`readme`](crate::scan::readme) 与 [`sample`](crate::scan::sample) 引用,故置于本 `mod.rs`。
pub(super) fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
pub fn normalize_path(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
}

View File

@@ -0,0 +1,122 @@
//! README 读取与首段抽取 — 纯 IO + 文本规则。
//!
//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`)
//! (`collect_sample`)共用,故 `pub(super)` 暴露。
use std::path::Path;
use super::truncate_chars;
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 读 README 原始内容(不做处理)。
///
/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等),
/// 返回首个存在的文件内容。
pub(super) fn read_readme_raw(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(content);
}
}
}
None
}
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme_raw(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,167 @@
//! 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码。
//!
//! README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用。
use std::path::Path;
use anyhow::Result;
use super::readme::read_readme_raw;
use super::scan_helpers::{collect_images, strip_readme_noise, ImageRef};
use super::{truncate_chars, SAMPLE_IGNORED_DIRS};
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let raw = read_readme_raw(root);
let (readme, images) = match raw {
Some(text) => {
let images = collect_images(&text);
let cleaned = strip_readme_noise(&text);
if cleaned.trim().is_empty() {
(None, images)
} else {
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
}
}
None => (None, Vec::new()),
};
Ok(ProjectSample {
readme,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(
d.join("README.md"),
"# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.",
)
.unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
// 徽章行剥,内容图 + 正文保留
let readme = s.readme.as_deref().unwrap_or("");
assert!(readme.contains("test project"), "readme={readme}");
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
// 内容图收集(badge 跳,arch 留)
assert_eq!(s.images.len(), 1);
assert_eq!(s.images[0].src, "./docs/arch.png");
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
#[test]
fn strips_frontmatter_and_toc_and_badges() {
let d = scratch("noise");
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
fs::write(d.join("README.md"), readme).unwrap();
let s = collect_sample(&d).unwrap();
let r = s.readme.as_deref().unwrap_or("");
assert!(r.contains("# Foo"), "标题应保留: {r}");
assert!(r.contains("real intro"), "正文应保留: {r}");
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,176 @@
//! 技术栈探测 — 浅读根目录标志文件识别技术栈。
//!
//! 纯函数、零状态。读 `Cargo.toml`/`go.mod`/`package.json` 等标志,
//! 不递归遍历(控制性能与安全)。
use std::path::Path;
use anyhow::{Context, Result};
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)。
///
/// 被 `detect_stack`(C# csproj)与 `super::discover`(项目清单判定)共用,
/// 故 `pub(super)`。
pub(super) fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "df-relay"
version = "0.1.0"
edition = "2021"
description = "跨端 AI Chat 云中继服务(axum WS Server,转发小程序 ↔ 桌面端)"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# binary 入口(main.rs)日志初始化
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# HTTP + WS 服务端(axum ws 内部用 tungstenite 协议层;df-relay 不直接依赖 tokio-tungstenite。
# TLS 由 nginx wss 终止,:9180 明文 ws → 去 native-tls 避 openssl 依赖,利 linux 交叉/容器编译)
axum = { version = "0.7", features = ["ws"] }
futures-util = "0.3"

View File

@@ -0,0 +1,21 @@
# df-relay 跨端中继(Phase3 云端部署,u-work.1216.top)
# multi-stage:rust:slim 编译 df-relay → debian:slim 运行(static-ish binary)
#
# 构建(context = repo 根,workspace):
# docker build -f crates/df-relay/Dockerfile -t df-relay .
# 运行(容器内 :8080,映射宿主 :9180,避开测试机 8080 占用):
# docker run -d --name df-relay --restart unless-stopped -p 9180:8080 df-relay
FROM rust:1.85-slim AS builder
WORKDIR /build
COPY . .
RUN cargo build --release -p df-relay
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/df-relay /usr/local/bin/df-relay
ENV DF_RELAY_ADDR=0.0.0.0:8080
EXPOSE 8080
CMD ["df-relay"]

View File

@@ -0,0 +1,145 @@
//! df-relay 广播消息骨架
//!
//! 设计依据:设计文档「Layer2」—— 云后端纯转发,无业务逻辑。
//! 中继消息骨架定义「谁发给谁」的路由语义:
//! - `Event`:桌面端事件 → 广播给该 device 绑定的小程序
//! - `Command`:小程序指令 → 转发给该 device 绑定的桌面端
//! - `Control`:控制面消息(配对/心跳/在线状态)
//!
//! 与 df-tunnel::TunnelMessage 区分:
//! - TunnelMessage 是隧道两端(桌面↔云)的传输单元
//! - BroadcastMessage 是云后端内部的路由单元(标识来源/去向/广播范围)
//!
//! AiChatEvent JSON 透传:relay 不解析 AiChatEvent(类型在 src-tauri/df-types,
//! relay 不依赖避跨 crate 强耦合),payload 用 serde_json::Value 透传。
use serde::{Deserialize, Serialize};
use crate::conn::ConnId;
/// 客户端类型(用于路由判断)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ClientKind {
/// 桌面端(出站长连接,事件真相源)
Device,
/// 小程序(远程操作发起方)
Miniapp,
}
/// 中继路由消息语义分类
///
/// - `Event`:桌面端 → 小程序(渲染事件,AiChatEvent 透传)
/// - `Command`:小程序 → 桌面端(操作指令,触发 Tauri command)
/// - `Control`:控制面(配对/心跳/Ping-Pong,不进业务流)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MessageKind {
Event,
Command,
Control,
}
/// 中继路由消息骨架(云后端内部的路由单元)
///
/// 每条消息携带 `device_id`(配对绑定键),云后端按 device_id
/// 找到绑定的对端连接进行转发/广播。payload 为原始 JSON,转发时不解析业务字段,
/// 保持轻量。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BroadcastMessage {
/// 配对绑定的设备 ID(桌面端首次配置时生成)
pub device_id: String,
/// 消息语义(Event / Command / Control)
pub kind: MessageKind,
/// 消息来源连接(用于鉴权校验:device 不能发 Command)
pub source: ConnId,
/// 来源客户端类型
pub from: ClientKind,
/// 消息载荷(原始 JSON,透传不解析)
pub payload: serde_json::Value,
/// 时间戳(毫秒,用于冲突判断 —— 桌面端为真相源,但保留时间戳辅助)
pub ts: i64,
}
impl BroadcastMessage {
/// 便捷构造:device 发出的事件
pub fn from_device(
device_id: impl Into<String>,
source: ConnId,
payload: serde_json::Value,
ts: i64,
) -> Self {
Self {
device_id: device_id.into(),
kind: MessageKind::Event,
source,
from: ClientKind::Device,
payload,
ts,
}
}
/// 便捷构造:小程序发出的指令
pub fn from_miniapp(
device_id: impl Into<String>,
source: ConnId,
payload: serde_json::Value,
ts: i64,
) -> Self {
Self {
device_id: device_id.into(),
kind: MessageKind::Command,
source,
from: ClientKind::Miniapp,
payload,
ts,
}
}
/// 便捷构造:控制面消息
pub fn control(
device_id: impl Into<String>,
source: ConnId,
from: ClientKind,
payload: serde_json::Value,
ts: i64,
) -> Self {
Self {
device_id: device_id.into(),
kind: MessageKind::Control,
source,
from,
payload,
ts,
}
}
}
/// 客户端在线状态(用于离线降级提示)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PresenceState {
/// 在线
Online,
/// 离线(对端可缓存指令待重连)
Offline,
}
/// 控制面消息(配对绑定 / 心跳 / 在线状态广播)
///
/// 仅作为 payload 的语义约定,relay 对 Control 消息同样透传;部分控制语义
/// (如 Ping/Pong 心跳)由 WS 连接层直接处理,不进 BroadcastMessage 流。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "control_kind", rename_all = "snake_case")]
pub enum ControlMessage {
/// 配对绑定请求(小程序持 token 申请绑定 device_id)
Pair { device_id: String, token: String },
/// 心跳(保活 + 在线确认)
Heartbeat { device_id: String },
/// 在线状态变更通知
Presence { device_id: String, state: PresenceState },
/// 心跳 Ping(WS 应用层心跳,与协议层 Ping 区分)
Ping,
/// 心跳 Pong
Pong,
}

202
crates/df-relay/src/conn.rs Normal file
View File

@@ -0,0 +1,202 @@
//! df-relay 连接注册表
//!
//! 设计依据:设计文档「Layer2」—— 按 device_id 配对路由(非全局广播)。
//!
//! 注册表结构:`HashMap<conn_id, ConnHandle>` + `device_id → conn_id` 索引。
//! 配对语义:小程序 device_id ↔ 桌面端绑定该 device_id 的 token(同 device_id
//! 的小程序与桌面端互为对端)。relay 按 device_id 找到对端连接转发消息。
//!
//! 并发:tokio::sync::Mutex 单锁(注册表操作高频但临界区小,先简单后优化)。
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use crate::broadcast::{BroadcastMessage, ClientKind};
/// 连接唯一标识(u64 自增,进程内唯一)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ConnId(pub u64);
impl ConnId {
/// 伪值(用于无来源场景,如服务端构造的控制消息)
pub const NIL: ConnId = ConnId(0);
}
static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
/// 分配下一个 conn_id(进程内递增,从 1 起,0 留作 NIL)
pub fn next_conn_id() -> ConnId {
ConnId(NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed))
}
/// 连接句柄(注册表条目)
///
/// 每个连接持一个 mpsc::UnboundedSender,广播时向其投递 BroadcastMessage,
/// 由连接自己的读取任务转发给对端 socket。
#[derive(Debug)]
pub struct ConnHandle {
/// 连接 ID
pub id: ConnId,
/// 客户端类型
pub kind: ClientKind,
/// 配对绑定的设备 ID(桌面端首次配置生成,小程序携带)
pub device_id: String,
/// 广播投递通道(连接读取任务消费 → 写入 socket)
pub sender: mpsc::UnboundedSender<BroadcastMessage>,
}
impl ConnHandle {
pub fn new(
id: ConnId,
kind: ClientKind,
device_id: String,
sender: mpsc::UnboundedSender<BroadcastMessage>,
) -> Self {
Self {
id,
kind,
device_id,
sender,
}
}
}
/// 连接注册表(RelayState 内部状态)
#[derive(Default)]
pub struct ConnRegistry {
/// conn_id → 连接句柄
by_id: HashMap<ConnId, ConnHandle>,
/// device_id → 绑定的连接集合(同 device_id 的小程序 + 桌面端)
/// 设计为 Vec:兼容同 device_id 多连接(多端登录),MVP 一般 1+1。
by_device: HashMap<String, Vec<ConnId>>,
}
impl ConnRegistry {
pub fn new() -> Self {
Self::default()
}
/// 注册连接
pub fn add(&mut self, handle: ConnHandle) {
let id = handle.id;
let device_id = handle.device_id.clone();
self.by_device.entry(device_id).or_default().push(id);
self.by_id.insert(id, handle);
}
/// 注销连接(返回其 device_id 用于清理索引)
pub fn remove(&mut self, id: ConnId) -> Option<String> {
let handle = self.by_id.remove(&id)?;
let device_id = handle.device_id.clone();
if let Some(ids) = self.by_device.get_mut(&device_id) {
ids.retain(|c| *c != id);
if ids.is_empty() {
self.by_device.remove(&device_id);
}
}
Some(device_id)
}
/// 查询 device_id 是否有在线连接(离线降级判断用)
pub fn is_device_online(&self, device_id: &str) -> bool {
self.by_device
.get(device_id)
.map(|ids| !ids.is_empty())
.unwrap_or(false)
}
/// 投递消息给指定 device_id 配对的对端连接(排除来源 source)
///
/// 路由规则:
/// - Event(Device 发):转发给同 device_id 的所有 Miniapp 连接
/// - Command(Miniapp 发):转发给同 device_id 的所有 Device 连接
/// - Control:转发给同 device_id 的所有连接(除来源)
///
/// 返回成功投递的连接数(用于日志/降级判断)。
pub fn route(&self, msg: &BroadcastMessage) -> usize {
let Some(ids) = self.by_device.get(&msg.device_id) else {
return 0;
};
let mut delivered = 0usize;
for &id in ids {
if id == msg.source {
// 不回环给来源
continue;
}
let Some(handle) = self.by_id.get(&id) else {
continue;
};
match (msg.kind, msg.from, handle.kind) {
// Event:仅投递给 Miniapp
(crate::broadcast::MessageKind::Event, _, ClientKind::Miniapp) => {}
// Command:仅投递给 Device
(crate::broadcast::MessageKind::Command, _, ClientKind::Device) => {}
// Control:全投递(除来源)
(crate::broadcast::MessageKind::Control, _, _) => {}
// 其余方向不匹配(Event 不投递给 Device,Command 不投递给 Miniapp)
_ => continue,
}
if handle.sender.send(msg.clone()).is_ok() {
delivered += 1;
}
}
delivered
}
/// 当前总连接数(诊断用)
pub fn len(&self) -> usize {
self.by_id.len()
}
/// 是否为空(诊断用)
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
}
/// 共享中继状态(axum State 传递)
///
/// 注册表用 tokio Mutex 包裹:广播路径在锁内调用 route,锁粒度小。
#[derive(Clone, Default)]
pub struct RelayState {
inner: Arc<Mutex<ConnRegistry>>,
}
impl RelayState {
pub fn new() -> Self {
Self::default()
}
/// 从 Arc 直接构造(用于 axum state)
pub fn from_arc(inner: Arc<Mutex<ConnRegistry>>) -> Self {
Self { inner }
}
/// 暴露内部 Arc(测试/外部 start 使用)
pub fn inner(&self) -> Arc<Mutex<ConnRegistry>> {
self.inner.clone()
}
/// 注册连接
pub async fn add_conn(&self, handle: ConnHandle) {
self.inner.lock().await.add(handle);
}
/// 注销连接
pub async fn remove_conn(&self, id: ConnId) -> Option<String> {
self.inner.lock().await.remove(id)
}
/// 查询 device 在线状态
pub async fn is_device_online(&self, device_id: &str) -> bool {
self.inner.lock().await.is_device_online(device_id)
}
/// 投递消息给对端连接(返回成功投递数)
pub async fn route(&self, msg: &BroadcastMessage) -> usize {
self.inner.lock().await.route(msg)
}
}

View File

@@ -0,0 +1,41 @@
//! df-relay 错误类型
//!
//! 设计:单一 RelayError 覆盖服务启动/客户端管理/广播/WS 协议四类失败,
//! thiserror 派生保留错误链。脚手架阶段仅定义类型。
use thiserror::Error;
/// 中继服务运行期错误
#[derive(Debug, Error)]
pub enum RelayError {
/// 服务启动失败(端口占用 / 绑定失败)
#[error("中继服务启动失败: {0}")]
Start(String),
/// 客户端管理错误(配对失败 / 重复连接 / 鉴权拒绝)
#[error("客户端管理错误: {0}")]
Client(String),
/// 广播失败(无可用连接 / 发送失败)
#[error("广播失败: {0}")]
Broadcast(String),
/// WS 协议层错误(底层 axum::extract::ws / tungstenite)
#[error("WS 协议错误: {0}")]
WebSocket(String),
/// 序列化/反序列化失败
#[error("消息序列化失败: {0}")]
Serde(#[from] serde_json::Error),
/// 广播发送失败(mpsc channel 对端已断开)
#[error("广播 channel 发送失败: {0}")]
Channel(String),
/// 其他未归类错误(降级通道)
#[error("中继内部错误: {0}")]
Other(#[from] anyhow::Error),
}
/// 模块级 Result 别名
pub type Result<T> = std::result::Result<T, RelayError>;

View File

@@ -0,0 +1,29 @@
//! # df-relay
//!
//! 跨端 AI Chat 云中继服务(设计文档 Layer2)。
//!
//! ## 职责
//! - axum WS Server 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)
//! - 广播中继:小程序操作 → 桌面端;桌面端事件 → 小程序
//! - 纯转发,无业务逻辑(保持轻量,设计文档明确)
//!
//! ## 边界
//! - 不依赖 df-tunnel / df-types / src-tauri(独立消息骨架,避免跨 crate 强耦合)
//! - 不含业务逻辑(Phase2 填连接生命周期 / 注册表 / 广播分发)
//!
//! ## 模块
//! - [`broadcast`]:广播消息骨架(BroadcastMessage / ClientKind / ControlMessage / MessageKind)
//! - [`conn`]:连接注册表(ConnId / ConnHandle / ConnRegistry / RelayState)
//! - [`relay`]:RelayServer trait + DefaultRelayServer + axum WS 路由与连接生命周期
//! - [`error`]:RelayError 错误类型
pub mod broadcast;
pub mod conn;
pub mod error;
pub mod relay;
// 顶层再导出常用项
pub use broadcast::{BroadcastMessage, ClientKind, ControlMessage, MessageKind, PresenceState};
pub use conn::{next_conn_id, ConnHandle, ConnId, ConnRegistry, RelayState};
pub use error::{RelayError, Result};
pub use relay::{build_router, DefaultRelayServer, RelayServer};

View File

@@ -0,0 +1,21 @@
//! df-relay binary 入口 — 启动跨端 AI Chat 云中继 axum WS Server。
//!
//! 监听 `0.0.0.0:8080`(`DF_RELAY_ADDR` env 覆盖),提供两个 WS 端点:
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
//!
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。
//!
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。
use df_relay::{DefaultRelayServer, RelayServer};
#[tokio::main]
async fn main() -> df_relay::Result<()> {
tracing_subscriber::fmt::init();
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
let server = DefaultRelayServer::new();
server.start(&addr).await
}

View File

@@ -0,0 +1,415 @@
//! df-relay 中继服务核心实现
//!
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对),按 device_id
//! 配对转发(非全局广播),纯转发无业务逻辑。
//!
//! 协议(简单握手):
//! 1. 客户端建立 WS 后,首条消息发 JSON `Hello { kind, device_id, token }` 宣告身份。
//! 2. relay 校验 token(MVP:env `DF_RELAY_TOKEN` 或硬编码常量;生产级鉴权留 Phase3)。
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
//!
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
use std::net::SocketAddr;
use async_trait::async_trait;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::get,
Router,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result};
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
/// 读取期望 token(env 优先,fallback 硬编码)
fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string())
}
/// 客户端首消息:身份宣告(简单协议)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// 客户端类型("device" / "miniapp")
pub kind: ClientKindWire,
/// 配对绑定的设备 ID
pub device_id: String,
/// 配对 token
pub token: String,
}
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClientKindWire {
Device,
Miniapp,
}
impl From<ClientKindWire> for ClientKind {
fn from(w: ClientKindWire) -> Self {
match w {
ClientKindWire::Device => ClientKind::Device,
ClientKindWire::Miniapp => ClientKind::Miniapp,
}
}
}
/// 中继服务抽象
///
/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。
#[async_trait]
pub trait RelayServer: Send + Sync {
/// 启动 HTTP/WS 服务监听指定地址
async fn start(&self, addr: &str) -> Result<()>;
/// 广播消息给指定 device_id 绑定的对端
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>;
/// 查询 device 是否有在线连接(离线降级判断用)
fn is_device_online(&self, device_id: &str) -> bool;
}
/// 默认中继服务(持有共享 RelayState)
pub struct DefaultRelayServer {
state: RelayState,
}
impl DefaultRelayServer {
pub fn new() -> Self {
Self {
state: RelayState::new(),
}
}
/// 从既有 RelayState 构造(测试 / 外部复用)
pub fn with_state(state: RelayState) -> Self {
Self { state }
}
/// 暴露共享状态(外部可读连接数等)
pub fn state(&self) -> RelayState {
self.state.clone()
}
}
impl Default for DefaultRelayServer {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl RelayServer for DefaultRelayServer {
async fn start(&self, addr: &str) -> Result<()> {
let socket_addr: SocketAddr = addr
.parse()
.map_err(|e| RelayError::Start(format!("地址解析失败 {addr}: {e}")))?;
let app = build_router(self.state.clone());
let listener = tokio::net::TcpListener::bind(&socket_addr)
.await
.map_err(|e| RelayError::Start(format!("监听绑定失败 {addr}: {e}")))?;
tracing::info!(%addr, "df-relay WS 服务已启动");
axum::serve(listener, app)
.await
.map_err(|e| RelayError::Start(format!("axum::serve 失败: {e}")))?;
Ok(())
}
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()> {
let delivered = self.state.route(&msg).await;
if delivered == 0 {
// 对端离线不算硬错误(MVP 返回 Ok,离线降级由调用方据 is_device_online 判断)
tracing::debug!(
device_id = %msg.device_id,
kind = ?msg.kind,
"广播无对端在线(消息丢弃)"
);
}
Ok(())
}
fn is_device_online(&self, device_id: &str) -> bool {
// trait 同步签名:tokio Mutex 用 try_lock 快照,失败保守返回 false
match self.state.inner().try_lock() {
Ok(g) => g.is_device_online(device_id),
Err(_) => false,
}
}
}
/// axum WS 路由构造
///
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
/// 共享 RelayState。端点仅决定「期望的客户端类型」,真正的身份宣告在首消息
/// Hello 中再次校验(防误连/误用)。
pub fn build_router(state: RelayState) -> Router {
Router::new()
.route("/ws/device", get(device_ws_handler))
.route("/ws/miniapp", get(miniapp_ws_handler))
.with_state(state)
}
/// 桌面端 WS upgrade handler
async fn device_ws_handler(
ws: WebSocketUpgrade,
State(state): State<RelayState>,
) -> impl IntoResponse {
tracing::debug!("桌面端 WS 连接接入");
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Device))
}
/// 小程序 WS upgrade handler
async fn miniapp_ws_handler(
ws: WebSocketUpgrade,
State(state): State<RelayState>,
) -> impl IntoResponse {
tracing::debug!("小程序 WS 连接接入");
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Miniapp))
}
/// WS 连接生命周期(握手 → 收发循环 → 注销)
///
/// 步骤:
/// 1. 等待首条 Hello 文本帧,校验 kind 与 token。
/// 2. 校验通过:分配 conn_id + mpsc,注册 ConnHandle,派发广播读取任务。
/// 3. 主循环:从 socket recv 文本帧 → 构造 BroadcastMessage → route 投递。
/// 4. 同时读取 mpsc 广播队列 → 写回 socket(双任务用 split sink/stream)。
/// 5. 任一端断开 → 注销连接、关闭 mpsc。
async fn handle_connection(socket: WebSocket, state: RelayState, expected: ClientKindWire) {
// 握手阶段:等待首条 Hello
let (mut socket_tx, mut socket_rx) = socket.split();
let hello = match recv_hello(&mut socket_rx).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(error = %e, "握手失败:未收到合法 Hello");
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"handshake_failed"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
};
// 身份 + token 双因子校验
if hello.kind != expected {
tracing::warn!(
?hello.kind,
?expected,
"握手失败:客户端类型与端点不匹配"
);
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"kind_mismatch"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
if hello.token != expected_token() {
tracing::warn!(
device_id = %hello.device_id,
"握手失败:token 校验不通过"
);
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"auth_failed"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
let conn_id = next_conn_id();
let kind: ClientKind = hello.kind.into();
let device_id = hello.device_id.clone();
tracing::info!(
conn_id = conn_id.0,
?kind,
device_id = %device_id,
"连接握手通过,进入收发循环"
);
// 握手通过:立即发 ack 控制帧给客户端。
// 客户端据此判定握手成功(首条非 error 消息即 handshaked),不依赖等待对端首条业务消息 ——
// 否则单端连入时(device 离线)relay 静默,客户端永卡 handshaking,send 被 handshaked 守卫拦截。
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","payload":{"control_kind":"hello_ack"}}"#,
)
.await;
// 建立广播投递 mpsc(连接读取任务消费 → 写回 socket)
let (bc_tx, bc_rx) = mpsc::unbounded_channel::<BroadcastMessage>();
let handle = ConnHandle::new(conn_id, kind, device_id.clone(), bc_tx);
state.add_conn(handle).await;
// 派发广播读取任务:从 bc_rx 取消息 → 序列化 → 写 socket
let mut bc_task = tokio::spawn(broadcast_pump(bc_rx, socket_tx));
// 主循环:从 socket recv → 构造 BroadcastMessage → route
loop {
tokio::select! {
// socket 入帧
maybe_msg = socket_rx.next() => {
match maybe_msg {
Some(Ok(Message::Text(text))) => {
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
}
}
Some(Ok(Message::Binary(_))) => {
// MVP 仅支持文本帧;二进制帧忽略(协议层可后续扩展)
tracing::debug!(conn_id = conn_id.0, "收到二进制帧,忽略");
}
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {
// axum/tungstenite 协议层 Ping/Pong 自动处理,这里仅记录
tracing::trace!(conn_id = conn_id.0, "协议层 Ping/Pong");
}
Some(Ok(Message::Close(_))) | None => {
tracing::info!(conn_id = conn_id.0, "客户端主动关闭连接");
break;
}
Some(Err(e)) => {
tracing::warn!(conn_id = conn_id.0, error = %e, "socket 接收错误,断开");
break;
}
}
}
// 广播 pump 任务结束(socket_tx 关闭或 mpsc 关闭)
res = &mut bc_task => {
match res {
Ok(()) => {
tracing::debug!(conn_id = conn_id.0, "广播 pump 任务正常结束");
}
Err(e) => {
tracing::warn!(conn_id = conn_id.0, error = %e, "广播 pump 任务 panic");
}
}
break;
}
}
}
// 注销连接
if let Some(d) = state.remove_conn(conn_id).await {
tracing::info!(conn_id = conn_id.0, device_id = %d, "连接已注销");
}
// 结束 pump 任务(若仍在运行)
bc_task.abort();
}
/// 接收并解析首条 Hello 文本帧
async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Result<Hello> {
let deadline = tokio::time::Duration::from_secs(10);
let next = tokio::time::timeout(deadline, rx.next())
.await
.map_err(|_| RelayError::Client("握手超时(10s 未收到 Hello)".into()))?;
let msg = next
.ok_or_else(|| RelayError::Client("握手阶段连接关闭".into()))?
.map_err(|e| RelayError::WebSocket(format!("握手 recv 失败: {e}")))?;
let text = match msg {
Message::Text(t) => t,
Message::Binary(_) => {
return Err(RelayError::Client("握手首帧必须为文本".into()));
}
_ => return Err(RelayError::Client("握手首帧类型非法".into())),
};
let hello: Hello =
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
Ok(hello)
}
/// 处理入站文本帧(构造 BroadcastMessage → route)
async fn handle_inbound_text(
state: &RelayState,
conn_id: ConnId,
kind: ClientKind,
device_id: &str,
raw: &str,
) -> Result<()> {
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
let now = now_ms();
let (msg_kind, from) = match kind {
ClientKind::Device => (MessageKind::Event, ClientKind::Device),
ClientKind::Miniapp => (MessageKind::Command, ClientKind::Miniapp),
};
let msg = BroadcastMessage {
device_id: device_id.to_string(),
kind: msg_kind,
source: conn_id,
from,
payload,
ts: now,
};
let delivered = state.route(&msg).await;
tracing::debug!(
conn_id = conn_id.0,
?msg_kind,
device_id = %device_id,
delivered,
"入站消息已路由"
);
Ok(())
}
/// 广播 pump:从 mpsc 取消息,序列化后写回 socket sink
///
/// 任务退出条件:bc_rx 关闭(对端 handle 全部 drop)/ socket_tx 关闭出错。
async fn broadcast_pump(
mut bc_rx: mpsc::UnboundedReceiver<BroadcastMessage>,
mut socket_tx: futures_util::stream::SplitSink<WebSocket, Message>,
) {
while let Some(msg) = bc_rx.recv().await {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = %e, "广播消息序列化失败,跳过");
continue;
}
};
if let Err(e) = socket_tx.send(Message::Text(text)).await {
tracing::warn!(error = %e, "广播写回 socket 失败,pump 退出");
break;
}
}
}
/// 便捷发送文本帧
async fn send_text(
tx: &mut futures_util::stream::SplitSink<WebSocket, Message>,
text: &str,
) -> Result<()> {
tx.send(Message::Text(text.to_string()))
.await
.map_err(|e| RelayError::WebSocket(format!("发送失败: {e}")))
}
/// 当前毫秒时间戳(避开 chrono workspace 依赖,直接用 std + SystemTime)
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}

View File

@@ -131,6 +131,53 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
// Repo 实现
// ============================================================
// ============================================================
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
// ============================================================
/// 灵感多条件查询入参。
///
/// 所有字段可选;全 None → 等价 `list_all`(向后兼容旧全量调用)。
/// 设计对齐 `查询能力补全方案-2026-06-21.md` 4.1:可选字段 struct 而非逐个加 IPC 参数,
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 分支化参数绑定)。
///
/// - `status`:状态精确匹配(走 `idx_tasks_status` 同类索引语义,后端 WHERE 收口前端 filter)
/// - `keyword`:`title LIKE %kw% OR description LIKE %kw%`(对齐知识库 LIKE 检索,不上 FTS5)
/// - `order_by`:白名单枚举(`created_at`/`updated_at`/`priority`/`status`/`score`,
/// 见 `validate_idea_order_by`,防 SQL 注入;不进字符串拼接)
/// - `limit`/`offset`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)
///
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
/// `Default`:命令层兼容旧 `status` 单参数路径(构造 `IdeaQuery { status, ..Default }`)。
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct IdeaQuery {
pub status: Option<String>,
pub keyword: Option<String>,
pub order_by: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
/// `order_by` 白名单校验(防 SQL 注入,对齐 `impl_repo!` 宏 `validate_column_name` 思路)。
///
/// 列名直进 SQL 字符串(`ORDER BY {col} DESC`),故必须白名单枚举校验,不接受任意字符串。
/// 允许的排序列:created_at(默认/新在前)、updated_at(最近更新)、priority(优先级)、
/// status(状态聚合)、score(评分,后端 NULL 视为 0)。
fn validate_idea_order_by(col: &str) -> df_types::error::Result<&'static str> {
Ok(match col {
"created_at" => "created_at",
"updated_at" => "updated_at",
"priority" => "priority",
"status" => "status",
"score" => "score",
_ => {
return Err(df_types::error::Error::Storage(format!(
"ideas order_by 不允许的字段名: {col}"
)))
}
})
}
impl_repo!(
/// 想法表 CRUD
IdeaRepo,
@@ -193,6 +240,109 @@ impl_repo!(
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl IdeaRepo {
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
///
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
/// 拼入(防 SQL 注入),`limit` 钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)。
///
/// **向后兼容**:空 query(全 None)→ `WHERE` 子句空 + 默认 `created_at DESC`,等价 `list_all`。
///
/// `score DESC NULLS LAST`:SQLite NULL 在 ASC 升序最前、DESC 降序最后,但为与前端原
/// computed 语义一致(`(b.score ?? 0) - (a.score ?? 0)`,NULL 当 0),显式 `COALESCE(score,0)`
/// 把 NULL 当 0 排序,避免 NULL 意外下沉到结果末尾。
pub async fn list_by_query(&self, q: &IdeaQuery) -> Result<Vec<IdeaRecord>> {
// 解析 + 校验 order_by(默认 created_at DESC)
let order_col = match &q.order_by {
Some(o) => validate_idea_order_by(o)?,
None => "created_at",
};
let conn = self.conn.clone();
// 所有按值移动进闭包的量,提前 clone 避免 move 借用问题
let status = q.status.clone();
let keyword = q.keyword.clone();
// 钳制 limit 上限 200(对齐 list_recent),默认不限制分页(None → 不拼 LIMIT)
let limit_i: Option<i64> = q.limit.map(|l| (l.min(200)) as i64);
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符)
let mut where_clauses: Vec<String> = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(kw) = &keyword {
let pat = format!("%{kw}%");
let p1 = where_clauses.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_clauses.join(" AND "))
};
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
let order_expr = if order_col == "score" {
"COALESCE(score, 0) DESC".to_string()
} else {
format!("{order_col} DESC")
};
// LIMIT/OFFSET:limit 为 None → 不拼(全量),offset 仅在 limit 存在时有意义。
let where_param_count = params_vec.len();
let limit_sql_bound = match limit_i {
Some(_) => format!(
" LIMIT ?{} OFFSET ?{}",
where_param_count + 1,
where_param_count + 2
),
None => String::new(),
};
let sql = format!(
"SELECT {IDEA_COLS} FROM ideas{where_sql} ORDER BY {order_expr}{limit_sql_bound}"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
// limit/offset 也压入 Box<Vec> 收口:借用引用需指向同一生命周期存活处,
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597)。
if let Some(l) = limit_i {
params_vec.push(Box::new(l));
params_vec.push(Box::new(offset_i));
}
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |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 {
/// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
///

View File

@@ -15,6 +15,62 @@ use crate::models::{
use super::impl_repo;
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
// ============================================================
// 项目查询入参(F-260621-02 P2/P3 查询维度补全)
// ============================================================
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
///
/// 对齐方案 docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1 统一查询结构。
/// 项目表无 status 生命周期(P1 跳过),仅软删 deleted_at(由 list_by_query 固定排除)。
/// 故本结构不含 status,只含 keyword/order_by/limit/offset 四维。
///
/// 序列化 derive:命令层经 serde 从前端 IPC 接收(Option 字段缺省 None,旧调用方不传等价全量)。
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProjectQuery {
/// 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词)
#[serde(default)]
pub keyword: Option<String>,
/// 排序字段(白名单校验,格式 "col" / "col asc" / "col desc",默认 created_at desc)
#[serde(default)]
pub order_by: Option<String>,
/// 返回上限(钳制 [0,200],None = 不加 LIMIT 全量)
#[serde(default)]
pub limit: Option<u32>,
/// 偏移量(None = 0,配合 limit 分页)
#[serde(default)]
pub offset: Option<u32>,
}
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let Some(raw) = order_by else {
return Ok("created_at DESC".to_string());
};
let raw = raw.trim();
if raw.is_empty() {
return Ok("created_at DESC".to_string());
}
// 拆 "col [dir]",最多 2 段。
let parts: Vec<&str> = raw.split_whitespace().collect();
let col = parts[0];
let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。
validate_column_name(col, "projects")?;
match dir.as_deref() {
None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")),
// 其它非法方向词:拒绝(防 "; DROP TABLE" 之类拼串绕过)。
Some(other) => Err(df_types::error::Error::Storage(format!(
"非法排序方向: {other}(仅支持 asc/desc)"
))),
}
}
// ============================================================
// from_row 辅助函数
// ============================================================
@@ -144,6 +200,74 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。
///
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
///
/// - `keyword`:name/description `LIKE %kw%`(对齐知识库 title/content LIKE)。
/// - `order_by`:走白名单 `validate_column_name` 防 SQL 注入(列名不可参数化,必须拼字符串,
/// 白名单是唯一防注入手段)。默认 `created_at DESC`(与 list_active 等价)。
/// - `limit`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`),防恶意/失误传超大值。
/// - `offset`:偏移量,配合 limit 做分页。
///
/// 向后兼容:全字段 None 时 SQL 退化为
/// `SELECT ... WHERE deleted_at IS NULL ORDER BY created_at DESC`
/// 与 `list_active` 完全等价,零破坏。
pub async fn list_by_query(&self, q: ProjectQuery) -> Result<Vec<ProjectRecord>> {
// order_by 白名单校验(列名拼字符串前必须校验,防注入)。
// 校验后再拼方向:允许 "col" / "col asc" / "col desc"(大小写不敏感)。
let order_clause = build_order_clause(q.order_by.as_deref())?;
let conn = self.conn.clone();
// limit 钳制:None 不加 LIMIT(全量,对齐 list_active);Some 钳到 [0,200]。
let limit_i: Option<i64> = q.limit.map(|n| n.min(200) as i64);
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
let mut sql = String::from(
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \
FROM projects WHERE deleted_at IS NULL",
);
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(kw) = &q.keyword {
let trimmed = kw.trim();
if !trimmed.is_empty() {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
let pattern = format!("%{trimmed}%");
params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern));
}
}
sql.push_str(" ORDER BY ");
sql.push_str(&order_clause);
// 分页:有 limit 才加 LIMIT/OFFSET(全量场景不加,等价 list_active)。
if let Some(lim) = limit_i {
sql.push_str(" LIMIT ? OFFSET ?");
params_vec.push(Box::new(lim));
params_vec.push(Box::new(offset_i));
}
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| project_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)?
}
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删项目,返回是否命中。
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();

View File

@@ -3,6 +3,7 @@
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use df_types::error::Result;
@@ -37,6 +38,59 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
})
}
// ============================================================
// 任务列表查询入参(F-260621-02 查询维度补全)
// ============================================================
/// 任务列表动态查询入参。全可选,空 query = 等价当前全量行为(向后兼容)。
///
/// 设计对齐方案文档(docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1):
/// ① 字段全 Option,旧调用方不传 / 传空 → 等价 list_active(全量未删),零破坏;
/// ② 复用 KnowledgeRepo::search 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 参数绑定);
/// ③ order_by 走白名单(TASK_ORDER_BY_WHITELIST)防 SQL 注入,对齐
/// impl_repo! 宏 validate_column_name 的白名单防注入思路;
/// ④ limit/offset 钳制上限(limit.min(500))防滥用,对齐 conversation_repo::list_recent
/// 的 limit.min(200)(任务场景放宽至 500,数据量 ~15 任务)。
///
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskQuery {
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
pub project_id: Option<String>,
/// 状态过滤(P1 下沉,命中 idx_tasks_status)。值集由上层 TaskStatus::is_valid 兜底校验。
pub status: Option<String>,
/// 优先级过滤(0=critical..3=low)。P3 基建就绪,视图暂不用。
pub priority: Option<i32>,
/// 负责人过滤。P3 基建就绪,视图暂不用。
pub assignee: Option<String>,
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
pub keyword: Option<String>,
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
pub order_by: Option<String>,
/// 分页上限(钳制 ≤500)。P3 基建就绪。
pub limit: Option<u32>,
/// 分页偏移。P3 基建就绪。
pub offset: Option<u32>,
}
/// order_by 白名单:只允许这些列名拼进 SQL(防注入,列名不可参数化只能白名单)。
/// 对齐 impl_repo! 宏 validate_column_name 的「白名单先于拼接校验」防注入思路。
/// 注:created_at 是默认值(白名单含它,query 未指定时用 created_at DESC)。
const TASK_ORDER_BY_WHITELIST: &[&str] = &["created_at", "updated_at", "priority", "status"];
/// 校验 order_by 列名在白名单内,否则返回 Err(防 SQL 注入)。
fn validate_order_by(field: &str) -> Result<()> {
if TASK_ORDER_BY_WHITELIST.contains(&field) {
Ok(())
} else {
Err(df_types::error::Error::Storage(format!(
"非法 order_by 字段名: {},合法值: {:?}",
field, TASK_ORDER_BY_WHITELIST
)))
}
}
// ============================================================
// Repo 实现
// ============================================================
@@ -218,6 +272,127 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 动态条件列出未删除任务(F-260621-02 查询维度补全)。
///
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
/// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行,
/// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。
///
/// - 过滤维度:project_id / status / priority / assignee(精确等值)+ keyword(title/description LIKE)
/// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search)
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
/// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
///
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
/// (非法 status 在 DB 无匹配行,返回空 Vec,无害)。
pub async fn list_by_query(&self, query: &TaskQuery) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
// order_by 白名单校验在闭包外做(提前 fail-fast,非法值不进 DB 层)。列名不可
// 参数化,只能白名单防注入(对齐 impl_repo! 宏 validate_column_name 思路)。
// order_col 转 String 拥有所有权:避免 &str 借用 query 跨 spawn_blocking 'static
// 闭包(E0521 borrowed data escapes)。
let order_col = query.order_by.as_deref().unwrap_or("created_at");
validate_order_by(order_col)?;
let order_col = order_col.to_string();
// limit 钳制上限 500 防滥用(对齐 conversation_repo::list_recent 的 limit.min(200)
// 思路,任务场景放宽;None → 不拼 LIMIT = 全量,语义同 list_active)。
let limit_i: Option<i64> = query.limit.map(|l| (l.min(500)) as i64);
let offset_i: i64 = query.offset.unwrap_or(0) as i64;
// 拷贝 query 字段进闭包('static 生命周期,spawn_blocking 要求)
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let assignee = query.assignee.clone();
let keyword = query.keyword.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)──
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(pid) = &project_id {
where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1));
params_vec.push(Box::new(p));
}
if let Some(a) = &assignee {
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1));
params_vec.push(Box::new(a.clone()));
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword {
let pat = format!("%{kw}%");
let p1 = where_clauses.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
// LIMIT/OFFSET:limit 为 None → 不拼(全量);有 limit 时 offset 紧跟其后。
let where_param_count = params_vec.len();
let limit_sql_bound = match limit_i {
Some(_) => format!(
" LIMIT ?{} OFFSET ?{}",
where_param_count + 1,
where_param_count + 2
),
None => String::new(),
};
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
let sql = format!(
"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 {} ORDER BY {} DESC{}",
where_clauses.join(" AND "),
order_col,
limit_sql_bound
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
// limit/offset 也压入 params_vec 收口:借用引用需指向同一生命周期存活处,
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597,
// 对齐 idea_repo.rs list_by_query 同名模式)。
if let Some(l) = limit_i {
params_vec.push(Box::new(l));
params_vec.push(Box::new(offset_i));
}
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |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)?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
///
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),

View File

@@ -27,8 +27,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底)
let steps: [(i32, fn(&Connection) -> Result<()>); 25] = [
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
let steps: [(i32, fn(&Connection) -> Result<()>); 26] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -54,6 +55,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(23, migrate_v23),
(24, migrate_v24),
(25, migrate_v25),
(26, migrate_v26),
];
for (version, migrate_fn) in steps {
@@ -632,6 +634,29 @@ fn migrate_v25(conn: &Connection) -> Result<()> {
Ok(())
}
/// V26:补建 tasks 表 priority/assignee 索引(F-260621-02 索引缺口)
///
/// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee),
/// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status /
/// idx_tasks_project_id 同类过滤维度。当前数据量 ~15(前瞻基建非性能驱动),
/// 但索引零成本(SQLite 维护代价极小)且向后续 priority 排序/分配人筛选铺路。
///
/// 实现选 CREATE INDEX IF NOT EXISTS:原生幂等(新库 V1_SQL 已建则跳过,
/// 老库无则补建),对新库与老库均安全,无需 PRAGMA 探测。索引定义须与 V1_SQL 中
/// 的同名索引一致(仅 priority/assignee 单列索引)。
///
/// 注:assignee 列允许 NULL(V1_SQL 未 NOT NULL),SQLite 索引正常包含 NULL 行,
/// 不影响 assignee = ? 等值查询命中(过滤掉 NULL 行)。
fn migrate_v26(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);\
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)",
)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [26])?;
tracing::info!("迁移 v26 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -762,6 +787,10 @@ CREATE TABLE IF NOT EXISTS node_executions (
-- 索引
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
-- V26 补建(F-260621-02 索引缺口):priority/assignee 过滤下推索引,新库一次性建;
-- 老库由 migrate_v26 CREATE INDEX IF NOT EXISTS 补建,两边索引定义须一致。
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
CREATE INDEX IF NOT EXISTS idx_releases_project_id ON releases(project_id);
CREATE INDEX IF NOT EXISTS idx_node_executions_workflow_id ON node_executions(workflow_id);
";

View File

@@ -0,0 +1,18 @@
[package]
name = "df-tunnel"
version = "0.1.0"
edition = "2021"
description = "本地 DevFlow 桌面端出站 WS 隧道客户端(穿 NAT 连云后端 df-relay)"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# WS 客户端(tungstenite 底层 + tokio 异步封装)
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"

View File

@@ -0,0 +1,43 @@
//! df-tunnel 错误类型
//!
//! 设计:单一 TunnelError 覆盖 WS 连接/收发/序列化/鉴权四类失败,
//! thiserror 派生保留错误链(`#[from]` 自动 From)。
//! 脚手架阶段仅定义类型,不涉及具体错误码细分(Phase2 填充)。
use thiserror::Error;
/// 隧道客户端运行期错误
///
/// 覆盖范围:
/// - 连接建立失败(网络不可达 / TLS 握手 / 鉴权拒绝)
/// - 收发失败(底层 WS 帧 / 序列化 / 反序列化)
/// - 协议层错误(未预期的消息类型 / 配对绑定失败)
#[derive(Debug, Error)]
pub enum TunnelError {
/// WS 连接建立或断线重连失败
#[error("隧道连接失败: {0}")]
Connect(String),
/// 底层 tungstenite WS 协议错误
#[error("WS 协议错误: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
/// 消息序列化/反序列化失败
#[error("消息序列化失败: {0}")]
Serde(#[from] serde_json::Error),
/// 云后端鉴权/配对拒绝(token 无效 / device 未绑定)
#[error("鉴权/配对失败: {0}")]
Auth(String),
/// 隧道未连接(对 send/recv 的前置状态校验失败)
#[error("隧道未连接")]
NotConnected,
/// 其他未归类错误(降级通道,Phase2 视需要细分)
#[error("隧道内部错误: {0}")]
Other(#[from] anyhow::Error),
}
/// 模块级 Result 别名,简化调用方签名
pub type Result<T> = std::result::Result<T, TunnelError>;

View File

@@ -0,0 +1,146 @@
//! df-tunnel 隧道事件类型
//!
//! 设计依据:设计文档「三、技术选型」指出 AiChatEvent 17 变体在两端同语言保证类型一致。
//! 但 AiChatEvent 定义在 src-tauri 非 crate 层,跨 crate path 引用会破坏分层边界
//! (lib crate 依赖 src-tauri 二进制不合理)。故此处**独立定义隧道透传子集**:
//! - 仅保留跨端高频透传的 4 个变体(TextDelta/ToolCall/Approval/Completed),
//! Phase2 可按需追加,不依赖 src-tauri。
//! - 携带 conv_id 字段做多会话路由(对齐 F-09 多会话 conv_id 契约)。
//!
//! 向后兼容:新增变体只加不减,旧消费方忽略未知变体(serde 配 `#[serde(other)]` 捕获兜底)。
use serde::{Deserialize, Serialize};
/// 握手身份宣告帧(对齐 df-relay::relay::Hello)
///
/// 桌面端 WS 连 `/ws/device` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 df-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。
///
/// 注意:本 crate 不依赖 df-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 df-relay::relay::Hello 与本类型。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// 客户端类型("device" / "miniapp"),桌面端恒为 Device
pub kind: HelloKind,
/// 配对绑定的设备 ID
pub device_id: String,
/// 配对 token(env DF_RELAY_TOKEN 同源)
pub token: String,
}
/// Hello.kind 传输表示(serde 字符串,与 df-relay::ClientKindWire 一致)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HelloKind {
Device,
Miniapp,
}
/// relay 控制面错误帧(握手失败时 relay 发送)
///
/// relay 在校验失败/连接异常时发 `{"kind":"control","error":"<reason>"}` 文本帧。
/// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayControlError {
/// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段)
pub kind: String,
/// 错误原因(handshake_failed / kind_mismatch / auth_failed)
pub error: String,
}
/// 隧道透传的事件类型(AiChatEvent 简化子集)
///
/// 变体语义对齐桌面端 ai-chat-event:
/// - `TextDelta`:流式增量文本块
/// - `ToolCall`:工具调用请求(含工具名/参数)
/// - `Approval`:审批请求(用户授权确认)
/// - `Completed`:单轮对话完成
///
/// Phase2 可扩展 Error/Stopped/Regenerated 等变体。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelEvent {
/// 流式文本增量
TextDelta {
/// 会话 ID(多会话路由用,对齐 F-09)
conv_id: String,
/// 增量文本片段
delta: String,
},
/// 工具调用请求
ToolCall {
/// 会话 ID
conv_id: String,
/// 工具调用唯一标识(用于回填结果)
call_id: String,
/// 工具名
name: String,
/// 工具参数(原始 JSON,由消费方按 name 解析)
arguments: serde_json::Value,
},
/// 审批请求(工具/操作需用户授权)
Approval {
/// 会话 ID
conv_id: String,
/// 审批项唯一标识
approval_id: String,
/// 审批描述(展示给用户)
description: String,
},
/// 对话单轮完成
Completed {
/// 会话 ID
conv_id: String,
/// 本轮产生的消息 ID
message_id: String,
},
}
impl TunnelEvent {
/// 取事件所属会话 ID(路由分发的便利方法)
pub fn conv_id(&self) -> &str {
match self {
TunnelEvent::TextDelta { conv_id, .. }
| TunnelEvent::ToolCall { conv_id, .. }
| TunnelEvent::Approval { conv_id, .. }
| TunnelEvent::Completed { conv_id, .. } => conv_id,
}
}
}
/// 云后端 → 桌面端的操作指令(小程序远程操作映射)
///
/// 对齐设计文档「Layer1 指令路由」:
/// send/stop/approve/regenerate/switch 五类操作。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelCommand {
/// 发送消息
Send { conv_id: String, content: String },
/// 停止当前生成
Stop { conv_id: String },
/// 审批通过/拒绝(approval_id + accepted)
Approve { conv_id: String, approval_id: String, accepted: bool },
/// 重新生成
Regenerate { conv_id: String },
/// 切换对话
Switch { conv_id: String },
}
/// 隧道传输的消息包装(双向)
///
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent)
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。
/// 同一 WS 连接复用,靠 kind 标签区分。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "dir", rename_all = "snake_case")]
pub enum TunnelMessage {
/// 桌面端发出的事件(下行)
Event(TunnelEvent),
/// 云后端转发的指令(上行,发往桌面端)
Command(TunnelCommand),
}

View File

@@ -0,0 +1,26 @@
//! # df-tunnel
//!
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
//!
//! ## 职责
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射)
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command
//!
//! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
//!
//! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型
//! - [`tunnel`]:TunnelClient trait + WsTunnelClient 骨架
//! - [`error`]:TunnelError 错误类型
pub mod error;
pub mod events;
pub mod tunnel;
// 顶层再导出常用项,简化调用方 use
pub use error::{Result, TunnelError};
pub use events::{Hello, HelloKind, RelayControlError, TunnelCommand, TunnelEvent, TunnelMessage};
pub use tunnel::{TunnelClient, WsTunnelClient};

View File

@@ -0,0 +1,547 @@
//! df-tunnel 隧道客户端(Phase2 完整实现)
//!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连。
//!
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
//! - relay 双因子校验(kind == expected_device + token 匹配),失败发
//! `{"kind":"control","error":"..."}` + Close,成功则静默进入收发循环(无显式 ack)
//! - 握手成功信号:**未收到 error 帧 且 socket 保持打开**(connect 用 timeout 探测首帧)
//! - 入站消息:relay 把对端 Miniapp 的文本帧包成 BroadcastMessage 转发,桌面端收到的
//! 是完整 BroadcastMessage JSON,payload 是 miniapp 指令原样 JSON(Phase3 纯透传,不解析)
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 BroadcastMessage)
//!
//! ## AiChatEvent 透传原则
//! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。
//! TunnelEvent 仅是高频子集,扩展变体不破坏向后兼容。
//!
//! ## 并发模型
//! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。
//! 收到 command 方向 payload(`serde_json::Value`)走 on_command 回调(调用方在 connect 时注册),不阻塞收发循环。
//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 device 端桥接层(src-tauri)。
//! `&self` + `Arc<Mutex<...>>` 持连接状态,trait 方法全部 async + 不持 stream 借用。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::error::{Result, TunnelError};
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent};
/// 收到 miniapp→device 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
///
/// Phase3 协议统一(D1=A):tunnel 纯透传,不再反序列化 TunnelCommand 强类型,
/// payload 以 `serde_json::Value` 上抛,业务协议解析在 device 端桥接层(src-tauri)。
/// 设计为 async 便于调用方执行 Tauri command 异步路由。回调内 panic 会中断收发循环,
/// 调用方应自行处理错误(回调返回值忽略,失败由调用方记录)。
pub type CommandHandler = Arc<dyn Fn(serde_json::Value) -> futures_util::future::BoxFuture<'static, ()> + Send + Sync>;
/// 隧道客户端抽象
///
/// 设计为 trait 而非具体结构,便于:
/// 1. 测试用 mock 实现(不依赖真实 WS)
/// 2. 未来替换底层实现(如换 quic / sse)不改调用方
///
/// 状态语义:
/// - `connect` 一次建立连接 + spawn 收发循环,重复 connect 视为重连(先 disconnect)
/// - `is_connected` 轻量查询,不发心跳
/// - `send_event` 非阻塞入队,实际 socket 写入由收发循环异步完成
/// - `disconnect` 优雅关闭(Close 帧 + 停 task + 清资源)
#[async_trait]
pub trait TunnelClient: Send + Sync {
/// 主动连接云后端 + 注册指令回调
///
/// `url` 形如 `wss://host/ws/device`(query 不带 token,token 走 Hello 帧)。
/// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。
/// `on_command` 收到云后端转发的 command 方向 payload Value 时被调用(在收发 task 内异步执行)。
/// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 device 端桥接层 match cmd 路由。
async fn connect(
&self,
url: &str,
device_id: &str,
token: &str,
on_command: CommandHandler,
) -> Result<()>;
/// 主动断开(优雅 Close 帧 + 释放 task / 通道)
async fn disconnect(&self) -> Result<()>;
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
fn is_connected(&self) -> bool;
/// 发送事件(桌面端 → 云后端 → 小程序)
///
/// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。
async fn send_event(&self, event: TunnelEvent) -> Result<()>;
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
///
/// Phase3 阶段2(D2 全 19 变体透传):EventBus subscriber 把 AiChatEvent Value 经此方法
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
/// 后者保留作高频子集快捷方式(D5 强类型保留)。非阻塞入队,连接断开返 NotConnected。
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
}
/// 握手探测窗口(relay 校验通过后静默进入收发循环,无 ack 帧;
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
/// 重连初始退避
const RECONNECT_BASE: Duration = Duration::from_secs(1);
/// 重连退避上限
const RECONNECT_MAX: Duration = Duration::from_secs(30);
/// 内部连接状态(disconnect 时 drop 即清资源)
struct ConnState {
/// 发送队列:send_event 投递 → 收发循环消费写 socket
tx: mpsc::UnboundedSender<OutFrame>,
/// 收发循环 task handle(disconnect 时 abort)
task: JoinHandle<()>,
}
/// 收发循环出站帧(SendLoop 内部消费)
enum OutFrame {
/// 业务事件(已序列化 JSON 字符串,run_loop 直接写 socket)
///
/// send_event(TunnelEvent) 与 send_raw_event(Value) 各自预序列化为 String 入队,
/// 统一经此变体写 socket(DRY:序列化在调用侧,run_loop 只负责写)。
Event(String),
/// 优雅关闭
Close,
}
/// 默认 WS 隧道客户端(完整实现)
pub struct WsTunnelClient {
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读)
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连),
/// 该字段保留以备 Phase3「断线后台自动重连」消费。
server_url: Mutex<Option<String>>,
device_id: Mutex<Option<String>>,
token: Mutex<Option<String>>,
/// 当前活跃连接状态(None = 未连接)
conn: Mutex<Option<ConnState>>,
/// 连接状态标志(与收发循环 task 共享同一 Arc,is_connected 无锁查询;
/// task 退出时置 false,connect 时置 true)
connected: Arc<AtomicBool>,
}
impl WsTunnelClient {
/// 创建未连接的客户端实例
pub fn new() -> Self {
Self {
server_url: Mutex::new(None),
device_id: Mutex::new(None),
token: Mutex::new(None),
conn: Mutex::new(None),
connected: Arc::new(AtomicBool::new(false)),
}
}
/// 当前已连接时取一份重连参数(未来自动重连用)
pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?;
let token = self.token.lock().await.clone()?;
Some((url, device_id, token))
}
}
impl Default for WsTunnelClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TunnelClient for WsTunnelClient {
async fn connect(
&self,
url: &str,
device_id: &str,
token: &str,
on_command: CommandHandler,
) -> Result<()> {
// 已连接则先清(支持重复 connect 视作重连)
if self.connected.load(Ordering::Relaxed) {
self.cleanup_conn().await;
}
// 1. 建立 WS 连接
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
let (mut sink, mut stream) = ws_stream.split();
// 2. 发 Hello 握手帧
let hello = Hello {
kind: HelloKind::Device,
device_id: device_id.to_string(),
token: token.to_string(),
};
let hello_json = serde_json::to_string(&hello)?;
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
TunnelError::Connect(format!("发送 Hello 失败: {e}"))
})?;
// 3. 探测握手结果:HANDSHAKE_PROBE 窗口内
// - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因)
// - 收到 Close / EOF → 连接被关(返回 Connect 错误)
// - 收到首条正常消息 / 超时无负帧 → 视为握手成功(relay 静默进入收发循环)
// - Ping/Pong 协议帧:忽略继续等
let mut first_frame_pending: Option<WsMessage> = None;
let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await;
match probe {
Err(_) => {
// 超时无负帧:relay 静默通过(常见路径)
tracing::debug!("握手探测窗口无负帧,视为成功");
}
Ok(None) => {
return Err(TunnelError::Connect(
"握手阶段连接关闭(relay 未响应)".into(),
));
}
Ok(Some(Err(e))) => {
return Err(TunnelError::Connect(format!("握手 recv 错误: {e}")));
}
Ok(Some(Ok(msg))) => {
first_frame_pending = Some(msg);
}
}
// 校验首帧是否为 relay 拒绝帧
if let Some(msg) = &first_frame_pending {
if let Some(err) = parse_relay_error(msg) {
return Err(TunnelError::Auth(format!(
"relay 拒绝握手: {}",
err.error
)));
}
if matches!(msg, WsMessage::Close(_)) {
return Err(TunnelError::Connect("握手被 relay Close".into()));
}
}
// 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环
*self.server_url.lock().await = Some(url.to_string());
*self.device_id.lock().await = Some(device_id.to_string());
*self.token.lock().await = Some(token.to_string());
let (tx, rx) = mpsc::unbounded_channel::<OutFrame>();
// 复用 self.connected 的 Arc,task 退出时置 false 与 self 同步
self.connected.store(true, Ordering::Relaxed);
let connected_for_task = self.connected.clone();
let task = tokio::spawn(run_loop(
sink,
stream,
first_frame_pending,
rx,
on_command,
connected_for_task,
));
*self.conn.lock().await = Some(ConnState { tx, task });
tracing::info!(%url, device_id, "WsTunnelClient 连接就绪,收发循环已启动");
Ok(())
}
async fn disconnect(&self) -> Result<()> {
self.cleanup_conn().await;
tracing::info!("WsTunnelClient 已断开");
Ok(())
}
fn is_connected(&self) -> bool {
self.connected.load(Ordering::Relaxed)
}
async fn send_event(&self, event: TunnelEvent) -> Result<()> {
// 序列化提前(OutFrame::Event 持 String),失败返 Serde 错给调用方
let json = serde_json::to_string(&event)?;
let conn = self.conn.lock().await;
let Some(state) = conn.as_ref() else {
return Err(TunnelError::NotConnected);
};
state
.tx
.send(OutFrame::Event(json))
.map_err(|_| TunnelError::NotConnected)?;
Ok(())
}
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()> {
// AiChatEvent Value 序列化为 JSON 字符串入队(与 send_event 共用 OutFrame::Event(String))
let json = serde_json::to_string(&payload)?;
let conn = self.conn.lock().await;
let Some(state) = conn.as_ref() else {
return Err(TunnelError::NotConnected);
};
state
.tx
.send(OutFrame::Event(json))
.map_err(|_| TunnelError::NotConnected)?;
Ok(())
}
}
impl WsTunnelClient {
/// 清理当前连接(Close 帧 + abort task + 置标志)
async fn cleanup_conn(&self) {
let mut conn = self.conn.lock().await;
if let Some(state) = conn.take() {
// 通知收发循环优雅关闭(忽略发送失败:循环已退出)
let _ = state.tx.send(OutFrame::Close);
// 给 task 一点时间自然退出(发完 Close 帧);超时则 abort 兜底
let mut task = state.task;
tokio::select! {
_ = &mut task => {}
_ = tokio::time::sleep(Duration::from_millis(500)) => {
task.abort();
}
}
}
*self.server_url.lock().await = None;
*self.device_id.lock().await = None;
*self.token.lock().await = None;
self.connected.store(false, Ordering::Relaxed);
}
}
/// 收发循环主体(spawn 后独立运行)
///
/// 职责:
/// 1. socket 入帧 → 解析 BroadcastMessage → 取 payload Value 纯透传 → on_command 回调(协议解析在桥接层)
/// 2. mpsc 出帧 → socket 写入(Event 序列化 / Ping 心跳 / Close 关闭)
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
/// 4. 任一端断开 → 退出 task,置 connected=false
///
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。
async fn run_loop(
mut sink: futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
WsMessage,
>,
mut stream: futures_util::stream::SplitStream<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
>,
first_frame: Option<WsMessage>,
mut rx: mpsc::UnboundedReceiver<OutFrame>,
on_command: CommandHandler,
connected: Arc<AtomicBool>,
) {
// 心跳 tick
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
// 首次 tick 立即触发会干扰,跳过
heartbeat.tick().await;
// 处理握手期已读但未消费的首帧(可能是首条业务消息)
if let Some(msg) = first_frame {
if !handle_inbound(&msg, &on_command).await {
tracing::info!("首帧处理后连接应关闭,退出收发循环");
connected.store(false, Ordering::Relaxed);
let _ = sink.close().await;
return;
}
}
loop {
tokio::select! {
// socket 入帧
maybe_msg = stream.next() => {
match maybe_msg {
Some(Ok(msg)) => {
if !handle_inbound(&msg, &on_command).await {
tracing::info!("对端关闭连接,收发循环退出");
break;
}
}
Some(Err(e)) => {
tracing::warn!(error = %e, "socket recv 错误,收发循环退出");
break;
}
None => {
tracing::info!("socket 流结束,收发循环退出");
break;
}
}
}
// 出站队列
maybe_out = rx.recv() => {
match maybe_out {
// OutFrame::Event 持已序列化 JSON 字符串(send_event/send_raw_event 预序列化)
Some(OutFrame::Event(json)) => {
if let Err(e) = sink.send(WsMessage::Text(json)).await {
tracing::warn!(error = %e, "socket 写入失败,收发循环退出");
break;
}
}
Some(OutFrame::Close) => {
tracing::debug!("收到 disconnect 指令,发 Close 帧优雅关闭");
let _ = sink.send(WsMessage::Close(Some(CloseFrame {
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal,
reason: "client_disconnect".into(),
}))).await;
break;
}
None => {
tracing::debug!("发送队列关闭(客户端 drop),退出收发循环");
break;
}
}
}
// 心跳
_ = heartbeat.tick() => {
if let Err(e) = sink.send(WsMessage::Ping(Vec::new())).await {
tracing::warn!(error = %e, "心跳发送失败,收发循环退出");
break;
}
}
}
}
connected.store(false, Ordering::Relaxed);
// 确保 sink 关闭(若上面 break 未发 Close,这里兜底)
let _ = sink.close().await;
}
/// 处理入站帧,返回 false 表示连接应关闭(Close/EOF/错误)
async fn handle_inbound(msg: &WsMessage, on_command: &CommandHandler) -> bool {
match msg {
WsMessage::Text(text) => {
// Phase3 纯透传(D1=A):relay 转发完整 BroadcastMessage,
// payload 是 miniapp 指令原样 JSON。tunnel 只提取 payload Value 上抛,
// 不解析业务协议(协议解析在 device 端桥接层 src-tauri)。
if let Some(payload) = parse_payload_from_broadcast(text) {
// 回调内执行指令路由(桥接层 match cmd → Tauri command);回调失败不影响收发循环
let fut = on_command(payload);
fut.await;
} else {
// 非 Command 消息(Control/Event 回环/未知):忽略,不中断
tracing::debug!(text = %text.chars().take(200).collect::<String>(), "收到非 Command 入站消息,忽略");
}
true
}
WsMessage::Binary(_) => {
tracing::debug!("收到二进制帧,忽略");
true
}
WsMessage::Ping(_) => {
// tungstenite 协议层自动应答 Pong,这里不处理
true
}
WsMessage::Pong(_) => true,
WsMessage::Close(_) => false,
WsMessage::Frame(_) => true, // 原始帧,上层已聚合,忽略
}
}
/// 从 BroadcastMessage JSON 提取 command 方向的 payload Value(纯透传,不解析业务协议)
///
/// Phase3 协议统一(D1=A):df-relay 入站包成 `BroadcastMessage { ..., payload: Value }`,
/// tunnel 只提取 payload 字段以 Value 上抛 on_command,不反序列化为 TunnelCommand 强类型
/// (业务协议解析在 device 端桥接层)。仅 kind=="command" 时提取(Event/Control 不提,避免回环噪音)。
fn parse_payload_from_broadcast(raw: &str) -> Option<serde_json::Value> {
// 仅取 payload 字段,避整结构强类型耦合(device_id/source 等字段本客户端不关心)
#[derive(serde::Deserialize)]
struct BroadcastLike {
#[serde(default)]
payload: Option<serde_json::Value>,
#[serde(default)]
kind: Option<String>,
}
let parsed: BroadcastLike = serde_json::from_str(raw).ok()?;
// 仅 kind == "command" 时才提 payload(Event/Control 不提)
if parsed.kind.as_deref() != Some("command") {
return None;
}
parsed.payload
}
/// 判断是否为 relay 控制面错误帧
fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
if let WsMessage::Text(t) = msg {
serde_json::from_str::<RelayControlError>(t).ok()
} else {
None
}
}
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分)
#[allow(dead_code)]
fn backoff_delay(attempt: u32) -> Duration {
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
let secs = RECONNECT_BASE
.as_secs()
.saturating_mul(1u64 << attempt.min(5));
Duration::from_secs(secs.min(RECONNECT_MAX.as_secs()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_caps_at_max() {
assert_eq!(backoff_delay(0), RECONNECT_BASE);
assert_eq!(backoff_delay(1), Duration::from_secs(2));
assert_eq!(backoff_delay(5), Duration::from_secs(32).min(RECONNECT_MAX));
assert_eq!(backoff_delay(10), RECONNECT_MAX);
}
#[test]
fn parse_payload_from_broadcast_extracts_value() {
// 模拟 relay 转发的 BroadcastMessage(miniapp 发的 MiniCommand {cmd, args} 在 payload)
let raw = r#"{"device_id":"dev-1","kind":"command","source":{"0":42},"from":"miniapp","payload":{"cmd":"send_message","args":{"message":"hi","conversation_id":"c1"}},"ts":1700000000000}"#;
let payload = parse_payload_from_broadcast(raw).expect("应提取出 payload Value");
// 纯透传:payload 原样上抛,字段保持 miniapp 端 {cmd, args} 结构(未做协议转换)
assert_eq!(payload["cmd"], "send_message");
assert_eq!(payload["args"]["message"], "hi");
assert_eq!(payload["args"]["conversation_id"], "c1");
}
#[test]
fn parse_payload_ignores_event_kind() {
// Event 方向(device→miniapp)不提取,避免回环噪音
let raw = r#"{"device_id":"dev-1","kind":"event","source":{"0":1},"from":"device","payload":{"type":"text_delta","conversation_id":"c1","delta":"x"},"ts":1}"#;
assert!(parse_payload_from_broadcast(raw).is_none());
}
/// D5 弱校验:payload Value 仍可反序列化为 TunnelCommand(保留强类型作桥接层可选校验)
#[test]
fn payload_compatible_with_tunnel_command() {
use crate::events::TunnelCommand;
let raw = r#"{"device_id":"dev-1","kind":"command","source":{"0":42},"from":"miniapp","payload":{"kind":"send","conv_id":"c1","content":"hi"},"ts":1}"#;
let payload = parse_payload_from_broadcast(raw).expect("应提取 payload");
let cmd: TunnelCommand = serde_json::from_value(payload).expect("payload 应兼容 TunnelCommand");
match cmd {
TunnelCommand::Send { conv_id, content } => {
assert_eq!(conv_id, "c1");
assert_eq!(content, "hi");
}
other => panic!("预期 Send,实际 {other:?}"),
}
}
#[test]
fn parse_relay_error_detects_auth_failure() {
let msg = WsMessage::Text(r#"{"kind":"control","error":"auth_failed"}"#.into());
let err = parse_relay_error(&msg).expect("应解析出错误帧");
assert_eq!(err.error, "auth_failed");
}
}

View File

@@ -6,7 +6,15 @@ use std::collections::HashMap;
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DagDef {
// BUG-260621-01: nodes/edges 加 #[serde(default)]。
// 前端 run_workflow 推进链传空 dag `{}`(设计意图:空 dag + target_status →
// workflow.rs:91 dag.nodes.is_empty() 进 template_for 自动选模板)。原无 default
// 时,`{}` 在 Tauri IPC 入口反序列化 DagDef 即报 `missing field nodes`,挡在选模板
// 逻辑之前。加 default 后 `{}` → 空 DagDef(HashMap/Vec 的 Default 均空集合),
// 顺利进 :91 选模板分支。非空 dag 零回归(serde default 仅补缺失字段)。
#[serde(default)]
pub nodes: HashMap<String, NodeDef>,
#[serde(default)]
pub edges: Vec<EdgeDef>,
}
@@ -78,3 +86,28 @@ impl Default for DagDef {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
// BUG-260621-01: 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
// 成空 DagDef(非报 missing field),后续 workflow.rs:91 dag.nodes.is_empty()
// 进 template_for 选模板。nodes/edges 加 #[serde(default)] 前此用例会失败。
#[test]
fn empty_json_deserializes_to_empty_dagdef() {
let dag: DagDef = serde_json::from_str("{}").expect("空 JSON 应反序列化为空 DagDef");
assert!(dag.nodes.is_empty());
assert!(dag.edges.is_empty());
}
// 零回归:非空 dag 反序列化行为不变(serde default 仅补缺失字段)。
#[test]
fn nonempty_dag_deserializes_unchanged() {
let json = r#"{"nodes":{"n1":{"id":"n1","node_type":"echo","config":{}}},"edges":[]}"#;
let dag: DagDef = serde_json::from_str(json).expect("非空 dag 反序列化");
assert_eq!(dag.nodes.len(), 1);
assert!(dag.nodes.contains_key("n1"));
assert!(dag.edges.is_empty());
}
}

View File

@@ -0,0 +1,257 @@
# AST 符号解析 — Code Intelligence 设计
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
## Context(为什么)
**起因**:实测会话 e46f5605「DevFlow上下文管理不足分」——LLM `read_file` 读 8 个源码文件(context.rs/anthropic_compat/intent.rs/prompt.rs/compress 等)各 500 行**全文回灌**,prompt 累积 **360K token 超 glm-5.2 上下文上限** → LLM 失败 → 末尾 tool 后无 assistant(卡)。8dfe0b94 更滚到 5M token 爆炸。
**已否决的治标方向**:
- ❌ read_file 限制行数(500→150):"等于没读",LLM 被迫 offset 翻页,更多轮次 + 仍累积
- ❌ read_file content 字节截断(8KB):"不讲武德",LLM 要全文被砍
**正路**:AST 语义解析,精准提取**调用链 + 函数代码**,替代物理读全文。
**双目标**(用户明确):
1. 精准获取调用链 + 函数代码,避免物理读文件的 LLM 上下文成本
2. AST 树作后期 ai coding 编码支撑工具(符号检索/依赖图/生成上下文/向量编码)
**根本目标(2026-06-24 深化)**:提高上下文**信息密度**——使进 prompt 的每一行与**当前关注主题**紧密结合,去噪、去多余。这是所有读取/压缩设计的判据。
## 核心原则:信息密度 ≠ 压缩(2026-06-24)
**密度 = 只给与当前任务相关的内容;压缩 = 无差别减体积。两者不同。** 压缩减了体积,密度不一定升——减什么是预设规则或盲猜,和"当前任务关注什么"无关。
**分水岭:压缩器带不带当前任务上下文。**
- 不带 → 通用摘要(治标,可能砍掉恰是任务关键的细节)
- 带(任务焦点 focus)→ 相关性提炼(治本,留相关去无关)
**被否的治标方向**:
- ❌ 物理行数截断上限(500→150→3):武断,任何值都不合理(3 行砍废、3 万行等于没设);且按行返本身错——该按**语义结构层级**返
- ❌ 算法裁剪(砍注释/doc/空行):格式去噪,留下的不一定和任务相关,密度提升有限
- ❌ 通用 LLM 摘要(不带 focus):压出"这个函数做什么"的通用摘要,可能丢任务关键行
**正路(本设计采纳)**:
1. **结构层级供给**(拉模式/Progressive Disclosure):read_symbol 默认返骨架 → LLM 看骨架下钻局部 → 精准取。进 prompt 的都是 LLM 主动要的,天然相关。
2. **主题驱动压缩**(带 focus):大块必须给时,压缩 LLM 接收当前任务焦点,做相关性提炼(非通用摘要)。
→ 不按物理行返,按**语义层级**返;压缩必须**带任务上下文**。
## 一、技术定位:AST vs LSP(两个不同层面)
| | AST(tree-sitter) | LSP(rust-analyzer) |
|---|---|---|
| 本质 | 数据结构(语法树,解析产物) | 通信协议(语义服务,JSON-RPC) |
| 层面 | 语法层 | 语义层 |
| 提供什么 | 符号结构/调用语法 | 类型/精准引用/重构/诊断 |
| 形态 | 库(链接进进程) | 独立服务器(子进程) |
| 成本 | 轻(ms/MB) | 重(二进制 30-50MB/平台 + 索引 + 内存) |
抽象层级:源码 → [tree-sitter] → AST(语法,Phase1-3 停此) → [类型推断] → 语义 → [LSP 协议] → 客户端。**LSP 依赖 AST**(rust-analyzer 内部也用),但停在更深(语义层)。
## 二、目标论证(能力边界,不夸大)
### 目标1:调用链 + 函数代码
| 能力 | tree-sitter | Phase |
|---|---|---|
| 函数结构(骨架·默认) | ✅ `function_definition` → 签名+分支结构+调用图+行数(极小,高密度) | 1 |
| 函数代码(定义体) | ✅ `full=true` / `drill` 取完整或局部(语法边界 100% 准) | 1 |
| 同文件调用点 | ✅ `call_expression` 命中符号 → 调用位置 | 2 |
| 跨文件调用链 | ✅ 符号索引 + import 解析 | 3 |
| **动态调度**(`dyn Trait`/虚函数) | ❌ 静态解析不能(需类型推断,LSP 级) | 局限 |
| 回调/闭包/函数指针/宏 | ❌ 需数据流/编译器 | 局限 |
**覆盖度**:静态直接调用占实际 ~70-80%(绝大多数业务代码);动态/间接/宏 ~20-30% 需 LSP。
**避免上下文成本**:`read_symbol(compress)` → 函数体几十行 + 静态调用点,非 `read_file` 全文 500 行 → prompt 降一个量级。对 e46f5605 类(读源码理解实现,静态为主)直接见效。
### 目标2:ai coding 编码支撑
AST 是语法层(parse tree)。**语法层能**:
- 符号级上下文(LLM 编码拿相关函数定义+调用关系,非全文 chunk)
- 结构化检索(符号/调用链/依赖,替代文本 grep)
- 变更影响分析(改函数 X → 影响哪些调用方,精准 review/测试范围)
- 代码生成约束(签名/调用语法)
**语义层不能**(需 LSP/编译器):类型检查/推断、安全跨文件重构、数据流/借用分析。
→ AST 够 aichat 的"精准读代码 + 调用链 + 上下文 + 影响分析"(95% 读代码场景);深层语义是 LSP 的事。
## 三、方案:tree-sitter + read_symbol
### 选型:tree-sitter
- GitHub 出品,增量 AST 解析,几十种语言 grammar
- Rust 生态 `tree-sitter` + 各语言 grammar crate(编译期静态链接)
- 增量解析:文件改只重解变更(<1ms)
### grammar 策略(2026-06-24 定稿)
**静态编译 + 集中 lookup**(架构成本最低,详见 [插件机制设计](插件机制-设计-2026-06-24.md)):
- **不做**动态加载(grammar 编共享库运行期 dlopen)、**不抽** trait(YAGNI,无第二实现不抽象)
- grammar 编译期静态链接进二进制(无 ABI 坑 / 无运行时状态 / 无分发负担)
- grammar 获取集中到一个普通函数 `grammar_for(ext) -> Language`(未来加动态只改这一点,不返工调用方)
**Phase1 范围**(覆盖 devflow 源码 + 主流用户项目):
| 文件类型 | grammar | 备注 |
|---|---|---|
| `.rs` | tree-sitter-rust | 后端主体 |
| `.ts`/`.tsx` | tree-sitter-typescript | 前端 script |
| `.js`/`.jsx` | tree-sitter-javascript | |
| `.vue` | **借 tree-sitter-typescript** | 切 `<script>` 段喂 ts;template/style 不做符号(Vue 逻辑在 script) |
**3 个 grammar 覆盖 4 类文件**。Vue 借 TS 省 grammar + 避 SFC 嵌套复杂度。
**扩展**:加标准语言 = 加 grammar crate + 一张节点映射表(表驱动,~30 行/语言)。小众(C++/Kotlin)按需;动态加载/集成插件化见插件机制设计文档。
### 三态读取(read_symbol,密度驱动)
read_symbol **不按物理行返、不截断**,按**语义结构层级**返(消解"截断多少行"争论):
| 态 | 触发 | 返回 | 密度 |
|---|---|---|---|
| **骨架**(默认) | `read_symbol(path, symbol)` | 签名 + 内部分支结构 + 调用列表 + 行数(不返函数体) | 极高(极小+全相关) |
| **下钻**(LLM 拉) | `read_symbol(..., drill=分支/调用名)` | 该局部分支/调用的精准定义 | 高(LLM 主动要) |
| **全文**(显式) | `read_symbol(..., full=true)` | 完整定义体(小函数常态直通) | 全 |
```
read_symbol(path, symbol, kind?, drill?, full?)
→ tree-sitter 解析 path,提取 symbol 定义节点
→ 默认:返骨架(签名 + 分支结构 + 调用图 + 行数)
→ drill=局部名:返该分支/调用的精准定义
→ full=true:返完整定义体
→ 返回 {symbol, kind, signature, skeleton|drill|def_body, line_range, file_hash, calls?}
```
### 主题压缩(compress_with_focus,大块兜底)
大块必须整体给时(复杂函数 / 大文件段),经**带焦点压缩**:
```
compress_with_focus(content, focus)
→ focus = 当前任务/问题(LLM 显式传)
→ LLM 相关性提炼:留与 focus 相关的路径/逻辑,去无关分支/防御/日志
→ 返提炼结果 + 原 hash(丢细节时可回取原始)
```
- **focus 必传**(分水岭:不带 focus 的通用摘要被否)
- hash 兜底:LLM 可凭 hash 回取原始(类似下钻)
- 与现有 `compress_via_llm`:现有压历史对话(P0-1 未真降 prompt),本工具压"工具返回入 prompt 当轮"——扩展触发点非新造
### 注册 / schema / 兜底
- read_symbol 注册 `register_file_tools`(与 read_file/grep 同域)
- schema:`object_schema([("path",string,req),("symbol",string,req),("kind",string,opt),("drill",string,opt),("full",bool,opt)])`
- handler:捕获 `allowed_dirs`,调 `resolve_workspace_path_with_allowed` 校验(复用 read_file 模式)
- RiskLevel::Low(只读)
- **不报错底线**(见七):无 grammar / 解析失败 / symbol 未找到 / 任何大小 → 走兜底或正常返,不 panic / 不阻断
## 四、Phase 路线
**两条线**:读取层(read_symbol 三态,tree-sitter)/ 压缩层(主题压缩,LLM,与 P0-1)。
**读取层**(tree-sitter):
| Phase | 能力 | 成本 | 缓存 |
|---|---|---|---|
| **1 单文件符号骨架+下钻+全文** | read_symbol 三态 | 低-中 | 实时解析(弃 AST) |
| **2 同文件调用点** | drill 跨分支调用图 | 中 | 实时 |
| **3 跨文件调用链** | 符号索引 + import | 高 | 符号表内存缓存 |
**压缩层**(LLM,与 P0-1):
| Phase | 能力 | 成本 | 缓存 |
|---|---|---|---|
| **C1 compress_with_focus** | 带 focus 相关性提炼 | 中(LLM 调用) | hash 缓存(同内容不重压) |
| **C2 触发管线接入** | 工具返回→压缩→入 prompt | 中 | 阈值(小不压)+ 缓存 |
**远期**:Encoding(符号检索/依赖图/向量编码)。
## 五、缓存策略(分情况,对齐 tree-sitter 增量特性)
**Phase1/2(单文件)**:**情况1 实时解析**
- tree-sitter 单文件 ms 级,解析完提取符号后**丢弃 AST**(不缓存)
- 每次 read_symbol 实时解析 → 最新最准(零缓存失效)
- 速度 ~1-10ms(几千行),增量 <1ms
**Phase3(跨文件索引)**:**情况2 缓存**
- 全项目符号表(file→symbol→pos+签名),累积大
- **2a 内存缓存**(devflow ~4000 符号 ~0.5-2MB,内存够)
- 大项目(几千文件/几十 MB)转 **2b 文件缓存**(`.devflow/symbols.cache`,LRU 按需加载)
- **失效**:文件 mtime/sha256 变 → tree-sitter 增量重解析该文件(<1ms)→ 更新该文件项(不全扫)
**增量解析是命脉**:让"实时"(情况1)够快、"缓存失效"(情况2)够便宜。
## 六、估算(devflow 实测基数)
- 源码:280 文件 / 82,444 行(Rust 142/52.7K,TS 87/12K,Vue 51/17.6K)
- 符号:~4000(Rust ~2840 + TS ~1044 + Vue ~510)
- 符号表:Phase1 ~400KB / Phase2 ~1MB / Phase3(含调用链)~1.6-2MB
-**内存缓存够**(情况2a)
## 七、兜底(不报错底线,不破坏现有)
**底线:任何情况不 panic / 不阻断 / 不因大小失败。**
- 无 grammar(语言不支持)/ 解析失败(语法错误)/ symbol 未找到 → 兜底提示 `grep` + `read_file`(offset 精准读)
- **任何大小函数** → 正常返(不截断天然不因大小失败;大函数走骨架+下钻/主题压缩,非报错)
- read_symbol 失败不阻断(返兜底提示让 LLM 回退 grep)
- compress_with_focus 失败 → 回原始内容(LLM 仍能用,降级非阻断)
## 八、LSP 后置结论(2026-06-24 评估)
**LSP 成本高,重 tree-sitter 100x**:
- 二进制 ~30-50MB/平台(跨平台 3 份,bundle 进安装包体积大)
- 首次索引 30s-2min(全项目类型推断),内存几百 MB-1GB,后台常驻
- JSON-RPC 通信复杂(初始化/能力协商/文件同步/多语言 server 生命周期)
- 分发:bundle 重 / 要求用户装(rustup/tsserver)门槛高
**收益**:语义层(动态调度/类型/重构/精准引用),tree-sitter 天花板外 ~20%。
**结论**:**Phase1-3 tree-sitter 先**(语法层,覆盖 ~80% 读代码/调用链/上下文,轻量零分发负担);**LSP 后置/按需**——检测用户装了 rust-analyzer 才启用深层语义,不强制 bundle,保分发轻。别一上来 LSP(成本/分发过重),tree-sitter 拿 80% 红利,LSP 留作"深层语义可选增强"。
## 九、与现有方向的关系(密度驱动,四层互补)
```
① 结构层级供给(read_symbol 骨架→下钻) ← 本设计,语义结构精准+密度
↓ 大块兜底
② 主题压缩(compress_with_focus) ← 带 focus 相关性提炼(非通用摘要)
↓ 兜底
③ grep+offset(read_file) ← 现有,文本精准
↓ 历史治理
④ 历史压缩(P0-1 compress_via_llm) ← prompt 层已读历史摘要
```
- LLM 默认 read_symbol 骨架 → 要细节下钻 → 大块经主题压缩 → 不支持回退 grep
- 已读历史由 P0-1 压缩
- **密度判据**:①②③④ 每层都服务"只给任务相关内容",非"减体积"
## 十、风险 / 改动面
| 风险 | 缓解 |
|---|---|
| tree-sitter 依赖体积 | 按语言按需 enable grammar(非全装) |
| 跨语言覆盖(小众语言 grammar 弱) | 兜底 grep+read |
| 动态调度解析不准 | 文档标注局限,需时评估 LSP(后置) |
| Phase3 跨文件索引重 | 渐进,Phase1/2 先(实时,无索引) |
## 十一、验证(Phase1 落地后)
- **解析速度**:tree-sitter 解析 devflow 大文件(context.rs 1552 行等),测 ms
- **符号表大小**:全项目解析后序列化字节数(验估算 ~400KB)
- **token 对比**:read_symbol(compress) vs read_file(context.rs) 返回 token 量(验降一个量级)
- **单测**:read_symbol 提取函数定义体 + 调用点(Phase2)
## 十二、落地步骤
1. **本文档**(设计定稿)
2. **Phase1 实施**:tree-sitter-rust 集成 + read_symbol 工具 + 单文件符号提取 + 兜底 + 实测
3. Phase2/3/4 + LSP 后置评估,按需
## 关键文件(Phase1 实施)
- `src-tauri/Cargo.toml`:加 tree-sitter + tree-sitter-rust
- `src-tauri/src/commands/ai/tool_registry.rs`:`register_file_tools` 内加 read_symbol(对齐 read_file handler)
- 新模块 `src-tauri/src/commands/ai/code_intel.rs`:tree-sitter 解析 + 符号提取纯函数
- 单测:符号提取 + 兜底

View File

@@ -0,0 +1,156 @@
# aichat 体验与 agent 能力系统化重构
> 2026-06-21 | 不破不立 · 长久治本 · 机制优先说教
## 0. 背景:为什么是系统化重构
7-agent UX 诊断 + kms 会话(8f607aa4,53 轮 0 产出)实证:**用户难受不是几个 bug,是失控感 + 黑箱感叠加**。诊断出 5 个高痛,但均匀修 5 痛 = 补丁堆砌([[no-patch-groundwork]] 元原则禁止)。
反思收敛:**DevFlow 给了 AI 一双手(工具),没给一双眼(环境感知)和一个刹车(止损求助)**。根是 **agent 元能力(感知+决策+求助)整体缺失**,表是 UX(降噪/控制/反馈/密度)。
**系统性解法**:立三层架构,L2/L3 已有设计待整合落地,L1 新立。5 痛作每层第一个消费场景驱动实现。每个 fix 是架构一块砖(可累积/可演进/可观测),非独立补丁。
## 1. 三层架构
| 层 | 职责 | 机制(代码强制,非 prompt 说教) | 现状 |
|---|---|---|---|
| **L1 agent 元能力层** | AI 看得见路、刹得住车、会求助 | 环境探测工具 + 断路器 + 求助协议 | **新立**(四空壳之一,[[aichat-arch-extensibility]]) |
| **L2 统一状态机** | 状态收敛,前后端同一真相 | generating/stopping/error enum 视图 + guard 写收敛 | 已设计([[devflow-generating-statemachine]] `generating状态机加固-2026-06-15.md`),待落地 |
| **L3 事件总线可观测性** | 进度/卡点/失败/求助统一可见 | pub-sub + request-reply + 流式统一总线 | 已设计([[global-event-bus-design]] `全局事件数据总线-2026-06-21.md`),推进 C |
**核心原则**([[ai-improvement-principles]]):**机制优先 prompt 说教**。断路器不靠"教 AI 别重试"(LLM 不听),靠代码强制熔断;环境感知不靠"prompt 写死 Windows 规则"(过时),靠动态探测工具。
## 2. L1 agent 元能力层(新立 · 核心)
L1 是根。三个组件:
### 2.1 环境感知(给 AI 一双眼)
**问题**:`prompt.rs:44 env_info_line()` 只注入"日期+OS"(25 token),AI 不知道 cmd 吃 `$`、PowerShell 才靠谱、python 路径在哪、编码 GBK/UTF-8。kms seq26-52 引号地狱根因。
**机制**(预防式 + 补救式 + 自省式三层):
- **预防式 — 启动详细环境注入**:扩 `env_info_line``env_profile`,启动注入:OS + 默认 shell 类型(Win→PowerShell / Linux·Mac→bash)+ python/node 路径 + 终端编码 + **平台陷阱提示**(Win: cmd 吃 `$/引号,复杂脚本写文件用 `powershell -File` / `python file`;Linux·Mac: bash 引号规则)。约 +120 token,消除引号地狱类系统性错误。
- **补救式 — `detect_environment` 工具**:AI 可主动调,返回详细环境(shell/编码/可用命令/Python 版本)。失败 N 次后**自动触发**(见 2.2 断路器),把结果注入 context。
- **自省式 — 环境画像缓存**:探测结果缓存(会话级 `env_profile`),避免重复探测;关键环境变化(如 shell 切换)刷新。
**跨设备**:env_profile 动态 `cfg!` 分支 + 运行时探测,平台无关。Win/Linux/Mac 各自正确姿势。
**兜底**:探测失败回退静态 env_profile(编译期 OS + 默认 shell),不阻断。
**开关**:`env_probe_enabled`(默认 on),off 则只静态注入。
### 2.2 断路器(给 AI 一个刹车)
**问题**:agentic loop 仅 `max_iterations=10` 硬截断,无智能止损。kms write_file 连撞 8 次、run_command 引号失败 20+ 次才到上限。路径防护错误 AI 视作普通错误反复换法。
**机制**(代码强制熔断,非说教):
- **同 tool_call 连续失败 ≥2 次**:熔断,强制停该工具,发求助。
- **同路径连撞 ≥2 次**:熔断(路径错误不可绕过,AI 换工具也撞)。
- **同错误类型连续 ≥3 次**:熔断(如引号错误反复)。
- **熔断动作**:停 loop → 发 `AiHelpRequired` 事件(2.3)→ 前端求助卡(而非 max_iterations 默默截断)。
- **错误分类**:路径防护错误标 `fatal_unbypassable`(AI 可操作提示:"路径 X 不在授权区,此错误不可换工具绕过,请改路径或 bind_directory")。
**开关**:`circuit_breaker_enabled`(默认 on)+ 阈值可调(`max_consecutive_fail=2`)。
**兜底**:断路器误判 → 用户在求助卡选"继续重试"可覆盖熔断(人工 > 机制)。
### 2.3 求助协议(让 AI 会求助)
**问题**:AI 卡死只靠 max_iterations 截断或用户手动打断,无主动求助。prompt 教"失败就问用户"是说教,不可靠。
**机制**(结构化求助通道):
- **`AiHelpRequired` 事件**:agent 断路器触发 / 自省(识别"我反复失败")时发,结构化字段:`reason`(死循环/环境未知/权限不足)+ `context`(已试 N 次/卡在哪)+ `options`([换策略/授权路径/人工接管])。
- **前端求助卡**:显式 UI(非隐藏),用户选 option → 注入 context → loop 续跑或停。
- **与审批区分**:审批=工具执行前确认;求助=AI 主动"我搞不定"。两套机制不混。
**开关**:`help_required_enabled`(默认 on)。
## 3. L2 统一状态机(整合现有设计)
落地 `generating状态机加固-2026-06-15.md`:guard 写收敛 + enum 视图读收敛。
- **单一真相**:`ConvState` enum(`Idle/Generating/Stopping/Error/Compressed`),消 streaming/generating/isStopping 多源割裂。
- **前端读 enum 视图**:停止按钮三态(可停/停中/停失败可重试)是 `Stopping` 状态自然产物,非加变量。
- **MaxRoundsCard 判断改 enum**:达轮次时 `Generating` 态可靠弹"继续/停止"卡(诊断痛②:看门狗误清 streaming 致卡片不弹的根解)。
## 4. L3 事件总线可观测性(整合现有设计)
落地 `全局事件数据总线-2026-06-21.md`:进度/卡点/失败/求助/心跳统一事件,前端订阅渲染。
- **心跳 UI 化**:复用 `AiHeartbeat`,每 10-15s 显"AI 已思考 Xs"(诊断痛③:130s 盲等)。
- **轮次可视化**:`AiAgentRound` → "轮次 X/Y"(Y=max_iterations 或 ∞)。
- **工具分级**:30s 前 10s 预警(橙),30s 后红 + toast。
- **审批倒计时**:剩余 X 分钟,临 1 分钟红。
- **求助消费**:`AiHelpRequired`(L1)→ 求助卡(L3 渲染)。
## 5. 5 痛 → 架构组件映射(消费场景驱动)
| 诊断痛 | 眼前补丁(弃) | 长久架构组件 | 阶段 |
|---|---|---|---|
| ① AI 死循环零交付 | prompt 教止损 + 折叠 | **L1 断路器 + 环境探测 + 求助协议** | 1 |
| ② 停止割裂/卡片不弹 | 加 isStopping | **L2 状态机**(三态是其产物) | 2 |
| ③ 无进度反馈 | 加心跳动画 | **L3 事件总线**(心跳是事件之一) | 3 |
| ④ 审批疲劳 | 分组按钮 | **L1 会话信任目录机制** + L3 批量事件 | 1/3 |
| ⑤ 基础硌人 | 各自修 | L2 状态驱动渲染(统一,非散修) | 3 |
## 6. 实施顺序(分阶段,每阶段独立可回退)
**阶段1 · 立 L1 根**(治痛①,最痛):
- 环境感知:`env_profile` 注入 + `detect_environment` 工具 + 缓存
- 断路器:agentic loop 熔断(同 tool_call/路径/错误类型)
- 求助协议:`AiHelpRequired` 事件 + 前端求助卡
- 会话信任目录(L1):首次授权后同会话自动放行(治痛④一部分)
**阶段2 · 立 L2 状态机**(治痛②):
- 落地 `ConvState` enum + guard 收敛
- 停止三态 + MaxRoundsCard 改 enum 判断
**阶段3 · 立 L3 事件总线 + 前端消费**(治痛③④⑤):
- 事件总线统一输出(进度/卡点/失败/求助/心跳)
- 前端订阅渲染:心跳/轮次/工具分级/审批倒计时/求助卡
- 降噪:失败折叠 + 同类聚类(x3 点击展开)
- 基础:滚动锁存 / 代码围栏占位 / Shift+Enter hint
每阶段:**独立可回退 + 开关 + 兜底**。阶段1 不依赖 2/3 可先落地见效。
## 7. 跨设备约束(硬约束)
| 组件 | 跨设备要求 |
|---|---|
| L1 env_profile | 动态 `cfg!` + 运行时探测,Win→PowerShell / Linux·Mac→bash,平台陷阱各自注入 |
| L1 断路器/求助 | 纯逻辑,平台无关 |
| L2 状态机 | 纯逻辑,平台无关 |
| L3 事件总线 | 纯逻辑 + 前端,平台无关 |
| 路径处理 | 已 `std::path` + workspace_root 归一化 |
本机跨平台(Win/Linux/Mac)先保证;跨设备同步(手机/多端)是 `df-relay/df-miniapp` 云后端路线([[cross-end-rust-backend]]),另立。
## 8. 兜底 / 开关 / 可回退([[ai-improvement-principles]])
每机制配:
- **开关**:`env_probe_enabled` / `circuit_breaker_enabled`(+阈值)/ `help_required_enabled`,默认 on,可关。
- **兜底**:探测失败→静态 profile;断路器误判→人工覆盖;求助不发→回退 max_iterations。
- **可回退**:每阶段独立 git 可 revert;开关 off 即降级旧行为。
## 9. 关联现有设计(整合不重复)
- L2 → `generating状态机加固-2026-06-15.md`(直接落地)
- L3 → `全局事件数据总线-2026-06-21.md`(直接落地)
- L1 决策 → `条件表达式引擎-2026-06-15.md` + `Agent架构说明-2026-06-14.md`(四空壳 coordinator/planner,元能力是其具体化)
- 痛①实证 → `aichat-技术债审查-2026-06-21.md` + kms 会话 8f607aa4
## 10. 验收
- kms 类场景(白名单外整理):AI 撞 ≤2 次即求助(非 53 轮 0 产出)
- 引号地狱:env_profile 注入后 AI 用对 shell / 写文件执行,不反复 -c 内联
- 停止按钮:三态可靠,打断后状态干净
- 进度:任意时刻用户知 AI 在哪/卡哪/还要多久
- 审批:会话信任目录后同路径不重复弹
- 跨设备:Win/Linux/Mac env_profile 各自正确
---
**下一步**:本设计过目 → 阶段1(L1 根)开整改 workflow 实施。L1 是根,先立见效。

View File

@@ -0,0 +1,108 @@
# 插件机制设计 — DevFlow 可扩展性
> 日期:2026-06-24 | 状态:方向定稿(grammar 静态 + 集成渐进,通用插件远期)
> 关联:[AST 符号解析设计](AST符号解析-设计-2026-06-24.md) / memory devflow-ast-code-intel-design / devflow-product-positioning / no-patch-groundwork
## Context(为什么有这文档)
grammar 动态加载讨论 → 引出"插件机制"概念 → 评估通用插件系统 → 落到"外部 API 集成(云效/禅道)"真场景 → 用"架构成本"统一评估。本文档沉淀可扩展性决策:何时插件化、怎么渐进、什么是正确切入点。
## 一、动态加载 grammar(技术可行性)
tree-sitter grammar 是 C 解析器,可编译成共享库运行期加载:
- grammar → `.dll`/`.so`/`.dylib`(tree-sitter CLI 或 cc 编译)
- Rust `libloading` 运行期 dlopen + 查 `tree_sitter_<lang>` 符号 → `Language`
- Helix / Neovim / Zed 行业先例
**但**:跨平台分发(每平台 × 每 grammar 共享库)+ ABI 版本管理(grammar 编译时 tree-sitter 版本须匹配运行期,错则崩)+ 加载机制 ~100-200 行。复杂度中-高。
## 二、插件形态谱系(窄 → 通用)
| 形态 | 契约 | 能力 | 复杂度 | 例子 |
|---|---|---|---|---|
| grammar 动态加载 | 单(一个函数) | 单(源码→AST) | 中 | Helix language |
| 集成插件 | 单(IntegrationProvider 几操作) | 中(API 调用+映射+同步) | 中 | 云效/禅道 |
| 通用插件系统 | 多(extension points) | 任意(命令/UI/规则...) | 极高 | VSCode 扩展 |
通用插件要:manifest / 生命周期 / 沙箱 / 权限 / 依赖图 / 市场 / API 稳定性。软件工程最难子系统之一。
## 三、复杂度 = 架构成本(评估框架)
复杂度不是抽象的"难",是**架构投资决策**。每个抽象/机制/插件点付架构成本,价值须覆盖:
| 成本 | 性质 |
|---|---|
| 抽象边界 | 间接层,理解/调试;抽象错束缚未来 |
| 契约维护 | 接口定义了就要养(兼容枷锁) |
| 运行时状态 | 故障模式 ×N |
| 耦合传播 | 改一处影响多少 |
| 演化阻力 | 架构固化后改方向成本(锁死) |
| 认知 | 上手成本 |
**持续 + 复利**:每个未来改动都付。投资回报判断。统一 [[no-patch-groundwork]](成本不被覆盖=补丁堆砌)/ [[ai-improvement-principles]](易迭代=架构成本低)。
## 四、三方案评估(grammar 视角)
| 方案 | 架构成本 | 价值 | 当前覆盖? |
|---|---|---|---|
| **静态 + 集中 lookup** | 最低 | 中 | ✅ 覆盖 |
| grammar 动态加载 | 中高(运行时状态+ABI+分发) | 中(红利未兑现) | ❌ |
| 通用插件系统 | 极高(沙箱/契约/演化枷锁) | 高(但无生态) | ❌ |
**决策:静态 + 集中 lookup**。grammar 获取集中到普通函数 `grammar_for(ext)`,不抽 trait(YAGNI,无第二实现不抽象)。
## 五、外部 API 集成:插件正确切入点
通用插件当前不做,但**集成是插件能力的正确起点**——真实高价值场景 + 低复杂度形态。
**需求**:DevFlow 工作流枢纽,任务/缺陷/需求也在云效/禅道 → 数据孤岛 + 重复录入。打通 = 双向同步 + aichat 跨系统操作。
**技术可行**:云效(阿里云 OpenAPI)/ 禅道(ZenTao REST)都有 API。Rust reqwest 调用,难度低于 LSP/沙箱插件。
**集成 vs 通用插件**:集成是"窄插件"(单契约 IntegrationProvider,无沙箱——受控 API 调用非执行任意代码)。有扩展红利,无重框架复杂度。
**架构成本(诚实)**:主要在**同步语义**(双向 DevFlow↔云效:冲突/时序/幂等/一致性,分布式级难题),非插件框架。其他:认证/凭证(中)、数据映射(中,每集成一套)、API 边界稳定性(中,外部依赖持续)。
**价值**:自用(省重复,aichat 跨系统)+ 分发(集成生态=差异化/粘性/护城河)。成本高但价值高,值得——同步是核心难点,单向起步。
## 六、渐进路径
```
1. 静态 grammar + 集中 lookup(现在,Phase1)
Rust/TS/JS/Vue(借TS),3 grammar 覆盖 4 类
不动态/不 trait
2. 首个集成:云效(近期,memory 已有 yunxiao 同向)
直接代码实现,验证打通价值
单向同步起步(避双向冲突复杂度)
3. 抽 IntegrationProvider trait(云效跑通后)
标准接口:sync_tasks/list_bugs/create_requirement
窄契约,低架构成本
4. 第二集成:禅道按接口实现(中期)
印证抽象可复用
5. 插件化(远期,集成 3+)
动态加载/分发;通用插件系统:产品有规模/生态后
```
每步独立有价值,不依赖后续。先具体后抽象,避免预先建空框架。
## 七、决策汇总
| 项 | 决策 | 理由 |
|---|---|---|
| grammar 加载 | 静态 + 集中 lookup | 架构成本最低,红利未兑现 |
| grammar trait | 不抽(YAGNI) | 无第二实现不抽象 |
| 通用插件系统 | 远期(有生态后) | 当前无生态,过早=负债 |
| 集成方向 | 定(云效/禅道,工作流打通) | 真实高价值,窄插件正确起点 |
| 集成实现 | 渐进(云效→trait→禅道) | 先具体后抽象 |
| 集成同步 | 单向起步 | 双向冲突分布式难题,远期 |
**核心**:插件愿景对(可扩展=产品长期价值),但时机/复杂度决定——从集成低复杂度高价值场景切入,静态 grammar 当前够,通用插件远期。不为假想需求建重框架。
## 关键文件(集成实施时)
- grammar 集成:`src-tauri/Cargo.toml` 加 tree-sitter + grammar crate;新模块 `code_intel.rs``grammar_for(ext)` 集中 lookup
- 集成(云效):新 crate `df-integration` 或 df-execute 内;IntegrationProvider trait;凭证存储复用 KV

View File

@@ -0,0 +1,114 @@
# F-260621-02 查询能力维度补全方案(任务/项目/灵感)
> **状态**:方案设计(待办登记),待排期实施
> **日期**:2026-06-21
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)
> **本轮排查结论**:本轮仅登记待办,不实施代码。
---
## 一、现状盘点
### 1.1 详情查询(已齐全,无需动)
| 实体 | 详情接口 | 位置 |
|------|---------|------|
| 任务 | `get_task_by_id` | `src-tauri/src/commands/task.rs:58` |
| 项目 | `get_project(id)` | `src-tauri/src/commands/project.rs:193` |
| 灵感 | `get_idea`(宏生成) | `src-tauri/src/commands/idea.rs` |
均按主键查,返回 `Option<Record>`/`Record`。这部分能力完整。
### 1.2 列表查询(本方案重点,维度薄弱)
| 实体 | 后端 WHERE 维度 | 前端筛选 | 分页 | 搜索 | 排序 |
|------|----------------|---------|------|------|------|
| 任务 | `project_id`(仅此 1 维,`task.rs:41`) | status 内存 filter | ❌ | ❌ | 固定 created_at DESC |
| 项目 | 仅软删(`deleted_at`) | ❌ 无 UI | ❌ | ❌ | 固定 created_at DESC |
| 灵感 | `status`(宏 `query`,单字段) | status+关键词+排序**全前端** | ❌ | ❌(后端) | 前端 computed |
| 知识库 | status+kind+LIKE+向量 | — | ✅ limit | ✅ LIKE+向量 | ✅ 多维 |
**核心问题**:任务/项目/灵感的筛选全堆在**前端内存**(`Tasks.vue:177` `.filter()` / `Ideas.vue:184` computed),后端只给"按 project_id 拉"或"全量拉"。数据量增长后线性退化,且无法做关键词/多条件。
> 注:AI agent 用的 `list_tasks` 工具(`tool_registry.rs`)另有 status 内存过滤,但那是给 LLM 调的,非 UI 查询链路。
---
## 二、缺口清单
1. **status 未下沉后端**:任务/灵感的 status 过滤是前端 `.filter()`,后端 `list_tasks`/`list_ideas` 未支持 status 维度 SQL WHERE。
2. **无关键词搜索**:三实体均无 title/description 文本检索。
3. **无排序选项**:全固定 `created_at DESC`,无按优先级/状态/更新时间排序。
4. **无分页**:全量返回,无 LIMIT/OFFSET。数据量大时撑爆前端内存 + IPC。
5. **索引缺口**:tasks 缺 `idx_tasks_priority`/`idx_tasks_assignee`(已有 `idx_tasks_status`/`idx_tasks_project_id`,`migrations.rs:763-764`);projects/ideas 表无任何显式索引(仅主键)。
---
## 三、可复用基建(非从零造)
知识库模块(`KnowledgeRepo`)是唯一查询能力完善的实体,其模式可直接推广:
| 模式 | 位置 | 复用点 |
|------|------|--------|
| **动态 WHERE 拼接** | `crates/df-storage/src/crud/idea_repo.rs:200` `KnowledgeRepo::search` | if-let 分支按可选条件拼 SQL + 分支化参数绑定 |
| **分页 + 上限钳制** | `crates/df-storage/src/crud/conversation_repo.rs:270` `list_recent` | `LIMIT ?1 OFFSET ?2` + `limit.min(200)` 防滥用 |
| **统一查询入口** | `crates/df-storage/src/crud/mod.rs` `impl_repo!``query(field,value)` | 单字段白名单动态查询,可扩展为多条件 |
SQL 库为 `rusqlite`,`?N` 占位符 + `params![]` 宏绑定参数。
---
## 四、设计方向
### 4.1 统一查询结构(对齐 [[no-patch-groundwork]],根本设计非补丁堆砌)
三实体各引入可选字段的 `XxxQuery` struct,而非逐个加 IPC 参数:
```rust
// 示意:TaskQuery(项目/灵感同构)
pub struct TaskQuery {
pub project_id: Option<String>,
pub status: Option<String>,
pub priority: Option<i32>,
pub assignee: Option<String>,
pub keyword: Option<String>, // title/description LIKE %kw%
pub order_by: Option<String>, // created_at/updated_at/priority/status
pub limit: Option<u32>, // 钳制上限(对齐 list_recent)
pub offset: Option<u32>,
}
```
### 4.2 repo 层:`list_by_query` 动态 WHERE
复用 `KnowledgeRepo::search` 模式:动态拼 `WHERE` 子句 + 参数绑定。`order_by` 走白名单(防 SQL 注入,对齐 `impl_repo!` 宏的 `validate_column_name`)。
### 4.3 命令层:`list_xxx` 吃 query,向后兼容
`list_tasks(query: Option<TaskQuery>)`。旧调用方不传(或传空 query)→ 等价全量(当前行为),零破坏。新调用方按需传筛选。
### 4.4 前端对接
- `api/{task,project,idea}.ts`:暴露 query 参数
- `stores/project/*.ts`:`loadXxx(query)` 透传
- 视图:现有前端内存 filter 改为构造 query 调后端;补 UI 控件(优先级筛选/搜索框;分页器待数据量大再加)
---
## 五、实施分阶段
| 阶段 | 维度 | 优先级 | 说明 |
|------|------|--------|------|
| P1 | status 下沉后端 | 中 | 任务 + 灵感 status 过滤从前端 filter 改后端 WHERE(已有 `idx_tasks_status` 索引) |
| P2 | 关键词搜索(LIKE) | 中 | title/description `LIKE %kw%`,对齐知识库。加搜索框 UI。不上 FTS5(需虚拟表 + 触发器同步,过度) |
| P3 | 排序 + 分页 | 低 | 排序选项(优先级/状态/更新时间)+ limit 钳制防爆。UI 分页器待数据量 > 百级再加 |
> 数据量:当前 ~6 项目 ~15 任务,性能无感。P1/P2 是能力补全,P3 性能驱动——数据量上来才有必要,与 PERF-260619-01 性能方案协同。
---
## 六、关联文档
- [PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)——查询**性能**(全量加载/无缓存/无字段投影),与本方案(查询**维度**)正交,实施时协同改 `list_xxx`
- todo.md `F-15-03 分页`——本方案 P3 细化。
- todo.md `BUG-260621-01`——run_workflow 空 dag 缺陷(本轮同批登记,与本查询方案独立)。

View File

@@ -0,0 +1,394 @@
# F-260622-01 跨端 AI Chat Phase3 联调设计(协议统一 + 桥接方案)
> 创建:2026-06-22 | 状态:📐 设计草案(纯设计,未实施任何代码)
> 上级索引:[../INDEX.md](../INDEX.md) | 续篇:[F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./F-260620-01-跨端AIChat-微信小程序-2026-06-20.md)
## 〇、定位与术语澄清(必读)
本文档是 **F-260620-01 的 Phase3 实施续篇**,专注「三层各自就绪后,如何把它们接线联调」的设计。
**术语对齐**(避免与 F-260620-01 已有阶段命名冲突):
| 术语 | 在 F-260620-01 的含义 | 在本文档的含义 |
|---|---|---|
| P1 / P2 / P3 / P4 | df-relay / df-tunnel / df-miniapp / 双向同步完善 | —(本文不重定义) |
| Phase3 | —(未用) | **三层联调总称**:协议统一 + 事件桥接 + 指令路由 + 真机联调 |
**已就绪状态(核验结论,只读 grep 得出)**:
| 层 | 状态 | commit | 协议契约(当前形态) |
|---|---|---|---|
| df-relay | ✅ Phase2 | 2b8b30e | 透传不解析 payload(`serde_json::Value`),按 `kind` 路向:Event(Device→Miniapp)/ Command(Miniapp→Device)/ Control |
| df-tunnel | ✅ Phase2 | 25d6565 | 收 `TunnelCommand{kind,...}`(`#[serde(tag="kind")]` 5 变体:Send/Stop/Approve/Regenerate/Switch);发 `TunnelEvent`(4 变体子集) |
| df-miniapp | ✅ | 280baea | 发 `MiniCommand{cmd, args}`(`cmd` 对齐 Tauri command 名,如 `"send_message"`;`args.message`/`args.conversation_id`) |
**核心阻断问题**:relay 透传不转换,miniapp 发 `MiniCommand`,tunnel 收 `TunnelCommand` —— **两端序列化结构不匹配**,联调在协议层即阻断。本文第一节即决策如何统一。
---
## 一、协议统一决策(A / B,论证给推荐)
### 1.1 不匹配点逐项对照(源码核验)
miniapp 端 `apps/df-miniapp/src/types/relay.ts:88-93` + `useAiChat.ts:379-385`:
```ts
interface MiniCommand { cmd: string; args: Record<string, unknown> }
// 实际发送:{ cmd: "send_message", args: { message, conversation_id } }
```
tunnel 端 `crates/df-tunnel/src/events.rs:119-132`:
```rust
#[serde(tag = "kind", rename_all = "snake_case")]
enum TunnelCommand {
Send { conv_id: String, content: String },
Stop { conv_id: String },
Approve { conv_id: String, approval_id: String, accepted: bool },
Regenerate { conv_id: String },
Switch { conv_id: String },
}
```
**三重不匹配**:
| 维度 | miniapp(发) | tunnel(收) | 不匹配 |
|---|---|---|---|
| 外层结构 | `{cmd, args}` 两字段 | `{kind, ...}` 内联(tag enum) | 结构不匹配 |
| 标签词 | `cmd:"send_message"` | `kind:"send"` | 词汇不匹配(send_message vs send) |
| 字段名 | `args.message` / `args.conversation_id` | `content` / `conv_id` | 字段名不匹配 |
| 审批/重试 | 未发(cmd 仅 3 条 MVP) | Approve/Regenerate 已定义 | 覆盖面不匹配 |
tunnel 的 `parse_command_from_broadcast`(`tunnel.rs:439-455`)对 payload 做 `serde_json::from_value::<TunnelCommand>` —— miniapp 的 `{cmd, args}` 反序列化为 TunnelCommand 必然失败(无 `kind` 字段、`cmd``"send_message"` 不在 5 变体内)。**联调第一步就被协议层挡死。**
### 1.2 方案 A:tunnel 改纯透传 + device 端 src-tauri 桥接层解协议
**做法**:
- df-tunnel 不再定义强类型 `TunnelCommand`,入站回调改为 `on_raw_command(payload: serde_json::Value)`(或保留 TunnelCommand 但加一条「payload 原样上抛」的兜底分支)。
- tunnel 退回纯传输层,只认「这是 Miniapp→Device 的 Command 方向」,不解析 payload 语义。
- 真正的协议解析在 **device 端 src-tauri 新增的桥接层**:收到 `Value` 后,按 miniapp 约定的 `{cmd, args}` 反序列化,`match cmd { "send_message" => ai_chat_send(...), "stop" => ai_chat_stop(...), ... }` 路由到现有 Tauri command。
**论证**:
| 维度 | 评价 |
|---|---|
| 架构原则(no-patch-groundwork) | ✅ **强对齐**。relay/tunnel 是传输层,职责是「把字节从 A 搬到 B」,业务协议(命令名、字段名)属于业务层。让传输层认 `kind:"send"` 是越界耦合 —— tunnel crate 不该知道「send 是什么意思」。把协议知识下沉到 tunnel 等于把业务逻辑泄露进传输层,违反单一职责。 |
| 改动面 | tunnel:删/弱化 `TunnelCommand` 强类型解析,改 `on_command` 签名(~30 行);miniapp:**零改动**(继续发 `MiniCommand`);src-tauri:新增桥接模块(~150 行,纯新增)。 |
| device 桥接复杂度 | 中。桥接层是「`MiniCommand``Tauri command 调用`」的翻译表,本质是 match 表,新增命令只加一行 match 臂。 |
| 扩展性 | ✅ **强**。miniapp 新增命令只动 miniapp + 桥接层 match 表,tunnel/relay 永不需要改。符合「两端同语言、中间无业务」的 F-260620-01 技术选型初衷(Rust 优势在两端类型一致,中间透传)。 |
| 向后兼容 | ✅ tunnel 不再定义命令类型,未来协议字段变更不波及 tunnel。 |
| 与现有 Tauri 命令对齐 | ✅ **天然对齐**`MiniCommand.cmd` 直接复用 Tauri command 函数名(`ai_chat_send` 等),桥接层几乎是 1:1 invoke。miniapp 已按此设计(relay.ts:82 注释「对齐 Tauri command 名」)。 |
### 1.3 方案 B:miniapp 改发 TunnelCommand 格式
**做法**:
- miniapp 端把 `MiniCommand{cmd, args}` 改成 `TunnelCommand{kind, ...}`,字段名对齐 tunnel(`conv_id`/`content`)。
- tunnel 端零改动,继续强类型解析。
**论证**:
| 维度 | 评价 |
|---|---|
| 架构原则 | ⚠️ **偏离**。等于承认「tunnel 定义的业务协议就是跨端协议」,传输层握有协议定义权。但 tunnel crate 文档明确写「不依赖 src-tauri/df-types(避跨 crate 强耦合)」(events.rs:5-7)—— 它的 TunnelCommand 是**独立镜像**,不是权威契约。让 miniapp 对齐一个非权威的镜像契约,契约源头混乱。 |
| 改动面 | miniapp:`relay.ts` + `useAiChat.ts` + 可能 `events.ts` 全改(~80 行);tunnel:零改动;src-tauri:仍需桥接层(把 `TunnelCommand::Send{conv_id, content}` 翻译成 `ai_chat_send(message=content, conversation_id=conv_id)`)—— **桥接层省不掉**,只是输入类型变了。 |
| device 桥接复杂度 | 中。仍是 match 翻译,但要做 `conv_id↔conversation_id``content↔message` 字段名映射(tunnel 用简写 conv_id,src-tauri 用全称 conversation_id)。 |
| 扩展性 | ⚠️ **弱**。每加一条命令要同时改 tunnel 的 TunnelCommand 枚举 + miniapp + 桥接层三处,tunnel 被迫随业务膨胀,违背它「轻量传输」定位。 |
| 向后兼容 | ⚠️ tunnel TunnelCommand 一旦发布就是协议的一部分,后续字段重命名(conversation_id 全称统一?)会破坏 miniapp。 |
| 与 Tauri 命令对齐 | ❌ **不对齐**`kind:"send"` ≠ Tauri `ai_chat_send`,`conv_id``conversation_id`。桥接层要做双重翻译(协议词 + 字段名)。 |
### 1.4 推荐:方案 A
**理由(按权重排序)**:
1. **no-patch-groundwork 元原则(最高权重)**:relay/tunnel 是传输层,纯透传是它的天职。当前 tunnel 强类型 `TunnelCommand` 是 Phase2 实现「先跑通握手」时的便利设计,但它在架构上是**业务知识下沉到传输层**的技术债。Phase3 联调正是清这笔债的时机 —— 要么现在清,要么以后协议每次扩张都拖 tunnel 一起改,债越滚越大。
2. **契约源头唯一**:权威业务协议在 src-tauri(Tauri command 即事实标准),miniapp 已主动对齐(relay.ts:82)。方案 A 让 tunnel 也对齐这个源头(透传即对齐「 whatever 两端约定」),三方契约源头唯一。方案 B 会造出第二个源头(tunnel 的 TunnelCommand)。
3. **改动面更小且更安全**:方案 A 的 tunnel 改动是「弱化解析」(删类型比加类型安全,删错最多漏一条命令,加错会阻断全部),miniapp 零改动,src-tauri 新增纯加法。方案 B 要改 miniapp 三个文件,回归风险更高。
4. **桥接层省不掉,方案 A 让它更简单**:无论 A/B,device 端都要把「线上协议」翻译成「Tauri command 调用」。方案 A 的输入是 `{cmd, args}`,cmd 已是 Tauri 函数名,翻译近乎恒等;方案 B 还要额外做 `send↔ai_chat_send``conv_id↔conversation_id` 词表映射。
**风险与缓解**:
- 风险:方案 A 后 tunnel 失去「编译期校验命令合法性」的能力,非法命令要到运行时桥接层才报错。
- 缓解:桥接层 match 末尾加兜底臂 `_ => log+忽略`,非法命令不崩溃只记录;miniapp 端 TS 类型(`MiniCommand`)仍提供编译期校验(前端侧),tunnel 侧的运行时校验丢失可接受(它本就不该是校验点)。
> **决策点(留待主代理/用户定)**:本节给推荐 A,但 A/B 均合理。若团队更看重「tunnel 零改动、协议在 miniapp 侧收敛」可定 B。一旦定 A,tunnel 的 TunnelCommand 是否彻底删除 vs 保留作可选强类型校验层(对常见命令做 best-effort 解析,失败回落透传)是次级决策,建议保留作弱校验(向后兼容更好)。
---
## 二、src-tauri AiSession 桥接设计(方案 A 假定下)
桥接分两个方向:**Event 上行透传**(device→miniapp)、**Command 下行路由**(miniapp→device)。
### 2.1 Event 上行:把 AiChatEvent 经 tunnel 推给 miniapp
#### 现状核验
- AiChatEvent 19 变体定义于 `src-tauri/src/commands/ai/mod.rs:104-216`(变体清单见附录 A)。
- 事件经 `app_handle.emit("ai-chat-event", AiChatEvent::...)` **全局广播**,事件名硬编码散布在 **7 个文件 55 处**(event_bus.rs / agentic/mod.rs / agentic/guard.rs / mod.rs / commands/chat.rs / stream_recv.rs / audit/mod.rs)。
- 前端(`src/api/ai.ts:258`)通过 `listen<AiChatEvent>('ai-chat-event', ...)` 单一事件名订阅,按 `event.type` discriminator 分派。
- 事件携带 `conversation_id: Option<String>` 字段做多会话路由。
#### 桥接注入点选择(关键设计)
**问题**:55 处 emit 散布各处,逐处加 tunnel 推送 = 55 处改动 + 必然遗漏。需要单一汇聚点。
**选项对比**:
| 注入点 | 做法 | 评价 |
|---|---|---|
| (a) 逐 emit 点 clone | 在每处 `app.emit` 旁加 `tunnel.send_event(...)` | ❌ 55 处改动,违反 DRY,遗漏必然 |
| (b) Tauri 事件拦截 | 注册一个全局 `on_event` 监听 `ai-chat-event`,收到后转发 tunnel | ⚠️ 在 src-tauri 内部 listen 自己 emit 的事件,绕一圈(后端→前端事件总线→后端),不优雅,且 Tauri 的 emit 是给前端的,后端监听要 `app.listen` API |
| (c) **EventBus 汇聚(推荐)** | 接入现有 `event_bus.rs` 骨架(已建未接),把 55 处 emit 改为 `event_bus.publish(...)` 一处汇聚,EventBus 内部同时做 `app.emit` + `tunnel.send_event` | ✅ **单一汇聚点**,tunnel 作为一个 subscriber 接入,符合 EventBus 设计初衷(event_bus.rs:6-7 注释明确「散布各处无统一总线」就是为汇聚而建) |
| (d) 新建 tunnel gate 函数 | 包装 `emit_ai_event(app, tunnel, event)`,55 处改调它 | ✅ 也汇聚,但与 EventBus 重复造轮子;EventBus 已是「为这件事」建的基础设施 |
**推荐 (c)**:接入 EventBus。理由:
1. EventBus 是**专门为此场景建的基础设施**(event_bus.rs 文档自述),当前是骨架「批2接入 emit 点」未做 —— Phase3 桥接正好是它第一批真实 subscriber。
2. tunnel 作为 EventBus 的一个 subscriber(`EventBus::subscribe` 返回 `EventSubscriber`),EventBus `publish` 时 fan-out 到所有 subscriber。tunnel 的 subscriber 收到事件后 `send_event(TunnelEvent::...)`
3. 与「全局事件数据总线」专项设计文档(docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md)阶段 6「跨端透传 df-tunnel adapter」完全对齐 —— 不是新造,是落地该规划的子集。
4. `EVENT_BUS_ENABLED` 开关(event_bus.rs:42)天然提供回退:桥接出问题关开关,原 emit 路径不受影响(event_bus.rs:45 注释明示此兜底语义)。
#### 哪些事件变体需要透传(子集决策)
miniapp MVP 已实现 `handleEvent`(useAiChat.ts:100-314)处理 18 个变体中的多数。但 tunnel 端 `TunnelEvent`(events.rs:63-101)只定义了 **4 个变体**(TextDelta/ToolCall/Approval/Completed)。方案 A 下 miniapp 收的是「relay 透传的 AiChatEvent 原样 JSON」,不依赖 tunnel 的 TunnelEvent 强类型 —— **所以 tunnel 也应纯透传 AiChatEvent payload,不再裁剪到 4 变体**
**推荐**:全 19 变体透传(不裁剪),理由:
- miniapp handleEvent 已能处理绝大多数变体,裁剪反而让某些事件(如 AiCompressed/AiHelpRequired)在 miniapp 永远收不到。
- 全透传 = tunnel 零业务知识(对齐方案 A 原则)。
- 带宽:19 变体大多是低频(AiCompleted/AiError 每轮一次),只有 AiTextDelta 高频但单条小。MVP 阶段全透传带宽无忧。
**实现要点(设计,非代码)**:
- tunnel `send_event` 入参类型从 `TunnelEvent` 放宽为 `serde_json::Value`(或新增 `send_raw_event(Value)`)。
- EventBus subscriber 在 src-tauri 桥接层:把 AiChatEvent 序列化为 Value → `tunnel.send_raw_event(value)` → tunnel 写 socket → relay 透传 → miniapp `onEvent(msg)``msg.payload as AiChatEvent` 直接用(useAiChat.ts:333 已是此假设)。
- TunnelEvent 强类型 4 变体(events.rs:63-101)**保留作可选**,不删除 —— 它可作为「高频路径的强类型快捷方式」或测试断言用,删它是方案 A 的过度演绎(对齐 dead-code-reserve-keep 原则:零调用方的预留保留)。
#### AiChatEvent 全局广播 vs 路由
当前 emit 是全局广播(无 conv 过滤)。miniapp 单会话视图只关心 `activeConversationId` 的事件(useAiChat.ts:109 按 `isCurrent` 过滤)。透传策略:
- **透传全部,miniapp 侧过滤**(推荐):tunnel 不做 conv 过滤,miniapp 收到后按 `event.conversation_id` 自行过滤。简单、对齐现状、不丢事件。
- tunnel 侧按 conv 过滤:需 tunnel 知道「miniapp 当前看哪个 conv」—— 引入反向状态同步,复杂度高,MVP 不做。
### 2.2 Command 下行:收到 MiniCommand 路由到 Tauri command
#### 桥接层设计
device 端 src-tauri 新增桥接模块(设计上,如 `commands/ai/remote_bridge.rs` 或挂在 tunnel 初始化处),注册为 tunnel 的 `on_command` 回调(方案 A 下回调签名收 `serde_json::Value`)。
路由表(MiniCommand.cmd → Tauri command,基于 src-tauri 现有命令清单核验,附录 B):
| MiniCommand.cmd | args 字段 | 调用的 Tauri command | 命令签名(附录 B 摘要) |
|---|---|---|---|
| `"send_message"` | `{message, conversation_id, model_override?}` | `ai_chat_send` | `(app, state, message, language?, skill?, model_override?, conversation_id?, parts?, mention_spans?)` |
| `"stop"` | `{conversation_id}` | `ai_chat_stop` | `(state, app, conversation_id?)` |
| `"switch_conversation"` | `{conversation_id}` | (无直接命令,见下) | — |
| `"regenerate"` | `{conversation_id}` | `ai_regenerate` | `(app, state, conversation_id, language?, model_override?)` |
| `"approve"` | `{tool_call_id, approved}` | `ai_approve` | `(app, state, tool_call_id, approved)` |
| `"authorize_dir"` | `{tool_call_id, decision}` | `ai_authorize_dir` | `(app, state, tool_call_id, decision)` |
| `"continue_loop"` | `{conversation_id}` | `ai_continue_loop` | `(app, state, conversation_id)` |
| `"stop_loop"` | `{conversation_id}` | `ai_stop_loop` | `(app, state, conversation_id)` |
#### 关键缺口:switch_conversation 无对应 Tauri command
核验发现:src-tauri **没有** `ai_switch_conversation` 命令。miniapp(useAiChat.ts:439)发 `switch_conversation` 但桌面端无对应入口。
**原因分析**:桌面端的「切换会话」是**纯前端操作**(改 `activeConversationId` 状态 + 从本地 store 加载消息历史),不经 Tauri command —— 因为真相源在桌面本地,切会话只是视图切换。
**跨端影响**:miniapp 切会话时,桌面端若不同步,两边 `activeConversationId` 不一致;但 AI 事件透传是全局广播(附 conv_id),miniapp 按 conv_id 自过滤,功能上不阻断。差异只在「桌面端 UI 高亮哪条会话」。
**设计建议(留待主代理定)**:
- (a) **不处理**:miniapp switch 仅本地视图切换,不通知桌面。桌面 UI 与 miniapp 各自维护 active。简单,但两端 active 可能不一致。
- (b) **加一个轻量 sync command**:新增 `ai_sync_active_conversation(app, state, conversation_id)`,仅更新 `AiSession.active_conversation_id`(mod.rs:350),不触发其他逻辑。供桌面 UI 监听同步高亮。
- 推荐 (a) 起步(MVP 不阻断),(b) 列入 P4 双向同步完善(F-260620-01 已留 P4 阶段)。
#### on_command 回调签名适配(方案 A)
tunnel 当前 `CommandHandler = Arc<dyn Fn(TunnelCommand) -> BoxFuture<()>>`(tunnel.rs:42)。方案 A 下需放宽为收 `serde_json::Value`:
```
CommandHandler = Arc<dyn Fn(serde_json::Value) -> BoxFuture<()>>
```
桥接层回调内部:
1. 尝试反序列化 `MiniCommand{cmd, args}`(失败 → log+忽略,可能 miniapp 发了未知格式)。
2. `match cmd { ... }` 路由到对应 Tauri command 函数(直接 async 调用,因为桥接层在 src-tauri 内,有 app+state 句柄)。
3. command 返回值忽略(结果经 AiChatEvent 回流,不走 command 返回值)。
**注意**:Tauri command 函数签名第一参数是 `AppHandle` / `State<AppState>`,桥接层需在初始化时持有这俩句柄(从 tunnel 启动处 clone 进闭包)。这是标准 Tauri 模式,无新风险。
---
## 三、AiSession 单例并发模型桥接风险(重点)
桥接最大风险不在协议,而在 **miniapp 远程指令与桌面本地操作并发撞击 AiSession 单例状态**。核验结论(mod.rs:348-383):
### 3.1 现状:F-09 决策 e 已落地真多会话并发
- `AiSession.per_conv: HashMap<String, PerConvState>`(mod.rs:364)是唯一真相源,顶层单例字段(messages/generating/stop_flag)已删除。
- 每个 conv 独立 `generating: bool` + `stop_flag: Arc<AtomicBool>` + `notify: Arc<Notify>`(PerConvState,mod.rs:632-716)。
- `pending_approvals: HashMap<String, PendingApproval>`(mod.rs:358)单表,带 `conversation_id` 字段路由。
- **真支持多会话并行生成**(commit 126bee5 「F-09生成中新建对话不中断」已去中断弹窗 + 死 key 清理)。
**这是跨端桥接的利好**:miniapp 操作 conv-X 时,桌面用户可在 conv-Y 继续操作,互不中断。conv_id 路由天然隔离。
### 3.2 风险点清单
| # | 风险 | 场景 | 影响 | 缓解 |
|---|---|---|---|---|
| R1 | **同 conv 并发发送** | miniapp 与桌面同时给 conv-X 发消息 | ai_chat_send 内部若未做「generating 中拒绝」校验,可能触发两路 agent loop 抢同一 PerConvState | 核验:ai_chat_send 应有 generating guard(对照 desktop useAiSend 发送前检查)。桥接层额外加 `if conv_read(conv).generating { 拒绝+回错事件 }` 兜底。 |
| R2 | **审批双端竞态** | miniapp 与桌面同时批/拒同一 tool_call_id | ai_approve 两次调用,pending_approvals 第二次取不到 → 返回错;但 try_continue_agent_loop 可能被触发两次 | 核验 ai_approve 是否幂等(取走即删);桥接层不额外加锁(依赖 command 内部 HashMap 操作的原子性,Tauri command 在 state 锁内串行)。低风险。 |
| R3 | **审批阻塞 + 远程指令堆积** | conv-X 卡在 AwaitingApproval,miniapp 持续发 send/stop | stop 命令对 AwaitingApproval 状态的语义不清(停止生成 vs 停止等待审批?) | 桥接层对 stop 命令加状态判断:`if session_state(conv) == AwaitingApproval { 走 ai_stop_loop 或提示「等待审批中」} `。需核验 ai_chat_stop 对审批态的行为。 |
| R4 | **generating 状态机复位散布** | (已知技术债,见 memory devflow-generating-statemachine)generating 复位逻辑散布,远程 stop 若走到非常规路径可能不复位 | 卡在 generating=true 假死 | 桥接层 stop 走标准 ai_chat_stop command(复用现有复位逻辑),不开新路径。状态机加固是独立工作项,不在 Phase3 范围。 |
| R5 | **AiSession 单例锁竞争** | 远程指令高频 + 桌面本地高频 → 抢 state Mutex | 锁等待,延迟增高(非死锁) | AiSession 用 tokio Mutex,临界区小。MVP 无忧,P4 高并发再优化。 |
| R6 | **事件风暴压垮 miniapp** | 桌面多 conv 并行生成,miniapp 收到大量非当前 conv 事件 | miniapp 端过滤(useAiChat.ts:109)但 WS 带宽与解析压力 | tunnel 侧可选 conv 过滤(需反向状态同步,见 2.1,P4 做);MVP 全透传 + miniapp 过滤。 |
| R7 | **WS 断连期间远程操作丢失** | miniapp 发 send 时 WS 已断(uni-app send 静默失败) | useAiChat.ts:386 已有发送失败回错,但用户可能误以为发出 | miniapp wsStatus 已暴露,UI 应在 disconnected 时禁用发送按钮(前端职责)。 |
| R8 | **桌面端离线,relay 丢弃指令** | relay route 无对端在线时丢弃(relay.rs:135-143 delivered=0 返回 Ok) | miniapp 发的命令静默消失 | miniapp 侧应先查 device 在线状态(relay 可加 presence 查询,P4);MVP 提示「桌面端未连接」。 |
### 3.3 最高优先级风险:R1(同 conv 并发发送)
这是唯一可能造成**状态损坏**(非仅体验问题)的风险。需在实施前**先核验** `ai_chat_send` 内部是否有 generating guard:
- 若有(返回 Err 如「正在生成中」):桥接层把 Err 转成 `AiError` 事件 emit 回 miniapp,安全。
- 若无:桥接层必须在调 ai_chat_send 前自行检查 `conv_read(conv).generating`,true 则拒绝。**这是桥接层的硬性兜底,不可省**。
> 设计层面定:桥接层 send 路由 **必须**前置 generating 检查,无论 ai_chat_send 内部是否有 guard(防御性编程,双保险)。其他命令(stop/approve)无需此检查。
---
## 四、分阶段实施路线(每阶段可独立验证可回退)
按「协议统一 → 单向桥接 → 双向桥接 → 真机联调」四阶段递进,每阶段产出可独立验证、出问题可单独回退。
### 阶段 1:协议统一(tunnel 改纯透传)
**目标**:消除 1.1 的三重不匹配,tunnel 与 miniapp 在协议层握手成功。
**改动**:
- df-tunnel:`CommandHandler` 签名 `Fn(TunnelCommand)``Fn(serde_json::Value)`(events.rs/tunnel.rs);`handle_inbound` 直接把 payload Value 上抛回调,不再 `from_value::<TunnelCommand>`(tunnel.rs:407-454);`TunnelEvent` 保留但新增 `send_raw_event(Value)` 伴行。
- df-miniapp:**零改动**(继续发 MiniCommand)。
- src-tauri:本阶段不涉及。
**验证**:单元测试 —— tunnel 收到 relay 转发的 `{kind:"command", payload:{cmd:"send_message", args:{...}}}` 后,回调收到 `Value` 正是 payload 内容;`parse_command_from_broadcast` 改为返回 Value 而非 TunnelCommand。
**回退**:tunnel git revert 单 commit 即恢复强类型 TunnelCommand。miniapp 未动,无回退负担。
**风险**:低。纯类型放宽,不增业务逻辑。
### 阶段 2:单向桥接 — Event 上行透传(device→miniapp)
**目标**:桌面端 AI 生成事件能流到 miniapp,miniapp 能渲染流式回复。此阶段 **Command 方向不通**(miniapp 发的命令无人处理)。
**改动**:
- src-tauri:接入 EventBus(把 55 处 `app.emit("ai-chat-event", e)` 改为 `event_bus.publish(...)` 一处;EventBus 内部 fan-out:`app.emit` + `tunnel.send_raw_event(value)`)。tunnel 作为 subscriber 注册。
- df-tunnel:零改动(阶段 1 已支持 send_raw_event)。
- df-miniapp:零改动(useAiChat.ts:333 已按 `msg.payload as AiChatEvent` 处理)。
**依赖**:EventBus 当前是骨架(event_bus.rs:53 标 allow dead_code),需先做「批2 接入 emit 点」(其文档自述的下一步)。这是 EventBus 自身的里程碑,Phase3 阶段 2 与之合并。
**验证**:桌面端发一条 AI 消息,miniapp 收到流式 TextDelta 并渲染。**端到端单向通路打通**。
**回退**:`EVENT_BUS_ENABLED=false`(event_bus.rs:42),EventBus 静默丢事件但原 `app.emit` 路径不受影响(event_bus.rs:45 兜底语义)。**桌面前端不回归**。tunnel subscriber 失败不影响 EventBus 对前端的 emit。
**风险**:
- EventBus 接入 55 处 emit 是大改(纯机械替换但有遗漏风险)→ 用 grep 全量替换 + 编译校验 + 逐文件 review。
- 若 EventBus 有 bug,publish 可能阻塞 emit 路径 → EventBus.publish 应是非阻塞 fan-out(订阅方 send 失败不阻断其他订阅)。
### 阶段 3:双向桥接 — Command 下行路由(miniapp→device)
**目标**:miniapp 发 send/stop/approve 等命令,桌面端执行,全双工闭环。
**改动**:
- src-tauri:新增桥接模块,注册为 tunnel `on_command` 回调(收 Value → 解 MiniCommand → match 路由到 Tauri command)。持 AppHandle + State 句柄。**含 R1 兜底**:send 前置 generating 检查。
- df-tunnel:零改动(阶段 1 已支持 Value 回调)。
- df-miniapp:补齐命令发送(approve/authorize_dir/continue_loop/stop_loop 的 MiniCommand 构造,useAiChat.ts 当前只发 send/stop/switch/regenerate 4 条)。
**验证**:miniapp 发 send → 桌面触发 ai_chat_send → AI 回复事件经阶段 2 通路回流 miniapp 渲染。**全双工闭环**。
**回退**:桥接模块独立,tunnel `on_command` 回调改为 no-op(空闭包)即切断下行,阶段 2 的上行通路不受影响。
**风险**:
- R1(同 conv 并发)→ 阶段 3 必须实现 generating 前置检查。
- R3(审批阻塞 + 远程指令)→ stop 命令对 AwaitingApproval 状态行为需先核验 ai_chat_stop。
- 桥接层调 Tauri command 是直接 async fn 调用(非 IPC),需确保 AppHandle/State 句柄在桥接闭包内有效(从 tunnel 初始化处 clone,标准模式)。
### 阶段 4:真机联调 + 真多会话并发验证
**目标**:微信开发者工具 + 真机 + 桌面端三方联调,验证 F-09 多会话并发在跨端场景的鲁棒性。
**验证矩阵**:
| 场景 | 预期 |
|---|---|
| miniapp 单会话收发 | 全双工正常 |
| 桌面 + miniapp 同 conv 同时发 | 一方被 R1 兜底拒绝,另一方成功 |
| 桌面 conv-X + miniapp conv-Y 并行 | 互不中断,事件按 conv_id 路由 |
| miniapp 发 send 后桌面立即关 tunnel | miniapp 收到 incomplete 事件或断连提示 |
| miniapp 审批 + 桌面同时审批同 tool_call | 一方成功一方收到「已处理」(R2) |
| WS 断线重连(uni-app onShow 触发) | 事件不丢(重连后 AiConvStateChanged 同步状态) |
**回退**:每阶段独立,阶段 4 出问题回退到阶段 3 的 mock 联调环境。
**风险**:
- 真机 WS 稳定性(微信小程序 WS 限制:单连接、超时回收、后台冻结)→ 心跳间隔(25s,tunnel.rs:87)+ onShow 重连(ws.ts:82)已覆盖大部分。
- R6 事件风暴(多 conv 并行)→ 真机若卡顿,阶段 4 后期考虑 tunnel 侧 conv 过滤。
---
## 五、总结:决策点与待办映射
### 设计决策点(本文给推荐,留主代理/用户定)
| # | 决策 | 推荐 | 备选 | 依据 |
|---|---|---|---|---|
| D1 | 协议统一方案 | **A**(tunnel 纯透传 + src-tauri 桥接解协议) | B(miniapp 改发 TunnelCommand) | §1.4 no-patch-groundwork + 契约源头唯一 |
| D2 | Event 透传子集 | **全 19 变体透传** | 裁剪高频子集 | §2.1 方案 A 下 tunnel 零业务知识 |
| D3 | Event 注入点 | **EventBus 汇聚**(subscriber 接入) | 逐 emit 点 clone / 新建 gate 函数 | §2.1 EventBus 即为此建 |
| D4 | switch_conversation 处理 | **(a) 不处理**(MVP) | (b) 新增 sync command | §2.2 列入 P4 |
| D5 | TunnelCommand/TunnelEvent 强类型 | **保留**(标 allow,作弱校验/测试) | 删除 | dead-code-reserve-keep 原则 |
| D6 | R1 兜底前置位置 | **桥接层 send 路由硬性加** | 依赖 ai_chat_send 内部 guard | §3.3 双保险 |
### 待办登记(供主代理入 todo)
> 以下为实施项,本文只设计不实施。
- [ ] F-260622-01-阶段1:tunnel CommandHandler 签名放宽为 Value + parse_command_from_broadcast 改返回 Value
- [ ] F-260622-01-阶段2:EventBus 接入 55 处 emit 点(批2)+ tunnel 注册为 subscriber + send_raw_event
- [ ] F-260622-01-阶段3:src-tauri 桥接模块(MiniCommand→Tauri command 路由 + R1 兜底)+ miniapp 补齐 approve/authorize_dir/continue/stop_loop 命令
- [ ] F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵
- [ ] 核验项:ai_chat_send 内部 generating guard 现状(决定 R1 兜底是否冗余)
- [ ] 核验项:ai_chat_stop 对 AwaitingApproval 状态行为(R3)
---
## 附录 A:AiChatEvent 19 变体清单(核验自 mod.rs:104-216)
AiTextDelta / AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult / AiCompleted / AiError / AiAgentRound / AiHeartbeat / AiMaxRoundsReached / AiStreamRetry / AiDirAuthRequired / AiContextCleared / AiCompressing / AiCompressed / AiHelpRequired / AiConvStateChanged
(注:18 变体 + ConvState 派生,文档统称 18-19,F-260620-01 称「17 变体」是早期计数,以源码核验为准。)
miniapp handleEvent(useAiChat.ts:112-313)已覆盖其中 16 个,未覆盖:AiToolAutoApproved(仅 console)、AiDirAuthRequired(仅提示)、AiContextCleared/AiCompressing(no-op)—— 均属合理简化。
## 附录 B:src-tauri AI Chat 相关 Tauri command 清单(核验自 commands/chat.rs)
| 命令 | 关键参数 |
|---|---|
| `ai_chat_send` | message, language?, skill?, model_override?, conversation_id?, parts?, mention_spans? |
| `ai_chat_force_send` | (同 send,强制绕过 generating 检查) |
| `ai_chat_stop` | conversation_id? |
| `ai_regenerate` | conversation_id, language?, model_override? |
| `ai_chat_edit` | conversation_id, new_message, language?, model_override? |
| `ai_approve` | tool_call_id, approved |
| `ai_authorize_dir` | tool_call_id, decision("once"/"always"/"deny") |
| `ai_continue_loop` | conversation_id |
| `ai_stop_loop` | conversation_id |
| `ai_is_generating` | conversation_id? → bool |
| `ai_pending_tool_calls` | conv_id → Vec<PendingToolCallInfo> |
| `ai_chat_clear` / `ai_chat_clear_context` / `ai_chat_compress_context` | (上下文管理类) |
注:`ai_chat_force_send` 可作为 R1 兜底的备选(若桥接层确需在 generating 中强发,改调 force_send),但默认不用于远程(避免误并发)。
## 附录 C:核验证据索引(源码行号)
| 结论 | 证据 |
|---|---|
| relay 透传不解析 | `crates/df-relay/src/broadcast.rs:58-59`(payload: serde_json::Value)+ `relay.rs:341`(from_str 为 Value) |
| tunnel 收 TunnelCommand | `crates/df-tunnel/src/tunnel.rs:407-454`(parse_command_from_broadcast) + `events.rs:119-132` |
| miniapp 发 MiniCommand | `apps/df-miniapp/src/types/relay.ts:88-93` + `useAiChat.ts:379-385` |
| AiChatEvent emit 散布 55 处 | grep `emit("ai-chat-event"` 跨 7 文件 55 次 |
| AiSession 真多会话 | `src-tauri/src/commands/ai/mod.rs:348-383`(per_conv HashMap)+ `:632-716`(PerConvState) |
| 审批挂起 + 恢复 | `src-tauri/src/commands/ai/agentic/mod.rs:1446`(return 挂起)+ `commands/chat.rs:521`(try_continue 恢复) |
| EventBus 骨架未接 | `src-tauri/src/commands/ai/event_bus.rs:53`(dead_code)+ `:18`(批2接入留注) |
| 前端 listen 单事件名 | `src/api/ai.ts:258`(listen('ai-chat-event')) |

View File

@@ -0,0 +1,185 @@
# AI Chat 真实会话实测分析(上下文 / 工具 / Agent 行为)
> 日期:2026-06-22 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow-dev.db`)实测会话 `84dc36c8` 全量 86 条消息反推,Python 脚本统计(`.temp/analyze_session.py`)
> 基线:会话 prompt **1,320,713** / completion **7,578** tokens(completion/prompt 比 0.57%)
> 产物:**3 P0 / 3 P1 / 2 P2**
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
> 关联:[[devflow-compress-ui-no-fold]] [[devflow-generating-statemachine]] [[aichat-techdebt-audit-2026-06-21]] [[code-review-anti-contamination]] [[devflow-aichat-streaming-md]]
---
## 会话概况
| 项 | 值 |
|---|---|
| conversation_id | `84dc36c8-b416-44bc-a7c6-c4e00de82b6f` |
| 标题 | `D:\Downloads\kms` |
| 模型 | glm-5.2 |
| 跨度 | 2026-06-22 01:29 ~ 12:23(中间大间隔,有效工作段 ~11:5712:23) |
| 消息数 | 86 |
| prompt / completion tokens | 1,320,713 / 7,578 |
| completion/prompt 比 | **0.57%**(极端输入重) |
| 工具调用 | patch_file **16** / read_file **10** / list_directory 3 / grep 1 |
| tool 返回总量 | **194KB 跨 41 条** |
| `parts` 字段 | 0/86(全 null) |
| 用户催促次数 | 7(继续×2 / 完成了吗×2 / 仔细检查 / 核对 收尾 / 核对 内容) |
| 任务 | 整理 `D:\Downloads\kms` 下 README/INDEX + 三知识库 HTML 头尾修复 |
---
## P0(3 项)
### [P0-1] 上下文管理实质失效:压缩标记 `compressed` 未降 prompt,token 累积至 1.32M
- **现象**:seq 065 `status=compressed`(仅个别 active),但最终 `prompt_tokens=1,320,713`。压缩"生效"了,prompt 却没降。
- **db 证据**:全量 86 条中 66 条 `status='compressed'`;conv 元数据 `prompt_tokens=1320713 / completion_tokens=7578`
- **根因假设**:`compressed` 仅压历史消息的展示态/落库态,**未真正削减送入 LLM 的 prompt**。与 [[devflow-compress-ui-no-fold]](2026-06-18 决策"后端省 token 不前端藏")冲突——决策要求后端真省,实测后端没省。
- **影响**:1.32M 接近模型上下文上限,长会话必爆;成本几乎全烧在重复搬运文件内容。
- **待核验源码点**:compress 模块(`src-tauri/src/commands/ai/` 下 compress 相关)——压缩后重组 messages 时是否真替换为摘要,还是仅打标记原样回灌。
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] 3-compress 单元的 2 个 smell。
### [P0-2] 工具返回值协议不一致:`patch_file` / `read_file` 部分路径裸吐文件全文
- **现象**:同一工具返回结构两副面孔:
- seq 16 patch_file → `{"changed":true,"diff":...}` ✅ JSON 正常
- seq 37/40/42/46/49/51/53/60/63/65/75/76 patch_file → `raw 6204c` ❌ 裸文本(parse 失败)
- seq 6/7 read_file → `{"content":...}` ✅ 正常
- seq 11/29/31/33 read_file → `raw 6204c` ❌ 裸文本
- **db 证据**:41 条 tool 返回总计 194KB;`6204` 字符数反复出现(≈被操作 HTML 文件大小)。
- **根因假设**:`patch_file` 成功路径本应只回 `changed`+`diff`,却把整个文件内容回灌;或某分支(超长 / 特定路径)跳过 JSON 包装直接返回文件串。`6204c` 即文件实际长度被全文塞回 context。
- **影响**:**这是 prompt 1.32M 的直接元凶**。每次 patch 一次 = 文件全文再进一遍 context。16 次 patch × ~6KB ≈ 100KB 仅 patch 回灌。
- **待核验源码点**:`tool_registry.rs` patch_file handler(~1247 区,[[devflow-patch-file-design]] 落地点)与 read_file handler(~892-976)的返回序列化分支——确认哪条路径丢 JSON 包装。
- **关联**:与 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」同源(工具契约失配家族)。
### [P0-3] 重复 tool 结果:同一结果多次持久化,去重缺失
- **现象**:
- seq 11/12/13/14 → 连续 4 条 `raw 6204c`,同一 read 结果存 4 份
- seq 69/70/71 → 3×6204c;seq 83/84/85 → 3×6204c
- seq 75/76 → 2×6204c
- **db 证据**:上述 seq 的 content 长度完全一致(6204),且时间戳相同(同一秒)。
- **根因假设**:一次 assistant 回合并行触发多个 read(读 mysql+redis+linux 三文件),结果落库时按 tool_call 重复写,或同一 `tool_call_id` 结果被多次持久化;前端流式接收去重逻辑缺失。
- **影响**:context 进一步虚胖;且会污染后续压缩/摘要(同一内容被当作多条独立事实)。
- **待核验源码点**:tool 结果落库路径(`ai_messages` 写入)——按 `tool_call_id` 是否做唯一约束/去重;流式 patch 合并逻辑。
---
## P1(3 项)
> **总纲(用户归纳)——编排规划能力弱**:P1-1(无自检)与 P1-3(无并行调度)同源。agent 无任务分解、无并行 fan-out、无宣称完成前的自检规划,单链 ReAct 逐工具串行推进,识别不出"N 个独立子任务可并行"。这是 agent 自主性/可靠性的核心缺口,印证 [[aichat-decision-capability]] 与 [[aichat-b-route-parallel-multiround]]。归 B 路线立项。
### [P1-1] Agent 过早宣称完成,无 checklist 自检,用户被迫 7 次催促
- **现象**:assistant 3 次宣称完成,3 次被用户推翻:
| seq | 时刻 | 宣称 | 推翻证据 |
|---|---|---|---|
| #26 | 12:01 | "整理完成" | #27 用户"文档内容需要按要求整理" |
| #66 | 12:10 | "**全部完成了!**" | #67"仔细检查" → #72 发现 `theme.js` vs `claude-artifact-theme.js` 引用名错(漏改 2 个文件) |
| #80 | 12:19 | "全部核对完毕,7 个标记全部对齐无遗漏" | #81"核对 内容"(用户不信,要求再核)→ 会话中断于此 |
- **db 证据**:`theme.js` 引用名错误本可一次 grep 抓出,却要用户肉眼挑(实际 #78 才首次用 grep,且是用户第三次催后)。
- **根因假设**:无宣称完成前的强制自检机制(如 grep 引用一致性 / 闭合标记核对)。Agent 倾向于 patch 完即宣布成功,不验证。
- **影响**:用户对 agent 完成度零信任,交互成本极高;本质是 agent 自主性/可靠性的核心缺口。
- **待核验/落点**:属 agent 行为策略层(prompt / loop 收尾钩子),非单点代码 bug。建议与 [[aichat-b-route-parallel-multiround]] B 路线(决策能力)合并立项。
- **关联**:[[aichat-decision-capability]](单链 ReAct,无规划式智能)的现实印证。
### [P1-2] 末尾会话中断:tool 全部返回后无 assistant 回复(generating 状态机卡死)
- **现象**:#82 assistant 触发读三文件 → #83/84/85 tool 全部正常返回(`status=active`)→ **之后再无 assistant 消息**,会话 12:23 停尸。
- **db 证据**:seq 85 是最后一条,role=tool;无 seq 86 的 assistant 总结回复。`active` 状态说明 tool 执行完成,但后续生成未发生。
- **根因假设**:tool 执行完但 `generating` 状态未复位,后续回复被吞。高度吻合 [[devflow-generating-statemachine]] 记录的"generating 复位散布 + 无 panic 兜底"卡死模式。
- **影响**:用户看到"读完了"就没下文,必须手动重发;且该会话 token 已 1.32M,重发极可能直接触上限。
- **待核验源码点**:agentic loop 的 tool-后-续生成衔接(`mod.rs` 收尾 / `try_continue` / per-conv generating 复位)。
### [P1-3] 三个独立文件整理任务严格串行,无并行 fan-out 调度
- **现象**:`mysql_guide` / `redis_guide` / `linux_ubuntu_guide` 三个 HTML 整理是独立子任务(无依赖),本可并行。但 agent 严格串行:mysql(12:0212:06)→ redis(12:0712:08)→ linux(12:0812:10),**时段完全不重叠**;每个文件内还 read→patch→read→patch 多轮。assistant 从未在一个 turn 内对三文件 fan-out 工具调用。
- **db 证据**:时间线三文件处理时段分明不重叠;seq 68"仔细检查"后只 read mysql 一个,未并行触发 redis/linux 的核对。
- **根因假设**:单链 ReAct 无任务分解层。缺"识别 N 个独立子任务 → 同 turn 并行发起 N 路工具调用"的编排能力。
- **影响**:耗时线性叠加;同一指令在 context 里反复加载三遍;用户等待感差。**直接印证 [[aichat-b-route-parallel-multiround]]**(用户 2026-06-20 已决策"单对话并行多轮必须做")。
- **归并**:与 P1-1 同属"编排规划能力弱"(见 P1 区总纲),归 B 路线。
---
## P2(2 项)
### [P2-1] system 消息污染 + 时序错乱
- **现象**:
- seq 0 `role=system`,content 却是 assistant 语义残留:"我需要查看 mysql\_guide.html 的最后几行来确认尾部内容..."
- seq 0 timestamp `12:08:02`,seq 1 却是 `01:29:35`——seq 0 比 seq 1 晚 10 小时。
- **根因假设**:system/上下文注入消息的 role 标记错位;timestamp 用"插入时刻"而非"会话时刻"。
- **影响**:排序、压缩、计费全受污染;system 消息混入 assistant 内容会误导模型。
- **待核验源码点**:system prompt 注入路径(`ai_messages` 写入 system 行的位置)+ timestamp 赋值逻辑。
### [P2-2] 同文件反复 read,无缓存 / 无 diff 感知
- **现象**:`mysql_guide.html` read **4×**`linux_ubuntu_guide.html` read **4×**;patch_file 共 16× 改 3 个文件。read→patch→read→patch 循环。
- **根因假设**:每次 patch 后又全文 read 确认(叠加 P0-2 全文回灌);无 session 级文件缓存或"patch 后不重读"约定。
- **影响**:context 雪崩放大器;与 P0-2 叠加是 prompt 爆炸的复合原因。
- **待核验/落点**:工具侧可加"会话内文件指纹缓存,LLM 重读同文件时返回缓存命中提示";或 prompt 侧约束 patch 后不重读。
---
## 附录:`parts` 字段全空(待观察项)
- **现象**:`msgs_with_parts=0/86`。[[devflow-aichat-streaming-md]] 记录块级 memo(memo=mapped.lexer+v-memo)已落地(ARC-260615-08)。
- **不定论**:本会话可能早于改造,或 `parts` 存于别处 / 未回填。需对照**新产生**的会话核验,不能据此断定流式渲染回退。
---
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
| 项 | 落点 |
|---|---|
| P0-1 / P0-2 / P0-3 | 并入 [[aichat-techdebt-audit-2026-06-21]] P1 主线,或单列"上下文/工具返回"专项 |
| P1-2 中断 | 印证 [[devflow-generating-statemachine]] 待落地链(已有详案) |
| P1-1 自检 + P1-3 并行调度 | 同属「编排规划能力弱」总纲,归 [[aichat-b-route-parallel-multiround]] B 路线(任务分解 + 并行 fan-out + 宣称前自检) |
| P2-1 / P2-2 | 低优登记 todo |
**最高杠杆**:P0-2(工具返回全文回灌)——改一处(工具序列化返回 diff/changed 而非全文),prompt 直接降一个量级。
---
## 复现方法
```bash
# 分析脚本(已留存)
python E:/wk-lab/devflow/.temp/analyze_session.py
# DB 只读连接
sqlite3 "file:%APPDATA%/top.1216.devflow/devflow-dev.db?mode=ro" -uri
# 表:ai_conversations / ai_messages / ai_tool_executions
```
数据快照:2026-06-22 12:36 抓取,会话可能仍在变化(WAL 活跃)。
---
## 2026-06-23 修正与落地(自主推进,290 src-tauri + 324 df-ai 测试全过)
**核验推翻**(实测 DB tool_call_id + timestamp 独立核验,[[code-review-anti-contamination]]):
- **P0-3 撤销**:两会话(4ae73423/84dc36c8)"重复 tool 结果"实测 tool_call_id **全不同** + timestamp 间隔数分钟/小时 → LLM 不同时刻重调,非落库去重 bug。原 P0-3 误判(06-22 报告 + 核验 agent 都错,实测数据证伪)。
- **P0-2 性质修正**:非"prompt 爆炸元凶"(prompt 用内存完整态,非落库截断版)。真因是 `conversation.rs:72` truncate_for_persist 对**序列化 JSON 中间截断**插裸 `\n\n[...省略 N 字符...]\n\n`,破坏 JSON 致**落库坏 JSON**(reload/审计 parse FAIL,pos3072=TRUNCATE_HEAD)。
- **中断A 非bug**:续轮机制完整(mod.rs:1362-1478),84dc36c8 seq85 后无回复疑达 max_iterations 或自然收敛。
- **中断B 真缺陷**:4ae73423 末尾 advance_task 审批 `__PENDING__` 卡死(mod.rs:1464 return 无超时),非 generating 卡死。
**本次修复落地**:
| 项 | 位置 | 修复 |
|---|---|---|
| intent 收敛误杀(4ae73423 主因) | intent.rs | L0 strip_mention_tags 剥离 mention 前缀 + L1 Project/Task/Idea 加 File domain |
| write_file parent 误拒(P0,授权内文件被拒) | tool_registry.rs:1114/1421/1559 | 改 allowed_dirs.is_authorized(脱 workspace_root) |
| read_file limit 忽略 | tool_registry.rs:1023 | 无 offset 尊重 LLM limit(原固定 500) |
| truncate 破坏 JSON(P0-2) | conversation.rs:175 | 跳 tool role 截断 |
| 压缩丢末条消息 | agentic/mod.rs:1225 | 全 provider 失败 return 前补 save_conversation |
| run_command && PS5 失败 | df-execute/shell.rs | pwsh(PS7)优先探测 + PS5 兜底(OnceLock 缓存) |
| remote_bridge args default | remote_bridge.rs:97 | custom default 返空 Object(#[serde(default)] 给 Null) |
| remote_bridge r1 双轨测试 | remote_bridge.rs:872 | 设态对齐双轨(conv_state=Generating) |
**写 docs/待决策.md**(非确定性,需决策):
- BUG-260623-03 审批 pending 无超时(中断B,推荐 b 前端倒计时)
- ARC-260623-01 workspace_root 写死残留(delete_file trash/resolve/run_command working_dir,分发适配)
**生效**:需重启 devflow app(cargo tauri dev / 重新 build),新会话才用新二进制。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

View File

@@ -50,6 +50,16 @@
---
### 🔍 2026-06-21 查询能力缺口 + run_workflow 缺陷(本轮排查登记·未实施)
> 用户排查任务/项目/灵感查询能力现状,登记缺口为待办/缺陷,本轮不实施代码。详单见 [查询能力补全方案-2026-06-21.md](./02-架构设计/专项设计/查询能力补全方案-2026-06-21.md)。
- [x] BUG-260621-01 [P1🔴·确定修法] — **run_workflow 空 dag 反序列化失败(missing field nodes),阻塞任务工作流推进****2026-06-22 修复**:DagDef.nodes/edges 各加 `#[serde(default)]`,`{}` 反序列化成空 DagDef → workflow.rs:91 `is_empty()` 进 template_for 选模板。2 单测防回归(空 `{}` + 非空零回归),df-workflow cargo test 51 passed。— `crates/df-workflow/src/dag_def.rs` + `src/views/TaskDetail.vue`(:333)
- [x] F-260621-02 [P2] — **任务/项目/灵感列表查询维度补全(下沉后端+关键词搜索+排序分页)****2026-06-22 实施**:三实体新增 `list_by_query` 动态 WHERE(累积式 where_clauses+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制;命令层 `list_{tasks,projects,ideas}``Option<XxxQuery>` 双参向后兼容(旧无参/单参路径等价全量);前端 Tasks/Ideas status/keyword 筛选下沉后端 query(Tasks.vue buildTaskQuery / Ideas.vue watch 防抖)。索引缺口已补(2026-06-22 V26 migrate_v26 idx_tasks_priority/assignee + SCHEMA 同步,commit 82059ba)+ P3 分页 UI 已落(Paginator.vue 客户端切片 + Tasks/Ideas/Projects 三视图接,commit 83cee61)。详见查询能力补全方案-2026-06-21.md。— `commands/{task,project,idea}.rs` + `crates/df-storage/src/crud/{task,project,idea}_repo.rs` + `src/api/{task,project,idea}.ts` + `src/views/{Tasks,Projects,Ideas}.vue`
---
### 📋 编排推进总览2026-06-18 更新)
未完成待办按可执行性分 8 组(详细条目见下方各分类,勿重复记录):
@@ -257,8 +267,8 @@
**🆕 sweep 派生登记2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep**:
- [ ] ARC-260618-01-d [P1/行为变更·待确认产品意图] — **conditions 条件引擎未求值**`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点)
- [ ] ARC-260618-01-e [P1/行为变更·待确认产品意图] — **adversarial `evaluate_with_llm` 一致性未校验**`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构)
- [ ] ARC-260618-01-d [P1/🟡已实现待启用] — **conditions 条件引擎**(核验2026-06-22:conditions.rs:31 ConditionEngine + executor.rs:106 已调 evaluate 求值 + executor.rs:95 `cfg!(feature="conditions-eval")` feature flag 控制,求值逻辑已实现待产品确认启用时机)`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点)
- [x] ✅(2026-06-22 核验·adversarial.rs:147 `self.recommendation_for(&analyst.final_assessment)` 已守卫 final_assessment/recommendation 一致性,矛盾组合经 recommendation_for 收敛·漏勾销账) **ARC-260618-01-e**adversarial evaluate_with_llm 一致性。已有守卫`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构)
- [x] ✅(2026-06-20·formatRelativeZh→formatRelative 重命名 6 文件:time.ts 定义+注释去「中文」限定 + ConversationSidebar/MessageList/ActiveProjectsPanel/AuditLog/Tasks 5 调用点·AiChat 已拆 750 行无引用·grep 全 src 无残留 + vue-tsc --noEmit EXIT 0) SW-260618-21 [P2] — **formatRelativeZh 重命名 formatRelative**`src/utils/time.ts` `formatRelativeZh` 名带 Zh 后缀但实际非中文硬编码(已读 i18n.global.locale, sweep 批2 time.ts formatDate 已修 CR-260615-08 漏修),函数名误导。原缓做前提(AiChat 沉淀)已满足,本次落地。— `src/utils/time.ts`(formatRelative) + 5 调用点(ConversationSidebar/MessageList/ActiveProjectsPanel/AuditLog/Tasks)
- [x] ✅(2026-06-20 核验·resolveLang 已抽 `src/composables/ai/aiShared.ts:30-35 resolveAiLang` 共享,消除重复) SW-260618-22 [P2/已知 B-260617-03] — **useAiSend resolveLang DRY**`useAiSend``resolveLang` 与其他 composable 语言解析逻辑重复,应抽 `aiShared` 共享。已知登记为 B-260617-03,此处补 sweep 维度记录便于检索。— `src/composables/ai/useAiSend.ts`(resolveLang) → 抽 `src/composables/ai/aiShared.ts`
@@ -425,7 +435,7 @@
**P0(超红线/最脏,本周期优先)**
- [x] ✅(2026-06-20 核验已拆分·`commands.rs` 已删除→`src-tauri/src/commands/ai/commands/` 目录 5 模块:chat.rs(56888 字节·发送审批控制 B 域)/config.rs(5496·杂项 E)/conversation.rs(38573·会话CRUD D)/mod.rs(2058·re-export only,`pub use self::{chat,conversation,provider,config}::*` 透传)/provider.rs(15262·提供商 C)·glob 链保留:commands/mod.rs:13 注释明 `pub use self::{chat,conversation,provider,config}::*` → ai/mod.rs `pub use self::commands::*` 透传 commands::ai::*) **REFACTOR-260619-01 [P0]****commands.rs(ai,1923 行)拆 5 模块**。5 组职责正交:B 发送审批控制(1140,13 IPC)/C 提供商(305)/D 会话CRUD(360)/E 杂项(63)+A helper。**先拆 C `provider_cfg.rs`(零风险试水:仅依赖 ai_providers 无 session 锁/per_conv/spawn)→ D `conversation_crud.rs`(`conversation.rs` 已存在,命名避冲突)→ E `misc_cfg.rs`→ B `chat_control.rs`(最复杂,等 F-09 B 批4 更稳)**。关键:`pub use self::commands::*;` glob 保留(mod.rs:58)否则 invoke_handler 注册断。— `src-tauri/src/commands/ai/commands.rs` + `mod.rs:58`
- [ ] **REFACTOR-260619-02 [P0]****anthropic_compat.rs(1025 行)拆模块**。含本会话加的 precheck/summarize(诊断)+ convert_request(159)+ SSE 解析(109)。**保守拆 3 模块**:`types.rs`(请求响应结构体 ~90)+ `sse.rs`(apply_anthropic_event ~160+测试)+ 残留 provider.rs(convert/complete/stream/precheck 留 impl 块不拆,因 Rust impl 不跨文件)。激进拆 4(加 convert.rs)需把 convert_request 等 4 关联 fn 改自由函数(动 6 处 Self:: 调用点),风险高不推荐。— `crates/df-ai/src/anthropic_compat.rs`
- [x] ✅(2026-06-22 核验·types+SSE 已抽 crates/df-ai/src/anthropic_helpers.rs(5 struct+2 const+apply_anthropic_event),anthropic_compat.rs 残留 881 行 Provider+impl(Rust 硬约束 impl 不跨文件不可再拆),lib.rs:4-5 双模块注册·对齐 _compat/_helpers 同构约定(openai/model_fetch/model_probe/context 同)·漏勾销账) **REFACTOR-260619-02** — anthropic_compat.rs 拆模块。已完成(types+SSE 抽出)。
- [x] ✅(2026-06-20 核验已拆分·`audit.rs` 已删除→`src-tauri/src/commands/ai/audit/` 目录:mod.rs(31813 字节·process_tool_calls 热路径 + 编排)/reason.rs(7796·build_approval_reason)/finalize.rs(3874·audit_tool_call+audit_finalize,mod.rs:115 `pub(crate) use finalize::{audit_finalize, audit_tool_call}`)/cache.rs(7685·find_cached_high_risk_result)/diff.rs(1686·build_write_file_diff)/restore.rs(10033·restore_pending_approvals)/idea_source.rs(13607·任务→灵感溯源)/data_change.rs(2432)/utils.rs(1486·risk_str/常量)) **REFACTOR-260619-03 [P0]****audit.rs(959 行)拆 5 模块**`audit/{mod,list,reason,finalize,dedup,process}.rs``process_tool_calls`(250+行热路径)**整块搬 process.rs 不拆函数体**(锁内 await 边界不动,CR-260618-11#5 性能注记)。11+ 私有 fn 升 pub(super):audit_tool_call/audit_finalize/find_cached_high_risk_result/build_write_file_diff/build_approval_reason 等。`PENDING_APPROVAL_PLACEHOLDER`/`risk_str` 提 mod.rs 共享。— `src-tauri/src/commands/ai/audit.rs`
**P1**
@@ -434,12 +444,12 @@
- [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 核验已拆分·`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`
- [ ] **REFACTOR-260619-08 [P1]****(已有 SMELL-P0-2)tool_registry.rs(2023)按功能分组注册函数拆**。register_crud_tools/register_file_tools(已抽)/register_workflow_tools 等,每个 <200 行。— `src-tauri/src/commands/ai/tool_registry.rs`
- [x] ✅(2026-06-22 核验·tool_registry.rs:442-514 已拆 register_*_tools 系列 data/file/http/project/task/workflow/idea/trash,SMELL-P0-2 批次落地·漏勾销账) **REFACTOR-260619-08** — tool_registry.rs 按功能分组注册函数拆。已完成。register_crud_tools/register_file_tools(已抽)/register_workflow_tools 等,每个 <200 行。— `src-tauri/src/commands/ai/tool_registry.rs`
**P2 暂缓(窗口未到/收益低)**
- [ ] **REFACTOR-260619-09 [P2 暂缓]****context.rs(1332)等 F-15 压缩链路稳定再拆**。生产 745+测试 587。impl 跨文件方案(同 crate 多 impl 块,零字段可见性改动)。先 sanitize.rs(最大连续块 ~184)。当前 context.rs 被 F-15/压缩频繁改动,拆分窗口未到。— `crates/df-ai/src/context.rs`
- [ ] **REFACTOR-260619-10 [P2]****scan.rs(1015)拆 4 模块**(stack/discover/readme/sample)。纯函数低风险收益低。共享 SAMPLE_IGNORED_DIRS/truncate_chars/read_readme_raw 提 mod.rs。— `crates/df-project/src/scan.rs`
- [x] ✅(2026-06-22·scan.rs(实际741行)拆 scan/{mod,stack,discover,readme,sample}.rs,共享 SAMPLE_IGNORED_DIRS/truncate_chars 提 mod.rs pub(super),has_file_with_ext/read_readme_raw 跨模块降 pub(super),测试 29→29 不降,cargo check --workspace EXIT 0 外部消费方零改) **REFACTOR-260619-10** — scan.rs 拆 4 模块。已完成。
- [x] ✅(2026-06-20 核验·`crud.rs` 已删→`crates/df-storage/src/crud/` 5 文件 mod/conversation_repo/idea_repo/message_repo/project_repo·与 SMELL-P1-9 同件) **(已有 SMELL-P1-9)crud.rs(2212)按表拆** project_repo/task_repo/conversation_repo/idea_repo。— `crates/df-storage/src/crud.rs`
**执行顺序建议**:01-C provider_cfg(零风险试水) → 02 anthropropic → 03 audit → 04 ToolCard useToolFormat → 05 agentic_runtime → 06 ai_node → 07 AiChat.vue → 08 tool_registry。每步 cargo check+test+clippy 分 crate + 手测。
@@ -452,7 +462,7 @@
- [x] ✅(2026-06-19·df-execute shell.rs 全仓唯一子进程源(grep 确认)·tokio Command 加 creation_flags(0x0800_0000) CREATE_NO_WINDOW·cargo check df-execute EXIT 0 无 warning) **B-260619-01 [P1]****执行命令行弹黑色窗口闪烁**。Windows tokio::process::Command 创建 cmd/powershell 子进程默认带控制台窗口(黑窗闪现)。修:`#[cfg(windows)] cmd.creation_flags(0x0800_0000)`(CREATE_NO_WINDOW)。tokio Command Windows 自带 creation_flags 方法(无需 std CommandExt trait)。覆盖 run_command 工具 + 工作流 shell 节点(全经 df-execute)。— `crates/df-execute/src/shell.rs`(:86-93)
- [ ] **B-260619-02 [P1]****GLM 1214 messages 非法(数据驱动定位:单条 tool_result 过大)**。DB 直查报错对话 f64dee94:全量 546 条(多数 compressed),**active(发往GLM)仅 7 条结构合法**(system/assistant/user/assistant/user 交替,tool_use↔tool_result 配对 orphan=0,无连续 role,57K 字节 ~19K tokens **未超 GLM 128K**)。**真凶**:单条 tool_result 巨大(read_file 整文件 #2=21843B/#3=16511B/#6=11482B),T-05 截断阈值 50KB 未触发(21KB<50KB),但 **GLM anthropic 端点单条 tool_result content 限制更严(~10-20KB 即拒 1214)**。次要:#4 assistant content 空(len=0 只有 tool_use);"回复一半消失"=1214 在 GLM 流极早(message_start 前)error→stream_recv InitFailed→emit AiError→前端清 currentText,保文逻辑未覆盖。**修法方向**:① tool_result 截断阈值收紧 50KB→8-10KB(read_file/list_directory 大文件截断+提示)② MidStream 早 1214 保文(apply_anthropic_event error 时若已 message_start 不清流式)③ 空 assistant convert 兜底。**已加诊断(待重编译验证)**:anthropic_compat precheck(首条/连续/input/空content/orphan 5 类 Init bail)+ MidStream error 附 messages 摘要(SSE error chunk 塞摘要到前端 raw)+ HTTP/1.1 治 GLM HTTP/2 RST + 错误源链进 anyhow 文案。— `crates/df-ai/src/anthropic_compat.rs`(precheck/summarize/MidStream 摘要/version HTTP_1_1) + tool_result 截断(T-05 `tool_registry.rs`/`audit.rs` 阈值 50K→8-10K) + MidStream 保文(`stream_recv.rs`)
- [x] ✅(2026-06-22·conversation.rs:65 TRUNCATE_THRESHOLD 50KB→8KB + HEAD/TAIL 各3KB + 截断提示含原字节数让 LLM 感知非全文·cargo check devflow EXIT 0 + conversation 测试 25 passed·仅作用持久化视图不污染内存真相源) **B-260619-02** — GLM 1214 tool_result 过大截断。已修(大小根因,配 CR-260620-04 配对错根因互补)。DB 直查报错对话 f64dee94:全量 546 条(多数 compressed),**active(发往GLM)仅 7 条结构合法**(system/assistant/user/assistant/user 交替,tool_use↔tool_result 配对 orphan=0,无连续 role,57K 字节 ~19K tokens **未超 GLM 128K**)。**真凶**:单条 tool_result 巨大(read_file 整文件 #2=21843B/#3=16511B/#6=11482B),T-05 截断阈值 50KB 未触发(21KB<50KB),但 **GLM anthropic 端点单条 tool_result content 限制更严(~10-20KB 即拒 1214)**。次要:#4 assistant content 空(len=0 只有 tool_use);"回复一半消失"=1214 在 GLM 流极早(message_start 前)error→stream_recv InitFailed→emit AiError→前端清 currentText,保文逻辑未覆盖。**修法方向**:① tool_result 截断阈值收紧 50KB→8-10KB(read_file/list_directory 大文件截断+提示)② MidStream 早 1214 保文(apply_anthropic_event error 时若已 message_start 不清流式)③ 空 assistant convert 兜底。**已加诊断(待重编译验证)**:anthropic_compat precheck(首条/连续/input/空content/orphan 5 类 Init bail)+ MidStream error 附 messages 摘要(SSE error chunk 塞摘要到前端 raw)+ HTTP/1.1 治 GLM HTTP/2 RST + 错误源链进 anyhow 文案。— `crates/df-ai/src/anthropic_compat.rs`(precheck/summarize/MidStream 摘要/version HTTP_1_1) + tool_result 截断(T-05 `tool_registry.rs`/`audit.rs` 阈值 50K→8-10K) + MidStream 保文(`stream_recv.rs`)
- [x] ✅(2026-06-20·cargo df-ai+devflow EXIT 0 零 warning + vue-tsc EXIT 0·主 worktree 改,workflow worktree baseRef 故障绕过) **CR-260620-04 [P0]****1214/400 messages 非法·配对错根因(compress 绕过 sanitize)+ 停用模型路由穿透**。用户贴 deepseek 400「tool_calls must be followed by tool messages」/「tool must be response to tool_calls」确认 **tool_use/tool_result 配对错**(与 B-260619-02 tool_result 大小根因**互补,1214 多根因**)。**根因**:`sanitize_messages`(context.rs:212 畸形配对自愈,三档+孤儿)只在 `build_for_request` 调(主对话),**`compress_via_llm`(compress.rs:46)构造 prompt 不经它**(:70 extend active_msgs 直进)→ 压缩带毒 → 400「自动上下文压缩失败」。**修法**:① `context.rs:212 fn sanitize_messages``pub fn`(跨 crate);② `compress.rs` 空检查后 `let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs)` 治毒 + `:90` 错误带 `active_count` 诊断;③ 停用模型 `agentic/mod.rs:188,450` model_override `.any(|m|m.model_id==id)``==id && m.enabled`(会话内 stale override 不穿透 disabled)。**关联**:B-260619-02(tool_result 大小根因 GLM 1214,截断 50K→8-10K **仍待做** `:412`)/ F-09 newConversation(CR-260620-02 ✅)/ 用户实测 deepseek 400。**未做**:tool_result 截断(B-260619-02 大小根因,GLM 若还犯需做)+ stream_recv SSE 错误 summarize(诊断仅 compress 层,stream_recv 待补)。— `crates/df-ai/src/context.rs` + `src-tauri/src/commands/ai/compress.rs` + `src-tauri/src/commands/ai/agentic/mod.rs`
@@ -696,8 +706,8 @@
### ⚠️ 灵感升级-遗留缺陷(2026-06-21·待另一会话修)
- [P0 功能] related_ids 白名单缺失:crates/df-storage/src/crud/settings.rs:121-124 的 ideas 白名单(13 列)无 related_ids → 批3c 关联管理 Ideas.vue onUpdateRelated 调 update_field('related_ids') 被 validate_column_name 拒,关联存不进 DB(表面能选确定后静默失败)。修复:settings.rs:123 ideas 白名单加 "related_ids"(一行)。已核验确认
- [P2 并发·待核] 评估历史 version 并发重复:src-tauri/src/commands/idea.rs evaluate_idea 用 list_by_idea().first().version+1 算 version,无事务/(idea_id,version) 唯一约束,并发评估同一 idea 两次可能 version 重复。单用户桌面低概率,严格讲需加唯一约束或事务。批2 引入。
- [x] ✅(2026-06-22 核验·settings.rs:123 ideas 白名单已含 related_ids,14 列非 13,批3c 落地时已补,关联 update_field 放行非静默失败·过时 todo 销账) **related_ids 白名单** — 原"P0 功能缺失"为过时信息,实际已落地
- [P2 并发·🟡部分完成] 评估历史 version 并发重复:idea.rs:288-293 evaluate_idea 已加重试兜底 + V25 (idea_id,version) 唯一约束(migrations.rs)兜底。约束+重试就位,运行时确认约束生效后销账。单用户桌面低概率,严格讲需加唯一约束或事务。批2 引入。
- [P2 运行·未验] 批1-3c 前端运行时未实测:编译/vue-tsc 过,但运行(评估历史加载/关联管理交互/统计 computed/ProjectDetail 来源卡片反查 store.ideas)未实际跑过。需 npm run tauri dev 实测。
---
@@ -719,7 +729,7 @@
> 用户并行开发工具结果展示时,`ToolResultBody.vue` + `composables/ai/useToolCard.ts` 引用了 `ToolResult` 类型不存在的字段(vue-tsc 7+ 错)。**非本次灵感模块改动引入**,是用户工作区并行开发中间态。不阻断 `vite dev`(dev 宽松),阻断 `npm run build`(prod 严格)。
- [ ] **TD-260621-07 [P2/预存债·待用户对齐]****ToolResult 类型缺 output_mode/files/counts/matches/total_files 字段**
- [x] ✅(2026-06-22 核验·useToolCard.ts:88-100 ToolResult 已含 output_mode/matches/files/counts/total_files 五字段(F-260621 grep 跨文件搜索)+注释说明三模式语义·ToolResultBody.vue:71-95 三模式渲染消费·useToolCard.ts:424-446 formatGrep 消费·vue-tsc --noEmit EXIT 0 无 TS 错·漏勾销账) **TD-260621-07 [P2/预存债·待用户对齐]****ToolResult 类型缺 output_mode/files/counts/matches/total_files 字段**
- **现状**:`ToolResult`(types.ts)未定义 `output_mode` / `files` / `counts` / `matches` / `total_files` 五字段,但 `ToolResultBody.vue` + `useToolCard.ts` 模板/computed 已引用 → vue-tsc 报 7+ Property does not exist
- **定位**:用户并行开发工具结果展示增强(可能是为了批1 评估深度标签/批2 历史时间线之外的工具结果多模式渲染 — output_mode 暗示 tab/列表/树等多种展示模式),属进行中的中间态
- **修法**:① `ToolResult` 类型补齐 5 字段(对齐后端返回结构) + ② `ToolResultBody.vue` / `useToolCard.ts` 消费方与类型对齐(可能需后端 `tool_result_summary` IPC 返回结构同步扩字段)
@@ -743,7 +753,15 @@
- **P3** df-miniapp 小程序(前端 UI 复用 + WSS 连云)— 依赖 P1/P2
- **P4** 双向同步完善(冲突处理 + 安全 + 离线降级 + 部署)— 依赖 P1/P2/P3
**状态**:📋 待决策(晋升灵感 4495fbcd → 启动 P1?或暂缓单机版稳定后)
**状态**:✅ Phase2 三层全落地(2026-06-22:df-relay `2b8b30e` Hello握手+鉴权+ConnRegistry配对路由+BroadcastMessage透传 / df-tunnel `25d6565` WS客户端出站穿NAT+心跳25s+指数退避+TunnelCommand 5变体 / df-miniapp `280baea` uni-app WS连relay透传+18变体AiChatEvent镜像+useAiChat分派;双 crate 独立消息骨架不依赖 src-tauri/df-types 避跨 crate 强耦合)。📐 **Phase3 联调设计已出** [F-260622-01](../docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md)(协议统一方案A纯透传 + AiSession桥接风险 + 4阶段路线),待实施。
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
- [x] ✅(2026-06-22)F-260622-01-阶段1(D1=A):tunnel `CommandHandler``serde_json::Value` + `parse_payload_from_broadcast` 返回 Value(入站纯透传,cargo check + 5 测试过,D5 保留 TunnelCommand 弱校验)。**出站 `send_raw_event` 移阶段2**(无阶段1 验证场景,阶段2 EventBus 接入同步加)
- [x] ✅(2026-06-22)F-260622-01-阶段2:前置决策 ② EventBus Value(0fb5af5)+ emit 双写全接入 chat19/audit12/guard3/stream_recv2/agentic18(612de4c)+ send_raw_event(030ddb6)+ tunnel subscriber(ed8e2fc)。**后端上行闭环**(EventBus→tunnel→relay→miniapp)。剩余:relay_url/device_id Settings UI(方案A)+ token 生产级(Phase4)
- [x] ✅(2026-06-22)F-260622-01-阶段3:remote_bridge 桥接(0fb5af5)+ miniapp 4命令(8ccdce2)+ on_command 注册(ed8e2fc)。**下行闭环**(miniapp Command→tunnel on_command→remote_bridge→Tauri command)
- [ ] F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵(6 场景含 F-09 跨端并发)
- [x] ✅ 核验项:ai_chat_send 内部 generating guard(chat.rs:354-358 有 `is_gen → Err<String>`);R1 仍加作双保险(桥接层前置 + 内部 guard 双层)
- [x] ✅ 核验项:ai_chat_stop AwaitingApproval(chat.rs:1469-1484 finalize_pending_placeholders + retain 清 pending + generating=false + emit AiCompleted,R3 已缓解)
---
@@ -782,7 +800,7 @@
**关联**:`intent.rs`(CR-08)/ 跑题 P0 intent 接入(102d398)/ `coordinator.rs` 空壳(SMELL-P2-4)/ conditions(T-260614-11)/ F-09 B 路线 / memory [[aichat-arch-extensibility]] / [[aichat-roadmap-ab-split]](B 路线)
**状态**:📋 待决策(是否推进单对话内并行多轮?轻规划先 / 重规划独立?)。属 aichat B 路线(决策能力)范畴,非刚需(P0-P2 跑题已大幅改善,ReAct 单链覆盖大多数场景)
**状态**:🔧 Phase1 已落(2026-06-22 PLANNING_ENABLED=true 主 loop 规划开关启用 + filter_tool_defs_planned 接入点就绪 commit cf7fe86,plan_hint 函数 PLAN_HINT_ENABLED 就绪双层开关可回退)。Phase2 评估(2026-06-22 agent 核验):plan_hint 完整非空壳(4 启发式规则 plan_hint.rs:264-351,Phase1 已生效生产路径非占位),Phase2=接 planner.rs LLM Plan DAG 重写主 loop 调度(L 工作量+🔴高回归风险:主 loop 绑 F-09并发/DIRAUTH/generating状态机/断路器/压缩 6 子系统),**暂缓先验 Phase1 收益**(埋点 agentic/mod.rs:648-656 就绪跑数据)。📋 待决策(Plan 严格调度 vs hint 模式双语义未定 plan_hint.rs:20-25)。属 aichat B 路线(决策能力)范畴,P0-P2 跑题已大幅改善 ReAct 单链覆盖大多数场景。
---
@@ -831,3 +849,119 @@
**关联**:CR-260620-02 审查 LOW-1 / F-09 决策e
**状态**:✅ 已实施(删 aiChat.ts confirmNewConvWhileGenerating key zh/en + 改 TopBar/Sidebar 3 处 stale 注释,vue-tsc EXIT 0,2026-06-20)
---
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
> 用户 /review miniapp + workflow 汇总安排。5 agent 独立 grep/read 核验源码(不信注释/文档) + 主代终态核验(ws.ts 真路径 `src/api/ws.ts`、`relay.rs:255/355`、remote_bridge 命令数 10 非 11)。**18 findings → 去重 P1×5 / P2×3 / P3×1**,2 项 refuted。关联 F-260620-01 阶段4(真机联调前置)。**守 session-role-diagnose-only:本会话仅走查 + 登记,未实施代码。**
**P1(阻塞真机/可靠性·5 项)**:
- [ ] P1-D-260622 [P1🔴·真机分发最高阻断] — **token/deviceId 硬编码 + 无配对绑定 + 无 storage 持久化**`config.ts:35-39` 硬编码本机 device_id+token(注释自认「联调期硬编码,后续做配对绑定」),`:52-54` setConfig 仅内存赋值无 `uni.setStorageSync`。真机分发不能用本机硬编码 device_id。修:settings 页输入 + setConfig 持久化 storage + getConfig 读回退 default。— `apps/df-miniapp/src/config.ts`
- [ ] P1-A-260622 [P1🔴·可靠性] — **心跳 pong 闭环缺失**`ws.ts:226``{control_kind:'ping'}`,`relay.rs:355` miniapp 入站全标 `MessageKind::Command` 透传 device,但 ping 无 cmd 字段 → `remote_bridge.rs:143` match 无 ping 分支 → device 收不到有效命令无 pong 回;`useAiChat.ts:440` onControl 仅 console.log 无 pong 处理;`ws.ts:245-257` 重连仅靠 onClose/onError 无心跳超时。移动网络 TCP 半连接高发,device 静默挂死无感知。修:relay 解析 ping 构造 Control 类型 + device 响应 pong + miniapp 维护 lastPongTime 看门狗(3×interval 未收 pong 主动重连)。— `ws.ts` + `relay.rs:handle_inbound_text` + `useAiChat.ts`
- [x] P1-B-260622 [P1🔴·首屏连不上·✅2026-06-23核验已修] — **握手判定脆弱**`ws.ts:185` `text.includes('"error"')` 字符匹配判握手失败(真机业务消息含 error 字符误判);同时 `ws.ts:181` 注释「relay 无显式 ack」错——`relay.rs:255` 实发 `{control_kind:"hello_ack"}` 但代码漏检。修:handleHandshakeResponse 改结构化解析——判 `msg.kind==='control' && msg.control_kind==='hello_ack'` 为成功,error 判 `msg.error` 字段存在性。— `apps/df-miniapp/src/api/ws.ts`
- [x] P1-C-260622 [P1🔴·体验阻断·✅2026-06-23 核验已修] — **冷启动不恢复会话历史**`App.vue:8-10` onLaunch 仅 connect,`chat/index.vue:62-64` onShow 仅 scrollToBottom,`useAiChat.ts:61` activeConversationId 初始 null → 真机首次打开空白。修:连接成功后 refreshConversations;chat onShow 中 activeConversationId=null 且有会话时自动选最近会话 + switchConversation(load_messages)。— `App.vue` + `chat/index.vue` + `useAiChat.ts`
- [x] P1-E-260622 [P1🟡·多轮场景·✅2026-06-23] — **continueLoop/stopLoop 无 UI 入口**`useAiChat.ts:699-700` 已导出,`useAiChat.ts:241-250` AiMaxRoundsReached 仅 clearWatchdog + 系统气泡「请去桌面端处理」,chat/index.vue 全文无调用。多轮场景用户真机无法继续/停止。修:max rounds 时 chat 渲染继续/停止按钮调 continueLoop/stopLoop。— `apps/df-miniapp/src/pages/chat/index.vue`
**P2(UX/健壮性)**:
- [x] P2-B-260622 [P2·quickfix·✅2026-06-23核验已修] — **deviceOnline 离线不复位**`useAiChat.ts:76-77` 注释自认「MVP 不做超时复位」,全 src 无 `deviceOnline.value=false`,onStatus 断开/重连只 clearWatchdog。device 断线状态滞后。修:onStatus 回调 disconnected/reconnecting 加 `deviceOnline.value=false`(一行)。— `useAiChat.ts`
- [x] P2-C-260622 [P2·quickfix·✅2026-06-23] — **路由表注释陈旧(双侧)**`types/relay.ts:86` 称「8 条」实际 match 10 条;`remote_bridge.rs:13-25` 注释列 9 条实际 10(F-#95 扩展 list_conversations/load_messages 未同步注释)。修:双侧注释补 list_conversations/load_messages + 数字改 10。— `types/relay.ts:86-88` + `remote_bridge.rs:13-25`
**P3/defer**:
- [x] P2-A-260622 [P2·defer·✅2026-06-23] — **messages 内存态无上限(OOM)**`useAiChat.ts:56` reactive 无 MAX_MESSAGE。联调短会话无风险,长会话再做(MAX_MESSAGES=100 + truncate 保留最新 50)。— **已修**:watch trim MAX_STORE_MESSAGES=500(渲染另有 visibleMessages slice 200 兜底)。
- [x] P3-A-260622 [P3·defer·✅2026-06-23] — **输入框单行非 textarea**`chat/index.vue:176-184` `<input type="text">`。MVP 单行可接受,多行场景再升级 textarea auto-height。— **已改 input→textarea auto-height**(max-height 120px + flex-end 对齐;⚠️ 原生层级遮联想浮层风险待用户实测,fallback cover-view)。
**已驳回(refuted·不记 todo)**:regenerate 双 user 气泡(UI 展示层非逻辑错,switchConversation 即清)/ 看门狗 130s(实现正确无需修)/ 审批卡+流式光标(完成项核对,非问题)。
**真机分发前置序(P1 按依赖)**:P1-D(token/配对)→ P1-A(心跳闭环)→ P1-B(握手解析)→ P1-C(冷启动恢复)→ P1-E(max UI)。P2-B/C 可同批 quickfix(注释对齐 + 一行复位)。
**用户补充核验(2026-06-22 用户详读源码+设计文档,主代逐项核源码判真伪)**:
> ⚠️ 用户分析 P0 三项(协议三重不匹配 / EventBus 未接 / 桥接层占位)**经源码核验全部失实**,系基于 Phase3 阶段1-3 落地前的设计文档「计划态」描述,未核当前代码。证据:
> - **P0-1 协议**:`crates/df-tunnel/src/tunnel.rs:457/327/424` tunnel 纯透传 payload `Value` 上抛 on_command,**不**做 `from_value::<TunnelCommand>` 强类型反序列化(方案A 阶段1 已落地 commit 0fb5af5)→ miniapp `{cmd,args}` 反序列化在桥接层 `remote_bridge.rs:96 from_payload` 成功,非阻断。tunnel.rs:457 注释自证「不反序列化为 TunnelCommand 强类型」。
> - **P0-2 EventBus**:`src-tauri/src/lib.rs:128` `state.ai_event_bus.subscribe()`(tunnel subscriber 已接)+ `remote_bridge.rs:303/332/358/388` + `commands/ai/mod.rs:209/225` `publish_event` 跨端透传。**已双写接入**(todo line 760 commit 0fb5af5/612de4c emit 双写 chat19/audit12/guard3/stream_recv2/agentic18)。`event_bus.rs:53/115` 的 `#![allow(dead_code)]`+「骨架阶段」注释是**过时历史标注**(publish_event 现有调用方 remote_bridge/mod),非未接入。
> - **P0-3 桥接层**:`remote_bridge.rs` 完整实装(`handle_remote_command`:121 + `MiniCommand`:83 + `from_payload`:96 + match 路由 10 条 + `route_list/load_messages` publish_event)+ **已 wire** `lib.rs:150-154` `on_command = Arc::new(...handle_remote_command...)` + `:171` `.connect(&relay_url,&device_id,&token,on_command.clone())`。非占位/设计态。`remote_bridge.rs:40-42` 的 `#![allow(dead_code)]`+「零调用方」注释写于联调前,现 lib.rs 已调(过时标注,待清)。
**属实补充项(用户提出 + 主代核源码确认,追加 todo)**:
- [x] P1-F-260622 [P1🟡·✅2026-06-23] — **消息操作缺失(复制 / regenerate UI 入口)**`regenerate()`(`useAiChat.ts:520`)已实现但 chat 页无入口;复制消息功能全缺(桌面端有 hover 操作栏)。修:消息气泡加长按/点击操作(复制全文 / regenerate)。— `apps/df-miniapp/src/pages/chat/index.vue`
- [x] P1-G-260622 [P1🟡·体验·✅2026-06-23] — **Markdown 渲染简陋**`MdView.vue` 用 rich-text 无代码高亮 / 图片预览 / 链接拦截(MdView.vue 注释自认弱点);流式无节流(注释承认「长回复卡顿后续 P1 加 setTimeout 节流」)。修:评估 mp-html CLI local import `<node>` 子组件不渲染问题的修复,或换方案 + 流式 setTimeout 节流。— `apps/df-miniapp/src/components/MdView.vue`
- [x] P2-D-260622 [P2·健壮·✅2026-06-23核验已修] — **断连期间操作静默丢失**`wsClient.send()` 失败仅 console.warn + 返 false,但 `stop()/approve()/authorizeDir()` 忽略返回值 → 用户点「批准」UI 已乐观移除 pending 但命令未发出。修:这些方法检查 send 返回值,失败 toast 提示 + 不移除 pending。— `apps/df-miniapp/src/composables/useAiChat.ts`
- [x] P2-E-260622 [P2·健壮·✅2026-06-23] — **审批乐观移除无失败回退**。approve() 即 splice pending,若 device 端 tool_call_id 已被桌面端处理致审批失败,miniapp 已移除 UI 误导用户以为成功。修:approve 失败(超时 / AiError 事件)回填 pending。— `useAiChat.ts`
- [ ] P3-B-260622 [P3·真机前置] — **manifest appid 占位**`PLACEHOLDER` 无法真机调试,需微信公众平台注册的真 appid。— `apps/df-miniapp/src/manifest.json`
- [x] P3-C-260622 [P3·清理·✅2026-06-23 核验已清] — **mp-html 死依赖残留**。package.json 仍引 `mp-html@^2.5.2` 但 MdView 已改 rich-text 弃用。确认不回退 mp-html 则移除依赖(当前 `src/components/mp-html/` 预留保留,依 [[dead-code-reserve-keep]] 待回退决策)。— `apps/df-miniapp/package.json`**核验:package.json 已无 mp-html + grep 全 src 零引用,已清(陈旧 todo)**
- [x] P3-D-260622 [P3·体验·✅2026-06-23] — **token 用量 UI 缺失**`tokenUsage` 状态有但页面未展示。— `apps/df-miniapp/src/pages/chat/index.vue`
### ✅ 2026-06-23 miniapp 全功能审查加固(workflow wll7qabgr + 主代串行)
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程;2 区域限流主代补审)。**自主实施**(逐批 vue-tsc 0 + build DONE 核验):
> - **F1** `AiConvStateChanged` idle/error 终态漏 clearWatchdog → 补 clearWatchdog(兑现注释承诺,防 timer 泄漏假超时)
> - **F2** regenerate() 无 generating 守卫 → 加守卫 + toast(防 send 静默 return 无反馈)
> - **F3** flushCurrentText 启发式回填错位 → 引入 currentAssistantMsgId 按 id 精确回填(治 retry/error/compressed 气泡穿插污染)
> - **F9** stop() 无终态兜底 + **chat 页无停止按钮** → stopping 标志乐观复位 + 忽略迟到 delta + 生成中显「停止」按钮(原 generating 时 send 禁用且无停止入口,用户无法中断生成)
> - **F21** 连接状态裸枚举字符串 → 中文文案 + 断开/重连中点状态栏手动重连
> - switch 加 default warn / ws.ts 二进制帧 warn / scheduleReconnect 加 MAX_RECONNECT_ATTEMPTS=20 + 手动重连复位计数
> - 删 pages/test 死页(uniapp alpha tap 排查探针,grep 零引用)
>
> **06-22 走查项核验销账**(源码已修,标 [x]):P2-B deviceOnline 复位 ✅ / P2-D 断连操作 send 返回值检查 ✅ / P1-B 握手结构化解析 ✅
>
> **06-22 项部分缓解**(alternative approach):P1-A 心跳 pong 闭环 → 当前用「任意入站消息」活性检测(HEARTBEAT_DEAD_FACTOR)替代 pong 往返,半开死连接已有兜底;pong 精确闭环见 MINIDEC-260623-06 ⏸️ / P1-C 冷启动恢复 → active conv id 已持久化 + 握手发 list_conversations;冷启动自动 load_messages 待核
>
> **剩余开放项 → 待决策**(见 [待决策.md](./待决策.md) df-miniapp 组):MINIDEC-260623-01 配置层(=旧 P1-D)/ 02 配对 / 06 心跳历史协议 / 07 regenerate+备份组件漂移。(03 重连续流 / 04 审批双源 / 05 会话页重命名 已实施,见下)。06-22 走查剩余 [ ]:P1-A(缓解)/P1-E(MaxRounds UI)/P1-F(复制+regenerate UI)/P1-G(Markdown 渲染)/P2-C(注释)/P2-E(审批回填)/P3-A/B/C/D。
### ✅ 2026-06-23 miniapp 用户需求批(req1/2/3 · 用户决策后实施)
> 用户 3 要求:(1) 会话重命名对齐桌面端 (2) 断网消息不丢失+从远端拉完整 (3) 审批断网重连恢复卡片状态。逐批 cargo check 0 + vue-tsc 0 + build DONE 核验。
> - **req1 会话重命名**:remote_bridge `rename_conversation` 路由(调 ai_conversation_rename + 推 AiConversationList 刷新)+ useAiChat `renameConversation`(乐观本地更新)+ conversations/index.vue 长按 `uni.showModal` 编辑。
> - **req2 断网不丢消息**:useAiChat `syncOnConnect` —— ws 'connected' 触发发 load_messages 拉当前会话完整历史(断网 missed 消息恢复 + 兼修冷启动空白 P1-C-260622)。
> - **req3 审批重连恢复**:remote_bridge `sync_pending` 路由(读 AiSession.pending_approvals 按 conv 过滤,重发 AiApprovalRequired/AiDirAuthRequired)+ useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建 + handleEvent 同 id 去重防竞态。**审批卡改从 pendingApprovals 独立面板渲染**(与 messages 解耦,避重连 load_messages 替换 messages 的竞态;落地 MINIDEC-04 单一渲染源决策 a),内联工具卡保留状态徽标/参数/结果。
> - **P1-C-260622 冷启动恢复** 顺带修复(syncOnConnect 初始连接也拉历史 + active conv id 已持久化)。
### ✅ 2026-06-23 miniapp 🟡 收尾批(P1-E/F + P2-C/A + P3-D/C · 自主推进)
> 用户定「自用」后低风险收尾项一次性做完。逐项 vue-tsc 0 + build DONE 核验。
> - **P1-E MaxRounds 继续/停止**:useAiChat `maxRoundsActive` ref(AiMaxRoundsReached 置 true,continueLoop/stopLoop/send/switch/new 清)+ chat 「已达最大轮次」面板(继续一轮/停止按钮)。
> - **P1-F 重发 + 复制**:chat 输入栏「重发」按钮(调 regenerate,非生成中且有消息时显)+ 消息 @longpress 复制全文(uni.setClipboardData)。
> - **P3-D token 用量**:顶栏显 🪙 total(ai.tokenUsage)。
> - **P2-C 注释陈旧**:relay.ts 路由表 12→14(+ rename/sync_pending)+ remote_bridge.rs 表头补两行。
> - **P2-A messages 内存上限**:useAiChat watch trim MAX_STORE_MESSAGES=500(渲染另有 visibleMessages slice 200)。
> - **P3-C mp-html 死依赖**:核验 package.json 已无 + grep 全 src 零引用(陈旧 todo,无需改)。
> - **MINIDEC-07** 随 P1-F 选 a(regenerate 加 UI 不删)+ 备份组件保留预留,已落。
>
> **剩余 [ ](非阻塞)**:P1-A 心跳 pong(已缓解=MINIDEC-06 暂缓)/ P3-B manifest appid(真机前置,需注册)。(P1-G/P2-E/P3-A 见下 polish 批已落)
### ✅ 2026-06-23 miniapp polish 批(workflow wll377z2c 设计 → 串行实施)
> workflow 3 agent 并行设计(无限流)→ 汇总排序 → 主代串行实施,逐项 vue-tsc 0 + build DONE。
> - **P2-E 审批失败回填**(low):useAiChat `lastApprovedTc` ref —— approve(approved=true) 记录,device 返 AiError 回填 pendingApprovals + toast「审批操作未生效,请重试」让用户重试;AiApprovalResult/AiToolCallCompleted 确认 → 清;切会话/新建/断连 → 清。
> - **P1-G Markdown 渲染**(medium):新建 `utils/mdRenderer.ts` —— marked HTML 后处理注入 inline style(pre 深底等宽/code 橙色/a 蓝下划线/table border),规避 marked renderer v18 签名风险(rich-text 不认 class)。chat/index.vue renderMd + MdView.vue 接入。**链接跳转 rich-text 不可行**(<a> 节点事件屏蔽)降级展示+长按复制,跳转留 mp-html 后续。
> - **P3-A textarea 多行**(high):input→textarea auto-height(max-height 120px + .bar flex-end 对齐)。⚠️ **原生层级遮联想浮层风险待用户实测**(textarea 原生组件渲染在 WebView 之上,可能遮盖 .mention-pop);若遮挡严重,fallback 联想浮层改 cover-view(单独任务,本次未做)。
>
> **miniapp 自用阶段功能全部闭环**。剩 P1-A(暂缓)/P3-B(appid 真机前置,需你注册)。
**与主走查重复(用户项 = 已登记,跳过)**:用户 P1-5(配置硬编码)= P1-D / P1-6(MaxRounds UI)= P1-E / P2-11(deviceOnline 不复位)= P2-B / P2-9(R1 并发防护)= `remote_bridge.rs:27-33` `check_generating_reject` **已实现**(send_message 路由前置 generating 检查)。
---
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 · 诊断workflow→论证workflow)
> 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。
>
> **BUG-260624-01 消息重叠/堆叠根治**(用户 2d89921a×3/d056b5ad×2 共 5 次报告):
> - 根因:currentText 全局单例 + flushCurrentText 末尾盲写 + MessageList streamingBlocks(:69 实例级)跨轮/跨会话残留 + 渲染 isLastAi&&currentText 非 streaming 态也驱动。
> - 修复(4 处协同,治本非补丁):① useAiEvents `flushCurrentText` 回填后自清 currentText(消除调用方清空顺序依赖)② MessageList currentText watch 归零清 streamingBlocks+lastStreamText(新轮)③ 1011 v-if 加 streaming(堵非 streaming 残留)④ activeConversationId watch 切会话清 streamingBlocks+lastStreamText+cancelAnimationFrame(治 switchConversation/restoreGeneratingState 残留根因,论证完整性高优)。
>
> **BUG-260624-02 授权弹窗卡死根治**(用户 8e4944b7/b1f644a7「没审批直接死了」):
> - 根因:统一审批开关 `df-ai-unified-approval` 默认开 → path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(group-hidden display:none)→ 审批卡不可见 → 5min 超时(APPROVAL_TIMEOUT_MS)静默 deny → 误显「用户拒绝」卡死。ToolCard 根 :5 动态拼 `ai-tool-card--'+tc.status` 已挂 pending_approval class(scrollToFirstPending 可命中)。
> - 修复:useAiEvents AiDirAuthRequired/AiApprovalRequired case 内 `emit('ai-pending-arrived')`;ToolCardList onMounted listen → scrollToFirstPending(自动展开折叠组+scroll,原仅手动徽标触发)+ 卸载竞态 disposed 标志(论证回归中优)。
>
> **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。
>
> **剩余低优 [ ](非阻断)**:
> - [ ] BUG-260624-01 残留:scrollToFirstPending 去抖(同 toolCallId 不重复 scroll,防多 pending 连发滚动打断)+ 全实例广播过滤(props.toolCalls.some 跳过无 pending 实例,大列表性能)
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到)
> - [ ] MessageList:503 badge scrollToFirstPending return 过早(既有 bug,遍历实例应找含卡的那个,不首个 return)
> - [ ] F-09 根因:streaming/currentText 全局单例改 per-conv(根治多会话串扰,见 docs/待决策.md)

View File

@@ -50,6 +50,17 @@
- **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。
- **状态**:🟡 待用户实测
#### S-260623-01 「自托管」含义澄清
- **背景**:用户要求设置支持「自主执行(含高危)+ 自托管」。**自主执行含高危已实现** —— `autoExecuteMode` 三档 `low`/`medium`/`all`(AdvancedSection.vue F-#97, 2026-06-22),`all` = 完全 AI 接管含高危(delete/purge/run_command),选时二次确认。设置 → 高级 → AI 自动执行范围。但「自托管」grep 全项目无匹配,含义未明确。
- **待用户澄清**:「自托管」指什么?
- **选项**:
- a: 自托管 AI 模型 endpoint(用户配自己的模型 API/本地模型,非默认 provider)
- b: 自管执行策略(= autoExecuteMode 已覆盖,无需另做)
- c: 自托管后端服务(用户自部署 df-relay/df-tunnel,非默认 `u-work.1216.top`)
- d: 其他(用户补充)
- **关联**:task105 / memory(设置自主执行已实现)
- **状态**:✅ **自用阶段定案(2026-06-23)** —— 用户决策:小程序当前自用(开发阶段),分发是产品终态但留后续(过渡期靠改 device_id 实现多机)。故「自托管」现阶段 = 已实现的自主执行(autoExecuteMode all 含高危),无另义。分发阶段再做配置层(MINIDEC-01)+ 配对(MINIDEC-02)。
#### ~~B-260617-11 tauri.conf.json 打包目标收窄确认~~
- **状态**:✅ 已决(2026-06-18) — 已迁入归档。决策:A 临时本地构建,要求高速。提交前 revert 为 `"all"`
@@ -172,6 +183,92 @@
- **关联**:消息级溯源 P0(CR-10 ✅)/ P1(CR-12 ✅)/ 消息拆分存储设计文档
- **状态**:🟡 待用户拍板(P2 是否现在做 + 策略 a/b)。大改建议用户知情后启动。
#### BUG-260623-03 审批 pending 无超时兜底(aichat 卡死·架构)
- **背景**:实测会话 4ae73423 末尾 advance_task 触发审批 `__PENDING__` 后,`agentic/mod.rs:1464` loop return,恢复唯一依赖用户主动 ai_approve。全库无审批超时定时器(cache.rs:31 占位标记无时间戳)。用户离开/忘记 → generating=true 永久挂起,会话卡死,用户须手动重发(且该会话 token 已大,重发易触上限)。属 aichat 中断B,非 generating 状态机卡死(generating 已收敛 GeneratingGuard)。
- **决策点**:审批 pending 超时兜底怎么实现?
- **选项**:
- a: 后端 per-conv 定时器(到期自动 reject + emit 超时,触及 AiSession 单例 + 定时器机制,最彻底)
- b: 前端倒计时提示(用户可见到期,后端不变,最小改动)
- c: 无超时(现状,靠用户手动)
- **推荐**:**b 前端倒计时**(最小改动不触 AiSession;用户可见主动处理)。a 后端定时器最彻底但触及单例大改,归 [[devflow-async-approval-concept]]
- **关联**:memory devflow-async-approval-concept / docs/05-代码审查/aichat-会话实测分析-2026-06-22.md(CR-260622-01 修正)
- **状态**:🟡 待决策
### df-miniapp 全功能审查(2026-06-23 workflow wll7qabgr + 主代补审)
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程)。已**自主实施**:F1 看门狗 clearWatchdog 兜底 / F2 regenerate 守卫 / F3 flushCurrentText id 精确回填 / F9 stop 终态兜底 + 停止按钮(原无停止入口) / F21 连接状态中文文案 + 手动重连 / switch default 分支 / 二进制帧 warn / scheduleReconnect maxAttempts / 删 test 死页。以下为需决策项。
#### MINIDEC-260623-01 relay 配置层范围(F15·产品方向·分发卡点)
- **背景**:config.ts relayHost/deviceId/token 全硬编码(setConfig 零调用方,storage 未接,无设置页)。当前填测试服 wss + 本机 device_id(联调期)。分发后用户无法改连自托管中继 → 连不上。
- **决策点**:配置层做到什么程度?
- **选项**:
- a: 完整设置页(表单 + setConfig 接 storage 持久化 + getConfig 回退),用户可改 relay/device/token
- b: 仅接 storage 持久化(setConfig 真生效,无 UI,靠代码/build env 注入)
- c: MVP 接受硬编码(仅自用联调),设置页立项后续
- **推荐**:**b 至少接 storage**(成本极小让配置真生效);a 设置页 UI 视分发排期。依赖 S-260623-01「自托管」含义(c 自托管后端 → 必须 a)。
- **关联**:S-260623-01 / memory cross-end-rust-backend
- **状态**:⏸️ **暂缓(2026-06-23)** —— 用户决策:当前自用,硬编码够;分发阶段(产品终态)再做设置页。过渡期多机靠改 device_id。
#### MINIDEC-260623-02 配对绑定流程(F16·跨端大改)
- **背景**:ControlMessage.pair 类型已定义但从不发送,deviceId/token 靠用户手抄(查桌面端 KV)。无二维码配对/绑定校验链路。
- **决策点**:配对流程是否本轮做?
- **选项**:
- a: 完整配对(桌面端生成 QR → relay 校验绑定 → miniapp 扫码获 device_id/token)
- b: MVP 过渡(设置页手填 + UI 提示去哪找 device_id/token)
- c: 不做(手抄够用)
- **推荐**:**⏸️ b MVP 过渡**(a 跨端协作工作量大,依赖 MINIDEC-260623-01 设置页先落)
- **关联**:MINIDEC-260623-01
- **用户已定未来愿景(2026-06-23)**:分发阶段做完整配对授权,三步:① 设备端弹二维码 ② 小程序扫码识别并授权,双方来回点击确认 ③ 授权管理(设定授权的权限范围等)。本轮不做,留专项。
- **状态**:⏸️ **暂缓(2026-06-23)** —— 当前自用靠手抄 device_id;分发阶段按上述愿景做完整 QR 配对 + 权限范围管理(跨端 df-miniapp/df-relay/桌面端协同,专项立项)。
#### MINIDEC-260623-03 WS 重连续流策略(F10·行为差异·需拍板)
- **背景**:watchdog 在 WS 重连后不重启。重连期间 device 续推 AiTextDelta 会丢(断连窗口)或半截文本错位。当前断连 onStatus 已 reset generating + clearWatchdog(连接断肯定停),但重连后续流未处理。
- **决策点**:重连后续流策略?
- **选项**:
- a: onStatus 'connected' 主动发 load_messages 重新同步整个会话(最稳,但重置视图)
- b: 收到 AiTextDelta 时若 !generating 隐式恢复 + 补 assistant 占位(最平滑,有幽灵续流风险)
- c: 不处理(接受断连窗口该轮丢失)
- **推荐**:**c 现状**(MVP 断连窗口丢失可接受,a/b 复杂度高)。重度依赖移动端弱网再升级 a。
- **状态**:✅ **已实施 a**(2026-06-23)—— 用户决策「断网不丢消息 + 从远端拉完整」。useAiChat syncOnConnect:ws 'connected' → 发 load_messages 拉完整历史(断网期间 missed 消息恢复 + 兼修冷启动空白 P1-C)。cargo check 0 + vue-tsc 0 + build DONE。
#### MINIDEC-260623-04 审批双源状态分裂统一(F4/F5/F6/F13·渲染源决策)
- **背景**:审批状态双写——pendingApprovals 数组 + messages[].toolCalls.status。AiApprovalRequired 双写为源头,AiError/switchConversation 只清 pendingApprovals 一侧(messages 内 tc 留陈旧 pending_approval)→ 可能渲染陈旧审批按钮,点 approve 命中已失效 tool_call。chat 页按 m.toolCalls 内联渲染审批按钮,pendingApprovals 仅顶部徽标计数。
- **决策点**:统一单一渲染源?
- **选项**:
- a: 以 pendingApprovals 为准(messages tc 仅显示 status 不渲染按钮)—— 改 chat 页渲染逻辑(行为变更)
- b: switchConversation/AiError 清 pendingApprovals 时同步遍历 messages 把 pending_approval 标 rejected —— 改 useAiChat 状态清理(纯后端态,UI 不变)
- **推荐**:**b 同步清理**(最小行为变更,治陈旧按钮根因)。a 渲染源统一更彻底但 UI 改动大。
- **关联**:BUG-260623-03(审批超时,后端侧)
- **状态**:✅ **已实施 a 单一渲染源**(2026-06-23)—— 用户决策「审批断网重连恢复卡片状态」。审批卡改从 pendingApprovals 独立面板渲染(与 messages 解耦,避重连 load_messages 替换 messages 的竞态);remote_bridge 加 sync_pending 路由(读 AiSession.pending_approvals 按 conv 过滤重发 AiApprovalRequired/AiDirAuthRequired);useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建;handleEvent 加同 id 去重防竞态重复。内联工具卡保留状态徽标/参数/结果(按钮移面板)。cargo 0 + vue-tsc 0 + build DONE。
#### MINIDEC-260623-05 会话页管理缺口(删除/重命名·需后端命令)
- **背景**:conversations/index.vue 仅列表/切换/新建/下拉刷新。无删除/重命名(需后端 delete_conversation/rename_conversation 命令 + remote_bridge 路由,当前无)。
- **决策点**:miniapp 是否需要会话管理?
- **选项**:
- a: 本轮加(后端命令 + bridge 路由 + 会话页长按菜单)
- b: 暂不做(用户回桌面端管理,MVP 仅查看/切换)
- **推荐**:**⏸️ b 暂不做**(miniapp 定位轻量操作终端,会话管理桌面端足够)
- **状态**:✅ **重命名已实施 / 删除暂缓**(2026-06-23)—— 用户决策「小程序对齐桌面端能改会话名」。remote_bridge 加 rename_conversation 路由(调 ai_conversation_rename + 推 AiConversationList 刷新);useAiChat renameConversation 方法(乐观本地更新);conversations/index.vue 长按会话 uni.showModal 编辑。删除会话暂不做(桌面端管理)。cargo 0 + vue-tsc 0 + build DONE。
#### MINIDEC-260623-06 心跳/历史替换协议(F8/F20·跨模块·低优先)
- **背景**:(F8)心跳活性检测依赖「任意入站消息」,低活跃场景每 ~60s 误判半开死连接触发无谓重连(relay 无 pong);(F20)AiMessageHistory 整体替换会丢弃正在进行的乐观气泡(device 主动推历史路径难区分 load 响应)。
- **决策点**:是否本轮改 relay 协议?
- **选项**:
- a: relay 加 pong + 历史事件加 load_id/sync_source flag(跨模块改 df-relay + 后端 events)
- b: 前端缓解(放宽 DEAD_FACTOR / 「仅本地空才替换历史」启发式,语义脆弱)
- c: 暂缓(MVP 低频空闲场景可忍)
- **推荐**:**⏸️ c 暂缓**(跨模块改动 ROI 低)
- **状态**:⏸️ 暂缓
#### MINIDEC-260623-07 regenerate 零调用方 + 备份组件漂移(P3 收尾)
- **背景**:(1) useAiChat.regenerate() 零调用方(全死,已加 generating 守卫防御);(2) MdView.vue/MentionInput.vue 因绕工具组件解析 bug 被 chat 页内联,源文件保留备用但已与内联实现漂移(MdView 无 mdCache、MentionInput 是 MVP 占位)——「拆回」时会引入旧实现。
- **决策点**:regenerate 加 UI(重发按钮)or 删?备份组件同步/删/保留?
- **选项**:
- a: regenerate 加重发按钮 + 备份组件同步内联实现(或删)
- b: regenerate 删减负 + 备份组件保留标注释(预留工具 bug 修复后拆回)
- **推荐**:**b**(regenerate 删减负,备份保留预留意图)。或 a 视移动端重发需求。
- **状态**:✅ **随 P1-F 选 a**(2026-06-23)—— 🟡 收尾批给 regenerate 加「重发」UI 入口(对齐桌面端),故保留函数不再删;备份组件(MdView/MentionInput)保留预留注释(绕工具解析 bug,未来拆回)。
---
## workspace_root 分发适配(编译期 CARGO_MANIFEST_DIR 写死,跨机器/跨平台失效)
@@ -200,6 +297,8 @@
**关联**:F-260619-03 去固定根(已删 is_authorized 硬编码放行)/ projects.path 自动授权(reload_allowed_dirs)/ memory [[devflow-project-path-binding]]
**状态**:🟡 待决策(workspace_root 运行期动态方案 a/b/c/d)。**分发前必须解决**(否则分发后 AI 文件工具全部失效)。
**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 默认。
---
## F-260619-03 路径授权政策:工程内默认免授权(用户 2026-06-20 指令)
@@ -221,6 +320,25 @@
---
## F-09 streaming/currentText 改 per-conv(消息重叠/多会话串扰根治)
**背景**:BUG-260624-01 消息重叠核心修复后,论证 workflow 边界角度指出残留根因——`streaming`/`currentText` 是 store 全局单例(非 per-conv),F-09 多会话并发下 A 后台生成(streaming=true)+ 切 B 会话,B 末条 AI 气泡命中 `isLastAi&&streaming&&currentText` 会渲 A 残留 currentText。当前修复(flushCurrentText 自清 + activeConversationId watch 清 streamingBlocks)已大幅收窄残留窗口,但单例根因未除。
**决策点**:是否立项 per-conv 化(streaming/currentText/streamingBlocks 改 Map<convId,...>)?
- **a 立项根治**:store 改 per-conv Map,所有读写点改。大改(涉 ai.ts store + useAiEvents/useAiStream/useAiConversations/MessageList 多处),对齐 [[aichat-arch-extensibility]] AiSession 单例未动 + [[fe-arch-tech-debt]] status 无 union。
- **b 暂缓(当前缓解够用)**:自清 + activeConv watch 已覆盖主路径(新轮/切会话/结束清),残留仅极端并发场景(A 后台 streaming + 切 B)。等 B 路线多会话并发([[aichat-b-route-parallel-multiround]])正式推进时一并 per-conv。
**多角度论证(自主决策依据)**:
- 根治性:a 一次性消除单例串扰族 bug;b 缓解主路径,极端并发残留。
- 改动面:a 大(store + 5 composable + MessageList),回归风险高,需全链路测;b 零额外改动。
- 时机:b 与 B 路线多会话并发同批做更合理(那时 per-conv 是前置),现在单独做与 B 路线重复拆改。
- 当前用户场景:自用单会话为主,多会话并发(A 后台 + 切 B)非高频。
**倾向**:**⏸️ b 暂缓**(当前修复缓解有效 + per-conv 是 B 路线大改 + 与多会话并发同批更合理)。
**状态**:🟡 待拍板(若用户多会话并发场景频繁 → a 立项;当前单会话为主 → b 暂缓随 B 路线)。
---
## 已决归档
按月归档(随时间增长追加月份文件,防主文件膨胀):

View File

@@ -23,6 +23,25 @@
## 当前队列
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— 🟡 待审
- **范围**:实测数据反推(**非静态代码审查**)。从生产 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 均为"假设"待证)。
- **核验方式**:只读 DB(`mode=ro`)+ grep/read 源码,**不信报告结论**(防上下文污染,[[code-review-anti-contamination]])。
- **待审点(8)**:
- P0-1 压缩标记 `compressed` 未真降 prompt(1.32M)— 核 compress 模块是否替换为摘要还是仅打标记
- P0-2 `patch_file`/`read_file` 部分路径裸吐文件全文(`raw 6204c`)非 JSON — 核 `tool_registry.rs` 返回序列化分支
- P0-3 重复 tool 结果(seq 11-14 四份/69-71/83-85 三份同长度同秒)— 核 `ai_messages` 落库去重 / 流式 patch 合并
- P1-1 过早宣称完成 3 次被推翻,无 checklist 自检 — agent 行为层
- P1-2 末尾 #82#85 tool 全返回后无 assistant 回复(中断)— 核 generating 复位/`try_continue`
- P1-3 三独立文件(mysql/redis/linux)整理本可并行却严格串行,无 fan-out 调度 — 印证 [[aichat-b-route-parallel-multiround]]
- P2-1 seq 0 system 消息内容污染 + 时序错乱(晚 seq 1 十小时)— 核 system 注入 + timestamp
- P2-2 同文件反复 read(mysql/linux 各 4×)无缓存
- **最高杠杆**:P0-2(工具返回改 diff/changed 而非全文)→ prompt 降一个量级。
- **关联**:[[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[aichat-b-route-parallel-multiround]] [[aichat-decision-capability]]
---
### 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。

View File

@@ -10,7 +10,7 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"build:dev": "cargo tauri build --debug"
"build:dev": "cd src-tauri && cargo build"
},
"dependencies": {
"@arco-design/web-vue": "^2.58.0",

View File

@@ -36,7 +36,12 @@ df-ai = { path = "../crates/df-ai" }
df-ideas = { path = "../crates/df-ideas" }
df-project = { path = "../crates/df-project" }
df-mcp = { path = "../crates/df-mcp" }
# Phase3 跨端隧道:桌面端主动出站连 df-relay(/ws/device),透传 ai_event_bus → miniapp
# + 转发 miniapp 下行指令 → remote_bridge 路由 Tauri command。穿 NAT 无需端口映射。
df-tunnel = { path = "../crates/df-tunnel" }
futures = "0.3"
# Phase3 tunnel device_id:首次启动生成 UUID v4 落 settings KV 持久化(relay 按 device_id 路由配对)
uuid = { workspace = true }
# write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等)
base64 = "0.22"
# validate_path URL 解码:防 %2e%2e 等 URL 编码绕过路径遍历检查(BUG-260617-03)
@@ -52,6 +57,15 @@ keyring = { workspace = true }
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致),
# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。
reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] }
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
# Phase1 范围:Rust(.rs)/TypeScript(.ts/.tsx)/JavaScript(.js/.jsx)/Vue(.vue 借 TS 切 script 段),3 grammar 覆盖 4 类。
# 兼容:tree-sitter 0.25 主 crate 与 grammar crate 配对(各 grammar 声明 tree-sitter 依赖,0.25 系广泛兼容)。
tree-sitter = "0.25"
tree-sitter-rust = "0.24.2"
tree-sitter-typescript = "0.23.2"
tree-sitter-javascript = "0.25.0"
# 阶段3a/3b 统一审批模型开关(path_auth 审批链扎实重构 plan 阶段3)。
# 3a 后端合并(单 HashMap + kind 字段)为编译期结构性变更,本 flag 用于文档标记 +

View File

@@ -0,0 +1,468 @@
//! L2 统一状态机 — `ConvState` enum(单一真相源)+ 合法转换守卫。
//!
//! 来源:`generating状态机加固-2026-06-15.md` §3(轻量状态机决策)+ `aichat体验与
//! agent能力系统化重构-2026-06-21.md` §3(L2 统一状态机)。
//!
//! # 背景(为什么单独建这个 enum)
//!
//! 旧实现会话状态由三个标志组合隐式表达(`generating: bool` + `stop_flag: AtomicBool`
//! + `pending_approvals` HashMap),判别逻辑散落 `try_continue_agent_loop` /
//! `ai_chat_stop` / `ai_conversation_create` 三处(写侧散布 + 读侧散布双根因,见
//! generating状态机加固-2026-06-15.md §2.3)。本模块提供**显式的、单一真相的**状态枚举:
//!
//! - **写侧收敛**:`ConvState` 仅经 [`ConvState::transition_to`] 守卫方法迁移,非法转换
//! 直接拒绝(`Err(InvalidTransition)`),调用方无法绕过守卫写入非法组合。
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
//!
//! # 渐进第一步(本批范围)
//!
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等
//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState`
//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html)
//! 默认 on,off 降级纯旧 bool 行为(可回退)。
//!
//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到
//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。
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
// ============================================================
/// 对话生命周期状态(单一真相源,5 态)。
///
/// 设计取舍(对齐 generating状态机加固-2026-06-15.md §3.2 「轻量状态机,不引入框架」):
///
/// - **不引入状态机框架**(codegen / FSM 库):5 态 7 边转换简单,枚举 + 显式 `transition_to`
/// 守卫即可表达,过度封装得不偿失。
/// - **派生关系明确**:`Compressed` 是 `Generating`/`Idle` 期间的瞬时派生态(压缩跑完
/// 回到原态),非终端态;`Error` 可经用户「重试」回到 `Idle` 再 `Generating`。
/// - **与读视图 [`SessionState`](../enum.SessionState.html) 区分**:`SessionState`
/// (Streaming/AwaitingApproval/Idle)是面向「是否有审批挂起」的只读视图(读侧收敛①),
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
///
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
/// 预留,避免后续改动序列化兼容性)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConvState {
/// 空闲:无 agentic loop 活跃,可接新请求。
///
/// 终态目标:所有正常退出路径(完成 / 停止完成 / 错误恢复)的归处。
Idle,
/// 生成中:agentic loop 流式生成 + 工具执行活跃中(`generating=true`)。
///
/// 审批挂起期间 `ConvState` 仍为 `Generating`(审批是 loop 内的暂停点,非独立状态)。
Generating,
/// 停止中:用户点了停止(`stop_flag` 已置位),loop 检测中或正在收尾退出。
///
/// 瞬态:loop 检测到 `stop_flag` 后迁移到 `Idle`(完成收尾),或停止过程出错到 `Error`。
Stopping,
/// 错误态:生成过程出错(provider 失败 / Fatal / 断路器熔断等),loop 已退出。
///
/// 非终态:用户可「重试」从 `Error` → `Idle` → `Generating` 重新发起,或继续空闲。
Error,
/// 压缩派生态:loop 期间触发上下文压缩(LLM 摘要),生成流程的瞬时派生。
///
/// 派生而非独立态:压缩完成(成功 / 失败兜底)后回到原 `Generating`(loop 内压缩)或
/// `Idle`(手动压缩 IPC 触发)。前端可据此展示「压缩中」spinner(本批未接)。
Compressed,
}
impl Default for ConvState {
/// 新会话默认 `Idle`(无 loop 活跃)。
fn default() -> Self {
ConvState::Idle
}
}
impl ConvState {
/// 尝试迁移到目标态。合法返回 `Ok(新态)`,非法拒绝返回 `Err(InvalidTransition)`。
///
/// 调用方持锁后经本方法迁移状态(写收敛):不允许直接写字段绕过守卫。
/// 非法转换(如 `Idle → Stopping`,无活跃 loop 不可能停止)直接拒绝——调用方应视为
/// 逻辑 bug 并记录 warn 日志,不静默忽略(状态机完整性优先)。
///
/// # 合法转换表(7 边)
///
/// | from | to | 触发场景 |
/// |------|----|---------|
/// | Idle | Generating | `run_agentic_loop` 入口,guard set |
/// | Generating | Stopping | 用户点停止,置 `stop_flag` |
/// | Generating | Idle | loop 正常收敛退出,guard reset |
/// | Generating | Error | provider Fatal / 断路器熔断等 |
/// | Generating | Compressed | loop 内自动压缩派生 |
/// | Stopping | Idle | stop 收尾完成,guard reset |
/// | Stopping | Error | 停止过程出错 |
/// | Error | Idle | 用户重试前置位(准备再 `Generating`) |
/// | Error | Generating | 直接从错误态重新生成(重试) |
/// | Compressed | Generating | 压缩完成,loop 内继续 |
/// | Compressed | Idle | 手动压缩 IPC 完成(无 loop 活跃) |
///
/// 自环(同态 → 同态)允许(幂等写,如 guard 多次 set 同态),返回 `Ok(self)`。
pub fn transition_to(self, target: ConvState) -> Result<ConvState, InvalidTransition> {
// 自环幂等:同态迁移直接通过(防调用方重复 set 报错)。
if self == target {
return Ok(target);
}
// 合法转换白名单(显式列举,非 `match _ => Ok` 兜底——新加态必须显式补边,
// 编译器不会漏)。
let valid = matches!(
(self, target),
// Idle 起步
(ConvState::Idle, ConvState::Generating)
// Generating 分流:停 / 完成 / 错误 / 压缩派生
| (ConvState::Generating, ConvState::Stopping)
| (ConvState::Generating, ConvState::Idle)
| (ConvState::Generating, ConvState::Error)
| (ConvState::Generating, ConvState::Compressed)
// Stopping 收尾:完成 / 出错
| (ConvState::Stopping, ConvState::Idle)
| (ConvState::Stopping, ConvState::Error)
// Error 恢复:重试前置位 / 直接重新生成
| (ConvState::Error, ConvState::Idle)
| (ConvState::Error, ConvState::Generating)
// Compressed 派生回退:loop 继续 / 手动压缩完成
| (ConvState::Compressed, ConvState::Generating)
| (ConvState::Compressed, ConvState::Idle)
);
if valid {
Ok(target)
} else {
Err(InvalidTransition { from: self, to: target })
}
}
/// 是否处于活跃生成态(`Generating` / 压缩派生 `Compressed`)。
///
/// 读侧便利方法:压缩派生期间 loop 仍活跃(只是临时跑压缩 LLM),归「活跃」。
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
/// 故本方法返回 true——审批挂起不算「停止生成」。
///
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。
/// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,
/// 不再标 allow(dead_code)(有真实消费方)。
pub fn is_active(self) -> bool {
matches!(self, ConvState::Generating | ConvState::Compressed)
}
/// 是否可接受新请求(非活跃、非停止中、非压缩派生)。
///
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
///
/// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。
#[allow(dead_code)]
pub fn can_accept_request(self) -> bool {
matches!(self, ConvState::Idle | ConvState::Error)
}
}
// ============================================================
// InvalidTransition(非法转换错误)
// ============================================================
/// 状态机非法转换错误。
///
/// 携带 `from` / `to` 供调用方记录诊断日志(不 panic:状态机完整性违反应记 warn +
/// 兜底走旧 bool 行为,不阻断核心生成流程——对齐「机制优先,失败兜底」原则)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidTransition {
pub from: ConvState,
pub to: ConvState,
}
impl std::fmt::Display for InvalidTransition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ConvState 非法转换:{:?} → {:?}", self.from, self.to)
}
}
impl std::error::Error for InvalidTransition {}
// ============================================================
// 单元测试(纯逻辑无 IO)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---- 合法转换通过 ----
#[test]
fn test_idle_to_generating() {
assert_eq!(
ConvState::Idle.transition_to(ConvState::Generating),
Ok(ConvState::Generating)
);
}
#[test]
fn test_generating_to_stopping() {
assert_eq!(
ConvState::Generating.transition_to(ConvState::Stopping),
Ok(ConvState::Stopping)
);
}
#[test]
fn test_generating_to_idle_normal_complete() {
// loop 正常收敛退出
assert_eq!(
ConvState::Generating.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
#[test]
fn test_generating_to_error_provider_fatal() {
// provider Fatal / 断路器熔断
assert_eq!(
ConvState::Generating.transition_to(ConvState::Error),
Ok(ConvState::Error)
);
}
#[test]
fn test_generating_to_compressed_loop_auto_compress() {
// loop 内自动压缩派生
assert_eq!(
ConvState::Generating.transition_to(ConvState::Compressed),
Ok(ConvState::Compressed)
);
}
#[test]
fn test_stopping_to_idle_complete() {
// stop 收尾完成
assert_eq!(
ConvState::Stopping.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
#[test]
fn test_stopping_to_error() {
assert_eq!(
ConvState::Stopping.transition_to(ConvState::Error),
Ok(ConvState::Error)
);
}
#[test]
fn test_error_to_idle_retry_prep() {
// 用户重试前置位
assert_eq!(
ConvState::Error.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
#[test]
fn test_error_to_generating_retry() {
// 直接从错误态重新生成
assert_eq!(
ConvState::Error.transition_to(ConvState::Generating),
Ok(ConvState::Generating)
);
}
#[test]
fn test_compressed_to_generating_loop_continue() {
// 压缩完成 loop 内继续
assert_eq!(
ConvState::Compressed.transition_to(ConvState::Generating),
Ok(ConvState::Generating)
);
}
#[test]
fn test_compressed_to_idle_manual_compress_done() {
// 手动压缩 IPC 完成(无 loop 活跃)
assert_eq!(
ConvState::Compressed.transition_to(ConvState::Idle),
Ok(ConvState::Idle)
);
}
// ---- 自环幂等 ----
#[test]
fn test_self_transition_idempotent() {
// 同态迁移直接通过(防调用方重复 set 报错)
for s in [
ConvState::Idle,
ConvState::Generating,
ConvState::Stopping,
ConvState::Error,
ConvState::Compressed,
] {
assert_eq!(s.transition_to(s), Ok(s), "{:?} 自环应通过", s);
}
}
// ---- 非法转换拒绝 ----
#[test]
fn test_idle_to_stopping_rejected() {
// 无活跃 loop 不可能停止
let err = ConvState::Idle
.transition_to(ConvState::Stopping)
.unwrap_err();
assert_eq!(err.from, ConvState::Idle);
assert_eq!(err.to, ConvState::Stopping);
}
#[test]
fn test_idle_to_error_rejected() {
// Idle 直接到 Error 无意义(错误必经活跃态产生)
assert!(ConvState::Idle.transition_to(ConvState::Error).is_err());
}
#[test]
fn test_idle_to_compressed_rejected() {
// 无 loop 活跃不会触发压缩派生(手动压缩 IPC 不经状态机派生)
assert!(ConvState::Idle.transition_to(ConvState::Compressed).is_err());
}
#[test]
fn test_stopping_to_generating_rejected() {
// 停止中不能直接回生成(须先回 Idle 再起)
assert!(ConvState::Stopping
.transition_to(ConvState::Generating)
.is_err());
}
#[test]
fn test_stopping_to_compressed_rejected() {
assert!(ConvState::Stopping
.transition_to(ConvState::Compressed)
.is_err());
}
#[test]
fn test_error_to_stopping_rejected() {
// 错误态已停止,不能再停
assert!(ConvState::Error.transition_to(ConvState::Stopping).is_err());
}
#[test]
fn test_error_to_compressed_rejected() {
assert!(ConvState::Error.transition_to(ConvState::Compressed).is_err());
}
#[test]
fn test_compressed_to_stopping_rejected() {
// 压缩派生期间不直接到停止(压缩完成后由原态分流)
assert!(ConvState::Compressed
.transition_to(ConvState::Stopping)
.is_err());
}
#[test]
fn test_compressed_to_error_rejected() {
assert!(ConvState::Compressed
.transition_to(ConvState::Error)
.is_err());
}
// ---- 便利方法 ----
#[test]
fn test_is_active() {
assert!(!ConvState::Idle.is_active());
assert!(ConvState::Generating.is_active());
assert!(!ConvState::Stopping.is_active());
assert!(!ConvState::Error.is_active());
// 压缩派生期间 loop 仍活跃(临时跑压缩 LLM)
assert!(ConvState::Compressed.is_active());
}
#[test]
fn test_can_accept_request() {
assert!(ConvState::Idle.can_accept_request());
assert!(!ConvState::Generating.can_accept_request());
assert!(!ConvState::Stopping.can_accept_request());
// Error 态可接(用户重试即从 Error 起步)
assert!(ConvState::Error.can_accept_request());
assert!(!ConvState::Compressed.can_accept_request());
}
#[test]
fn test_default_is_idle() {
assert_eq!(ConvState::default(), ConvState::Idle);
}
// ---- 完整生命周期链 ----
#[test]
fn test_full_lifecycle_normal_complete() {
// 正常生命周期:Idle → Generating → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_full_lifecycle_with_stop() {
// 停止生命周期:Idle → Generating → Stopping → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Stopping).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_full_lifecycle_with_compress() {
// 压缩派生:Idle → Generating → Compressed → Generating → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Compressed).unwrap();
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_full_lifecycle_error_retry() {
// 错误恢复:Idle → Generating → Error → Generating → Idle
let s = ConvState::Idle;
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Error).unwrap();
let s = s.transition_to(ConvState::Generating).unwrap();
let s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
#[test]
fn test_invalid_transition_error_display() {
// Display 包含 from / to,供日志诊断
let err = ConvState::Idle
.transition_to(ConvState::Stopping)
.unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
}
}

View File

@@ -5,9 +5,12 @@
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
use crate::commands::ai::AiSession;
use crate::commands::ai::{AiChatEvent, AiSession};
use super::conv_state::{self, ConvState};
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
///
@@ -22,25 +25,106 @@ use crate::commands::ai::AiSession;
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
///
/// L2 统一状态机收尾(2026-06-22):本地 `state: Option<ConvState>` 视图层冗余字段已删除。
///
/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到
/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读
/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余
/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。
///
/// 收尾删除后:
/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端
/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
///
/// 开关 `CONV_STATE_ENABLED`(默认 on)门控:off 时跳过 enum 迁移与 emit,纯旧 bool 行为(可回退)。
pub(super) struct GeneratingGuard {
session: Arc<Mutex<AiSession>>,
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
conv_id: String,
done: bool,
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
/// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。
app_handle: AppHandle,
}
impl GeneratingGuard {
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String) -> Self {
Self { session, conv_id, done: false }
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String, app_handle: AppHandle) -> Self {
// L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state),
// 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state
// 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。
//
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
if conv_state::CONV_STATE_ENABLED {
let session = session.clone();
let app_handle = app_handle.clone();
let cid = conv_id.clone();
tauri::async_runtime::spawn(async move {
let new_state = {
let mut session = session.lock().await;
let conv = session.conv(&cid);
match conv.conv_state.transition_to(ConvState::Generating) {
Ok(ns) => {
conv.conv_state = ns;
ns
}
Err(e) => {
tracing::warn!(
conv_id = %cid,
error = %e,
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
);
return;
}
}
};
// 持久化层迁移成功后 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.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
});
}
Self { session, conv_id, done: false, app_handle }
}
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
///
/// 仅写 per_conv.conv_id.generating(唯一真相源)。
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,CONV_STATE_ENABLED 门控)。
pub(super) async fn reset(&mut self) {
if !self.done {
let mut session = self.session.lock().await;
session.conv(&self.conv_id).generating = false;
let conv = session.conv(&self.conv_id);
conv.generating = false;
// L2 批2:ConvState 持久化(Generating→Idle 写收敛,CONV_STATE_ENABLED 门控)。
if conv_state::CONV_STATE_ENABLED {
match conv.conv_state.transition_to(ConvState::Idle) {
Ok(ns) => {
conv.conv_state = ns;
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let ev = AiChatEvent::AiConvStateChanged {
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);
}
Err(e) => tracing::warn!(
conv_id = %self.conv_id,
error = %e,
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
),
}
}
self.done = true;
}
}
@@ -48,6 +132,9 @@ impl GeneratingGuard {
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
/// (B-260615-26: 修复审批执行后对话不续生成回归)
///
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
pub(super) fn disarm(&mut self) {
self.done = true;
}
@@ -55,13 +142,82 @@ impl GeneratingGuard {
impl Drop for GeneratingGuard {
fn drop(&mut self) {
// 未 done 即异常退出(panic/未走正常 return):核心 generating 经 spawn 复位,
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
// 开关 off / done=true(正常 reset/disarm 已走) → 跳过。
if !self.done {
let session = self.session.clone();
let conv_id = self.conv_id.clone();
// L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。
// app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。
let app_handle = self.app_handle.clone();
tauri::async_runtime::spawn(async move {
let mut s = session.lock().await;
s.conv(&conv_id).generating = false;
let conv = s.conv(&conv_id);
conv.generating = false;
// L2 批2:ConvState 持久化(异常退出兜底→Idle,CONV_STATE_ENABLED 门控)。
if conv_state::CONV_STATE_ENABLED {
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
conv.conv_state = ns;
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let ev = AiChatEvent::AiConvStateChanged {
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);
}
}
});
}
}
}
// ============================================================
// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。
///
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义——
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。
#[test]
fn test_guard_new_migrates_to_generating() {
// CONV_STATE_ENABLED on → Idle → Generating(读侧持久化字段迁移路径)。
assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设");
let mut s = ConvState::Idle;
s = s.transition_to(ConvState::Generating).unwrap();
assert_eq!(s, ConvState::Generating);
}
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
#[test]
fn test_guard_reset_migrates_to_idle() {
let mut s = ConvState::Generating;
s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
#[test]
fn test_guard_drop_path_migrates_to_idle() {
// guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。
let mut s = ConvState::Generating;
s = s.transition_to(ConvState::Idle).unwrap();
assert_eq!(s, ConvState::Idle);
}
/// disarm 不迁移状态(审批等待保持 Generating)。
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
#[test]
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
let s = ConvState::Generating;
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
}
}

View File

@@ -3,7 +3,7 @@
use std::sync::Arc;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
@@ -21,7 +21,7 @@ use df_ai::context_helpers::{
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
use df_ai::retry;
@@ -106,6 +106,30 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
pub const TOPIC_MARKER_ENABLED: bool = true;
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
///
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 →
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
///
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
pub const CIRCUIT_BREAKER_ENABLED: bool = true;
/// L1 断路器熔断时是否发结构化求助(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21)。
///
/// true(默认):熔断 emit AiHelpRequired(结构化求助卡:reason + context + options),
/// 引导用户换思路/授权路径/人工接管(机制优先 prompt 说教,非教 AI 自己止损)。
/// false(兜底回退):熔断仍 emit AiError(旧行为,前端错误气泡),用于求助卡未就绪/
/// 排障/对比。两路保留 guard.reset + return 强制熔断语义不变,仅换前端呈现形态。
/// 配合 CIRCUIT_BREAKER_ENABLED:CIRCUIT_BREAKER_ENABLED=false 时断路器整段跳过,
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
// 阶段2(path_auth 审批链重构):占位配对完整性开关(解 400 orphan)。
//
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
@@ -131,6 +155,22 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
mod guard;
use guard::GeneratingGuard;
// ============================================================
// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
//
// 本批范围(渐进):
// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。
// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState
// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。
// - guard.reset 同步 ConvState→Idle(正常退出路径)。
// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。
//
// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。
// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。
// ============================================================
pub mod conv_state;
// ============================================================
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
// ============================================================
@@ -314,11 +354,14 @@ async fn stream_one_provider(
candidate.name, delay.as_millis(), total_retry, max_retries + 1,
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry {
let ev = AiChatEvent::AiStreamRetry {
attempt: total_retry as u32,
max_attempts: (max_retries + 1) as u32,
conversation_id: Some(conv_id.to_string()),
});
};
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);
tokio::time::sleep(delay).await;
continue;
@@ -356,7 +399,8 @@ pub(crate) async fn run_agentic_loop(
) {
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone());
// L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone());
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
//
@@ -374,10 +418,27 @@ pub(crate) async fn run_agentic_loop(
//
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
//
// L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。
// 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum
// 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。
{
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
conv.generating = true;
// L2 批2:ConvState 持久化(Idle→Generating 写收敛,CONV_STATE_ENABLED 门控)。
// enum 与 generating bool 双轨(bool 核心复位,enum 供读侧/前端);off 降级跳过。
if conv_state::CONV_STATE_ENABLED {
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %conv_id,
error = %e,
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
),
}
}
}
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
@@ -456,11 +517,18 @@ pub(crate) async fn run_agentic_loop(
Ok(Err(ProviderResolveError::EnsureKeyFailed(msg))) => {
guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: msg,
error: msg.clone(),
// ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth
error_type: Some(ErrorType::Auth),
conversation_id: Some(conv_id.clone()),
});
// L3 emit 双写(2026-06-22):关键 AiError publish 到事件总线,供跨模块订阅。
// EVENT_BUS_ENABLED 门控在 publish 内部(false 静默丢弃),无消费者时空转不报错。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error: msg,
error_type: None,
conversation_id: Some(conv_id.clone()),
});
return;
}
Err(_elapsed) => {
@@ -468,11 +536,18 @@ pub(crate) async fn run_agentic_loop(
// 走 AiError 分支复位 generating,对齐 ensure_resolved_key 失败处理口径。
guard.reset().await;
tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死");
let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string();
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string(),
error: err_msg.clone(),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
});
// L3 emit 双写:超时 AiError publish 到事件总线(同上,门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error: err_msg,
error_type: None,
conversation_id: Some(conv_id.clone()),
});
return;
}
};
@@ -601,6 +676,10 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
// L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
@@ -636,6 +715,14 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
// L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens,
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
incomplete: None,
conversation_id: Some(conv_id.clone()),
});
return;
}
@@ -717,8 +804,14 @@ pub(crate) async fn run_agentic_loop(
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
if iteration > 0 {
let round_n = (iteration + 1) as u32;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound {
round: (iteration + 1) as u32,
round: round_n,
conversation_id: Some(conv_id.clone()),
});
// L3 emit 双写:AiAgentRound publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
round: round_n,
conversation_id: Some(conv_id.clone()),
});
}
@@ -771,9 +864,12 @@ pub(crate) async fn run_agentic_loop(
if should_compress {
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressing {
let ev = AiChatEvent::AiCompressing {
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);
let (active_msgs, lang) = {
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
@@ -833,10 +929,13 @@ pub(crate) async fn run_agentic_loop(
pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功,摘要已插首位"
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressed {
let ev = AiChatEvent::AiCompressed {
conversation_id: Some(conv_id.clone()),
summary,
});
};
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);
}
Ok(None) => {
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
@@ -883,11 +982,14 @@ pub(crate) async fn run_agentic_loop(
);
}
}
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
let ev = AiChatEvent::AiError {
error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e),
error_type: Some(ErrorType::Unknown),
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);
}
}
}
@@ -1080,10 +1182,16 @@ pub(crate) async fn run_agentic_loop(
// UX-260618-15: stream_llm 不再 emit AiError,此处统一 emit 最终错误气泡(单气泡聚合)。
guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error,
error: error.clone(),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.clone()),
});
// L3 emit 双写:Fatal AiError publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error,
error_type: None,
conversation_id: Some(conv_id.clone()),
});
return;
}
}
@@ -1101,12 +1209,22 @@ pub(crate) async fn run_agentic_loop(
"[ai] 全 provider 候选流前失败重试耗尽,放弃本轮",
);
guard.reset().await;
let err_msg = last_exhausted_error
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string());
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: last_exhausted_error
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()),
error: err_msg.clone(),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.clone()),
});
// L3 emit 双写:全 candidate 耗尽 AiError publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error: err_msg,
error_type: None,
conversation_id: Some(conv_id.clone()),
});
// BUG-260623-02:全 provider 失败也需落库 user 消息(已 push 内存 chat.rs:383),
// 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。
save_conversation(&session_arc, &db, &conv_id, None, Some(&resolved_model), true).await;
return;
}
}
@@ -1161,6 +1279,14 @@ pub(crate) async fn run_agentic_loop(
conversation_id: Some(conv_id.clone()),
incomplete: Some(true),
});
// L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens,
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
incomplete: None,
conversation_id: Some(conv_id.clone()),
});
return;
}
// F-260616-09 B 批5: global/per_conv permit 已上移 loop 入口(L520-521)整 loop 持有,
@@ -1221,6 +1347,14 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
// L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens,
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
incomplete: None,
conversation_id: Some(conv_id.clone()),
});
return;
}
@@ -1240,6 +1374,95 @@ pub(crate) async fn run_agentic_loop(
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
);
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
// CIRCUIT_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
// 仅检查自动执行(Low)的工具结果——pending_count>0(待审批)交给下方审批分支,
// 此处只看已回填的 Tool 消息。取末尾连续 Tool 消息(倒序 take_while role==Tool),
// 失败内容前 40 字符归一 key 计数,同 key 累计达阈值 → guard.reset + AiError + return。
if CIRCUIT_BREAKER_ENABLED {
let (max_count, sample_key) = {
let session = session_arc.lock().await;
// conv 可能已被删除(stop/新对话),get 不到 → 无消息可判,跳过本轮断路器检查。
let messages = match session.conv_read(&conv_id) {
Some(conv) => conv.messages.all_messages_clone(),
None => Vec::new(),
};
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果;非 Tool 即停)。
// MessageRole 未派生 PartialEq,用 matches! 宏判变体(不改共享类型 df-ai-core)。
let recent_tool_results: Vec<&ChatMessage> = messages
.iter()
.rev()
.take_while(|m| matches!(m.role, MessageRole::Tool))
.collect();
for m in recent_tool_results {
let content = m.content.as_str();
// 失败判定:禁止/跳过重试/失败/Error 关键词(覆盖权限拒绝/路径错误/异常等)。
let is_failure = content.starts_with("禁止")
|| content.starts_with("已跳过重试")
|| content.contains("失败")
|| content.contains("Error")
|| content.contains("error");
if is_failure {
// 前 40 字符归一 key:同类失败(同前缀)累加,不同错误各自计数互不干扰。
let key: String = content.chars().take(40).collect();
*fail_counts.entry(key).or_insert(0) += 1;
}
}
// 取当前最大计数及其 key(无失败 → max_count=0,不触发)。
fail_counts
.iter()
.max_by_key(|(_, &v)| v)
.map(|(k, &v)| (v, k.clone()))
.unwrap_or((0u32, String::new()))
};
// 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。
if max_count >= CIRCUIT_BREAKER_THRESHOLD {
tracing::warn!(
conv_id = %conv_id,
max_count,
sample_key = %sample_key,
"[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count
);
guard.reset().await;
// L1 求助协议(§2.3,2026-06-21):熔断改发结构化 AiHelpRequired(机制优先 prompt 说教)。
// 开关 CIRCUIT_BREAKER_HELP_EVENT=true(默认)→ AiHelpRequired(求助卡,显 reason/options
// 供用户选);false(兜底回退)→ AiError(旧错误气泡)。两路均 guard.reset + return 强制熔断,
// 仅前端呈现形态不同,语义不变(均终止 loop,逼用户介入)。
if CIRCUIT_BREAKER_HELP_EVENT {
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiHelpRequired {
reason: format!(
"连续同类失败 {} 次,疑似死循环已停止",
max_count
),
context: format!("最近错误: {}", sample_key),
options: vec![
"换思路".into(),
"授权路径".into(),
"人工接管".into(),
],
conversation_id: Some(conv_id.clone()),
},
);
} else {
// 兜底回退:求助卡未就绪/排障/对比时,沿用旧 AiError 错误气泡呈现。
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiError {
error: format!(
"连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}",
max_count, sample_key
),
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.clone()),
},
);
}
return;
}
}
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
if pending_count > 0 {
let usage = df_ai::provider::TokenUsage {
@@ -1280,9 +1503,12 @@ pub(crate) async fn run_agentic_loop(
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位
guard.disarm();
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached {
let ev = AiChatEvent::AiMaxRoundsReached {
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);
return; // generating 保持 true,等 ai_continue_loop / ai_stop_loop
}
@@ -1319,6 +1545,15 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await;
// generating 复位后再 emit Completed落库/标题/提炼已在后台,前端立即感知完成
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
// L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内,
// 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
total_tokens: usage_total,
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
incomplete: None,
conversation_id: Some(conv_id.clone()),
});
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
@@ -1375,7 +1610,15 @@ pub(crate) async fn try_continue_agent_loop(
.find_map(|a| a.conversation_id.clone());
let conv = session.conv_read(conv_id);
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
let is_generating = conv.map(|c| c.generating).unwrap_or(false);
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed),
// off 回退 generating bool。双轨过渡:off 等价旧行为。
let is_generating = conv.map(|c| {
if conv_state::CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false);
let agent_language = conv.and_then(|c| c.agent_language.clone());
let model_override = conv.and_then(|c| c.model_override.clone());
ContinueSnapshot {
@@ -1404,13 +1647,16 @@ pub(crate) async fn try_continue_agent_loop(
Some(cid) => cid,
None => conv_id.to_string(),
};
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(emit_conv_id),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
}
return;
}
@@ -1429,11 +1675,17 @@ pub(crate) async fn try_continue_agent_loop(
drop(session);
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: e,
error: e.clone(),
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id.to_string()),
});
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
error: e,
error_type: None,
conversation_id: Some(conv_id.to_string()),
});
return;
}
};
@@ -1469,19 +1721,29 @@ pub(crate) async fn try_continue_agent_loop(
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool。
let still_generating = {
let session = state.ai_session.lock().await;
session.conv_read(conv_id).map(|c| c.generating).unwrap_or(false)
session.conv_read(conv_id).map(|c| {
if conv_state::CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false)
};
if !still_generating {
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(conv_id_owned.clone()),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return;
}
@@ -1491,6 +1753,11 @@ pub(crate) async fn try_continue_agent_loop(
round: 0,
conversation_id: Some(conv_id_owned.clone()),
});
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
round: 0,
conversation_id: Some(conv_id_owned.clone()),
});
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;

View File

@@ -282,12 +282,16 @@ pub(crate) async fn process_tool_calls(
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
.map(|(idx, draft)| {
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallStarted {
// L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。
// AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。
let ev = AiChatEvent::AiToolCallStarted {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
(idx, draft, args)
})
.collect();
@@ -325,11 +329,14 @@ pub(crate) async fn process_tool_calls(
for (draft, reason) in path_denied {
let err_msg = format!("路径授权拒绝: {}", reason);
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
@@ -362,11 +369,14 @@ pub(crate) async fn process_tool_calls(
draft.id
);
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:重试跳过 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
@@ -393,13 +403,16 @@ pub(crate) async fn process_tool_calls(
// 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired {
// L3 emit 双写:路径授权挂起 AiDirAuthRequired 双路发布(tunnel 透传 miniapp 弹窗)。
let ev = AiChatEvent::AiDirAuthRequired {
id: draft.id.clone(),
tool: draft.name.clone(),
path: path_str,
dir: dir_str,
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
@@ -414,17 +427,43 @@ pub(crate) async fn process_tool_calls(
// 若 LLM 重试同命令(同 tool_name + 同 args键序无关命中已落定的旧 tool_result
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM跳过 insert pending + 跳过审批,
// 断「超时→重试→重新审批」循环。Med 不去重去重易误伤Low 无审批本就不进此分支。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
// F-#97:low_risk 向量携带 risk_level(治 securityReview blocker :710 审计失真)。
// 原 Vec 仅 (draft,args),回填审计硬编码 RiskLevel::Low;#97 让 Med/High(all/medium 模式)
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
// F-#97 自动执行范围三档(2026-06-22):KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low(=现状)。
// low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。
// 锁内 await KV 快读,不竞争 session 锁(settings 独立 repo)。
let auto_exec_mode = app_handle
.state::<crate::state::AppState>()
.settings
.get("df-ai-auto-execute-mode")
.await
.ok()
.flatten()
.unwrap_or_else(|| "low".to_string());
// trust_hits 收集AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
// (对齐 Low risk L673-720 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
// 元组携带 risk_level: 回填审计需原等级(Med/High 才进此分支),避免闭包内再查 registry
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
match risk_level {
RiskLevel::Low => low_risk.push((draft, args)),
RiskLevel::Medium | RiskLevel::High => {
// AE-2025-04 会话级信任Session Trust首批 write_file / run_command
// F-#97:按 auto_exec_mode 判定是否自动执行。should_auto=true 走 low_risk(自动),false 走原审批块。
// low(默认):Low→auto, Med/High→审批(等价现状)
// medium:Low/Med→auto, High→审批
// all:全 auto(完全接管,无审批)
let should_auto = match risk_level {
RiskLevel::Low => true,
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
RiskLevel::High => auto_exec_mode == "all",
};
if should_auto {
low_risk.push((draft, args, risk_level));
} else {
// 原 Medium|High 审批分支(trust_key 命中/dedup 缓存/retry guard/pending insert + emit + audit_tool_call)
// 整体保留,逻辑不变。trust_hits 元组携带 risk_level 语义不变:Med 进此处=Medium,
// High 进此处(仅 low/medium 模式)时=High。
// AE-2025-04 会话级信任Session Trust首批 write_file / run_command
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径
// 但与 F-05 不同F-05 复用缓存 tool_result 跳过执行trust 放行**真实执行工具**
@@ -450,12 +489,15 @@ pub(crate) async fn process_tool_calls(
);
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
// toast 即时反馈,锁内 emit 不阻塞
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolAutoApproved {
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
let ev = AiChatEvent::AiToolAutoApproved {
id: draft.id.clone(),
tool: draft.name.clone(),
dir: dir_label.clone(),
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
trust_hits.push((draft, args, dir_label, risk_level));
@@ -472,11 +514,14 @@ pub(crate) async fn process_tool_calls(
);
// F-260616-09 B 批2:写 per_conv.messages(conv_id 来源:本函数入参)。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(cached.clone()),
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await;
continue;
@@ -504,11 +549,14 @@ pub(crate) async fn process_tool_calls(
draft.id
);
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(skip_msg.clone()),
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
continue;
}
@@ -525,16 +573,18 @@ pub(crate) async fn process_tool_calls(
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
let ev = AiChatEvent::AiApprovalRequired {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
reason,
diff: approval_diff,
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
}
}
@@ -555,11 +605,14 @@ pub(crate) async fn process_tool_calls(
match exec_result {
Ok(val) => {
let content = val.to_string();
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone),
});
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-11数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name);
@@ -567,11 +620,14 @@ pub(crate) async fn process_tool_calls(
}
Err(e) => {
let content = e.to_string();
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone),
});
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
(draft, risk_level, Err(content))
}
}
@@ -596,8 +652,8 @@ pub(crate) async fn process_tool_calls(
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !low_risk.is_empty() {
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
let results: Vec<(ToolCallDraft, serde_json::Value, Result<String, String>)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
@@ -605,15 +661,18 @@ pub(crate) async fn process_tool_calls(
let result = tools.execute(&draft.name, args).await;
match result {
Ok(val) => {
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: val.clone(),
conversation_id: Some(conv_clone),
});
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-11方案A数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit见 emit_data_changed
emit_data_changed(&app_clone, &draft.name);
(draft, val.clone(), Ok(val.to_string()))
(draft, val.clone(), risk_level, Ok(val.to_string()))
}
Err(e) => {
// AR-6定向BLow 工具失败不 emit AiError。
@@ -622,19 +681,22 @@ pub(crate) async fn process_tool_calls(
// 现改为正常 emit AiToolCallCompletedresult=错误信息),错误包进 tool_result
// 让 LLM 看到工具失败自行决定下一步loop 正常续,前端状态一致。
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
// L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone),
});
(draft, serde_json::Value::Null, Err(err_msg))
};
let _ = app_clone.emit("ai-chat-event", ev.clone());
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
}
}
}
})).await;
// 串行回填 tool_result + 审计(持 session 锁)
for (draft, raw_result, outcome) in results {
for (draft, raw_result, risk_level, outcome) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
@@ -648,7 +710,15 @@ pub(crate) async fn process_tool_calls(
}
// F-260616-09 B 批2:写 per_conv.messages。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await;
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
// 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。
let decided_by: &str = match risk_level {
RiskLevel::Low => "auto",
RiskLevel::Medium => "auto_takeover_medium",
RiskLevel::High => "auto_takeover_all",
};
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await;
}
}

View File

@@ -0,0 +1,541 @@
//! AST 代码智能 — tree-sitter 符号解析(read_symbol 三态)
//!
//! 治 aichat read_file 全文回灌 prompt 爆(信息密度驱动 ≠ 压缩,见设计文档):
//! read_symbol 按语义层级返符号 **骨架**(默认)/ **全文**(full),替代物理读全文件。
//! LLM 默认拿骨架(签名 + 行范围 + 内部调用)→ 要细节再 full 取完整定义体 → 进 prompt 的都是主动要的,天然高密度。
//! 设计:docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md
//!
//! **不报错底线**:无 grammar / 解析失败 / symbol 未找到 / Vue 无 script / 任何大小函数 →
//! 返兜底提示(grep + read_file),不 Err / 不 panic / 不阻断。
use std::collections::HashSet;
use tree_sitter::{Language, Node, Parser};
/// Phase1 支持的定义节点 kind(Rust + TS/JS 通用,Vue 借 TS)。
///
/// tree-sitter 各 grammar 的定义节点 kind 基本一致:
/// - Rust:function_item / struct_item / impl_item 等(`_item` 后缀)
/// - TS/JS:function_declaration / class_declaration / interface_declaration / method_definition 等
/// 统一放一张表(find_definition 命中即提取),跨语言零分支。
const DEFINITION_KINDS: &[&str] = &[
// Rust
"function_item",
"function_signature_item",
"struct_item",
"enum_item",
"impl_item",
"trait_item",
"mod_item",
"const_item",
"static_item",
"type_item",
"macro_definition",
// TS / JS(与 Rust kind 不冲突)
"function_declaration",
"generator_function_declaration",
"class_declaration",
"abstract_class_declaration",
"interface_declaration",
"enum_declaration",
"method_definition",
];
/// 文件后缀 → tree-sitter grammar 集中 lookup(静态编译,不动态加载 / 不抽 trait)。
///
/// 设计(grammar 策略 2026-06-24 定稿,详见插件机制设计文档):
/// **3 个 grammar 覆盖 4 类文件**,架构成本最低。
/// - `.rs` → tree-sitter-rust
/// - `.ts` → tree-sitter-typescript(language_typescript)
/// - `.tsx` → tree-sitter-typescript(language_tsx)
/// - `.js` / `.jsx` → tree-sitter-javascript
/// - `.vue` → 返回 None 标记"需切 script 段"(Vue 借 TS,逻辑在 script,read_symbol 内 extract_vue_script 切段喂 ts)
///
/// 未来加语言 = 此处加一行 + 可能补 DEFINITION_KINDS(表驱动,~几行),不返工调用方。
/// tree-sitter 动态加载技术上可行(Helix/Neovim 先例)但跨平台分发 + ABI 坑,当前不值(见插件设计)。
pub fn grammar_for(ext: &str) -> Option<Language> {
match ext {
"rs" => Some(Language::from(tree_sitter_rust::LANGUAGE)),
"ts" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT)),
"tsx" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TSX)),
"js" | "jsx" => Some(Language::from(tree_sitter_javascript::LANGUAGE)),
// Vue 借 TS:返 None 标记需切 script 段(read_symbol 内 extract_vue_script + 喂 ts)
"vue" => None,
_ => None,
}
}
/// 判断 ext 是否为 Phase1 支持语言(含 Vue 借 TS)。
///
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
#[allow(dead_code)]
pub fn is_supported_ext(ext: &str) -> bool {
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue")
}
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
///
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
/// 切出 `<script>...</script>` 内容喂 tree-sitter-typescript:
/// - 省 1 个 grammar(tree-sitter-vue 不引,降依赖体积)
/// - 避 SFC 嵌套复杂度(tree-sitter-vue 解析 template/style/script 混合,符号提取只关心 script)
///
/// 多个 script 段取首个(`<script setup>` 主逻辑);无 script 返 None(走兜底)。
/// 返回 (script 内容, script 在原 source 中的字节起始偏移)用于行号映射。
fn extract_vue_script(source: &str) -> Option<(String, usize)> {
let start_tag = source.find("<script")?;
// 跳过 <script ...> 的属性(到 >),取标签体开始
let tag_close = source[start_tag..].find('>')? + start_tag + 1;
let end = source[tag_close..].find("</script>")?;
Some((source[tag_close..tag_close + end].to_string(), tag_close))
}
/// read_symbol 三态读取(信息密度驱动)。
///
/// **不按物理行返、不截断**,按语义结构层级返(消解"截断多少行"争论):
/// - `full=false`(默认 骨架态):返签名 + kind + 行范围 + 内部调用列表 + 总行数(极小,高密度)
/// - `full=true`(全文态):返完整定义体(语法边界 100% 准)
///
/// **不报错底线**:无 grammar / 解析失败 / symbol 未找到 / Vue 无 script → 返兜底提示(grep + read_file),不 panic。
///
/// 参数:
/// - `source`:文件全文(由 handler 读 + 算 hash 后传入,纯函数不碰 fs)
/// - `file_hash`:文件 hash(handler 算,回传 LLM 供后续定位 / 兜底回取)
/// - `path`:路径(取 ext + 兜底消息展示)
/// - `symbol`:目标符号名(精确匹配定义节点的 name field)
/// - `full`:true=完整定义体,false=骨架
/// - `kind_hint`:可选 kind 过滤(如 "function"/"struct"),None 匹配任意定义 kind
pub fn read_symbol(
source: &str,
file_hash: &str,
path: &str,
symbol: &str,
full: bool,
kind_hint: Option<&str>,
) -> serde_json::Value {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
let is_vue = ext == "vue";
// Vue 借 TS:切 script 段(Vue 逻辑在 script,template/style 不做符号)
let (parse_source, parse_ext, line_offset): (String, &str, usize) = if is_vue {
match extract_vue_script(source) {
Some((script, byte_off)) => {
// Vue 切段后节点行号是 script 内相对行,加原文件中 script 起始行偏移映射回原行号
let off_lines = source[..byte_off].matches('\n').count();
(script, "ts", off_lines)
}
None => return fallback(symbol, path, file_hash, "vue_no_script",
"Vue 文件无 <script> 段(纯 template/style 无可提取符号)"),
}
} else {
(source.to_string(), ext.as_str(), 0)
};
let grammar = match grammar_for(parse_ext) {
Some(g) => g,
None => {
return fallback(symbol, path, file_hash, "no_grammar",
&format!("不支持 {} 文件符号解析(Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue)", ext));
}
};
let mut parser = Parser::new();
if parser.set_language(&grammar).is_err() {
return fallback(symbol, path, file_hash, "parse_failed",
"grammar 加载失败(tree-sitter 版本/ABI 不兼容,疑似环境问题)");
}
let tree = match parser.parse(&parse_source, None) {
Some(t) => t,
None => return fallback(symbol, path, file_hash, "parse_failed", "解析失败(源码可能有语法错误)"),
};
let root = tree.root_node();
let mut found: Option<Node> = None;
find_definition(&root, symbol, kind_hint, parse_source.as_bytes(), &mut found);
match found {
Some(node) => extract_symbol(node, parse_source.as_bytes(), source, path, file_hash, full, line_offset),
None => fallback(symbol, path, file_hash, "not_found",
&format!("文件内未找到符号「{}」(可能拼写/作用域差异,试 grep 搜定义)", symbol)),
}
}
/// 递归遍历找匹配 symbol 名的定义节点。
///
/// 命中条件:node.kind() 在 DEFINITION_KINDS 内 + name field 文本 == symbol + kind_hint(若传)匹配。
/// 首个命中即返(find_definition 调用方短路)。
fn find_definition<'a>(
node: &Node<'a>,
symbol: &str,
kind_hint: Option<&str>,
source: &[u8],
found: &mut Option<Node<'a>>,
) {
if found.is_some() {
return;
}
if DEFINITION_KINDS.contains(&node.kind()) {
let kind_ok = kind_hint.map_or(true, |h| kind_matches(node.kind(), h));
if kind_ok {
if let Some(name_node) = node.child_by_field_name("name") {
let name = node_text(name_node, source);
if name == symbol {
*found = Some(*node);
return;
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
find_definition(&child, symbol, kind_hint, source, found);
if found.is_some() {
return;
}
}
}
/// kind_hint 宽松匹配:user 传 "function" 命中 function_item/function_declaration/method_definition 等。
fn kind_matches(node_kind: &str, hint: &str) -> bool {
let h = hint.to_lowercase();
node_kind.contains(&h)
|| (h == "function" && (node_kind.contains("function") || node_kind == "method_definition"))
|| (h == "struct" && node_kind.contains("struct"))
|| (h == "class" && node_kind.contains("class"))
|| (h == "enum" && node_kind.contains("enum"))
}
/// 取节点文本(字节切片避免 utf8_text 的 lifetime/Result 坑)。
/// 返 owned String(符号体不大,避免借用 lifetime 纠缠调用方)。
fn node_text(node: Node<'_>, source: &[u8]) -> String {
std::str::from_utf8(&source[node.start_byte()..node.end_byte()])
.unwrap_or("")
.to_string()
}
/// 提取符号三态信息,组装返回 Value。
fn extract_symbol(
node: Node<'_>,
source: &[u8],
_orig_source: &str,
path: &str,
file_hash: &str,
full: bool,
line_offset: usize,
) -> serde_json::Value {
let name = node
.child_by_field_name("name")
.map(|n| node_text(n, source).to_string())
.unwrap_or_default();
let kind = node.kind().to_string();
// 行号:script 内相对行(0-based)+ Vue 偏移 → 原文件 1-based 行
let start_row = node.start_position().row + 1 + line_offset;
let end_row = node.end_position().row + 1 + line_offset;
let body_lines = end_row - start_row + 1;
let node_src = node_text(node, source);
let signature = extract_signature(&node_src);
let calls = collect_calls(node, source);
if full {
serde_json::json!({
"symbol": name,
"kind": kind,
"path": path,
"file_hash": file_hash,
"line_start": start_row,
"line_end": end_row,
"body_lines": body_lines,
"signature": signature,
"calls": calls,
"mode": "full",
"content": node_src,
})
} else {
serde_json::json!({
"symbol": name,
"kind": kind,
"path": path,
"file_hash": file_hash,
"line_start": start_row,
"line_end": end_row,
"body_lines": body_lines,
"signature": signature,
"calls": calls,
"mode": "skeleton",
"hint": "骨架态:传 full=true 取完整定义体(语法边界精准);下钻局部分支 drill 待 Phase2",
})
}
}
/// 提取签名:节点文本到首个 `{`(函数/结构体体开始)或 `;`(声明)之前的部分。
/// 无 `{`/`;` 取首行。trim 收尾空白 + 注释。
fn extract_signature(node_src: &str) -> String {
let cut = node_src
.find('{')
.or_else(|| node_src.find(';'))
.unwrap_or_else(|| node_src.len());
let sig = node_src[..cut].trim();
if sig.is_empty() {
node_src.lines().next().unwrap_or("").trim().to_string()
} else {
sig.to_string()
}
}
/// 收集节点内所有 call_expression 的被调用者(同文件静态调用点)。
///
/// Phase1:扁平收集去重(Phase2 按分支结构组织调用图)。
/// Rust/TS/JS 的 call_expression 都有 "function" field(callee),统一提取。
fn collect_calls(node: Node, source: &[u8]) -> Vec<String> {
let mut calls = Vec::new();
let mut seen = HashSet::new();
collect_calls_inner(node, source, &mut calls, &mut seen);
calls
}
fn collect_calls_inner(node: Node, source: &[u8], out: &mut Vec<String>, seen: &mut HashSet<String>) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "call_expression" {
if let Some(func) = child.child_by_field_name("function") {
let name = node_text(func, source).to_string();
if !name.is_empty() && seen.insert(name.clone()) {
out.push(name);
}
}
}
collect_calls_inner(child, source, out, seen);
}
}
/// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。
fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value {
serde_json::json!({
"symbol": symbol,
"path": path,
"file_hash": file_hash,
"fallback": true,
"reason": reason,
"suggestion": suggestion,
"tip": "回退:用 grep(pattern=symbol) 搜定义位置 → read_file(path, offset=行号) 精准读取",
})
}
// ============================================================
// 单测:三态提取 + 兜底 + grammar lookup + Vue 切段
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
const RUST_FIXTURE: &str = r#"use std::io;
pub fn read_symbol(path: &str) -> Result<(), io::Error> {
let content = load(path)?;
parse(&content);
validate(path);
Ok(())
}
fn parse(content: &str) {
helper(content);
}
fn helper(_x: &str) {}
struct Config {
name: String,
}
"#;
#[test]
fn test_grammar_for_all_exts() {
assert!(grammar_for("rs").is_some());
assert!(grammar_for("ts").is_some());
assert!(grammar_for("tsx").is_some());
assert!(grammar_for("js").is_some());
assert!(grammar_for("jsx").is_some());
// Vue 借 TS:grammar 本身返 None(切段后用 ts)
assert!(grammar_for("vue").is_none());
// 不支持语言
assert!(grammar_for("py").is_none());
assert!(grammar_for("").is_none());
}
#[test]
fn test_is_supported_ext() {
assert!(is_supported_ext("rs"));
assert!(is_supported_ext("vue"));
assert!(!is_supported_ext("py"));
}
#[test]
fn test_read_symbol_rust_skeleton() {
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", false, None);
assert_eq!(r["fallback"], serde_json::Value::Null);
assert_eq!(r["symbol"], "read_symbol");
assert_eq!(r["kind"], "function_item");
assert_eq!(r["mode"], "skeleton");
// 骨架态不返 content,返 signature + calls
assert!(r["content"].is_null());
assert!(r["signature"].as_str().unwrap().contains("read_symbol"));
// calls 含同文件静态调用(parse/validate)+ load(跨文件也命中名,Phase1 扁平)
let calls = r["calls"].as_array().unwrap();
assert!(calls.iter().any(|c| c == "parse"));
assert!(calls.iter().any(|c| c == "validate"));
assert_eq!(r["body_lines"].as_u64().unwrap(), 6);
}
#[test]
fn test_read_symbol_rust_full() {
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", true, None);
assert_eq!(r["mode"], "full");
let content = r["content"].as_str().unwrap();
// full 态返完整定义体(含函数体 {})
assert!(content.contains("pub fn read_symbol"));
assert!(content.contains("let content = load(path)?;"));
assert!(content.trim_end().ends_with('}'));
}
#[test]
fn test_read_symbol_struct() {
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, None);
assert_eq!(r["symbol"], "Config");
assert_eq!(r["kind"], "struct_item");
}
#[test]
fn test_read_symbol_kind_hint_filter() {
// kind_hint="struct" 时,"read_symbol"(function)不应被误匹配为 struct
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, Some("struct"));
assert_eq!(r["symbol"], "Config");
assert_eq!(r["kind"], "struct_item");
}
#[test]
fn test_read_symbol_not_found_fallback() {
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "nonexistent_fn", false, None);
assert_eq!(r["fallback"], true);
assert_eq!(r["reason"], "not_found");
assert!(r["suggestion"].as_str().unwrap().contains("grep"));
}
#[test]
fn test_read_symbol_no_grammar_fallback() {
let src = "def hello():\n print('hi')\n";
let r = read_symbol(src, "h", "test.py", "hello", false, None);
assert_eq!(r["fallback"], true);
assert_eq!(r["reason"], "no_grammar");
assert!(r["suggestion"].as_str().unwrap().contains("不支持"));
}
#[test]
fn test_read_symbol_vue_script_extraction() {
let vue = r#"<template><div>{{ msg }}</div></template>
<script setup lang="ts">
function greet(name: string) {
console.log(name);
helper();
}
function helper() {}
</script>
<style scoped></style>
"#;
let r = read_symbol(vue, "h", "App.vue", "greet", false, None);
assert_eq!(r["fallback"], serde_json::Value::Null);
assert_eq!(r["symbol"], "greet");
assert_eq!(r["kind"], "function_declaration");
// Vue 切段后行号映射回原文件(greet 在原文件第 5 行附近)
let start = r["line_start"].as_u64().unwrap();
assert!(start >= 4, "Vue 行号应映射回原文件(script 段偏移后),实际 {}", start);
let calls = r["calls"].as_array().unwrap();
assert!(calls.iter().any(|c| c == "helper"));
}
#[test]
fn test_read_symbol_vue_no_script_fallback() {
let vue = "<template><div>hi</div></template>\n<style></style>";
let r = read_symbol(vue, "h", "App.vue", "greet", false, None);
assert_eq!(r["fallback"], true);
assert_eq!(r["reason"], "vue_no_script");
}
#[test]
fn test_extract_vue_script() {
let vue = "a\nb\n<script>let x = 1;</script>";
let (script, off) = extract_vue_script(vue).unwrap();
assert_eq!(script, "let x = 1;");
// script 在第 3 行(0-indexed offset 后),行偏移应 = 2(\n 数)
assert_eq!(vue[..off].matches('\n').count(), 2);
}
#[test]
fn test_extract_signature() {
// function 带 body
let s = extract_signature("pub fn foo(x: i32) -> i32 {\n x + 1\n}");
assert_eq!(s, "pub fn foo(x: i32) -> i32");
// struct 带 body
let s = extract_signature("struct Config {\n name: String,\n}");
assert_eq!(s, "struct Config");
// 声明以 ; 结尾
let s = extract_signature("fn foo();");
assert_eq!(s, "fn foo()");
}
#[test]
fn test_collect_calls_dedup() {
let src = "fn a() { b(); b(); c(); }";
let mut parser = Parser::new();
parser.set_language(&Language::from(tree_sitter_rust::LANGUAGE)).unwrap();
let tree = parser.parse(src, None).unwrap();
let calls = collect_calls(tree.root_node(), src.as_bytes());
// b 去重只出现一次
assert!(calls.iter().any(|c| c == "b"));
assert_eq!(calls.iter().filter(|c| *c == "b").count(), 1);
assert!(calls.iter().any(|c| c == "c"));
}
/// 多角度验证(用户要求:实现后看效果 + 多角度测试):
/// 解析 devflow 真实源码 code_intel.rs 自身,验骨架态密度提升(设计目标:降一个量级)。
/// 对比 read_symbol 骨架(单符号签名+calls)vs read_file 全文(整个文件所有符号)的体积。
#[test]
fn test_read_symbol_real_devflow_token_density() {
let manifest = env!("CARGO_MANIFEST_DIR"); // src-tauri/
let path_str = format!("{}/src/commands/ai/code_intel.rs", manifest);
let source = std::fs::read_to_string(&path_str)
.expect("应能读到 devflow 自身 code_intel.rs");
let full_bytes = source.len();
// 骨架态:解析自身 read_symbol 函数(只给该符号,非整个文件)
let skeleton = read_symbol(&source, "h0", "code_intel.rs", "read_symbol", false, None);
assert_ne!(skeleton["fallback"], true, "真实文件应解析出 read_symbol 符号");
assert_eq!(skeleton["symbol"], "read_symbol");
assert_eq!(skeleton["kind"], "function_item");
let skeleton_str = serde_json::to_string(&skeleton).unwrap();
let skeleton_bytes = skeleton_str.len();
// 密度断言:read_symbol 骨架(单符号)<< read_file 全文(整个文件)。
// 正是治 prompt 爆的核心:LLM 要理解 read_symbol,工具只给该符号骨架,不给全文件。
let ratio = full_bytes as f64 / skeleton_bytes.max(1) as f64;
eprintln!(
"[密度效果] code_intel.rs 全文 {} 字节 vs read_symbol 骨架 {} 字节 → 降 {:.1}x",
full_bytes, skeleton_bytes, ratio
);
assert!(ratio > 5.0, "骨架密度应显著高于全文(降 >5x),实际 {:.1}x", ratio);
// full 态:完整定义体(语法边界 100% 准)
let full_sym = read_symbol(&source, "h1", "code_intel.rs", "read_symbol", true, None);
let content = full_sym["content"].as_str().expect("full 态应返 content");
assert!(content.contains("pub fn read_symbol"));
// calls 应含 read_symbol 内部调用的函数(grammar_for/extract_symbol/fallback 等)
let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
assert!(!calls.is_empty(), "应提取出内部调用");
}
}

View File

@@ -13,7 +13,7 @@
use std::sync::atomic::Ordering;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use df_ai::provider::{ChatMessage, ContentPart};
use df_storage::models::AiProviderRecord;
@@ -30,7 +30,7 @@ use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt;
use super::super::prompt::build_system_prompt;
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};
use super::super::{AiChatEvent, ApprovalKind, SessionState};
@@ -137,6 +137,29 @@ fn span_to_mention_ref(span: &MentionSpanDto) -> Option<MentionRef> {
}
}
/// 从 mention_spans 提取 (被@的项目 id 列表, 被@的任务 id 列表)。
///
/// 用于 [`build_system_prompt_with_excluded`] 的去重入参:被@的 project/task 已有 augmentation
/// 精准投影,需从全局清单中排除以免同一实体两次入 prompt(skill/idea 不在清单内,无需排除)。
///
/// - `None` / 空 spans → 两个空 vec(行为退化为无去重的全貌清单,等同旧 build_system_prompt)。
/// - 仅收集 kind == "project" / "task" 的 `ref_id`(idea/skill 不进项目/任务清单)。
/// - 不去重(同实体被@多次理论上不会出现,即便出现 excluded 列表多次命中也只是 continue,无副作用)。
fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<String>, Vec<String>) {
let mut proj = Vec::new();
let mut task = Vec::new();
if let Some(spans) = spans.as_ref() {
for span in spans {
match span.kind.as_str() {
"project" => proj.push(span.ref_id.clone()),
"task" => task.push(span.ref_id.clone()),
_ => {}
}
}
}
(proj, task)
}
// ============================================================
// 发送 / 审批 / 控制
// ============================================================
@@ -259,7 +282,15 @@ pub async fn ai_is_generating(
// 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。
return Ok(false);
}
Ok(session.conv_read(&target).map(|c| c.generating).unwrap_or(false))
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state==Generating(含 Compressed 派生态,
// 压缩期间 loop 仍活跃),off 回退 generating bool(可回退)。双轨过渡:开关 off 等价旧行为零破坏。
Ok(session.conv_read(&target).map(|c| {
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false))
}
/// 发送消息并获取流式 AI 响应
@@ -363,7 +394,11 @@ pub async fn ai_chat_send(
// 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let mut system_prompt = build_system_prompt(&state, &lang).await;
// 去重:从 mention_spans 提取被@的 project/task id,传给 prompt 构建器从全局清单中排除
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
@@ -457,11 +492,15 @@ pub async fn ai_approve(
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作");
}
let conv_id = approval.conversation_id.clone();
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
let ev = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控,
// 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
drop(session);
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
@@ -568,16 +607,22 @@ pub async fn ai_approve(
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
});
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id,
approved: true,
conversation_id: conv_id.clone(),
});
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
drop(session);
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
@@ -769,16 +814,22 @@ pub async fn ai_authorize_dir(
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
}
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: conv_id.clone(),
});
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
});
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
@@ -864,16 +915,22 @@ pub async fn ai_authorize_dir(
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
}
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
let ev_completed = AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
});
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
};
let _ = app.emit("ai-chat-event", ev_completed.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
let ev_approval = AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: true,
conversation_id: conv_id.clone(),
});
};
let _ = app.emit("ai-chat-event", ev_approval.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
@@ -988,9 +1045,12 @@ pub async fn ai_chat_clear_context(
// 空会话/全在保护区(消息 ≤ N 条) → 无可分段消息,emit 后 noop
if protect_start == 0 {
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conversation_id.clone()),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
@@ -1004,9 +1064,12 @@ pub async fn ai_chat_clear_context(
};
// 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
let ev = AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok(())
}
@@ -1041,9 +1104,12 @@ pub async fn ai_chat_compress_context(
return Err("压缩正在进行中".to_string());
}
// emit 开始 + 置位防重入(成对释放,见下方所有出口)
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressing {
let ev = AiChatEvent::AiCompressing {
conversation_id: Some(conversation_id.clone()),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
conv.messages.set_compressing(true);
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
@@ -1052,10 +1118,13 @@ pub async fn ai_chat_compress_context(
conv.messages.set_compressing(false);
let cid = session.active_conversation_id.clone();
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
let ev = AiChatEvent::AiCompressed {
conversation_id: cid,
summary: String::new(),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
@@ -1075,10 +1144,13 @@ pub async fn ai_chat_compress_context(
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
let ev = AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary: String::new(),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
@@ -1090,11 +1162,14 @@ pub async fn ai_chat_compress_context(
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Auth),
conversation_id: Some(conv_id),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Err(format!("压缩失败: {}", e));
}
};
@@ -1113,11 +1188,14 @@ pub async fn ai_chat_compress_context(
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false);
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
let ev = AiChatEvent::AiError {
error: format!("压缩失败: {}", e),
error_type: Some(super::super::ErrorType::Unknown),
conversation_id: Some(conv_id),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Err(format!("压缩失败: {}", e));
}
};
@@ -1131,10 +1209,13 @@ pub async fn ai_chat_compress_context(
conv.messages.set_compressing(false);
}
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
let ev = AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary,
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok(())
}
@@ -1341,19 +1422,24 @@ pub async fn ai_chat_force_send(
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(cid.clone()),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
}
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let mut system_prompt = build_system_prompt(&state, &lang).await;
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
@@ -1409,7 +1495,15 @@ pub async fn ai_chat_stop(
return Ok(());
}
// 仅查目标 conv 的 generating(真并发下各 conv 独立)。
let is_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false);
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed),
// off 回退 generating bool。双轨过渡:off 等价旧行为。
let is_gen = session.conv_read(&target).map(|c| {
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
c.conv_state.is_active()
} else {
c.generating
}
}).unwrap_or(false);
if !is_gen {
return Ok(());
}
@@ -1431,10 +1525,13 @@ pub async fn ai_chat_stop(
conv.generating = false;
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
drop(session);
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
conversation_id: Some(target),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
return Ok(());
}
// 流式生成中:置位让目标 conv 的 loop 自行收尾
@@ -1468,16 +1565,16 @@ pub async fn ai_chat_stop(
if still_gen {
session.conv(&target_owned).generating = false;
drop(session); // 释放锁后再 emit避免持锁调 runtime emit
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: conv_id,
},
);
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: conv_id,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
});
@@ -1550,12 +1647,15 @@ pub async fn ai_stop_loop(
conv.generating = false;
}
// 暂停态进入前已 save_conversation此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(conversation_id),
});
};
let _ = app.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
Ok("ok".to_string())
}

View File

@@ -57,12 +57,18 @@ pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
///
/// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增
/// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。
pub(crate) const TRUNCATE_THRESHOLD: usize = 50_000;
const TRUNCATE_HEAD: usize = 20_000;
const TRUNCATE_TAIL: usize = 20_000;
///
/// B-260619-02:50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对
/// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码
/// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。
/// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。
pub(crate) const TRUNCATE_THRESHOLD: usize = 8_192;
const TRUNCATE_HEAD: usize = 3_072;
const TRUNCATE_TAIL: usize = 3_072;
/// 落库前对超长 content 做截断(保留头尾各 ~20KB + 中段标注省略字符数)。
/// 50KB 阈值以下原样返回(零开销);按字符而非字节切避免 UTF-8 切坏中文。
/// 落库前对超长 content 做截断(保留头尾各 ~3KB + 中段标注省略字符数 + 总字节数)。
/// 8KB 阈值以下原样返回(零开销);按字符而非字节切避免 UTF-8 切坏中文。
/// 截断提示含原始字节数,让 LLM 明确感知非全文(防它误以为已看全)。
pub(crate) fn truncate_for_persist(content: &str) -> String {
let chars: Vec<char> = content.chars().collect();
if chars.len() <= TRUNCATE_THRESHOLD {
@@ -72,15 +78,15 @@ pub(crate) fn truncate_for_persist(content: &str) -> String {
let tail: String = chars[chars.len() - TRUNCATE_TAIL..].iter().collect();
let omitted = chars.len() - TRUNCATE_HEAD - TRUNCATE_TAIL;
format!(
"{}\n\n[...省略 {} 字符(已截断,完整内容仅在内存态可读)...]\n\n{}",
head, omitted, tail
"{}\n\n[...省略 {} 字符(已截断,{} 字节,仅显示前 8KB,完整内容仅在内存态可读)...]\n\n{}",
head, omitted, content.len(), tail
)
}
/// 落库前对 `ChatMessage.parts` 做截断(F-260614-05 Phase 2a)。
///
/// - `Text` 片:复用 `truncate_for_persist` 头尾截断语义(单 Text 片 > 50KB 才截)。
/// - `Image` 片:base64 通常已 50KB+,落库前替换为占位 `Text` 片
/// - `Text` 片:复用 `truncate_for_persist` 头尾截断语义(单 Text 片 > 8KB 才截)。
/// - `Image` 片:base64 通常已 8KB+,落库前替换为占位 `Text` 片
/// `<image: base64 已省略, 共 N 字节>`,避免大体量图把对话 JSON 撑爆
/// (一张 1MB PNG 的 base64 ≈ 1.3MB 字符串)。原 Image 片仅在内存真相源(ContextManager)
/// 保留,重发时仍带图——对齐现有「持久化视图不污染内存真相源」约定。
@@ -149,9 +155,10 @@ pub(crate) async fn save_conversation(
touch_updated_at: bool,
) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 50KB 时截断头尾各 ~20KB + 中段标注,防大体量结果(read_file 1MB 洞 /
// list_directory 13782 项)落库后每轮重发累积致 token 暴增。仅影响持久化视图,不污染
// 内存真相源(ContextManager)——build_for_request 仍读全量 messages。
// 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
// 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
// 仍读全量 messages。
//
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
@@ -161,7 +168,13 @@ pub(crate) async fn save_conversation(
let mut session = session_arc.lock().await;
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
for m in &mut msgs {
m.content = truncate_for_persist(&m.content);
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
// (实测 tool result read 大文件后落库 Invalid control character @pos3072)。
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
m.content = truncate_for_persist(&m.content);
}
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
if let Some(parts) = m.parts.as_ref() {
@@ -354,9 +367,11 @@ mod tests {
// 头尾各 20k 字符应在结果中
assert!(result.starts_with(&"x".repeat(TRUNCATE_HEAD)));
assert!(result.ends_with(&"x".repeat(TRUNCATE_TAIL)));
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 51000 - 20000 - 20000 = 11000)
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048)
assert!(result.contains("已截断"));
assert!(result.contains("省略 11000 字符"));
assert!(result.contains("省略 3048 字符"));
// B-260619-02:截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文
assert!(result.contains("原 9192 字节"));
// 结果总长应远小于原长(20k 头 + 20k 尾 + 标注)
assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000);
}
@@ -425,4 +440,68 @@ mod tests {
fn truncate_parts_empty_returns_none() {
assert_eq!(truncate_parts_for_persist(&[]), None);
}
// ---------- P0-2: tool role content 不截断(防 JSON 结构破坏) ----------
/// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型
fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage {
df_ai::provider::ChatMessage {
id: None,
role,
content: content.to_string(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: None,
timestamp: None,
}
}
/// save_conversation 的 content 截断决策片段(抽测 role 分支,避免起 DB/锁)。
/// 对 tool role 跳过截断,其余 role 走截断。
fn truncate_decision(m: &mut df_ai::provider::ChatMessage) {
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
m.content = truncate_for_persist(&m.content);
}
}
#[test]
fn tool_role_large_content_not_truncated() {
// tool result 是结构化 JSON,中段截断插裸换行+省略标记会破坏 JSON parse。
// >8192(阈值)的 tool content 必须原样保留(不进 truncate_for_persist)。
let json_like = format!(
"{{\"file\":\"x.rs\",\"content\":\"{}\"}}",
"a".repeat(TRUNCATE_THRESHOLD + 2000)
);
let original = json_like.clone();
let mut m = make_msg(df_ai::provider::MessageRole::Tool, &json_like);
truncate_decision(&mut m);
// 关键断言:tool role 不截断,原样保留(长度与内容完全一致)
assert_eq!(m.content.len(), original.len(), "tool role content 不应被截断");
assert_eq!(m.content, original, "tool role content 原样保留");
// 不含截断标记(证明未进 truncate_for_persist)
assert!(!m.content.contains("已截断"), "tool role 不应含截断标注");
}
#[test]
fn assistant_role_large_content_still_truncated() {
// 对照:assistant role 超阈值仍截断(回归保护)
let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
let mut m = make_msg(df_ai::provider::MessageRole::Assistant, &long);
truncate_decision(&mut m);
assert!(m.content.contains("已截断"), "assistant role 超阈值应截断");
assert!(m.content.len() < long.len(), "assistant role 截断后应变短");
}
#[test]
fn tool_role_short_content_unchanged() {
// 短 tool content:即使进了 truncate_for_persist 也不会改(阈值以下),
// 但此处验证 role 分支本身对短 tool content 也安全(原样保留)。
let short = r#"{"ok":true,"rows":3}"#;
let mut m = make_msg(df_ai::provider::MessageRole::Tool, short);
truncate_decision(&mut m);
assert_eq!(m.content, short);
}
}

View File

@@ -0,0 +1,313 @@
//! 全局事件数据总线 — pub-sub 骨架(载荷 serde_json::Value 透传)
//!
//! 关联设计:docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md
//! 跨端透传:docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md(§2.1 D2/D3)
//!
//! 本模块是 **统一事件数据总线(Unified Event Bus)** 的后端骨架,聚焦 **pub-sub**(发布订阅)
//! 语义。背景:AI 事件经 `app_handle.emit("ai-chat-event", ...)` 散布在各处(55 处/7 文件),
//! 无统一总线,跨模块直接耦合,阻碍跨端透传(df-tunnel)。
//!
//! ## 载荷决策(Phase3 D2 = 全 19 变体透传)
//! 载荷用 `serde_json::Value`(即 AiChatEvent 序列化 JSON),**不用**强类型 enum 子集。
//! 理由:跨端透传需 AiChatEvent 全 19 变体(含 ApprovalRequired/Compressed 等),早期骨架的
//! AiBusEvent 8 变体强类型子集不足以覆盖,且总线在透传场景不该解析业务语义(传输层天职)。
//! 改 Value 后:tunnel subscriber 收 Value 原样转发 miniapp,零变体丢失,零业务耦合。
//!
//! ## 本批范围(L3 骨架,纯新增无行为变化)
//! - `EventBus` 结构(基于 tokio::sync::broadcast,容量 256,载荷 `serde_json::Value`)
//! - `subscribe()` / `publish()` 基础接口
//! - `EVENT_BUS_ENABLED` 开关(预留,默认 on —— 总线骨架自带,接入事件源时此开关控制是否经总线)
//! - pub/sub 基础单元测试
//!
//! ## 不做(留后续批次)
//! - 不接入 55 处 emit 点(批3 双写:publish + 原 app.emit 并行,关开关回退)
//! - 不接 df-tunnel subscriber(Phase3 阶段2:tunnel 注册 subscriber,收 Value 转发 miniapp)
//! - 不实现 request-reply / 流式 reply(设计阶段2/5,本批仅 pub-sub)
//!
//! ## 开关与兜底(渐进可回退)
//! - `EVENT_BUS_ENABLED: bool`(默认 true):接入事件源后,关闭则 publish 静默丢弃(不影响原 emit 路径)。
//! 骨架阶段未接入,无实际效果,预留作接入期的快速回退开关。
//! - publish 返回 `usize`(接收者数量),调用方可忽略;无接收者时不报错(broadcast 语义)。
//! - 慢消费者(broadcast 满):`send` 返 `Err(SendError)`,本骨架 `publish` 静默丢弃并返回 0,
//! 不 panic(对齐设计待决策6「背压/限流」的保守默认,正式背压策略留后续)。
//!
//! ## 与 df-workflow EventBus 关系
//! df-workflow 的 `EventBus`(`crates/df-workflow/src/eventbus.rs`)是工作流节点间专用总线
//! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。
//! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。
use tokio::sync::broadcast;
use super::AiChatEvent;
// ============================================================
// 开关(EVENT_BUS_ENABLED,预留,默认 on)
//
// 机制:接入事件源后,关闭则 EventBus::publish 静默丢弃事件(原 app.emit 路径不受影响,
// 双写桥接由接入批负责,关闭总线仅丢总线副本)。骨架阶段未接入,开关预留作接入期快速回退。
//
// 兜底:即便开关误关,总线静默丢事件不影响原 emit 路径(AiChatEvent 经 app.emit 仍正常推送前端)。
// ============================================================
/// 事件总线开关(预留,默认 on)。
///
/// 接入实际事件源后,`EventBus::publish` 在 `false` 时静默丢弃(返回 0),原 `app.emit`
/// 路径不受影响。骨架阶段未接入,无实际效果。
///
/// dead_code 说明:本批为骨架,EVENT_BUS_ENABLED 暂无消费方(未接入 emit 点);
/// 标 allow 保留作批3 接入期的快速回退开关(零调用方≠垃圾,预留保留)。
#[allow(dead_code)]
pub const EVENT_BUS_ENABLED: bool = true;
/// 默认事件通道容量(对齐 df-workflow EventBus 默认 256)。
///
/// 容量权衡:太小 → 慢消费者丢事件(订阅者消费不及时);太大 → 内存占用。
/// 256 是 broadcast 常见默认值,覆盖典型 AI 流场景(文本片段 + 工具调用 + 心跳混合)。
///
/// dead_code 说明:骨架阶段 EventBus::new() 内联用字面量路径常量,但常量本身供
/// 外部自定义容量场景引用,标 allow 保留(预留)。
#[allow(dead_code)]
pub const DEFAULT_BUS_CAPACITY: usize = 256;
// ============================================================
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
//
// 选 broadcast 而非 mpsc:
// - pub-sub 多订阅者(mpsc 单消费者不满足「多模块订阅同一事件」场景)
// - broadcast 容量固定,慢消费者丢老事件而非阻塞发布者(对齐流式场景:宁可丢老 chunk 不阻塞 LLM 流)
//
// 载荷 serde_json::Value(Phase3 D2):透传 AiChatEvent 全 19 变体原样 JSON,subscriber(tunnel)
// 收到后零解析转发 miniapp。总线不持业务类型知识(传输层天职),扩展 AiChatEvent 变体无需改总线。
//
// Clone 语义:broadcast::Sender 内部 Arc,clone 共享同一通道(多持有者 publish 到同一总线)。
// 对齐设计 §架构「模块订阅 + 发布,无相互 import」—— 各模块持 clone 的 EventBus 即可 pub/sub。
// ============================================================
/// AI 事件总线(pub-sub 域,载荷 `serde_json::Value`,L3 骨架)。
///
/// 基于 `tokio::sync::broadcast`,多订阅者多发布者共享同一通道。
/// - `subscribe()`:订阅,返回 [`broadcast::Receiver`] recv `serde_json::Value`
/// - `publish(payload)`:发布,所有活跃订阅者收到(broadcast 语义)
///
/// 载荷是 AiChatEvent 序列化后的 JSON(Phase3 D2 全 19 变体透传),subscriber 不解析业务语义。
///
/// 容量默认 256(`DEFAULT_BUS_CAPACITY`),可经 [`EventBus::with_capacity`] 自定义。
///
/// 兜底:
/// - 无订阅者时 publish 不报错(broadcast 语义,事件丢弃)
/// - 慢消费者(容量满)publish 返 0(静默丢老事件,不 panic)
/// - `EVENT_BUS_ENABLED=false` 时 publish 静默丢弃(预留开关,接入期回退用)
///
/// dead_code 说明:骨架阶段 emit 点未双写(publish 无调用方);标 allow 保留作批3 接入
/// (零调用方≠垃圾,总线核心结构,批3 接入即消费)。
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct EventBus {
sender: broadcast::Sender<serde_json::Value>,
}
/// 事件订阅者(broadcast::Receiver,serde_json::Value 接收端)。
///
/// dead_code 说明:骨架阶段无外部消费方(批3 接入 tunnel subscriber),标 allow 保留。
#[allow(dead_code)]
pub type EventSubscriber = broadcast::Receiver<serde_json::Value>;
// dead_code 说明(impl 块):骨架阶段 EventBus publish 无调用方(emit 点未双写)
// (批3 接入 emit 点 + tunnel subscriber 后立即消费)。标 allow 覆盖 new/with_capacity/
// subscribe/publish/subscriber_count 全部关联项的 never-used 警告。
// 对齐零调用方原则:预留保留不盲删(批3 接入即消除)。
#[allow(dead_code)]
impl EventBus {
/// 创建默认容量(256)的事件总线。
pub fn new() -> Self {
Self::with_capacity(DEFAULT_BUS_CAPACITY)
}
/// 创建指定容量的事件总线。
///
/// capacity 为 0 会 panic(broadcast::channel 要求 capacity ≥ 1)。
pub fn with_capacity(capacity: usize) -> Self {
let (sender, _) = broadcast::channel(capacity);
Self { sender }
}
/// 订阅事件总线,返回 Receiver。
///
/// 订阅后仅收到订阅时刻之后的 publish(历史事件不补发)。可多次 subscribe 获多个独立 Receiver,
/// 每个 Receiver 各自维护消费进度(broadcast 多消费者语义)。
pub fn subscribe(&self) -> EventSubscriber {
self.sender.subscribe()
}
/// 发布 AiChatEvent 序列化 JSON 到总线。
///
/// 返回值:成功送达的活跃订阅者数量(0 = 无订阅者或容量满丢老事件)。
/// `EVENT_BUS_ENABLED=false` 时静默丢弃返回 0(预留开关,骨架阶段未接入)。
///
/// 注:broadcast::send 是同步方法(非 async),与 df-workflow EventBus::send 的 async 签名
/// 不同 —— 本骨架 publish 同步返回更直接(broadcast::send 内部无 await 点),df-workflow 的
/// async 签名是为对齐 trait 抽象,本总线无此约束故同步。
pub fn publish(&self, payload: serde_json::Value) -> usize {
if !EVENT_BUS_ENABLED {
return 0;
}
// broadcast::send 返 Result<usize, SendError>:
// Ok(n) = 送达 n 个活跃订阅者
// Err(SendError(_)) = 无订阅者(事件丢弃)
// 两种情况都不 panic,Err 视为 0(对齐「无订阅者静默丢弃」兜底)。
self.sender.send(payload).unwrap_or(0)
}
/// 便捷发布:接收强类型 AiChatEvent,内部序列化为 Value 透传(D2 全 19 变体透传)。
///
/// 调用方传 AiChatEvent(业务侧已有强类型对象),本方法内部 `serde_json::to_value`
/// 序列化为 Value 后调 [`publish`](Self::publish)。供 emit 点双写(publish + app.emit),
/// 经 EventBus 透传给 tunnel subscriber(阶段2 接入)。
///
/// 序列化失败兜底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。
pub fn publish_event(&self, event: AiChatEvent) -> usize {
match serde_json::to_value(&event) {
Ok(v) => self.publish(v),
Err(_) => 0,
}
}
/// 当前活跃订阅者数量(broadcast::receiver_count)。
///
/// 诊断/监控用:接入期可观测订阅者是否到位。骨架阶段无实际调用,预留作可观测性扩展。
pub fn subscriber_count(&self) -> usize {
self.sender.receiver_count()
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
// ============================================================
// 单元测试 — pub/sub 基础语义(载荷 serde_json::Value)
//
// 覆盖:
// 1. subscribe 后 publish,receiver 收到 Value(基础 pub-sub)
// 2. 多订阅者都收到(broadcast 多消费者)
// 3. 无订阅者时 publish 不 panic(兜底)
// 4. publish 返回值 = 活跃订阅者数量
// 5. AiChatEvent 形态 Value 透传(含 type tag,模拟真实 AiChatEvent JSON)
//
// 不覆盖(留后续批次接入时):
// - 慢消费者容量满丢老事件(需灌满容量场景,接入期补)
// - 跨模块解耦实际效果(需接入真实 emit 点,批3)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 基础 pub-sub:subscribe 后 publish,receiver 收到 Value 原样。
#[tokio::test]
async fn test_basic_pub_sub() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
// 模拟 AiChatEvent::AiTextDelta 序列化 JSON(type tag discriminator)
let payload = serde_json::json!({
"type": "text_delta",
"conversation_id": "conv-1",
"delta": "hello"
});
let delivered = bus.publish(payload.clone());
assert_eq!(delivered, 1, "publish 应送达 1 个订阅者");
let received = rx.recv().await.expect("receiver 应收到事件");
assert_eq!(received, payload, "Value 应原样透传(零解析)");
assert_eq!(received["type"], "text_delta");
}
/// 多订阅者都收到(broadcast 多消费者语义)。
#[tokio::test]
async fn test_multiple_subscribers() {
let bus = EventBus::new();
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
let payload = serde_json::json!({"type": "heartbeat", "conversation_id": null});
let delivered = bus.publish(payload.clone());
assert_eq!(delivered, 2, "publish 应送达 2 个订阅者");
// 两个 receiver 各自收到(独立消费进度)
let r1 = rx1.recv().await.expect("rx1 应收到");
let r2 = rx2.recv().await.expect("rx2 应收到");
assert_eq!(r1["type"], "heartbeat");
assert_eq!(r2["type"], "heartbeat");
}
/// 无订阅者时 publish 不 panic,返回 0(兜底)。
#[test]
fn test_publish_no_subscribers() {
let bus = EventBus::new();
// 无 subscribe 直接 publish
let delivered = bus.publish(serde_json::json!({"type": "error", "error": "no one listening"}));
assert_eq!(delivered, 0, "无订阅者 publish 应返回 0");
}
/// publish 返回值 = 活跃订阅者数量(subscriber_count 对齐)。
#[test]
fn test_delivered_count_matches_subscribers() {
let bus = EventBus::new();
assert_eq!(bus.subscriber_count(), 0);
let _rx1 = bus.subscribe();
assert_eq!(bus.subscriber_count(), 1);
let _rx2 = bus.subscribe();
assert_eq!(bus.subscriber_count(), 2);
// delivered 应等于 subscriber_count
let delivered = bus.publish(serde_json::json!({"type": "agent_round", "round": 1}));
assert_eq!(delivered, bus.subscriber_count());
}
/// EventBus Clone 后共享同一通道(同一总线多持有者)。
#[tokio::test]
async fn test_clone_shares_channel() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
let bus_clone = bus.clone();
// 经 clone 的实例 publish,原实例订阅的 receiver 也应收到(共享通道)
bus_clone.publish(serde_json::json!({
"type": "completed",
"total_tokens": 100,
"prompt_tokens": 50,
"completion_tokens": 50,
"conversation_id": null
}));
let received = rx.recv().await.expect("clone 共享通道,receiver 应收到");
assert_eq!(received["type"], "completed");
}
/// 全 19 变体形态 Value 均可透传(不限于子集,验证 D2 全透传不丢变体)。
#[test]
fn test_full_variants_passthrough() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
// 覆盖早期 AiBusEvent 子集外的变体(ApprovalRequired/Compressed 等),证明全透传
let events = vec![
serde_json::json!({"type": "approval_required", "conversation_id": "c1"}),
serde_json::json!({"type": "compressed", "conversation_id": "c1"}),
serde_json::json!({"type": "dir_auth_required", "conversation_id": "c1"}),
serde_json::json!({"type": "context_cleared", "conversation_id": "c1"}),
];
for event in &events {
bus.publish(event.clone());
}
for expected in &events {
let received = rx.try_recv().expect("应收到每个变体");
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
}
}
}

View File

@@ -13,6 +13,7 @@
//! - [`prompt`] — 系统提示词构建
//! - [`provider_pool`] — F-260614-04 多 Provider 负载均衡池选择(纯逻辑)
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
//! - [`event_bus`] — L3 全局事件数据总线 pub-sub 骨架(broadcast,渐进第一步,未接入 emit)
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//! - [`augmentation`] — 上下文增强
@@ -28,19 +29,24 @@
pub mod agentic;
pub mod augmentation;
pub mod audit;
pub mod code_intel;
pub mod commands;
pub mod compress;
pub mod conversation;
pub mod event_bus;
pub mod http;
pub mod knowledge_inject;
pub mod prompt;
pub mod provider_pool;
pub mod remote_bridge;
pub mod secret;
pub mod skills;
pub mod stream_recv;
pub mod title;
pub mod tool_registry;
use agentic::conv_state::ConvState;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -50,6 +56,8 @@ use serde::{Deserialize, Serialize};
// RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
use df_ai::context::ContextManager;
use df_ai::context::ContextConfig;
// F-#95 扩展:AiMessageHistory 事件载荷 Vec<ChatMessage>(历史消息同步)。
use df_ai::provider::ChatMessage;
// ============================================================
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
@@ -140,6 +148,24 @@ pub enum AiChatEvent {
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染):
///
/// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`,
/// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息,
/// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。
///
/// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息,
/// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。
/// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 →
/// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt
/// → **孤立响应气泡**。此事件填补该缺口。
///
/// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观
/// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。
AiUserMessage {
message: String,
conversation_id: Option<String>,
},
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
@@ -175,6 +201,125 @@ pub enum AiChatEvent {
AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
AiCompressed { conversation_id: Option<String>, summary: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
/// 「失败就问用户」——LLM 不可靠,故用结构化事件 + 前端求助卡(机制优先 prompt 说教)。
///
/// 字段语义:
/// - `reason`:求助原因(死循环/环境未知/权限不足等可读摘要,前端直接展示)
/// - `context`:已试情况(失败次数/最近错误 sample/卡在哪),供用户判断如何介入
/// - `options`:建议选项(如 [换思路/授权路径/人工接管]),前端渲染为按钮供用户选
/// - `conversation_id`:多对话路由(与其他事件一致)
///
/// 与 AiApprovalRequired 区分:审批=工具执行前确认;求助=AI 主动「我搞不定」。
/// 与 AiMaxRoundsReached 区分:达 max 是被动截断;求助是主动识别卡点。
/// 兜底:开关 CIRCUIT_BREAKER_ENABLED=false 或求助消费方未接 → 后端保留 AiError 终态分支兜底。
AiHelpRequired {
reason: String,
context: String,
options: Vec<String>,
conversation_id: Option<String>,
},
/// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations`
/// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。
///
/// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 双职责:
/// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表)
/// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」)
///
/// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定
/// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。
AiConversationList {
/// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文)
conversations: Vec<ConvSummary>,
},
/// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。
///
/// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` →
/// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。
/// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。
AiMessageHistory {
/// 对话 ID
conversation_id: String,
/// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段)
messages: Vec<ChatMessage>,
},
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
///
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
///
/// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生,
/// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
/// 供前端 union discriminator 直接使用。
AiConvStateChanged {
conv_state: ConvState,
conversation_id: Option<String>,
},
/// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令,
/// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。
///
/// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。
/// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?),
/// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。
AiSkillList {
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
skills: Vec<crate::commands::ai::skills::SkillInfo>,
},
/// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令,
/// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。
///
/// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status
/// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。
///
/// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。
/// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是
/// #[tauri::command] 返回值,必然 impl Serialize)。
AiEntityList {
/// 项目全量列表(list_active,deleted_at IS NULL)
projects: Vec<df_storage::models::ProjectRecord>,
/// 任务全量列表(list_active 等价,未删任务)
tasks: Vec<df_storage::models::TaskRecord>,
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
ideas: Vec<df_storage::models::IdeaRecord>,
},
}
// ============================================================
// F-#95 跨端会话列表同步:会话摘要结构
// ============================================================
/// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。
///
/// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽,
/// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值)
/// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。
///
/// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名
/// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConvSummary {
pub id: String,
pub title: String,
/// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条)
pub last_message: Option<String>,
/// 最近更新时间(ms 数值)
pub updated_at: Option<i64>,
/// 创建时间(ms 数值,可选展示)
pub created_at: Option<i64>,
}
// ============================================================
@@ -604,6 +749,10 @@ pub struct PerConvState {
pub messages: ContextManager,
/// 是否正在生成
pub generating: bool,
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
/// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。
pub conv_state: ConvState,
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
@@ -654,6 +803,7 @@ impl PerConvState {
Self {
messages: ContextManager::new(ContextConfig::default()),
generating: false,
conv_state: ConvState::Idle,
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,

View File

@@ -31,24 +31,31 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
}
}
/// 当前运行环境信息(注入 system prompt,供 LLM 写日期/命令时参考)
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
/// L1 环境感知(治 kms seq26-52 引号地狱):教 AI 避命令行 -c 内联,复杂脚本写文件执行。
///
/// - 日期LLM 无法自行获取当前日期,不注入则写文件日期全靠猜(常从项目已有文件名模式推断出错)
/// - 操作系统LLM 写 shell 命令需知道平台dir vs ls、路径分隔符等
/// - 日期:LLM 无法自行获取,不注入则写文件日期全靠猜
/// - OS + shell:平台对应 shell(Windows=PowerShell / Linux·macOS=bash),AI 写命令需对齐
/// - 执行姿势:复杂脚本(多行/含引号/$变量)写 .ps1/.py 文件用 `powershell -File`/`python` 执行,
/// 避免命令行 -c 内联(cmd/PowerShell 引号转义易出错,kms 实证 20+ 次失败)
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
///
/// 两项合计约 25 token消除一整类系统性错误
fn env_info_line() -> String {
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)
fn env_profile_line() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
let os = if cfg!(target_os = "windows") {
"Windows"
let (os, shell, interp) = if cfg!(target_os = "windows") {
("Windows", "PowerShell", "python(无 python3)、node")
} else if cfg!(target_os = "macos") {
"macOS"
("macOS", "bash/zsh", "python3、node")
} else if cfg!(target_os = "linux") {
"Linux"
("Linux", "bash", "python3、node")
} else {
"Unknown"
("Unknown", "sh", "python3、node")
};
format!("当前日期: {today} | 运行环境: {os}\n\n")
format!(
"当前日期: {today} | 运行环境: {os} | shell: {shell}\n\
执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}\n\n"
)
}
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
@@ -113,9 +120,34 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
/// 非 build_system_prompt 职责 —— 两套并行:全局清单(全貌)+ augmentation(用户本次引用)。
///
/// 注入克制:仅各取最近 20 条,防 context 膨胀。
///
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
build_system_prompt_with_excluded(state, lang, &[], &[]).await
}
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
///
/// 与 [`build_system_prompt`] 的差异(机制而非说教):
/// 1. **去重**:被 @ 的 project/task 已由 augmentation 层精准投影(含 path/正文),清单中
/// 再现会造成同一实体两次入 prompt(实测 build f64dee94 会话 list_projects 8 次 /
/// list_tasks 20 次)。此处把 `excluded_project_ids`/`excluded_task_ids` 命中的条目
/// 从全局清单循环中跳过,被@实体只保留 augmentation 一次。
/// 2. **注明语**:清单末尾按语言追加一行,告知 LLM 「项目已全部列出无需再调 list_projects」/
/// 「任务仅最近 20 条,全量查询请用 list_tasks」,从机制层降低工具与清单重复调用
/// (而非在行为准则里反复说教)。
///
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
/// - 注入克制策略不变:仍各 take(20)。
pub(crate) async fn build_system_prompt_with_excluded(
state: &AppState,
lang: &str,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
) -> String {
let (prefix, proj_label, task_label) = system_prompt_parts(lang);
let mut prompt = env_info_line();
let mut prompt = env_profile_line();
prompt.push_str(prefix);
// 附加当前数据上下文
@@ -123,9 +155,15 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
if !projects.is_empty() {
prompt.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) {
continue;
}
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description));
}
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
prompt.push_str(&projects_listed_note(lang, 20));
}
}
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
@@ -133,15 +171,48 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() {
prompt.push_str(task_label);
// 去重:被 @ 的任务跳过(同项目去重机理)
for tk in tasks.iter().take(20) {
if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue;
}
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description));
}
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang));
}
}
prompt
}
/// 项目清单尾部注明语(中/英)。
///
/// 机制层降重复:直接告知「已全部列出」,LLM 知道再调 list_projects 是冗余。
/// `shown` 取实际注入条数上限 20(避免传运态 list_active 长度,只描述清单本身)。
fn projects_listed_note(lang: &str, shown: usize) -> String {
match lang {
"en" => format!(
"(Listed {} active projects above; the active set is shown, no need to call list_projects again.)\n",
shown
),
_ => format!(
"(以上为活跃项目清单,无需重复调用 list_projects)\n"
),
}
}
/// 任务清单尾部注明语(中/英)。
///
/// 机制层降重复:明确「仅最近 20 条 + 全量查询走 list_tasks」,避免 LLM 误以为清单即全量
/// 而反复调 list_tasks 校验(实测单会话 20 次)。
fn tasks_listed_note(lang: &str) -> String {
match lang {
"en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool.)\n".to_string(),
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具)\n".to_string(),
}
}
/// 上下文压缩 system prompt 模板F-15 §4.2,纯函数无 IO
///
/// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留
@@ -284,4 +355,20 @@ mod tests {
assert!(en.contains("topic words"));
assert!(en.contains("breaks the context"));
}
// 项目/任务清单注明语(中/英)存在
#[test]
fn listed_notes_present_zh_en() {
let zh_proj = projects_listed_note("zh-CN", 20);
assert!(zh_proj.contains("无需重复调用 list_projects"));
let en_proj = projects_listed_note("en", 20);
assert!(en_proj.contains("no need to call list_projects"));
let zh_task = tasks_listed_note("zh-CN");
assert!(zh_task.contains("仅显示最近 20 条任务"));
assert!(zh_task.contains("list_tasks"));
let en_task = tasks_listed_note("en");
assert!(en_task.contains("20 most recent tasks"));
assert!(en_task.contains("list_tasks"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Manager};
use futures::StreamExt;
use tracing::warn;
@@ -226,10 +226,13 @@ pub(crate) async fn stream_llm(
Ok(chunk) => {
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta {
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let ev = AiChatEvent::AiTextDelta {
delta: chunk.delta,
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
if let Some(tc_deltas) = &chunk.tool_calls {
for tc_delta in tc_deltas {
@@ -326,9 +329,12 @@ pub(crate) async fn stream_llm(
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
_ = heartbeat.tick() => {
// 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiHeartbeat {
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
let ev = AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()),
});
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
// 3) 即时停止唤醒(B-260615-14):用户点 stop → ai_chat_stop 置 stop_flag 后 notify_one()。
// stream.next() 正阻塞等 chunk 时立即被唤醒,不再等 30s 心跳 tick 或 120s idle timeout。

View File

@@ -1021,9 +1021,15 @@ fn register_file_tools(
let more = (skip + page.len()) < line_count;
(page.join("\n"), Some(skip), more)
} else {
// 无 offset 默认前 500 行(大文件翻页友好,避免一次灌入全量)
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
const DEFAULT_PREVIEW_LINES: usize = 500;
let page: Vec<&str> = content.lines().take(DEFAULT_PREVIEW_LINES).collect();
let limit = args["limit"].as_u64()
.unwrap_or(DEFAULT_PREVIEW_LINES as u64)
.min(2000) as usize;
let page: Vec<&str> = content.lines().take(limit).collect();
let more = line_count > page.len();
(page.join("\n"), None, more)
};
@@ -1036,6 +1042,50 @@ fn register_file_tools(
})
})},
);
registry.register(
"read_symbol", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false)]),
RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?;
let full = args["full"].as_bool().unwrap_or(false);
let kind_hint = args["kind"].as_str();
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
let file_hash = compute_file_hash(&metadata);
let mut content = String::new();
if let Err(e) = file.read_to_string(&mut content).await {
if e.kind() == std::io::ErrorKind::InvalidData {
return Ok(serde_json::json!({
"path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash,
"fallback": true, "reason": "binary",
"suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
}));
}
anyhow::bail!("读取文件失败: {}", e);
}
// 调 code_intel 纯函数(三态 + 兜底,不 panic)
Ok(crate::commands::ai::code_intel::read_symbol(
&content, &file_hash, path, symbol, full, kind_hint,
))
})
})},
);
registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
@@ -1108,8 +1158,10 @@ fn register_file_tools(
Err(_) => None, // 不存在(新建)
};
if let Some(parent) = target.parent() {
// FR-S8 残余:parent 必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
if !parent.starts_with(&workspace_root()) {
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
@@ -1415,7 +1467,8 @@ fn register_file_tools(
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.starts_with(&workspace_root()) {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
@@ -1552,7 +1605,8 @@ fn register_file_tools(
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
let to_target = std::path::Path::new(to_path);
if let Some(parent) = to_target.parent() {
if !parent.starts_with(&workspace_root()) {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
@@ -1835,6 +1889,140 @@ fn register_file_tools(
}))
})),
);
// ── 环境感知 (Low risk, L1 agent 元能力层) ──
// detect_environment:AI 主动探测运行环境的「眼睛」(设计 §2.1 补救式感知)。
// 返回 JSON:{ os, default_shell, python_path, node_path }。
//
// 为什么需要:prompt.rs env_profile_line 只注入静态 OS+shell+解释器提示(编译期 cfg!),
// AI 不知 python 真实路径/版本/node 是否安装/GBK 还是 UTF-8,撞墙后才补救。
// 本工具让 AI 在「要做某环境相关操作前」(如跑 python 脚本/装依赖)主动探测,
// 而非靠 prompt 教(机制优先 prompt 说教,见设计核心原则)。
//
// 接入断路器(设计 §2.2):后续断路器触发(run_command 连撞 N 次引号/路径错)后,
// 系统提示会引导 AI 调本工具刷新环境认知(本工具不主动调断路器,仅提供能力)。
//
// 安全:Low risk 纯只读探测。which/where 只读 PATH 不写不删,无副作用。
// 不走白名单(探测系统 PATH,非 workspace 文件),不审批(只读)。
//
// 开关(env_probe_enabled,设计 §2.1 §8):DEVFLOW_ENV_PROBE_ENABLED=off 关闭探测,
// 返回静态 profile(编译期 OS + 默认 shell,与 env_profile_line 一致),兜底降级旧行为。
// 默认 on(空值/未设/on/1/true 均视为开)。开关 off 不致工具调用失败,只退化能力。
//
// 兜底(设计 §2.1):探测失败(超时/python 未装/which 不可用)→ 该字段 null + errors 收集原因,
// 不阻断工具返回。LLM 据此知「该能力不可用」,回退静态 profile 语义。
registry.register(
"detect_environment",
"探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
df_ai::ai_tools::object_schema(vec![]),
RiskLevel::Low,
Box::new(|_args: serde_json::Value| Box::pin(async move {
// 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
let os = if cfg!(target_os = "windows") {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else {
"Unknown"
};
// 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
// 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
// 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
let default_shell = if cfg!(target_os = "windows") {
"PowerShell"
} else {
"bash"
};
// 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
// 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
Err(_) => true, // 未设 = 默认开
};
let mut errors: Vec<String> = Vec::new();
let (python_path, node_path) = if probe_enabled {
// Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
// probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
// Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
let py = if cfg!(target_os = "windows") {
probe_executable("python", "where python", &mut errors).await
} else {
let p3 = probe_executable("python3", "which python3", &mut errors).await;
if p3.is_some() {
p3
} else {
// python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
probe_executable("python", "which python", &mut errors).await
}
};
let node = if cfg!(target_os = "windows") {
probe_executable("node", "where node", &mut errors).await
} else {
probe_executable("node", "which node", &mut errors).await
};
(py, node)
} else {
// 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
(None, None)
};
Ok(serde_json::json!({
"os": os,
"default_shell": default_shell,
"python_path": python_path,
"node_path": node_path,
"probe_enabled": probe_enabled,
"errors": errors,
}))
})),
);
}
/// 探测可执行文件路径(which/where 风格),8s 超时兜底。
///
/// 执行 `cmd`(如 "which python3" / "where node")取 stdout 首行(trim 后)作为路径。
/// 失败(命令不存在/解释器未装/超时/退出码非 0)记 `error` 到 `errors` 并返回 None。
///
/// 超时兜底(对齐 workflow-cargo-timeout-wrap 记忆):防 which/where 偶发卡死拖垮会话。
/// 用 df_execute::shell::execute(对齐 run_command 同源,Windows 无控制台窗口 + kill_on_drop),
/// shell_type 用 default(Windows=PowerShell / Unix=sh)。
async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec<String>) -> Option<String> {
let request = ShellRequest {
command: cmd.to_string(),
working_dir: None,
env: HashMap::new(),
// 8s 超时:which/where 通常 <1s,8s 足够且不拖垮会话。超时视为不可用(记错返 None)。
timeout_secs: Some(8),
shell_type: Default::default(),
};
match execute(request).await {
Ok(result) => {
// 退出码非 0:which/where 未找到目标(如 python 未装)→ 正常情况,记错返 None
if !matches!(result.exit_code, Some(0)) {
errors.push(format!(
"{}: 探测命令退出码 {:?}({})",
label, result.exit_code, cmd
));
return None;
}
let first_line = result.stdout.lines().next().map(|s| s.trim().to_string());
match first_line {
Some(p) if !p.is_empty() => Some(p),
_ => {
errors.push(format!("{}: 探测输出为空({})", label, cmd));
None
}
}
}
Err(e) => {
errors.push(format!("{}: 探测失败({}): {}", label, cmd, e));
None
}
}
}
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
@@ -2262,7 +2450,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================
/// build_ai_tool_registry 应注册恰好 30 个工具(18 data + 11 file + 1 http),且工具名集合稳定。
/// build_ai_tool_registry 应注册恰好 31 个工具(18 data + 12 file + 1 http),且工具名集合稳定。
///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -2275,17 +2463,18 @@ mod tests {
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
// 总量基线:30(18 data + 11 file + 1 http)。拆分前后必须一致。
// 总量基线:31(18 data + 12 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
assert_eq!(
registry.len(),
30,
"工具总数应为 30(18 data + 11 file + 1 http),实际 {}", registry.len()
31,
"工具总数应为 31(18 data + 12 file + 1 http),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 18 个(持 db):CRUD/状态机/工作流
// file 层 11 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep
// file 层 12 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![
// ── data 层 (18) ──
@@ -2295,12 +2484,13 @@ mod tests {
"run_workflow", "delete_task", "create_idea",
"delete_project", "restore_project", "purge_project",
"list_trash", "get_project_count", "get_task_count",
// ── file 层 (11) ──run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621
// ── file 层 (12) ──run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
// detect_environment 新增 L1 环境感知 设计 §2.1
"read_file", "list_directory", "write_file",
"patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command",
"grep",
"grep", "detect_environment",
// ── http 层 (1) ──
"http_request",
];
@@ -2475,6 +2665,73 @@ mod tests {
fs::remove_dir_all(&tmp).ok();
}
// ============================================================
// read_file 分页 limit 尊重测试BUG-260623-01 回归守护)
//
// read_file handler 无 offset 分支旧实现固定 take(500),忽略 LLM 传入的 limit。
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
// 修复:无 offset 时也读 args[limit](对齐有 offset 分支语义),未传默认 500。
//
// 经完整 AiToolRegistry.execute 调用 read_file handler(端到端覆盖 offset/limit 分支),
// 而非内联重复分页逻辑——避免测试与实现两处分页代码漂移(实现改测试不红)。
// ============================================================
/// 造 600 行文件,无 offset + limit=15 → returned_lines=15(limit 生效,旧 bug 返 500)。
#[tokio::test]
async fn test_read_file_no_offset_respects_limit() {
let tmp = std::env::temp_dir().join(format!("df_readfile_limit_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
// 600 行(>500 默认,确保 limit=15 与默认 500 行为可区分;15 < 500 锁定 limit 被读)
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
let file = tmp.join("big.txt");
fs::write(&file, body).unwrap();
// persistent 存词法形态(对齐 is_authorized:candidate 经 strip_verbatim 归一词法去 \\?\ 前缀;
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
let mut persistent = std::collections::HashSet::new();
persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs);
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多");
fs::remove_dir_all(&tmp).ok();
}
/// 造 600 行文件,无 offset 无 limit → 默认 500 行(DEFAULT_PREVIEW_LINES)。
#[tokio::test]
async fn test_read_file_no_offset_no_limit_defaults_500() {
let tmp = std::env::temp_dir().join(format!("df_readfile_default_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
let file = tmp.join("big.txt");
fs::write(&file, body).unwrap();
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
let mut persistent = std::collections::HashSet::new();
persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
let registry = build_ai_tool_registry(&db, &allowed_dirs);
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多");
fs::remove_dir_all(&tmp).ok();
}
// ============================================================
// patch_file 三模式共用底层测试F-260617-01
// 纯函数 apply_line_range / resolve_anchor_to_lines不依赖 fs/async/锁。

View File

@@ -8,7 +8,7 @@ use tauri::State;
use df_ai::provider::LlmProvider;
use df_types::types::{new_id, Priority};
use df_ideas::capture::Idea;
use df_storage::crud::is_unique_constraint_err;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectRecord};
use crate::state::AppState;
@@ -32,16 +32,29 @@ fn default_priority() -> i32 {
1
}
/// 列出灵感,可选按 status 过滤(指定状态走 query 走白名单索引列,否则全量)
/// 列出灵感
///
/// **双路径向后兼容**(F-260621-02):
/// - 旧调用方仅传 `status`(`ideaApi.list(status)`)→ 转 IdeaQuery 仅带 status,走
/// `list_by_query`(白名单 status 列 WHERE),与原 `query("status", s)` 等价。
/// - 新调用方传 `query`(`ideaApi.list(query)`)→ 多条件(status/keyword/order_by/limit/offset)。
/// - 两者都不传 → 等价全量(`list_by_query` 空 query 走默认 created_at DESC,与 list_all 等价)。
///
/// `query` 优先于 `status`(二者同传时以 query 为准,避免重复过滤语义冲突)。
#[tauri::command]
pub async fn list_ideas(
state: State<'_, AppState>,
status: Option<String>,
query: Option<IdeaQuery>,
) -> Result<Vec<IdeaRecord>, String> {
match status {
Some(s) => state.ideas.query("status", &s).await.map_err(err_str),
None => state.ideas.list_all().await.map_err(err_str),
}
let q = match query {
Some(q) => q,
None => IdeaQuery {
status,
..Default::default()
},
};
state.ideas.list_by_query(&q).await.map_err(err_str)
}
/// 列出指定灵感的评估历史(version DESC,最新版本在前)。

View File

@@ -17,6 +17,7 @@ use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description,
normalize_path, DiscoveredProject,
};
use df_storage::crud::ProjectQuery;
use df_storage::models::ProjectRecord;
use crate::state::AppState;
@@ -38,10 +39,27 @@ pub struct CreateProjectInput {
pub stack: Option<String>,
}
/// 列出未删除项目(过滤回收站)
/// 列出未删除项目(过滤回收站)
///
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectRecord>, String> {
state.projects.list_active().await.map_err(err_str)
pub async fn list_projects(
state: State<'_, AppState>,
query: Option<ProjectQuery>,
) -> Result<Vec<ProjectRecord>, String> {
match query {
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active,
// 与历史行为完全等价(列表上 list_by_query 空也等价,但保留 list_active 分支明示向后兼容契约)。
Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
|| q.order_by.is_some()
|| q.limit.is_some()
|| q.offset.is_some() =>
{
state.projects.list_by_query(q).await.map_err(err_str)
}
_ => state.projects.list_active().await.map_err(err_str),
}
}
/// 创建项目,返回完整记录

View File

@@ -4,6 +4,7 @@ use serde::Deserialize;
use tauri::State;
use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery;
use df_storage::models::TaskRecord;
use crate::state::AppState;
@@ -30,18 +31,41 @@ fn default_priority() -> i32 {
2 // medium — 新任务默认中优先级(非 high),符合常识
}
/// 列出未删除任务(deleted_at IS NULL),可按 project_id 过滤
/// 列出未删除任务(deleted_at IS NULL)。
///
/// 软删对标 projects:默认过滤回收站(语义与 list_projects 一致)。
/// 有 project_id 时走 list_active_by_project(SQL 下推 WHERE deleted_at IS NULL AND
/// project_id = ?,避免旧 list_active 全表扫 + 内存 retain 的 N×M 热点);
/// 无 project_id 时 fallback list_active(列全部未删任务)。
/// 注意:不能用通用 query 宏——它不带 deleted_at 过滤,会把回收站任务也返回。
/// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id)
/// 必须等价改造前的全量行为,零破坏。
///
/// F-260621-02 查询维度补全(机制优先 prompt 说教):
/// - 优先走 `query`(`TaskQuery` 多维动态 WHERE):status(P1 下沉)/ keyword(P2 LIKE)
/// /project_id/priority/assignee/order_by/limit/offset 任意组合。
/// - 旧调用方仍可直接传 `project_id`(单维度),此时走 list_active_by_project
/// (SQL 下推,命中 idx_tasks_project_id),保持等价行为。
/// - query 与 project_id 同时传时:query 优先(其内含 project_id 维度,更全),project_id 忽略。
/// - 均不传时:全量未删任务(等价改造前 list_active 行为)。
///
/// status 值合法性兜底:TaskStatus::is_valid 拦截非法值(拼写错/越界),非法值返回 Err
/// (与 update_task 的 status 校验一致),不静默返回空结果误导调用方。
#[tauri::command]
pub async fn list_tasks(
state: State<'_, AppState>,
project_id: Option<String>,
query: Option<TaskQuery>,
) -> Result<Vec<TaskRecord>, String> {
if let Some(q) = &query {
// status 值兜底校验(非法值早 Err,不进 DB 层)
if let Some(status) = &q.status {
if !TaskStatus::is_valid(status) {
return Err(format!(
"非法 status 值 {:?},合法值: {:?}",
status,
TaskStatus::valid_values()
));
}
}
return state.tasks.list_by_query(q).await.map_err(err_str);
}
// 旧调用方:单维度 project_id(SQL 下推,fallback 等价改造前行为)
let tasks = match project_id {
Some(pid) => state
.tasks

View File

@@ -6,6 +6,8 @@ mod state;
use tauri::{Emitter, Listener, Manager};
use state::AppState;
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
use df_tunnel::TunnelClient;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -86,6 +88,135 @@ pub fn run() {
});
});
// Phase3 跨端隧道集成:tunnel connect relay + subscriber 透传 ai_event_bus → miniapp
// + on_command 下行路由(miniap 指令 → remote_bridge → Tauri command)。
// spawn 后台 task:device_id/relay_url 解析(持久 KV,无则默认)→ subscriber 透传
// → on_command 回调注册 → connect(失败非阻断,可后续手动重连)。
let tunnel_app = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = tunnel_app.state::<AppState>();
// device_id:KV 取,无则生成 UUID v4 落库(持久,relay 按 device_id 路由配对)。
let device_id = match state.settings.get("device_id").await {
Ok(Some(id)) if !id.is_empty() => id,
_ => {
let id = uuid::Uuid::new_v4().to_string();
let _ = state.settings.set("device_id", &id).await;
id
}
};
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
// settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON):
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底,
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
let relay_url = state
.settings
.get("relay_url")
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
.map(|raw| {
serde_json::from_str::<String>(&raw)
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
})
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
let token = "devflow-relay-default-token".to_string();
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
let mut rx = state.ai_event_bus.subscribe();
let tunnel_for_sub = state.tunnel.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("[tunnel-sub] subscriber task 启动,透传 ai_event_bus → relay");
while let Ok(value) = rx.recv().await {
if let Err(e) = tunnel_for_sub.send_raw_event(value).await {
tracing::warn!("[tunnel-sub] send_raw_event 失败(可能未连接): {}", e);
}
}
tracing::info!("[tunnel-sub] subscriber task 退出(rx 关闭)");
});
// on_command 回调:miniapp 下行指令 → remote_bridge 路由 Tauri command。
// CommandHandler = Arc<dyn Fn(Value) -> BoxFuture<'static, ()> + Send + Sync>
// (tunnel.rs:45)。回调返 BoxFuture,Box::pin 构造 + 类型标注对齐签名。
//
// 借用处理:`handle_remote_command(payload, app, state)` 的 state 是
// `State<'_, AppState>`(newtype `&AppState`),借用 app。若先 `let state =
// app.state()` 再 move app 入函数,E0505(借用的 app 被 move)。
// 正解:同一表达式内 `app.clone()` move clone、`app.state()` 借用原 app,
// 原 app 不 move(仅被 state 借用到调用结束),借用检查通过。
let cmd_app = tunnel_app.clone();
let on_command: df_tunnel::tunnel::CommandHandler = std::sync::Arc::new(
move |payload: serde_json::Value| {
let app = cmd_app.clone();
Box::pin(async move {
crate::commands::ai::remote_bridge::handle_remote_command(
payload,
app.clone(),
app.state::<AppState>(),
)
.await;
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = ()> + Send>,
>
},
);
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => tracing::info!(
"[tunnel] 连接 relay 成功 device_id={} url={}",
device_id,
relay_url
),
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
}
// ── supervisor:断线自动重连(根治 device 离线) ──
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
let mut backoff = std::time::Duration::from_secs(2);
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if !state.tunnel.is_connected() {
tracing::info!(
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
relay_url,
device_id
);
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => {
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
backoff = std::time::Duration::from_secs(2);
}
Err(e) => {
tracing::warn!(
error = %e,
backoff_secs = backoff.as_secs(),
"[tunnel-supervisor] 重连失败,退避后重试"
);
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
}
}
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![

View File

@@ -366,6 +366,16 @@ pub struct AppState {
pub resolvers: Arc<ResolverRegistry>,
/// AI 会话状态
pub ai_session: Arc<Mutex<AiSession>>,
/// L3 AI 事件总线(pub-sub 域,独立于工作流 event_bus)。
///
/// 批2b:总线入 AppState 就绪(基建就位)。emit 点双写 + 前端订阅**留批3**:
/// 现阶段无后端订阅者(工作流独立域)+ 前端经 ai-chat-event 已收 → 全双写空转,
/// 待真实消费者(跨端透传/多模块订阅)出现再针对性双写(渐进接入,机制优先)。
///
/// dead_code:批2b 仅入 AppState,emit 点未双写(publish 无调用方);
/// 标 allow 保留作批3 接入(零调用方≠垃圾,预留保留)。
#[allow(dead_code)]
pub ai_event_bus: crate::commands::ai::event_bus::EventBus,
// ── 知识库 ──
/// 知识库 Repo
pub knowledge: KnowledgeRepo,
@@ -402,6 +412,12 @@ pub struct AppState {
/// workspace_root 始终在白名单(向后兼容)。
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
// ── 跨端隧道(Phase3 Layer1)──
/// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp
/// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析
/// device_id/relay_url 后 connect(失败非阻断,可后续手动重连)。
/// Arc 包裹:setup task(subscriber + on_command 回调)与 AppState 共享同一客户端。
pub tunnel: std::sync::Arc<df_tunnel::WsTunnelClient>,
}
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
@@ -649,6 +665,10 @@ impl AppState {
ai_messages: AiMessageRepo::new(&db),
ai_tool_executions: AiToolExecutionRepo::new(&db),
ai_session: Arc::new(Mutex::new(AiSession::new())),
// L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。
ai_event_bus: crate::commands::ai::event_bus::EventBus::new(),
// Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
knowledge: KnowledgeRepo::new(&db),
knowledge_events: KnowledgeEventsRepo::new(&db),
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),

View File

@@ -4,7 +4,6 @@
"identifier": "top.1216.devflow",
"build": {
"beforeDevCommand": "bun dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},

View File

@@ -1,10 +1,21 @@
import { invoke } from '@tauri-apps/api/core'
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord } from './types'
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord, IdeaQuery } from './types'
export const ideaApi = {
/** 列出灵感,可选按 status 过滤draft/pending_review/promoted 等) */
list(status?: string): Promise<IdeaRecord[]> {
return invoke('list_ideas', { status: status ?? null })
/**
* 列出灵感。
*
* **双签名向后兼容**(F-260621-02):
* - `list(status?)`:旧签名,仅状态过滤(等价 `list({ status })`)。
* - `list(query?)`:新签名,多条件(status/keyword/order_by/limit/offset)走后端 WHERE。
*
* 参数为空 → 后端返回全量(created_at DESC,与旧 list_all 等价)。
*/
list(queryOrStatus?: IdeaQuery | string): Promise<IdeaRecord[]> {
if (typeof queryOrStatus === 'string') {
return invoke('list_ideas', { status: queryOrStatus ?? null, query: null })
}
return invoke('list_ideas', { status: null, query: queryOrStatus ?? null })
},
create(input: CreateIdeaInput): Promise<IdeaRecord> {

View File

@@ -46,9 +46,29 @@ export interface ImportBatchResult {
items: ImportBatchItemResult[]
}
/**
* 项目列表查询条件(F-260621-02 P2/P3)。
* 全可选;不传(或全 undefined)= 当前全量行为(deleted_at IS NULL + created_at DESC)。
* 对齐后端 df_storage::crud::ProjectQuery。
*/
export interface ProjectQuery {
/** 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词) */
keyword?: string
/** 排序:格式 "col" / "col asc" / "col desc",默认 created_at desc(列名白名单) */
order_by?: string
/** 返回上限(后端钳制 [0,200],undefined = 不加 LIMIT 全量) */
limit?: number
/** 偏移量(undefined = 0,配合 limit 分页) */
offset?: number
}
export const projectApi = {
list(): Promise<ProjectRecord[]> {
return invoke('list_projects')
/**
* 列出未删除项目。
* F-260621-02:可选 query(关键词/排序/分页)。不传 = 当前全量行为(向后兼容)。
*/
list(query?: ProjectQuery): Promise<ProjectRecord[]> {
return invoke('list_projects', { query })
},
create(input: CreateProjectInput): Promise<ProjectRecord> {

View File

@@ -1,9 +1,21 @@
import { invoke } from '@tauri-apps/api/core'
import type { TaskRecord, CreateTaskInput } from './types'
import type { TaskRecord, CreateTaskInput, TaskQuery } from './types'
export const taskApi = {
list(projectId?: string): Promise<TaskRecord[]> {
return invoke('list_tasks', { projectId: projectId ?? null })
/**
* 列出任务。
*
* **双签名向后兼容**(F-260621-02):
* - `list(projectId?)`:旧签名,仅项目过滤(等价 `list({ project_id: projectId })`)。
* - `list(query?)`:新签名,多条件(status/keyword/project_id/priority/assignee/...)走后端 WHERE。
*
* 参数为空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。
*/
list(queryOrProjectId?: TaskQuery | string): Promise<TaskRecord[]> {
if (typeof queryOrProjectId === 'string') {
return invoke('list_tasks', { projectId: queryOrProjectId ?? null, query: null })
}
return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null })
},
get(id: string): Promise<TaskRecord> {

View File

@@ -34,6 +34,23 @@ export interface CreateIdeaInput {
source?: string
}
/**
* 灵感多条件查询入参(F-260621-02,对齐后端 IdeaQuery)。
*
* 所有字段可选;全空 → 后端返回全量(created_at DESC)。
* - `status`:状态精确匹配(后端 WHERE,取代前端 filter)
* - `keyword`:title/description LIKE %kw%(后端 LIKE,取代前端 filter)
* - `order_by`:白名单 created_at/updated_at/priority/status/score(后端白名单校验防注入)
* - `limit`/`offset`:分页(limit 钳制上限 200)
*/
export interface IdeaQuery {
status?: string | null
keyword?: string | null
order_by?: string | null
limit?: number | null
offset?: number | null
}
/**
* 灵感评估历史记录(后端 list_idea_evaluations 返回)。
* 每次评估(启发式/LLM/降级)落一行,version 递增,按 version DESC 返回。
@@ -152,6 +169,32 @@ export interface CreateTaskInput {
idea_id?: string
}
/**
* 任务多条件查询入参(F-260621-02,对齐后端 TaskQuery)。
*
* 所有字段可选;全空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。
* - `project_id`:项目精确匹配(命中 idx_tasks_project_id)
* - `status`:状态精确匹配(P1 下沉,命中 idx_tasks_status,取代前端 filter)
* - `priority`:优先级精确匹配(0=critical..3=low)
* - `assignee`:负责人精确匹配
* - `keyword`:title/description LIKE %kw%(P2,取代前端 filter)
* - `order_by`:白名单 created_at/updated_at/priority/status(后端白名单校验防注入),恒 DESC
* - `limit`/`offset`:分页(limit 钳制上限 500)
*
* 注:project_id/priority/assignee/order_by/limit/offset 为基建就绪,当前视图仅用
* status(P1)+keyword(P2);P3 排序分页 UI 待数据量增长。
*/
export interface TaskQuery {
project_id?: string | null
status?: string | null
priority?: number | null
assignee?: string | null
keyword?: string | null
order_by?: string | null
limit?: number | null
offset?: number | null
}
// ============================================================
// 工作流
// ============================================================
@@ -287,6 +330,21 @@ export interface ModelConfig {
*/
export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
/**
* L2 统一状态机 — 对话生命周期状态字面量(对齐后端 ConvState serde rename_all=snake_case,
* crates/df-ai/.../agentic/conv_state.rs)。5 态:
* - idle:空闲,可接新请求
* - generating:生成中(含审批挂起期,审批是 loop 内暂停点非独立态)
* - stopping:停中(用户已点停止,loop 收尾中,瞬态)
* - error:出错(用户可重试回 generating)
* - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态)
*
* 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发
* AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为
* 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。
*/
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
/** AI 聊天事件(后端 emit 的结构conversation_id 用于多对话流式路由) */
export type AiChatEvent = ({
type: 'AiTextDelta'; delta: string
@@ -315,6 +373,11 @@ export type AiChatEvent = ({
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
} | {
type: 'AiError'; error: string; error_type?: AiErrorType
} | {
// F-260622-02 跨端用户消息同步:远程(miniapp)发来的用户消息,后端 route_send_message
// 放行后 publish_event 广播。桌面 useAiEvents 收到后补 user 气泡(桌面本地 sendMessage 是
// 乐观渲染不发此事件)。handleEvent 去重防双气泡(末条已是同 content user 跳过)。
type: 'AiUserMessage'; message: string
} | {
type: 'AiAgentRound'; round: number
} | {
@@ -326,6 +389,20 @@ export type AiChatEvent = ({
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
} | {
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
// 字段对齐后端 AiChatEvent::AiHelpRequired:reason(原因摘要)/context(已试情况)/options(建议选项)。
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
type: 'AiHelpRequired'; reason: string; context: string; options: string[]
} | {
// L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
// 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。CONV_STATE_ENABLED 门控:off 时
// 后端不 emit,前端 conv_state 收不到 → 消费方须回退旧 streaming/generating bool 判断(双轨过渡)。
type: 'AiConvStateChanged'; conv_state: ConvState
} | {
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
type: 'AiStreamRetry'; attempt: number; max_attempts: number

View File

@@ -57,6 +57,10 @@
</div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
@@ -136,6 +140,7 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useProjectStore } from '../stores/project'

View File

@@ -0,0 +1,168 @@
<template>
<!--
通用分页器(F-260621-02 P3 分页 UI 基建)
纯客户端切片分页:父级传 total(总数) + v-model:page(当前页,1 ) +
v-model:pageSize(每页条数),本组件只管 UI,不做数据请求(数据量 ~15 前瞻基建)
向后兼容铁律:pageSize=0 不分页(全量),整组件隐藏,等价改造前行为
用户从下拉选具体 N 触发分页(切换 page 重置到第 1 页由父级 watch 控制)
-->
<div v-if="pageSize > 0 && totalPages > 0" class="paginator">
<div class="page-size-group">
<span class="paginator-label">{{ $t('common.pagination.perPage') }}</span>
<select
class="page-size-select"
:value="pageSize"
@change="onPageSizeChange(($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
<span class="paginator-total">
{{ $t('common.pagination.total', { n: total }) }}
</span>
</div>
<div class="page-nav">
<button
class="page-btn"
:disabled="page <= 1"
@click="goTo(page - 1)"
>{{ $t('common.pagination.prev') }}</button>
<button
v-for="p in pageNumbers"
:key="p"
class="page-btn page-num"
:class="{ active: p === page }"
@click="goTo(p)"
>{{ p }}</button>
<button
class="page-btn"
:disabled="page >= totalPages"
@click="goTo(page + 1)"
>{{ $t('common.pagination.next') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
/** 数据总数(父级传当前过滤后的总数) */
total: number
/** 每页条数;0 = 不分页(整组件隐藏,等价旧行为) */
pageSize: number
/** 当前页码(1 起) */
page: number
/** 每页条数候选(默认 10/20/50) */
pageSizeOptions?: number[]
}>(), {
pageSizeOptions: () => [10, 20, 50],
})
const emit = defineEmits<{
'update:page': [page: number]
'update:pageSize': [size: number]
}>()
// 总页数:pageSize=0 已被 v-if 拦截,此处保证 pageSize ≥ 1。
const totalPages = computed(() => {
if (props.pageSize <= 0) return 0
return Math.max(1, Math.ceil(props.total / props.pageSize))
})
// 简单页码序列:数据量小,全列(1..totalPages),不做省略窗口(7 页内可读)。
// 若数据量未来增长到几十页,再加窗口折叠逻辑。
const pageNumbers = computed(() => {
const arr: number[] = []
for (let i = 1; i <= totalPages.value; i++) arr.push(i)
return arr
})
function goTo(p: number) {
if (p < 1 || p > totalPages.value) return
emit('update:page', p)
}
function onPageSizeChange(val: string) {
const n = Number(val)
if (!Number.isFinite(n) || n < 0) return
emit('update:pageSize', n)
// 切换每页条数后页码可能越界,重置到第 1 页(父级也可自行 watch 处理)
emit('update:page', 1)
}
</script>
<style scoped>
.paginator {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: var(--df-gap-grid, 16px);
padding: 8px 4px;
font-size: 12px;
color: var(--df-text-dim);
}
.page-size-group {
display: flex;
align-items: center;
gap: 6px;
}
.paginator-label { white-space: nowrap; }
.page-size-select {
padding: 3px 8px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 12px;
cursor: pointer;
}
.paginator-total { color: var(--df-text-dim); }
.page-nav {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
min-width: 28px;
padding: 3px 8px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.page-btn:hover:not(:disabled) {
background: rgba(108, 99, 255, 0.06);
color: var(--df-text);
}
.page-btn.active {
background: var(--df-accent);
color: #fff;
border-color: var(--df-accent);
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 600px) {
.paginator { justify-content: center; }
}
</style>

View File

@@ -46,9 +46,10 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
@@ -299,6 +300,34 @@ function scrollToFirstPending(): void {
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):监听 pending 审批卡到达事件,
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
let _unlistenPending: UnlistenFn | null = null
// 论证 workflow 回归中优:防卸载竞态——await listen 解析前组件已卸载(HMR/快速切会话)时,
// onBeforeUnmount 跑到 _unlistenPending 仍 null 跳过 unlisten → 孤儿监听器泄漏。disposed 标志兜底。
let _disposed = false
onMounted(async () => {
const fn = await listen('ai-pending-arrived', () => {
// nextTick 等 pending_approval 类挂到 DOM 后再查(scrollToFirstPending 查 .ai-tool-card--pending_approval)。
// 多个 ToolCardList 实例(每条 AI 消息一个)各自 listen,querySelector 只命中自身 root 内的
// pending 卡,非含 pending 卡的实例查 null no-op,天然过滤不串扰。
nextTick(() => scrollToFirstPending())
})
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
if (_disposed) { fn(); return }
_unlistenPending = fn
})
onBeforeUnmount(() => {
_disposed = true
_unlistenPending?.()
_unlistenPending = null
})
defineExpose({ collapseInactive, scrollToFirstPending })
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */

View File

@@ -84,8 +84,26 @@
</template>
</div>
</div>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle( conv_state 未收到回退旧 streaming=false)显发送按钮双轨过渡不强制全替旧 bool -->
<button
v-if="store.state.streaming"
v-if="stopBtnState === 'stopping'"
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
:title="$t('aiChat.stopping')"
disabled
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else-if="stopBtnState === 'retry'"
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
:title="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg width="14" height="14" 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 1 1-2.12-9.36L23 10"/></svg>
</button>
<button
v-else-if="stopBtnState === 'stop'"
class="ai-send-btn ai-send-btn--stop"
:title="$t('aiChat.stopGenerating')"
@click="store.stopChat"
@@ -119,6 +137,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
const props = defineProps<{
@@ -237,6 +256,28 @@ async function onDrop(e: DragEvent): Promise<void> {
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
//
// 三态映射(对齐后端 ConvState 语义):
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
//
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
if (cs === 'generating' || cs === 'compressed') return 'stop'
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
return store.state.streaming ? 'stop' : 'idle'
})
// ── 技能 `/` 联想 ──
const skillOpen = ref(false)
const skillIndex = ref(0)
@@ -912,6 +953,18 @@ defineExpose({
.ai-send-btn--stop:hover {
background: #d63a3f;
}
/* L2 停止中态:disabled 灰显(防重复点),复用 :disabled 通用规则(opacity 0.4) */
.ai-send-btn--stopping {
cursor: not-allowed;
}
/* L2 停止失败可重试态:橙色边框提示(区别于红色停止),hover 加深 */
.ai-send-btn--retry {
background: #e5484d;
color: #fff;
}
.ai-send-btn--retry:hover {
background: #d63a3f;
}
.ai-input-hint {
font-size: 10px;
color: var(--df-text-dim);

View File

@@ -0,0 +1,105 @@
<template>
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断后展示的操作卡
触发:后端断路器连续同类失败达阈值 emit AiHelpRequired( guard.reset,loop 终止)
useAiEvents handleLifecycleEvent pendingHelp(模块级 ref) 本卡渲染
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref
reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)
option 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
路径授权走 DirAuthDialog 既有链路)机制优先 prompt 说教:代码强制熔断 + 结构化求助,
"教 AI 失败就问用户"(LLM 不可靠) -->
<div v-if="helpActive" class="ai-help-required">
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
<div class="ai-help-required-options">
<button
v-for="opt in helpActive.options"
:key="opt"
class="ai-help-required-btn"
@click="handleSelectOption(opt)"
>{{ opt }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingHelp } from '../../composables/ai/useAiEvents'
// toast 经 emit 转父(保持单一 toast 源,与 MaxRoundsCard 一致)
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 当前挂起的求助(有值即后端发过 AiHelpRequired)。pendingHelp 由 useAiEvents 翻:
// AiHelpRequired 设值;AiCompleted/AiError 清值(重新发送或错误中断后求助卡消失)。
//
// helpActive 既做守卫(挂起会话===当前展示会话)又做模板取值源:返回 PendingHelp 非空或 null,
// 模板 v-if="helpActive" 取真值后 Vue 类型收窄为非 null,可直接访问 .reason/.context/.options。
// 显示守卫:仅当挂起求助属当前展示会话时显(F-09 多会话并发不串台)。
// 注:不依赖 streaming——求助事件后端已 guard.reset(generating=false),若依赖 isGenerating
// 会导致卡片不显(对齐 MaxRoundsCard F-260620 去 streaming 依赖的根治理)。
const helpActive = computed(() => {
const p = pendingHelp.value
if (p && p.conversationId === store.state.activeConversationId) return p
return null
})
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
function handleSelectOption(opt: string): void {
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
pendingHelp.value = null
emit('toast', { msg: t('aiChat.helpRequiredAck'), type: 'info' })
}
</script>
<style scoped>
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
.ai-help-required {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
border-radius: var(--df-radius);
}
.ai-help-required-text {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-help-required-hint {
font-size: 10.5px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-all;
}
.ai-help-required-options {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.ai-help-required-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
}
.ai-help-required-btn:hover { filter: brightness(1.1); }
</style>

View File

@@ -27,7 +27,7 @@
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingMaxRounds } from '../../composables/ai/useAiEvents'
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
import { aiApi } from '../../api'
const emit = defineEmits<{
@@ -47,6 +47,20 @@ const isViewingGenerating = computed(() =>
store.isGenerating(store.state.activeConversationId),
)
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
// 理论不并存,此处宽放以防边界)。
const isViewingGeneratingState = computed(() => {
const cs = getConvState(store.state.activeConversationId)
if (cs === 'generating' || cs === 'compressed') return true
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
return isViewingGenerating.value
})
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
@@ -56,7 +70,7 @@ const maxRoundsActing = ref(false)
const showMaxRoundsCard = computed(() =>
!!pendingMaxRounds.value
&& pendingMaxRounds.value === store.state.activeConversationId
&& isViewingGenerating.value,
&& isViewingGeneratingState.value,
)
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。

View File

@@ -425,7 +425,19 @@ watch(() => store.state.currentText, (text) => {
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
if (store.state.streaming && text) scheduleStreamParse(text)
else onContentChange()
else {
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):currentText 归零(新轮 AiAgentRound
// 清空 / 生成结束)时,同步清上一轮流式分块缓存 streamingBlocks + lastStreamText。
// 原:streaming watch(:435)仅在 streaming 翻 false 时清;但 agent 多轮连续生成时
// streaming 保持 true,新轮 AiAgentRound push 新空气泡 + currentText='',streamingBlocks
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
if (!text) {
streamingBlocks.value = []
lastStreamText = ''
}
onContentChange()
}
})
watch(mdReady, (ready) => {
if (ready && store.state.streaming && store.state.currentText) {
@@ -844,6 +856,17 @@ watch(() => store.state.activeConversationId, () => {
wasNearBottom = true
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
// 清 currentText 但实例级 streamingBlocks 未同步;② restoreGeneratingState(useAiWindow)
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
if (streamingBlocks.value.length) {
streamingBlocks.value = []
lastStreamText = ''
}
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
@@ -1008,7 +1031,7 @@ defineExpose({
<!-- 文本内容流式或固定Markdown 渲染 -->
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 -->
<div
v-if="item.msg.content || (isLastAi(item.msg) && store.state.currentText)"
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"

View File

@@ -1,18 +1,21 @@
<template>
<!-- 高级设置(AI 自动执行 / 日志级别) -->
<!-- 高级设置(AI 自动执行范围 / 日志级别) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.sectionAdvanced') }}</h2>
</div>
<div class="general-settings">
<!-- F-#97:三档 low/medium/alllow=仅低风险自动(等价旧 autoExecute=false);
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
<label class="toggle">
<input type="checkbox" v-model="settings.autoExecute" @change="markSaved('autoExecute')" />
<span class="toggle-slider"></span>
</label>
<select v-model="settings.autoExecuteMode" class="setting-select" @change="onAutoModeChange">
<option value="low">{{ $t('settings.autoModeLow') }}</option>
<option value="medium">{{ $t('settings.autoModeMedium') }}</option>
<option value="all">{{ $t('settings.autoModeAll') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
<select v-model="settings.logLevel" class="setting-select" @change="markSaved('logLevel')">
<select v-model="settings.logLevel" class="setting-select" @change="markSaved">
<option value="error">Error</option>
<option value="warn">Warning</option>
<option value="info">Info</option>
@@ -20,35 +23,130 @@
</select>
</SettingRow>
</div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死删除不适合确认接管) -->
<Transition name="confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div>
</div>
</div>
</Transition>
</section>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { reactive, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
// ============================================================
// 高级域 — AI 自动执行开关 / 日志级别
// 拆自 GeneralPanel,忠实迁移原逻辑:原 GeneralPanel 这两项为 session 内 reactive
// (无 appSettings 持久化/watch),此处保持一致(无 watch/IPC),避免阶段2 引入行为变更
// 阶段5:SettingRow 封装行布局,控件变更后调 markSaved 显"已保存"
// 后续若需持久化,单独建改进项
// 高级域 — AI 自动执行范围(三档) / 日志级别
// F-#97(2026-06-22):原 autoExecute boolean checkbox → autoExecuteMode select 三档
// (low/medium/all),KV 持久化 df-ai-auto-execute-mode,默认 low
// low 必须等价旧行为(autoExecute=false 仅 Low 风险自动):后端 audit/mod.rs 按 mode
// 判定 Med/High 是否走自动分支,low 时 Med/High 仍进审批块,行为与旧完全一致
// all(完全接管)选时二次确认:防误选致无审批全自动执行高危操作。
// ============================================================
const { t } = useI18n()
const appSettings = useAppSettingsStore()
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) }
// confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText
// 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 三档合法值 + 脏值白名单兜底:KV 被外部改库为非法值(空串/非三档)时回退 low,
// 防 <select v-model> 无匹配 option 致控件空白(后端 audit 脏值 fail-safe 降级审批,前端需对齐可视)。
const VALID_MODES = ['low', 'medium', 'all'] as const
type AutoMode = (typeof VALID_MODES)[number]
function clampMode(raw: string): AutoMode {
return (VALID_MODES as readonly string[]).includes(raw) ? (raw as AutoMode) : 'low'
}
const settings = reactive({
autoExecute: false,
// 三档 low/medium/all,默认 low(=旧 autoExecute=false 现状),KV 持久化 df-ai-auto-execute-mode。
// clampMode 兜底脏值。
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
logLevel: 'info',
})
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
function markSaved(key: 'autoExecute' | 'logLevel') {
const map = { autoExecute: rowAutoExecute.value, logLevel: rowLogLevel.value }
map[key]?.markSaved()
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
// 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。
let lastConfirmedMode: AutoMode = settings.autoExecuteMode
// all(完全接管)二次确认:防误选致无审批全自动执行高危操作(删文件/执行任意命令)
// 选 all → 弹确认 → 确认落 KV + markSaved + 更新 lastConfirmedMode;取消恢复 lastConfirmedMode(不落 KV)。
// 非 all 切换:直接落 KV + 更新 lastConfirmedMode(watch 兜底同值无副作用)。
async function onAutoModeChange() {
if (settings.autoExecuteMode === 'all') {
const msg = t('settings.autoModeAllConfirmTitle') + '\n\n' + t('settings.autoModeAllConfirmContent')
const ok = await confirmDialog(msg, t('common.confirm'))
if (!ok) {
settings.autoExecuteMode = lastConfirmedMode // 取消恢复原档位(非写死 low)
return
}
}
appSettings.set('df-ai-auto-execute-mode', settings.autoExecuteMode)
lastConfirmedMode = settings.autoExecuteMode
rowAutoExecute.value?.markSaved()
}
// watch 兜底:非 all 切换直接落 KV + 更新 lastConfirmedMode(all 走 onAutoModeChange 确认后落,
// 避免 watch 抢先持久化未确认的 all 值)。onAutoModeChange 已落 KV 时此 watch 重复 set 同值无副作用。
watch(
() => settings.autoExecuteMode,
(v) => {
if (v !== 'all') {
appSettings.set('df-ai-auto-execute-mode', v)
lastConfirmedMode = v
}
},
)
// logLevel 行即时反馈(无参:logLevel 仅 session 内 reactive 无持久化,markSaved 只显「已保存」反馈)
function markSaved() {
rowLogLevel.value?.markSaved()
}
</script>
<style scoped>
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
.setting-select/.toggle/.toggle-slider + SettingRow(封装 .setting-row/.setting-info/
.setting-label/.setting-desc/.setting-control)。本 Section 无特有 scoped 样式。 */
.setting-select + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control)。本 Section 无特有 scoped 样式。
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-box {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 20px;
min-width: 280px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>

View File

@@ -1,20 +0,0 @@
<template>
<!-- 通用设置(聚合壳:外观 + 性能 + 高级)
阶段2 UX 重构: GeneralPanel 内联内容拆为 3 Section
本阶段(阶段2)Settings.vue 仍引 GeneralPanel,故此处作聚合壳组合 3 Section 保持不破;
下一阶段(阶段3 master-detail)Settings.vue 改为直接引各 Section ,本聚合壳即可弃用 -->
<AppearanceSection />
<PerformanceSection />
<AdvancedSection />
</template>
<script setup lang="ts">
import AppearanceSection from '@/components/settings/AppearanceSection.vue'
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
</script>
<style scoped>
/* 阶段2 UX 重构:聚合壳仅组合 3 Section,无自身样式。
各 Section 复用 settings.css 全局 .panel 等 class,Section 之间间距由父 .settings-grid 控制。 */
</style>

View File

@@ -54,7 +54,7 @@
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
// security 安全(allowed_dirs)
// advanced 高级(autoExecute/logLevel + 连接)
// advanced 高级(autoExecuteMode/logLevel + 连接)
// ============================================================
import { computed, ref, watch } from 'vue'

View File

@@ -42,7 +42,7 @@ export interface SettingIndexEntry {
* performance 性能(global-concurrency/per-conv-concurrency/
* max-iterations/max-retries)
* security 安全(allowed_dirs)
* advanced 高级(autoExecute/logLevel + 连接)
* advanced 高级(autoExecuteMode/logLevel + 连接)
*/
export const SETTINGS_INDEX: SettingIndexEntry[] = [
// ===== 外观 =====
@@ -82,7 +82,8 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
{ category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' },
// ===== 高级 =====
{ category: 'advanced', key: 'autoExecute', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
// F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名
{ category: 'advanced', key: 'autoExecuteMode', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
{ category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' },
{ category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' },
]

View File

@@ -20,7 +20,7 @@
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event'
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -29,7 +29,7 @@ import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearA
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
import type { AiChatEvent, AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
@@ -45,7 +45,8 @@ const appSettings = useAppSettingsStore()
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
// L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -121,6 +122,52 @@ export interface PendingDirAuth {
}
export const pendingDirAuths = ref<PendingDirAuth[]>([])
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
//
// agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。
// 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。
// 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号,
// 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId,
// 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。
// 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。
export interface PendingHelp {
reason: string
context: string
options: string[]
conversationId: string | null
}
export const pendingHelp = ref<PendingHelp | null>(null)
// L2 统一状态机(批2 1c):per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。
//
// 模块级 reactive Map(convId → ConvState)直接 import 读(同 pendingMaxRounds/pendingHelp 模式,
// 不进 store.state,与生成生命周期语义独立于消息/会话列表)。停止按钮/MaxRoundsCard 读之
// 派生三态(generating/stopping/error),旧 streaming/generatingConvs bool 双轨过渡保留——
// conv_state 未收到时(CONV_STATE_ENABLED=off 或后端老版)消费方回退旧 bool 判断(兜底)。
//
// 写收敛:仅本模块 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 等触发刷新侧栏) */
export function notifyConversationChanged() {
emit('ai-conversation-changed', {})
@@ -148,6 +195,13 @@ export function flushCurrentText() {
break
}
}
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):回填后立即自清 currentText。
// 原:清空依赖调用方(AiAgentRound/AiCompleted/AiError/AiHelpRequired 各跟一行 currentText='')。
// 竞态:任一新增 flush 调用点漏清,或事件乱序致渲染先于调用方清空,全局单例 currentText
// 残留 → MessageList 渲染侧 isLastAi(msg)&&currentText 把残留文本渲到新气泡 → 重叠堆叠。
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
// currentText='' 对已清空值幂等,无副作用。
state.currentText = ''
}
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
@@ -266,16 +320,23 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
return true
}
case 'AiMaxRoundsReached':
case 'AiMaxRoundsReached': {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
// BUG-260623-06: NO_RESET 只跳 reset 不 clear,达 max 前活跃事件(delta/tool)设的 timer 仍走,
// 130s 后触发 onStreamTimeout → generating=true(后端保持)+ tool completed → 误报
// "工具已执行完成,后续回复中断"(实测用户报"对话完成后等一会弹出")。
// 修:对齐 AiApprovalRequired/AiDirAuthRequired(等用户场景均 clear),达 max 也 clear watchdog。
// 用户点继续(ai_continue_loop→后端续跑→delta reset)/停止(ai_stop_loop→AiCompleted clear)时重计。
clearStreamWatchdog(event.conversation_id || undefined)
// TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
return true
}
case 'AiDirAuthRequired': {
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
@@ -324,6 +385,11 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
// 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。
startApprovalTimer(event.id, event.tool, 'path')
}
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):path 类审批卡归一进 ToolCard
// 内联后,可能落入同名工具≥2 的折叠分组(ToolCardList group-hidden display:none)对用户
// 不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默 authorizeDir('deny')→ 误显
// "用户拒绝" → 用户全程未见审批入口即卡死。通知 ToolCardList 自动展开折叠组 + scroll。
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
// 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。
@@ -416,6 +482,8 @@ function handleToolEvent(event: AiChatEvent): boolean {
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
// 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。
startApprovalTimer(event.id, event.name, 'risk')
// BUG-260624-02:同 AiDirAuthRequired,risk 类 pending 卡落折叠分组不可见时自动展开 + scroll。
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
@@ -453,6 +521,61 @@ function handleToolEvent(event: AiChatEvent): boolean {
}
}
/** L2 状态机域:消费 AiConvStateChanged,维护 per-conv conv_state 真相源。
*
* 非终止态(generating/stopping/compressed):仅写 convStates,不动看门狗/旧 bool
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。
* 终止态(idle/error):
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
* 返回值:true=已处理(命中 case);false=未命中。 */
function handleConvStateEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiConvStateChanged') return false
const convId = event.conversation_id || state.activeConversationId
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
const cs = 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
}
/** 跨端用户消息域:F-260622-02 miniapp→桌面 user 气泡同步。
*
* 仅处理 AiUserMessage 一个事件。语义:远程(miniapp)发来的用户消息经后端 publish_event
* 广播到 ai_event_bus,桌面收到后补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件,
* 故桌面 store 不会有这条 user 气泡,需要此事件填补,否则桌面只看到孤立 assistant 响应气泡)。
*
* 去重(防双气泡):若末条消息已是同 content 的 user(理论上桌面本地已乐观 push 过,
* 或 miniapp 自身乐观 push 后又被此事件回灌),跳过不重复 push。MVP 按末条 + content 比对
* 足够覆盖单会话场景;极端情况下两条不同 user 恰巧同 content 紧邻不会丢(末条不同才 push)。
*
* 返回值:true=命中 AiUserMessage;false=其他事件。 */
function handleUserMessageEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiUserMessage') return false
// 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
const last = state.messages[state.messages.length - 1]
if (last && last.role === 'user' && last.content === event.message) {
return true
}
state.messages.push({
id: `user-${nextMsgId()}`,
role: 'user',
content: event.message,
timestamp: Date.now(),
})
return true
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) {
@@ -463,11 +586,19 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
state.currentText = ''
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
state.generatingConvs.delete(event.conversation_id || '')
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)。
convStates.delete(event.conversation_id || '')
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
@@ -523,6 +654,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
state.generatingConvs.delete(event.conversation_id || '')
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。
if (event.conversation_id) convStates.set(event.conversation_id, 'error')
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
@@ -533,6 +668,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
const errConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
@@ -558,6 +697,47 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
return true
}
case 'AiHelpRequired': {
// L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
// 显 reason + options 按钮供用户选。
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers()
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
state.generatingConvs.delete(event.conversation_id || '')
state.currentText = ''
state.agentRound = 0
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
state.pendingApprovals = []
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。
const helpConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
const helpConv = event.conversation_id || ''
if (helpConv) {
localStorage.removeItem(`df-ai-gen-${helpConv}`)
localStorage.removeItem(`df-ai-text-${helpConv}`)
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
pendingHelp.value = {
reason: event.reason,
context: event.context,
options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null,
}
void loadConversations()
notifyConversationChanged()
return true
}
default:
return false
}
@@ -574,19 +754,34 @@ export function handleEvent(event: AiChatEvent) {
state.activeConversationId = convId
void appSettings.set('df-ai-active-conv', convId)
}
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
if (handleConvStateEvent(event)) return
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
// 不影响其他态(handleUserMessageEvent 去重幂等)。
if (handleUserMessageEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) {
if (event.type === 'AiCompleted' || event.type === 'AiError') {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
state.generatingConvs.delete(convId || '')
void loadConversations()
}
return
}
// 标记正在生成的对话(完成/错误事件除外)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
state.generatingConvs.add(convId)
// L2 双轨过渡:会话进入生成(活跃事件到达)时,清 convStates 中可能残留的 error 项,
// 避免上一轮错误态 conv_state 未收敛致停止按钮错显"重试"(此时实际已在生成)。
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已更新;此处兜底 off/老后端。
convStates.delete(convId)
}
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。

View File

@@ -47,6 +47,19 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
*/
export function onStreamTimeout(convId?: string) {
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
// (AiCompleted 走 per-conv _watchdogTimers Map)路径不对齐,clear 传 convId 时只清 Map 不动 legacy,
// 致正常收尾(AiCompleted 已清 streaming)后 legacy timer 残留,130s 到期触发本函数补
// "⚠ 工具已执行完成,后续回复中断"误报气泡(实测:用户报"回复完后输出该报错")。
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
const stillGenerating = convId
? state.generatingConvs.has(convId)
: state.streaming
if (!stillGenerating) {
clearStreamWatchdog(convId)
return
}
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
@@ -108,6 +121,9 @@ export function resetStreamWatchdog(convId?: string) {
if (old) clearTimeout(old)
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
_watchdogTimers.set(convId, timer)
// 根治(TD-260621-01 遗留):切到 per-conv 时清 legacy(doSend 启动的 legacy timer 与活跃事件
// per-conv 共存期,legacy 是残留)。防 AiCompleted clear(convId) 清不到 legacy 致 130s 误报。
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
} else {
// 禁动域 fallback:行为对齐旧版单 timer。
if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
@@ -126,6 +142,10 @@ export function clearStreamWatchdog(convId?: string) {
if (convId) {
const timer = _watchdogTimers.get(convId)
if (timer) { clearTimeout(timer); _watchdogTimers.delete(convId) }
// 根治(TD-260621-01 遗留):doSend reset 走 legacy,AiCompleted clear 走 per-conv Map,clear 清不到
// legacy 致 130s 残留误报。批2 单 loop 阶段 legacy 单例只属当前会话,clear(convId) 兜底清 legacy 安全;
// 批3+ 多会话落地时 useAiSend reset 迁 per-conv + 删 legacy fallback,此兜底随之移除。
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
} else {
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
}

View File

@@ -98,6 +98,9 @@ export default {
// ── Send ──
stopGenerating: 'Stop generating',
// L2 状态机停止按钮三态(ConvState 派生)
stopping: 'Stopping',
stopFailedRetry: 'Stop failed, click to retry',
// ── Input hint ──
inputHint: 'Enter to send · Shift+Enter for newline',
@@ -145,6 +148,9 @@ export default {
continueLoopFailed: 'Continue failed: {msg}',
stopLoopFailed: 'Stop failed: {msg}',
// ── L1 help protocol (aichat experience & agent capability redesign §2.3, 2026-06-21): circuit-breaker help card ──
helpRequiredAck: 'Choice noted. Please rephrase your request to try another approach / continue',
// ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ──
dirAuthRequired: 'Path authorization required',
dirAuthHint: '{tool} wants to access: {path}',

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