文档: 无人值守专项设计档+todo 销账(#10/#11/#12), Web 规范合规批(App/视图/组件样式)

This commit is contained in:
lxy
2026-10-06 12:13:39 +08:00
parent 04db8c0d3b
commit 0dfb50be9b
29 changed files with 419 additions and 84 deletions
+14
View File
@@ -0,0 +1,14 @@
# DevFlow 工作规范
## 任务看板自动领单(最高优先级纪律)
1. **会话启动即读板**:任何会话开工前,先读 `docs/todo.md`——按优先级领任务:🔴 高危项 > B 批次/编号项 > C 类候选。
2. **批收敛即回读**:每完成一个任务批(提交后),重读看板领下一项——挂机/连续工作模式下每批必回读,禁凭记忆连续开工。
3. **领单动作**:领单后将条目改为 `[→]`(进行中),完成后改 `[x]` 并附验收命令输出(G4);无法完成的改 `[!]` 附卡点,禁静默跳过。
4. **🔴 门禁(G2)**:看板存在未清零 🔴 条目时,只允许领 🔴 销账任务,新功能任务禁领(除非用户明示)。
5. **提交纪律(G3/G4)**:提交描述与 diff 机械对应;一笔一事或逐件列全;分组提交按看板标注的主题分组。
6. **汇报实测**:任何进度/完成汇报,先跑 `git log -1 --format="%h %s"` 与 `git status --short | wc -l` 实测再报,禁凭记忆报状态。
7. **看板顶部「挂机必读」警示**:启动时若存在,视为最高优先级上下文覆盖。
## 其他规范
- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。
@@ -0,0 +1,44 @@
# 无人值守可靠性专项设计(2026-10-06 swarm 产出)
> 对象:会话侧挂机护栏四条 + 任务侧 V49 断点续跑收尾。标准:合理·兼容·优雅·可扩张。
> 侦察/设计产出:temp/V49断点续跑设计-20261006.md(详设)+ 本档(总纲与任务单)。
## 一、本质定性
挂机≠无人值守:挂机是行为模式,无人值守是它最危险的子场景(无人工兜底)。
今天三案例:09:48 桌面纠偏(在场,安全)/ 11:23 汇报失实(离机,自报≠事实)/ 09:51 跨端互斥(正确但话术吞因)。
结论:在场靠「过程可见」,离机靠「机制护栏」——本专项只做机制,不做提示词说教。
## 二、会话侧护栏四条(落点全部现成,最小增量)
### H1 汇报附 git 实测
落点:chat.rs:919-941 单一发出点——组装 summary 前调 tools/git.rs exec_git_exit 取 branch/HEAD/未提交计数,追加「实测附录」段。
收益:飞书 watcher 与桌面任务卡零改动同享(单一事实源)。
### H2 token 预算闸
落点:主 loop 抄 sub_agent.rs:346 round_budget_stop 三闸(轮数/token/挂钟),挂 agentic/mod.rs:1115 for 头部(tokens.total() 现成)。
超限走既有 emit_task_parked("token_budget")——前端词表已预留,零新契约。
### H3 外部干预感知
落点:mod.rs:1119 轮首——loop_epoch 之外轻量 git 实测(HEAD 是否仍为自己上次记录),变更即 emit_task_parked("external_change")。
ConvStateStore.transition 唯一写口,补干预事件 emit。
### H4 产出审计
落点:AiToolCallStarted/Completed 已全量双写 event_bus——加审计 subscriber(event_bus.rs:157)或挂 audit 落库点,不动 loop。
## 三、任务侧 V49(详见 temp/V49断点续跑设计-20261006.md)
核心选型:①防覆盖=独立单列 UPDATE+摘除 SET 列(单写者根除,非乐观锁)②list_parked 补唯一 IPC ai_list_recoverable(完备真相查询,兼飞书投影源)③续跑新命令 ai_recover_parked(进程死后复活与进程内流断路径不同,禁混一个命令)。
状态机:LoopState{Idle,Running,Parked}(持久层,跨重启真相)/ ConvState{Idle,Generating,Stopping,Error}(进程内)分层;写点收敛 6 处全走 set_loop_state 单方法。
新识别 P4:重启后 parked 会话无 DB→内存重建+重 spawn 路径——闭环最后一块。
## 四、与异步架构(13/14/16/17)兼容性
- H1 单点改动:飞书/桌面零改动 ✔
- H2 走既有 emit_task_parked:前端零新契约 ✔
- 13 过程流式白得事件源:AiTaskProgress(每 5 轮,mod.rs:1119)已存在,飞书 refresher 补消费即可
- 14 委托引擎的 watcher 与 H1/H3 同通道,零竞态新增 ✔
- 16 纠偏注入与 H3 互补(人在场=16,离机=H3) ✔
## 五、实施序(4 批次,S/M/L/S 全可独立交付)
B1(S):H1 汇报 git 实测 + 看门狗口径修正(inflight 覆盖 spawn 窗口)
B2(M):H2 token 三闸 + H4 审计 subscriber
B3(M):V49 防覆盖定稿+回归单测 + ai_list_recoverable IPC + 桌面「可恢复」入口
B4(L):ai_recover_parked 重启恢复路径 + H3 外部干预感知
+57 -5
View File
@@ -1,5 +1,8 @@
# DevFlow 工作看板
> ⚠️ **挂机必读(2026-10-06 10:50 仓库状态变更)**:今早 10:13 的三笔提交(cec4d11/84df9b5/622507c)已被外部退回(G2 🔴门禁/G3 描述不达标),当前 HEAD=03dc27b,全部改动在工作区未提交。11:23 汇报中「已提交」的说法与事实不符。**重做要求见文末退回段;每次汇报前先跑 `git log -1` 实测再报。**
> 来源:`docs/02-架构设计/滚动规范/功能决策记录-2026-06-14.md`「需求与待办」+ `PROGRESS.md` 各 Sprint 遗留,2026-06-14 汇总去重 + 代码核对修正。
> 互操作:执行走 mission-control,回写 mission_id;审查走 cr;发布走 publish-*。
> 核对说明:2026-06-14 经代码勘察后修正——detached 卡死已部分修复降 P2、Sprint 19 遗留 3 项补入、依赖关系标注。
@@ -16,9 +19,9 @@
> 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。
> 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md)
- [ ] P2-1 全局两刀:`:focus-visible` 兜底 outline(outline:none 19 处/14 文件大半裸奔)+弹层 `overscroll-behavior:contain`(全仓 0 命中覆盖面最广,约 12+ 弹层)——2 条全局 CSS 收敛两类
- [x] P2-1 全局两刀 ✅(2026-10-06 本批:① global.css `:focus-visible` 兜底 outline 2px accent——全仓 outline:none 25 处散点不逐处改,键盘导航触发鼠标点击不显;② 弹层 `overscroll-behavior:contain` 滚动锁——实证类名清单 modal-overlay 10 处/TopBar 四面板 inline-list/ai-more-menu/confirm-box,选择器挂在 global.css 注释注明来源防漂移)
- [ ] P2-2 高频可点 div 键盘化:ToolCard:11 卡头/MessageList:1423 轨迹头/AuditLog:67,119 审计行(@click div 全仓 56 处,去 mask 合规项约 20 真缺口,高频改 button 其余 tabindex+keydown)
- [ ] P3-1 transition:all 33 处/22 文件→机械替换具体属性(color/background/border/transform,sed 级批量低风险)
- [x] P3-1 transition:all ✅(2026-10-06 本批:全仓 37 处/24 文件归零——逐处看 hover 态实动属性机械替换为 background/color/border-color/box-shadow/transform/opacity 具体属性清单,不禁用布局属性意外动画;vue-tsc EXIT 0)
- [ ] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化
- 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+
@@ -1013,7 +1016,56 @@ graph TD
> 附:截图实证单会话 206 轮/1500 万 tokens——C 批次(上下文管理)紧迫性最强活证,随 C-r0 优先消化。
### 🔴 提交后跟进(2026-10-05 23:0x 走查发现,cb260ab 已入库但未修)
10. [ ] **loop_state 对新会话/编排会话静默失效(高)**:首次发送 conv 未落库 UPDATE 0 行,懒创建硬编码 loop_state: None(conversation.rs:534)——无人值守任务重启后无「可恢复」提示,断点续跑核心场景空转。修:首消息即落库 conv 或懒创建继承 loop_state;入口早退(key 失败/provider 超时)补清 running;list_parked 补消费方(侧栏「可恢复」入口)。验收:新会话发任务→重启→出现可恢复提示。
11. [ ] 【中】conversation_repo.rs:817 注释承诺防覆盖与实现矛盾(loop_state 已进 update_full SET 列)——lost-update 窗口,改回独立更新或注释改实话。
12. [ ] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键(TopBar.vue:117 tooltip 裸键名)+ChatInput 围栏冲突/256KB UTF-16 口径——随批修。
10. [x] **loop_state 对新会话/编排会话静默失效(高)** ✅修复(2026-10-06,提交「修复: loop_state 懒创建继承与入口早退清理」):懒创建继承 running(conversation.rs)+入口早退清残留(agentic/mod.rs)+list_parked 消费方=TopBar「可恢复」分区(V49 读侧)。验收:cargo check EXIT 0+vue-tsc EXIT 0。
11. [x] 【中】conversation_repo.rs:817 注释矛盾 ✅修复(2026-10-06 同批):update_full 剥离 loop_state 列,封 lost-update 窗口,注释与实现一致。
12. [x] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键 ✅修复(2026-10-06 同批):zh/en 补键。
> 以上为 72 文件走查结论(报告要点 23:0x 已交群),提交规范 18 笔全合规,质量项按本清单跟进。
## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置)
13. [x] **①过程流式** ✅ 2026-10-06 实施:run_stream_refresher 消费面扩容(AiTextDelta + AiAgentRound + AiToolCallStarted/Completed 四事件,conv 路由过滤共用)——过程流不刷屏:卡片改「头部一行摘要 + 正文增量」结构,头行=轮次 + ⚙当前工具执行中/已完成 N 步(历史步骤折叠为计数,决策价值过滤);过程事件标脏强制重刷(纯工具轮无文本也有"活着"信号,治 206 轮长任务卡片静止猜卡死);签名去重防同 tick 事件风暴空刷;超限保尾截断口径不变。4 单测锁格式契约(首轮无头行/工具执行中/轮间空档/截断)。验收:cargo check EXIT 0 + feishu 7 passed。参照 u-claw 进度卡治理;桌面过程视图同步对齐见「桌面端能力同步清单」。
14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。
15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。
16. [x] **纠偏注入通道**(用户 2026-10-05 深夜点名,飞书对话补充消息目前只能排队不能注入运行中任务)✅ 2026-10-06 实施:引擎层 `inject_runtime_message`(chat.rs,活跃守卫 conv_states.is_active → 短锁 push per_conv.messages → save_conversation 增量落库,loop 下轮 build_for_request 自然带上,u-claw 补拉同构);飞书侧 handle_message 白名单后加注入旁路(chat_map 命中且 conv 活跃 → 注入 + 卡片确认「下一轮生效」,非活跃 Err 落回常规 spawn 路径零行为变更)。不改锁语义/不打断 loop;桌面任务卡补充入口待 14 异步委托批配。验收:cargo check EXIT 0 + chat 17 passed + feishu 3 passed(含注入前缀格式契约单测)。
> **14 升级(用户拍板 2026-10-06 凌晨):桌面也要异步能力,不做双端分叉**——异步委托是引擎层能力,桌面与飞书都是它的前端视图。实现要点:①引擎层 spawn 即受理返回,完成走通知桥广播(oneshot 改事件+等待者订阅) ②桌面任务卡 UI:受理/进行中 N 轮/完成+系统通知,发消息后可切会话/最小化 ③飞书为同一语义投影(受理卡/完成原地更新) ④锁语义保守起步=单会话单在途+排队,多并发看用量再说 ⑤yml 开关灰度。依赖链:16 纠偏通道(受理后补充入口)→13 过程流式(进行中视图)→本项。验收:桌面发任务→切走→完成收系统通知;飞书同构;双端行为一致。
> **设计约束(用户拍板 2026-10-06 凌晨):任务会话高度复杂,代码设计一定要优雅**——①状态机显式化:受理/运行/暂停/完成/失败用枚举统一,禁散落 bool 拼凑 ②单一事实源:任务状态只在引擎层,桌面/飞书 UI 都是投影(订阅不轮询) ③事件驱动贯通:13/14/16 共用同一事件总线,不做渠道私有旁路 ④优雅优先于速度:宁可工期换结构,不做特判堆补丁(优雅架构铁律)。
> **桌面端能力同步清单**(与渠道对齐,2026-10-06):16 纠偏注入做在 AiSession 会话层桌面天然受益;13 过程流式桌面过程视图同步对齐;14 任务卡+系统通知已在升级注记;另两件桌面专属:①V49 可恢复入口——走查实锤 list_parked 读侧零消费,桌面侧栏补「可恢复」列表消费它(断点续跑闭环最后一块) ②缺陷9 会话标题泄漏桌面同症随批修。
17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。
18. [ ] **渠道错误话术吞因**(09:51 实证):spawn 被并发护栏拒绝时,飞书侧包装成「❌ 会话启动失败,请稍后重试」——真实原因「AI 正在生成中」被吞,用户误判 bot 坏了重试白撞。修:spawn Err 透传分类(生成中→「任务进行中,完成后自动处理」+受理排队选项;真失败→原因)。附带:探活告警口径 bug——任务在跑(inflight>0)却报「无在途会话」,inflight 计数未覆盖 spawn→回复窗口,修正计数点。验收:同场景复测,飞书提示真实状态。
> 附:09:48 桌面纠偏「不是 haogame 是 redtimer」被运行中 loop 下一轮直接接住——16 纠偏注入的桌面形态天然存在,飞书侧对齐即可。
## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标)
> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。
### 治理四条(对 devflow 工作流生效)
- [ ] **G1 结构化修复单**:走查/审查发现逐条独立成单,格式=`[编号] file:line + 复现/影响 + 验收命令`,🔴级不允许自由跳过(先销后立新)
- [ ] **G2 🔴门禁**:🔴级条目未清零前,新功能提交打回(cm 提交前置检查看板🔴余量)
- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强)
- [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成
### 桌面能力同步(随本批)
- [ ] G5 V49「可恢复」入口(桌面侧栏消费 list_parked,断点续跑闭环最后一块)
- [ ] G6 缺陷9 标题泄漏桌面同症随批修
### ⚠️ 退回重处理(2026-10-06 用户令,G2/G3 治理标准首次执行)
- 三笔提交(cec4d11 委托引擎/84df9b5 租约/622507c UI)已 reset --mixed 退回(内容全在工作区,零丢失,未推远端无需处理远端)
- **重做要求**:①先修 🔴10 loop_state 失效(G2 门禁:🔴不清零不得提交新功能)②描述按 G3 如实:实际干了 14+16+事件广播三件须逐件列全 ③i18n reattachToMain 键随批补上 ④修完按主题重新分组提交,销账 [x] 附验收输出(G4)
19. [ ] **飞书 outbound 统一出口**(11:23 实证):挂机模式进度汇总走独立发送路径,绕过 downgrade_md_tables/clip_for_card/卡片链——3 条回复带完整 md 表格未降级(踩回表格渲染不稳坑)。修:降级/裁剪/卡片链下沉为统一 outbound 出口函数,对话/挂机汇报/进度推送全部走它;验收:任意路径回复无 |---| 裸奔。
> 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。
## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md)
- [ ] B1(S):H1 汇报 git 实测附录(chat.rs:919 单点)+ 探活告警口径修正
- [ ] B2(M):H2 token 三闸(抄 sub_agent round_budget_stop)+ H4 产出审计 subscriber
- [ ] B3(M):V49 防覆盖定稿+单测 + ai_list_recoverable IPC + 桌面「可恢复」入口
- [ ] B4(L):ai_recover_parked 重启恢复 + H3 外部干预感知(git HEAD 比对→Parked)
## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行)
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
- [ ] U2 进度语义:三层模型(活性脉冲/位置计数/内容工具名)+防假进度五规则——核心"数字不动+脉冲停才算卡死",无分母禁画百分比
- [ ] U3 通知分级:必达(L0决策/L2完成)与静默累积(L3过程)分级表;飞书侧=哪些事件值得动占位卡,注入回执走 reply 不动占位卡
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查
+215 -31
View File
@@ -273,6 +273,27 @@ async fn handle_message(
return;
}
// ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ──
// 排队语义的痛点:per-chat 锁把补充指令排到 agent 完成后才处理,长任务期间纠偏无效。
// 注入语义:conv 活跃(Generating/Compressed)时直接写 per_conv.messages,loop 下一轮
// build_for_request 自然带上;引擎层能力(chat.rs inject_runtime_message),飞书/桌面同源。
// 非活跃(Idle/Error)→ Err → 落回下方常规路径,零行为变更。
{
let state = ctx.app.state::<crate::state::AppState>();
let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned();
if let Some(cid) = conv_id_now {
if state.conv_states.is_active(&cid) {
match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await {
Ok(true) => {
let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await;
return;
}
_ => { /* 注入失败/竞态退出:落回常规路径(spawn 会因 can_accept_request 被拦,收敛安全) */ }
}
}
}
}
// chat_id → conv_id 映射(内存命中优先,未命中新建)
feishu_log(&format!("收到消息 chat={chat_id} open_id={open_id} text={}", truncate(&text, 80)));
// 立即确认(交互丝滑):占位改用单行卡片并记下其 message_id,完成后原地更新为结果,
@@ -295,7 +316,11 @@ async fn handle_message(
ctx.inflight.fetch_add(1, Ordering::SeqCst);
let _inflight_guard = InflightGuard(ctx.inflight.clone());
let state = ctx.app.state::<crate::state::AppState>();
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
// 通知桥注册(#14):oneshot 接收端有意 drop——两段式下终版走事件广播
// (deliver_completion_report_if_waited 的 AiAgentFinished publish),桥条目存在的
// 唯一作用是 has_agent_completion_waiter 为真(触发引擎层取报告+广播);rx.drop 触发
// tx.send 失败静默,无泄漏(bridge 条目 send 后即移除)。
let (tx, _rx) = tokio::sync::oneshot::channel::<String>();
state.register_agent_completion(conv_id.clone(), tx);
// 流式预览:订阅既有 ai_event_bus 拿本 conv 的 AiTextDelta 增量(emit_ai_chat_event
@@ -314,6 +339,7 @@ async fn handle_message(
deliver_rx,
));
let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text)
match crate::commands::ai::commands::chat::spawn_agentic_session(
&ctx.app,
&state,
@@ -353,30 +379,99 @@ async fn handle_message(
}
}
// 等最终回复(通知桥 oneshot;超时 30 分钟,任务仍在后台跑不丢失)
let reply = match tokio::time::timeout(std::time::Duration::from_secs(1800), rx).await {
Ok(Ok(text)) if !text.trim().is_empty() => text,
// A 韧性兜底:通知桥带回空内容 → 回查 DB 该会话最后一条 assistant(事实源),有则照发
// ── #14 任务式委托(2026-10-06):受理即返回,完成走事件驱动 ──
// spawn Ok = 任务已受理并在后台运行(loop 独立 task,30min 超时底座既有,断点续跑兜底)。
// 原同步等待段(timeout 1800s oneshot)整体移除:发「走查代码」级重任务不再挂住本
// 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher
// 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited
// 统一发出,单一事实源),原地 edit_card 占位卡为结果。
// 卡片所有权移交(走查🔴#1 修正):占位卡自此只被 watcher 任务写——受理卡与终版
// 都在 watcher 单任务内按序落卡(受理卡先写,快任务兜底命中时跳过受理卡直投终版),
// 彻底消除「受理卡与终版两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。
drop(deliver_tx);
let _ = stream_task.await; // 等流式任务退出(快,只走通道关闭分支)
let ack = format!("✅ 已受理,后台执行中\n任务:{task_brief}");
feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}"));
// 完成订阅任务:独立于消息处理生命周期,直到 AiAgentFinished 广播到达投递终版。
// (spawn Err 路径在上方已 return,永不到达此处——watcher 依赖引擎层正常完成广播)
tauri::async_runtime::spawn(async move {
run_completion_watcher(ctx, conv_id, placeholder_id, message_id, chat_id, ack).await;
});
}
/// 完成事件订阅任务(#14 两段式):占位卡唯一写者。入场先投受理卡,再消费
/// ai_event_bus 的 AiAgentFinished(本 conv)原地 edit_card 为终版。引擎层
/// deliver_completion_report_if_waited 是广播单一发出点,watcher 纯消费无状态。
///
/// 快任务竞态兜底(走查🟡#1 加门槛):广播可能先于订阅(秒级任务)——立即回查
/// 一次,但须同时满足 conv 非活跃 + 回查时间晚于受理(防 spawn 返回但 loop 尚未
/// 置 active 的时序窗口误判) + 文本非空,三者齐才直投终版跳过受理卡(卡片写入
/// 单任务串行,无网络乱序覆盖)。任一不满足则先落受理卡再进事件等待。
///
/// 超时语义(走查🟡#2 修正):35min(> loop 30min 上限)超时投降级提示后**不退出**,
/// 继续挂事件等待——任务后台不丢,晚到的完成事件仍会送到卡片;watcher 与任务
/// 同生命周期,彻底无果(引擎侧异常未广播)才在二次超时后弃守。
async fn run_completion_watcher(
ctx: Arc<Ctx>,
conv_id: String,
placeholder_id: Option<String>,
message_id: String,
chat_id: String,
ack: String,
) {
let state = ctx.app.state::<crate::state::AppState>();
let mut rx = state.ai_event_bus.subscribe();
let accepted_at = tokio::time::Instant::now();
// 快任务兜底:立即回查一次(事件可能已错过);三分量门槛防时序窗口误判
let settled = !state.conv_states.is_active(&conv_id)
&& accepted_at.elapsed() >= std::time::Duration::from_secs(3)
&& matches!(last_assistant_reply(&state.db, &conv_id).await,
Some(t) if !t.trim().is_empty());
if settled {
let text = last_assistant_reply(&state.db, &conv_id)
.await
.unwrap_or_default();
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", text.len()));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
return;
}
// 落受理卡(占位缺失回退新发,deliver_result 内部链路)
deliver_result(&ctx.client, &placeholder_id.clone(), &message_id.clone(), &chat_id.clone(), &ack).await;
// 事件等待循环:首超时 35 分钟投降级提示不退出,继续等晚到事件;二次超时弃守。
let timeout = std::time::Duration::from_secs(2100);
loop {
let text = tokio::select! {
item = rx.recv() => match item {
Ok(v) => {
if v.get("type").and_then(|t| t.as_str()) != Some("AiAgentFinished") {
continue;
}
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) {
continue;
}
// 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造)
v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string()
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
},
_ = tokio::time::sleep(timeout) => {
// 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版,
// 无果投降级提示并**继续等待**(走查🟡#2:超时不弃投递,晚到事件仍送达)
match last_assistant_reply(&state.db, &conv_id).await {
Some(t) if !t.trim().is_empty() => t,
_ => {
let db = ctx.app.state::<crate::state::AppState>().db.clone();
let fallback = last_assistant_reply(&db, &conv_id).await;
feishu_log(&format!("conv={conv_id} 通知桥空/超时,DB回查={}", truncate(fallback.as_deref().unwrap_or("无"), 60)));
match fallback {
Some(text) => text,
None => "⚠️ 本轮没有生成文本回复(已记录排查)。任务若仍在进行可稍后 /new 重置或重发。".to_string(),
let notice = "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string();
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &notice).await;
continue;
}
}
}
};
feishu_log(&format!("回复 chat={chat_id} len={}", reply.len()));
// 终版投递:交流式任务单点执行(内部复用 deliver_result 原回退链);
// 任务已死(通道已关或异常退出)则兜底直投,此时无并发写卡方,回退链行为不变;
// 若任务在投递后异常退出,重复一次终版 edit_card 内容相同,无副作用
let delivered = match deliver_tx.send(reply.clone()) {
Ok(()) => matches!(stream_task.await, Ok(())),
Err(_) => false,
};
if !delivered {
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &reply).await;
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", text.len()));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
return;
}
}
@@ -423,6 +518,13 @@ async fn run_stream_refresher(
// 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额
let mut acc_chars = 0usize;
let mut painted_chars = 0usize;
// ── 过程流增量(todo.md 13):轮次/工具轨迹提炼,卡片头部一行摘要 ──
// 长任务(206 轮级)纯工具轮无文本增量,原版卡片静止疑似卡死;轨迹行给出「在干活」证据。
// 提炼原则:决策价值过滤——只留「第 N 轮 + 当前工具名」,历史工具折叠计数,不刷屏不顶正文。
let mut round: Option<u32> = None;
let mut current_tool: Option<String> = None;
let mut tool_steps: usize = 0usize;
let mut painted_sig = String::new();
let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL);
// 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -438,18 +540,39 @@ async fn run_stream_refresher(
}
item = delta_rx.recv() => match item {
Ok(v) => {
// 双过滤:只要本 conv 的流式增量;type tag 即变体名(AiChatEvent 无 serde rename)
if v.get("type").and_then(|t| t.as_str()) != Some("AiTextDelta") {
continue;
}
// conv 路由过滤(全事件共用);type tag 即变体名(AiChatEvent 无 serde rename)
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) {
continue;
}
match v.get("type").and_then(|t| t.as_str()) {
Some("AiTextDelta") => {
if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) {
acc.push_str(delta);
acc_chars += delta.chars().count();
}
}
// 轮次推进:立即标脏强制下一 tick 刷卡(纯工具轮无文本也有"活着"信号)
Some("AiAgentRound") => {
if let Some(r) = v.get("round").and_then(|r| r.as_u64()) {
round = Some(r as u32);
painted_chars = painted_chars.saturating_sub(1); // 标脏:强制重刷
}
}
// 工具开始:更新当前工具名;完成(含失败)即清空当前工具
Some("AiToolCallStarted") => {
if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
current_tool = Some(name.to_string());
tool_steps += 1;
painted_chars = painted_chars.saturating_sub(1); // 标脏
}
}
Some("AiToolCallCompleted") => {
current_tool = None;
painted_chars = painted_chars.saturating_sub(1); // 标脏
}
_ => {}
}
}
// 预览允许丢帧:终版事实源在通知桥 oneshot,消费落后只降级预览不断链路
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)");
@@ -467,11 +590,18 @@ async fn run_stream_refresher(
let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else {
continue;
};
// 信号标脏:过程事件(轮次/工具)已把 painted_chars 回拨 1,此处字符数不等即刷
if acc_chars == painted_chars {
continue;
}
if update_card(&ctx.client, pid, &stream_preview(&acc)).await {
// 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷)
let sig = format!("{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps);
if sig == painted_sig {
continue;
}
if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), tool_steps)).await {
painted_chars = acc_chars;
painted_sig = sig;
}
// 刷失败静默跳过,下个 tick 自然重试
}
@@ -479,9 +609,28 @@ async fn run_stream_refresher(
}
}
/// 流式中间态文本:尾部加光标符示意仍在生成;超上限从头截断保尾部,前缀标注省略事实
/// 防读者误把中间态当全文
fn stream_preview(acc: &str) -> String {
/// 流式中间态卡片:头部一行过程摘要(轮次/工具轨迹提炼)+ 正文流式增量(保尾截断)。
///
/// 提炼原则(todo.md 13「决策价值过滤」):历史步骤折叠为计数,只透出「当前在干什么」——
/// 读者要的是"活着没卡死+进行到哪",不是 20 个工具名的流水墙;正文仍以流式文本为主,
/// 纯工具轮(无文本)时头部行是唯一进度证据。
fn stream_preview(
acc: &str,
round: Option<u32>,
current_tool: Option<&str>,
tool_steps: usize,
) -> String {
// 头部摘要行:轮次 + 当前工具(执行中)/已执行步数;全部缺省(首轮文本前)不显头行
let mut head = String::new();
if round.is_some() || tool_steps > 0 {
let r = round.map(|n| format!("第 {n} 轮")).unwrap_or_default();
let t = match current_tool {
Some(name) => format!("⚙ `{name}` 执行中"),
None => format!("已完成 {tool_steps} 步工具调用"),
};
head = format!("**{r} · {t}**\n\n");
}
// 正文:超上限保尾截断(对齐原口径)
let total = acc.chars().count();
let body = if total > STREAM_PREVIEW_MAX_CHARS {
let tail: String = acc.chars().skip(total - STREAM_PREVIEW_MAX_CHARS).collect();
@@ -489,7 +638,7 @@ fn stream_preview(acc: &str) -> String {
} else {
acc.to_string()
};
format!("{body}\n▌")
format!("{head}{body}\n▌")
}
/// markdown 回复:interactive 卡片承载(lark_md 原生渲染标题/列表/粗体,text 类型会符号裸露)
@@ -759,7 +908,7 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)]
mod tests {
use super::strip_mention_placeholders;
use super::{strip_mention_placeholders, stream_preview};
#[test]
fn mention_cn_no_space_not_swallowed() {
@@ -782,4 +931,39 @@ mod tests {
assert_eq!(strip_mention_placeholders("普通消息不动"), "普通消息不动");
assert_eq!(strip_mention_placeholders("@_user_1"), "");
}
// ── 过程流增量摘要提炼(todo.md 13):头部行格式契约 ──
#[test]
fn preview_first_text_no_head() {
// 首轮纯文本(无轮次无工具):不显头行,原文+光标
let out = stream_preview("你好", None, None, 0);
assert_eq!(out, "你好\n▌");
}
#[test]
fn preview_tool_running_head() {
// 工具执行中:头行 = 轮次 + ⚙ 工具名 执行中
let out = stream_preview("分析中", Some(3), Some("read_file"), 5);
assert!(out.starts_with("**第 3 轮 · ⚙ `read_file` 执行中**\n\n"));
assert!(out.contains("分析中"));
assert!(out.ends_with("▌"));
}
#[test]
fn preview_tool_done_head() {
// 工具轮间空档(当前无工具):头行转「已完成 N 步」——纯工具轮的"活着"证据
let out = stream_preview("", Some(4), None, 7);
assert!(out.starts_with("**第 4 轮 · 已完成 7 步工具调用**\n\n"));
}
#[test]
fn preview_tail_keep_on_overflow() {
// 超上限保尾截断 + 前缀标注;头行不受截断影响
let long = "x".repeat(9000);
let out = stream_preview(&long, Some(1), None, 1);
assert!(out.starts_with("**第 1 轮 · 已完成 1 步工具调用**"));
assert!(out.contains("…(前文已略)"));
assert!(out.chars().count() < 8500); // 头行 + 8000 尾 + 标注
}
}
+1 -1
View File
@@ -337,7 +337,7 @@ onUnmounted(() => {
font-size: 13px; max-width: 90vw;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
.df-toast--info { background: var(--df-accent); }
+26 -4
View File
@@ -597,7 +597,12 @@ async function backfillQueued(idx: number) {
function scrollToBottom(smooth = false) {
messageListRef.value?.scrollToBottom(smooth)
}
// listener 租约句柄(mount 借/unmount 还,所有权模型;startListener 失败时为 null,归还侧判空)
let _listenerRelease: (() => void) | null = null
onBeforeUnmount(() => {
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
_listenerRelease?.()
_listenerRelease = null
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
@@ -609,9 +614,8 @@ onBeforeUnmount(() => {
// 流式 rAF 清理(streaming watch 内 cancelAnimationFrame)随 MessageList 迁移,子组件
// onBeforeUnmount 自管(子 watch streaming 已注册,卸载自动停)。
// 释放后端 listener(ai-chat-event/ai-conversation-changed)+清流式看门狗。
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
store.stopListener()
// 旧 stopListener 已删除(所有权模型:租约在 onBeforeUnmount 顶部归还,store 侧 releaseLease
// 在最后一份归还时拆 listener + 清看门狗/计时器,此处不再重复清)。
// F-15 阶段2: 释放上下文管理事件监听器
store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } //
@@ -635,9 +639,27 @@ onMounted(async () => {
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
// Markdown 渲染器预热随 MessageList 子组件迁移(子 onMounted 内 loadMarkdown);
// useMarkdown 模块级单例,父不再直接调用。
await store.startListener()
// ── 分级初始化(根本修 #5)──
// 第一级(致命):listener 租约。失败 = 无事件通道,面板功能尽失,toast + 止损
// (重开面板 re-mount 即重试);租约句柄 null,卸载侧判空归还。
try {
_listenerRelease = await store.startListener()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
console.error('[AiChat] listener 注册失败(致命,面板初始化中止):', e)
showToast(t('aiChat.initError', { msg }), 'error')
return
}
// 第二级(可降级):providers/conversations 列表。失败仅列表空,面板/发送/流式仍可用,
// toast 提示但继续初始化(skills/上下文监听/生成态恢复不依赖列表数据)。
try {
await store.loadProviders()
await store.loadConversations()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
console.error('[AiChat] 列表加载失败(降级继续):', e)
showToast(t('aiChat.initDegraded', { msg }), 'warning')
}
store.loadSkills()
store.loadToolRisks()
// @ 提及预加载: AiChat 的 @ 联想读取 projectsStore.projects/tasks,
+1 -1
View File
@@ -100,7 +100,7 @@ watch(() => props.visible, () => {})
border-radius: var(--df-radius-sm, 4px);
border: 0.5px solid var(--df-border, #ddd);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.confirm-btn--cancel {
background: transparent;
+1 -1
View File
@@ -143,7 +143,7 @@ function onPageSizeChange(val: string) {
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.page-btn:hover:not(:disabled) {
+1 -1
View File
@@ -491,7 +491,7 @@ const visibleArgs = computed(() => {
.ai-tool-btn {
padding: 5px 12px; font-size: 11px; font-weight: 500;
border-radius: var(--df-radius-sm); border: 0.5px solid var(--df-border); cursor: pointer;
display: flex; align-items: center; gap: 4px; transition: all 0.15s var(--df-ease); font-family: var(--df-font-sans);
display: flex; align-items: center; gap: 4px; transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease); font-family: var(--df-font-sans);
}
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
+1 -1
View File
@@ -539,7 +539,7 @@ watch(() => props.tc.status, (s) => {
padding: 2px 8px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s var(--df-ease);
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.ai-tool-expand-btn:hover {
color: var(--df-text-secondary);
+3 -3
View File
@@ -125,7 +125,7 @@ function onDismiss() {
border-radius: var(--df-radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft);
cursor: pointer;
transition: all 0.18s var(--df-ease);
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
user-select: none;
}
@@ -197,7 +197,7 @@ function onDismiss() {
color: var(--df-text-dim);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
}
.approval-overlay-close:hover {
@@ -208,7 +208,7 @@ function onDismiss() {
/* 进出动画:从右侧滑入 */
.approval-overlay-enter-active,
.approval-overlay-leave-active {
transition: all 0.25s var(--df-ease);
transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease);
}
.approval-overlay-enter-from,
+2 -2
View File
@@ -1219,7 +1219,7 @@ defineExpose({
background: var(--df-bg-raised);
color: var(--df-text-dim);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.ai-img-pick-btn {
@@ -1233,7 +1233,7 @@ defineExpose({
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
margin-right: 4px;
}
+1 -1
View File
@@ -122,7 +122,7 @@ const examplePrompts = [
text-align: left;
cursor: pointer;
opacity: 0.85;
transition: all 0.15s var(--df-ease);
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), opacity 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-empty-prompt:hover {
border-color: var(--df-accent);
+1 -1
View File
@@ -935,7 +935,7 @@ watch(() => props.idea.id, loadHistory)
border-radius: var(--df-radius);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
}
.btn-evaluate:hover {
+1 -1
View File
@@ -273,7 +273,7 @@ const secondaryNav = [
text-decoration: none;
font-size: 12px;
font-weight: 450;
transition: all 0.18s var(--df-ease);
transition: background 0.18s var(--df-ease), color 0.18s var(--df-ease);
position: relative;
}
.df-sidebar--collapsed .nav-link {
+1 -1
View File
@@ -740,7 +740,7 @@ async function onRemoveModule() {
color: var(--df-text-dim);
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
transition: color 0.15s, border-color 0.15s;
position: relative;
}
+2 -2
View File
@@ -859,7 +859,7 @@ onUnmounted(() => {
background: transparent;
color: var(--df-text-secondary);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
@@ -896,7 +896,7 @@ onUnmounted(() => {
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.outline-overlay-btn:hover {
+2 -2
View File
@@ -569,7 +569,7 @@ watch(() => props.refreshKey, () => {
border-radius: 4px;
display: flex;
align-items: center;
transition: all 0.15s;
transition: color 0.15s, background 0.15s;
}
.branch-switch-btn:hover {
color: var(--df-text);
@@ -581,7 +581,7 @@ watch(() => props.refreshKey, () => {
.gc-tab {
flex: 1; padding: 8px; background: none; border: none;
border-bottom: 2px solid transparent; color: var(--df-text-dim);
cursor: pointer; font-size: 12px; transition: all 0.15s;
cursor: pointer; font-size: 12px; transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); }
.gc-tab:hover { color: var(--df-text); }
+1 -1
View File
@@ -124,7 +124,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
transition: all 0.15s;
transition: border-color 0.15s;
cursor: pointer;
}
.project-card:hover {
+1 -1
View File
@@ -113,7 +113,7 @@
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
/* ===== 按钮(原 Settings.vue,列表型 Panel 共用) ===== */
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; }
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: background 0.15s, color 0.15s; }
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
+1 -1
View File
@@ -351,7 +351,7 @@
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
}
.ai-btn-icon:hover {
background: var(--df-bg-card);
+21 -2
View File
@@ -139,6 +139,25 @@ html, body, #app {
::-webkit-scrollbar-thumb { background: var(--df-scrollbar); border-radius: var(--df-radius-xs); }
::-webkit-scrollbar-thumb:hover { background: var(--df-scrollbar-hover); }
/* ── UX 走查 P2-1(2026-10-06):Web 规范合规全局两刀 ──
* ① :focus-visible 兜底 outline:全仓 outline:none 25 处散点只删可见环不给补偿,
* 键盘用户失焦可见性;全局一条兜底(仅键盘导航触发,鼠标点击不显),散点无需逐处改。
* ② 弹层 overscroll-behavior:contain:弹层内滚到边界时滚动穿透到底层页面
* (背景跟着晃),全仓此前零命中;约定所有浮层容器类统一滚动锁,新增弹层自动生效。
* 选择器=实证类名清单(2026-10-06 全仓 grep 核对):modal-overlay 10 处 /
* TopBar 四面板 inline-list / ai-more-menu / ConfirmDialog 根。
*/
:focus-visible {
outline: 2px solid var(--df-accent);
outline-offset: 1px;
}
.modal-overlay,
.ai-goals-inline-list, .ai-history-inline-list,
.ai-summary-inline-list, .ai-context-inline-list,
.ai-more-menu, .confirm-box {
overscroll-behavior: contain;
}
/* — Selection — */
/* 选中态用 accent 实色 + 白字:保证紫底气泡(accent)选中也高对比可读,
避免 accent-soft(透明紫)叠 accent 紫底导致紫字紫底不可见(深色主题) */
@@ -182,7 +201,7 @@ html, body, #app {
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
white-space: nowrap;
}
.btn-primary { background: var(--df-accent); color: #fff; }
@@ -255,7 +274,7 @@ html, body, #app {
color: var(--df-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
}
.filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.filter-btn.active {
+4 -4
View File
@@ -591,7 +591,7 @@ function onDragEnd() { isDragging.value = false }
color: var(--df-text-dim);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
}
.popup-header-close:hover {
background: var(--df-sidebar-hover);
@@ -629,7 +629,7 @@ function onDragEnd() { isDragging.value = false }
border-radius: var(--df-radius-sm);
background: var(--df-sidebar-hover);
border-left: 2px solid var(--df-accent);
transition: all 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease);
}
.popup-item--path { border-left-color: var(--df-accent); }
.popup-item--risk { border-left-color: var(--df-warning, #f0a020); }
@@ -725,7 +725,7 @@ function onDragEnd() { isDragging.value = false }
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.popup-btn:disabled {
opacity: 0.6;
@@ -806,7 +806,7 @@ function onDragEnd() { isDragging.value = false }
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
}
.popup-footer:hover {
background: var(--df-accent);
+1 -1
View File
@@ -656,7 +656,7 @@ onMounted(() => {
color: var(--df-text-dim);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
transition: color 0.15s, border-color 0.15s;
margin-bottom: -0.5px;
}
.audit-tabs .tab-btn:hover { color: var(--df-text); }
+1 -1
View File
@@ -585,7 +585,7 @@ watch(() => route.params.id, (id) => {
padding: 14px;
border-radius: var(--df-radius);
cursor: pointer;
transition: all 0.15s;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
margin-bottom: 4px;
}
.idea-card-body {
+2 -2
View File
@@ -498,7 +498,7 @@ onMounted(() => {
.top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); }
.top-tab {
padding: 8px 16px; border: none; background: transparent; color: var(--df-text-secondary);
font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s;
font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s;
display: flex; align-items: center; gap: 6px;
}
.top-tab:hover { color: var(--df-text); }
@@ -543,7 +543,7 @@ onMounted(() => {
.list-cards { flex: 1; overflow-y: auto; padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
.kn-card {
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: all 0.15s;
border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s;
}
.kn-card:hover { border-color: var(--df-accent); }
.kn-card.selected { border-color: var(--df-accent); box-shadow: inset 3px 0 0 var(--df-accent); }
+2 -2
View File
@@ -1399,7 +1399,7 @@ onUnmounted(() => {
color: var(--df-text-dim);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
transition: color 0.15s, border-color 0.15s;
margin-bottom: -0.5px;
}
.tab-btn:hover {
@@ -1495,7 +1495,7 @@ onUnmounted(() => {
color: var(--df-text-dim);
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.task-filter-chips .filter-chip:hover {
+1 -1
View File
@@ -555,7 +555,7 @@ async function runImport() {
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
}
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.view-toggle-btn.active {
+2 -2
View File
@@ -1022,7 +1022,7 @@ onUnmounted(() => {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.seg-btn:first-child { border-radius: var(--df-radius-sm) 0 0 var(--df-radius-sm); }
.seg-btn:last-child { border-radius: 0 var(--df-radius-sm) var(--df-radius-sm) 0; }
@@ -1239,7 +1239,7 @@ onUnmounted(() => {
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-chip:hover {