dsh-agent-board 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -10
- package/index.mjs +10 -3
- package/lib/client/board-list.js +47 -5
- package/lib/client/dashboard.js +151 -8
- package/lib/client/kernel.js +61 -15
- package/lib/client/task-detail.js +161 -9
- package/lib/client.js +420 -37
- package/lib/core.mjs +402 -21
- package/lib/dispatch.mjs +164 -12
- package/lib/health.mjs +43 -11
- package/lib/notify.mjs +42 -12
- package/lib/policy.mjs +17 -1
- package/lib/rpc.mjs +71 -13
- package/lib/session.mjs +24 -3
- package/lib/store.mjs +2 -1
- package/lib/usage.mjs +84 -3
- package/package.json +1 -1
- package/test/core.test.mjs +1906 -38
- package/test/health.test.mjs +86 -4
package/README.md
CHANGED
|
@@ -95,17 +95,22 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
95
95
|
- 六列状态流:草稿 → 待办 → 进行中 → 验证中 → 已完成 → 阻塞(空板也统一渲染六列骨架 + 引导 CTA:右上角「+ 新建任务」或对话一句 task_create 建第一张卡)
|
|
96
96
|
- 读路径 error 防线:get-tasks 返回 {error} 或网络抖动时保留现有卡片与设置(不再静默清空全板),面板头部下方出非阻断红色错误条,下次刷新成功自动消失
|
|
97
97
|
- 耗时口径三分离:待办/草稿卡显「⏳ 等待 N」(创建→被领取),执行后显「⏱ 执行 N」(被领取→完成,tooltip 写清口径)——排队时间不再混进"耗时"
|
|
98
|
-
-
|
|
98
|
+
- 拖拽流转、多选批量操作(带一步撤销)、文本/优先级/标签筛选;卡片键盘可达(Tab 聚焦 + Enter/Space 开详情),详情页附「流转到」按钮组(状态迁移的键盘替代通道,失败内联回显)
|
|
99
99
|
- 归档区:时间倒序 + 排序选择器 + 纵向滚动
|
|
100
100
|
- Esc 逐级关闭(详情 → 看板 → 面板)
|
|
101
101
|
- 全部结构性图标为 Lucide 线性 SVG(`currentColor` 跟随主题,浅深色自适应)
|
|
102
102
|
|
|
103
103
|
### 仪表盘(Token 消耗)
|
|
104
104
|
|
|
105
|
-
- 仪表盘视图新增「Token
|
|
106
|
-
-
|
|
105
|
+
- 仪表盘视图新增「Token 消耗」区:本看板累计**有效 / 缓存读 / 合计**三分量 + 输入 / 输出(缓存写非零时一并展示)拆分、按模型分布条形图、任务消耗 **Top 8**(标题可点击直达该任务详情,title 附「有效 N / 缓存读 M」拆分);大数字为**今日有效消耗**(本地日口径,无日账则显示 0,旁附「含缓存读共 X」小字对照,有效值不可知时以总量近似并标 `~`)、旁附「累计(本看板)」三分量小字,下方「近 7 天」迷你条形按日展示近 7 天**有效消耗**(今天高亮,title 补总量对照,7 天全为 0 时不渲染);进行中的卡片右上角显示本任务已累计消耗(`⛁ 数字`);区底固定一行口径 caption:**仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话**
|
|
106
|
+
- 口径要点:**有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)**。实证本板累计 10.3M 里缓存读占 9.7M(94%)、有效消耗仅 605K——总量被缓存读撑高约 17 倍,因此大数字与「近 7 天」一律用有效值,缓存读单列可查(差额一眼可见),避免「仪表盘与真实消耗差太多」
|
|
107
|
+
- 数据来源:每次 Worker/Verifier run 结算时读该 run 的 v4 会话日志(`~/.dsh/sessions/*/<runId>/session.v4.jsonl.zstd`),把 `assistant/message` 事件的 `usage`(`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `totalTokens`,字段形状以真实日志为准)按 zstd 帧逐帧累加到任务 `usage`(含按模型小计、`runs` 计数与 `byDay` 日账——本地日 `YYYY-MM-DD`,一次 run 整笔记在结算日;多轮重跑/驳回重做自动累加);**run 级留账**:本次用量同时原样写回 `t.runs` 对应条目的 `usage`(五分量俱全),因此按天 / 按模型 / 按阶段任何维度都能从 runs 精确重建,口径若要再调整不必回头猜
|
|
108
|
+
- `byDay` 为**双指标**形态 `{ t, e }`(`t` = 总量含缓存读,`e` = 有效消耗不含缓存读;一次 run 不跨日拆分)。聚合端兼容老数据:值是裸 number 的历史日账按 `{ t: n, e: null }` 处理——总量照常保留(「近 7 天」不会整段消失),有效值不可知就置 `null` 并由 UI 标 `~` 近似,**绝不把总量冒充有效值**
|
|
109
|
+
- `get-tasks` 再现算 board 级 `usageSummary`(总量 / 有效合计 / 按模型 / Top8(带有效与缓存读拆分)/ 日账 `byDay`,不落盘额外表;老任务无 `byDay` 时整笔近似归到 `updatedAt` 的本地日,无 `updatedAt` 则不计入任何日)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」
|
|
110
|
+
- 统计口径边界:本区只统计**看板派发的 Worker/Verifier run**,**不含主窗口对话本身**(不越界统计,UI caption 已明示)
|
|
107
111
|
- 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务**执行**时长 p90 >45min(claimedAt→resolvedAt 纯干活口径,不含排队)/ 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判
|
|
108
112
|
- 统计区耗时同口径拆分:「平均排队 / 平均执行」双行展示(平均验收单列不变)
|
|
113
|
+
- 统计区新增「调研 ROI」对比行:resolved/archived 卡按有无调研注入分组现算卡数 / 平均执行时长 / 平均 token(双组总样本 ≥4 才渲染,无调研组明显更慢时数字 warn 色提示)
|
|
109
114
|
|
|
110
115
|
### 学习反馈(候选教训信号 → 主窗口沉淀)
|
|
111
116
|
|
|
@@ -128,15 +133,20 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
128
133
|
- **依赖调度**:`dependsOn` 声明依赖(DFS 环检测),依赖全部完成后才会被派发,串行链路自动编排
|
|
129
134
|
- **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
|
|
130
135
|
- **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
|
|
131
|
-
- **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]
|
|
136
|
+
- **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`/`resolved`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁持到归档**:验收通过后继续持锁(护住「验收后-提交前」的提交窗口期),归档(含批量归档)才真释放,`cancelled` 立即放锁(放弃语义=不再产出)——避免并行 Worker 改同一批文件互踩。手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
|
|
132
137
|
- **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
|
|
133
138
|
- **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
|
|
134
|
-
- **子任务**:父子层级 + 上下文继承 + 父任务自动流转 +
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
- **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档(僵尸态出清:`archive-task` 对「无活跃 run 的 in-progress」——典型如被 parentKick 推进后子任务已全部归档的史诗——直接放行,有活跃 run 的仍拒);**归档子任务仍计入史诗进度并在详情留档可见**——进度分子口径 `settled = resolved | cancelled | archived`、分母也含归档,归档一张子卡不会再让史诗进度从 `0/10` 退化成 `0/9`(进度只增不减),全归档的父卡也照常显示徽章;详情子任务清单不排归档行(灰化 + 行尾「已归档」徽章 + 沉底排序,点击仍可进子卡看留档)
|
|
140
|
+
- **死会话零打扰**:15s 派发心跳对无活 root 的会话板直接跳过(不读盘/不写盘/零日志,会话重开后自动恢复派发);`create-task` 拒绝不完整 sessionId(防裸短 id 建出幻影板)
|
|
141
|
+
- **面板轮询渲染短路**:`get-tasks` 响应附 `tasksHash`,任务列表无变化时前端跳过重渲染(usage/池状态等轻量字段照常刷新)
|
|
142
|
+
- **看板抽屉稳定性(isRoot 蝶变防抖)**:生成开始/结束瞬间 agents 树重建会让 `isRoot` 出现单次瞬态 `false`——现在需**连续 3 次(≈9s)false** 才收起抽屉(会话生命周期内「曾确认 true」粘滞),看板不再在生成状态切换时突然消失;从未确认 root 的子代理会话仍即时隐藏入口,保护语义不变
|
|
143
|
+
- 卡片识别层:父卡显示「📦 史诗 · settled/total」徽章 + 3px 迷你进度条(`settled = resolved | cancelled | archived`,total 同口径含归档;有在跑子任务时附「▸ 在跑:标题」行);子卡标题下显示「↳ 父任务标题」;依赖未满足的待办卡底部灰字「⛓ 等待「依赖标题」」(childStats 由 host 现算,缺字段一律不渲染)
|
|
144
|
+
- 详情父子区块:父卡详情列子任务清单(状态色点 + 标题,**含已归档留档**——归档行灰化 + 行尾「已归档」徽章并沉底,点击直达子卡详情,标题行汇总 settled/total);子卡详情顶行「↳ 史诗:父标题」点击回跳父卡
|
|
145
|
+
- **可选 hooks(史诗可选点位:前置/后置各是一次真实 agent 运行)**:`epic.hooks = { pre: { enabled, prompt, state, runId }, post: 同构 }`——**前置**让子任务具备开跑条件(串行闸门:未完成前该史诗的子任务一张都不派),**后置**把这批活收口(全部子任务了结后跑一遍,完成后史诗才转验证中);prompt 只给薄框架契约,具体动作由 hook agent **自行决策**,**吃不准就走歧义上报**(失败即转阻塞等人裁决:重试/跳过/放弃,不自动重跑)。hook run 记在 `epic.runs[]`(role `hook-pre`/`hook-post`),卡片带相位徽章(`⏳ 前置准备中` / `🧪 收尾中` / `⚠️ hook 失败待裁决`,无 hooks 的老卡零渲染),详情页 hooks 区可编辑 enabled+prompt、查看 state/耗时并跳转 run 会话、失败一键跳裁决区;**hooks 仅主窗口可设**(工具与 RPC 双通道门禁),commit/push 不入任何默认形态
|
|
137
146
|
- **删除通道(真删,无 undo)**:`delete-task` RPC(卡片 hover 垃圾桶按钮 / 详情页「删除」按钮,均先 `confirm('删除不可恢复,确认删除「标题」?')`)+ `batch-op op='delete'`(多选模式底部「批量删除」,同样 confirm)。状态门禁:**草稿/待办/阻塞可删**;进行中/验证中拒绝并提示先用 `terminate-agent` 终止(避免在跑的 run 变孤儿);已完成/取消引导改用归档(`archive-task`,留档可检索);有**未归档子任务**时拒删(防 `parentId` 悬空破坏父任务自动流转);已归档任务幂等返回 ok。是真删(从 `tasks` 数组移除),因此**不产生 `batch-undo` 撤销快照**(批量条对 delete 不显示「↩️ 撤销」),删除操作在 host 端 `console.error` 留一行日志便于溯源
|
|
138
147
|
- **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team 模式提示词都写了这条契约
|
|
139
148
|
- **suggestSplit 软提示**:`task_create` / `create-task` 发现描述超 500 字符、或标题/描述命中「全量 / 整体 / 系统级 / 全面 / 重构 / 所有模块 / 整个」等史诗特征词时,返回体附带一行 `suggestSplit` 建议文案(**只提示,不阻断创建与派发**;未命中则不出现该字段,老调用方无感)
|
|
149
|
+
- **开关 `epicSplit`(史诗拆分总开关)**(⚙️ 入池配置弹层「**功能**」小节,默认**开**):关掉只停**引导**——Team 提示词第 6 条拆分条款整条不注入、`create-task`/`task_create` 响应不再附 `suggestSplit` 软提示;**机制不禁**(显式传 `parentId` 建子卡、史诗自动收口与 hooks 状态机照常工作,明确要拆时不受阻);老看板文件没有该字段 → 读路径自动补 `true`(与升级前行为一致)。工具描述里的粒度契约是静态工具定义(随 request header 快照),不在开关范围
|
|
140
150
|
|
|
141
151
|
### 一次性派发(v74 去池化)
|
|
142
152
|
|
|
@@ -149,6 +159,7 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
149
159
|
- 孤儿回收:子代理 run 结束/丢失超 2 分钟 → 任务自动回待办重派
|
|
150
160
|
- 看门狗:运行超时且事件流停滞 → 标记"疑似卡死"(不自动杀,裁决权交主窗口/用户)
|
|
151
161
|
- 歧义上报:Worker 遇到歧义不猜测,上报等主窗口裁决(任何模式下都通知);裁决后新 Worker 携带裁决答案接手
|
|
162
|
+
- 驳回详情全量带回:三条驳回路径(`board_verdict` 工具 / Verifier 文本结算 / 手动 `task_verify`·`verify-task`)统一往 `t.messages` 落一条 `kind: "rejection"` 完整驳回包(summary + checks 逐条核对证据;手动路径补写 `t.verification`),经 `buildMessages` 全量注入重派 Worker prompt——新 Worker 据此返工,不再只看到 300 字截断的 history 记录
|
|
152
163
|
- 手动派发:详情页「派发 / 派发验收」按钮可随时手动触发单任务派发(auto 模式补派、manual 模式主通道)
|
|
153
164
|
- 会话隔离:看板按会话分桶,多会话互不干扰
|
|
154
165
|
|
|
@@ -166,14 +177,14 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
166
177
|
|
|
167
178
|
- **歧义裁决**:Worker 遇歧义不猜测,一律上报;裁决后新 Worker 携带答案接手(Team 托管档附带 system prompt 派发引导 + 默认草稿护栏)
|
|
168
179
|
- **Verifier 验收**:`acceptance` 硬性验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑;跨档一致
|
|
169
|
-
- **touches 排他**:`touches`
|
|
180
|
+
- **touches 排他**:`touches` 文件级排他锁在活动任务间生效,冲突任务跳过本轮派发,**锁持到归档**(`in-progress`/`verifying`/`resolved` 持有,归档释放,`cancelled` 立即释放);跨档一致
|
|
170
181
|
- 孤儿回收、看门狗、级联归档、会话隔离同样三档一致
|
|
171
182
|
|
|
172
183
|
Team 托管档独有(调度员体验):
|
|
173
184
|
- 主窗口 system prompt 注入派发引导(提示词层面建议实质性改动走看板,不硬拦截);引导含**上下文书写提示**——子代理是全新会话、无会话记忆,description 写不够会自行调研跑偏
|
|
174
185
|
- **默认草稿护栏**:`task_create` / `create-task` 缺省建为草稿(草稿不派发),先把所有任务的 dependsOn、contextNotes/contextFiles 补齐,再逐个 `task_update publish=true` 统一发布;确实要立即派发的单个任务才显式传 `draft:false`
|
|
175
186
|
- **歧义通知 25s 去抖**:通知延迟 25s 投递,投递前重读看板——歧义已被裁决、或任务已 resolved/archived 就静默跳过(消除主窗口 turn 排队导致的过期回声);同一任务连续多次上报只投最新一条
|
|
176
|
-
- 任务完成/阻塞时主窗口收到**批量聚合回执**(45s 窗口或满 5
|
|
187
|
+
- 任务完成/阻塞时主窗口收到**批量聚合回执**(45s 窗口或满 5 条聚合,等主窗口空闲再发,不打断对话);⚙️ 设置区「通知」小节有两个回执开关——`notifyDispatch`(⚡ 派发回执:任务被 Worker/Verifier 领走时播报)与 `notifyDone`(✅ 完成回执:完成/阻塞时聚合播报),缺省均**开**(老看板文件缺字段自动补 `true`);**歧义裁决通知不受开关影响**(裁决通道不是回执,任务等人裁决必须提醒)
|
|
177
188
|
|
|
178
189
|
> 兼容:旧的 `set-board-mode` / `set-team-mode` 两个 RPC 原样保留(旧客户端与脚本不受影响),
|
|
179
190
|
> 内部仍以 `boardMode` + `teamMode` 两个字段落盘,老看板文件无损;`get-tasks` 额外返回派生字段 `workMode` 供 UI 单点读取。
|
|
@@ -217,7 +228,7 @@ v1.6.0 起 host 端从单体 index.mjs(1487 行)拆为薄壳 + 7 个领域
|
|
|
217
228
|
| 模块 | 域 | 内容 |
|
|
218
229
|
|---|---|---|
|
|
219
230
|
| `policy.mjs` | 策略层 | 粒度治理软闸门 + 学习飞轮候选教训(纯函数零状态) |
|
|
220
|
-
| `usage.mjs` | 统计 | v4 会话日志定位 / zstd 分帧 / token usage
|
|
231
|
+
| `usage.mjs` | 统计 | v4 会话日志定位 / zstd 分帧 / token usage 聚合(纯函数;有效消耗 `effectiveTokens` + `byDay` 双指标 `{t,e}` 聚合,兼容老 number 日账) |
|
|
221
232
|
| `session.mjs` | 会话 | root 解析缓存 / 会话 id 归一 / workMode 派生 / runsFor |
|
|
222
233
|
| `store.mjs` | 持久化 | boardPath / rt / wt 原子落盘 / 跨重启继承 / fileLocks 串行化 / mutateLocked |
|
|
223
234
|
| `notify.mjs` | 通知 | makeMsg / 歧义 25s 去抖 / 回执聚合 + 空闲门控 / 投递前过滤 |
|
package/index.mjs
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// 共享闭包状态全部收进 apply 内的显式 state 对象,逐模块经参数注入(禁止跨模块隐式引用)。
|
|
15
15
|
//
|
|
16
16
|
// 兼容 re-export:对外导出契约不变(单测与历史引用直接 import 自本文件)。
|
|
17
|
-
export { TASK_SIZE_CONTRACT, SUGGEST_SPLIT_TEXT, TEAM_SPLIT_RULE, suggestSplitOf, withSplitHint } from './lib/policy.mjs'
|
|
18
|
-
export { findRunLog, readRunUsage, aggregateUsageSummary } from './lib/usage.mjs'
|
|
17
|
+
export { TASK_SIZE_CONTRACT, SUGGEST_SPLIT_TEXT, TEAM_SPLIT_RULE, suggestSplitOf, withSplitHint, splitRuleOf } from './lib/policy.mjs'
|
|
18
|
+
export { findRunLog, readRunUsage, aggregateUsageSummary, effectiveTokens, taskEffectiveTokens } from './lib/usage.mjs'
|
|
19
19
|
import { createSession } from './lib/session.mjs'
|
|
20
20
|
import { createStore } from './lib/store.mjs'
|
|
21
21
|
import { createNotify } from './lib/notify.mjs'
|
|
@@ -32,6 +32,7 @@ export function apply(ctx) {
|
|
|
32
32
|
knownSessions: {}, // 心跳驱动的会话集合(>30 分钟无活跃淘汰)
|
|
33
33
|
teamModeCache: {}, // rt()/set-*-mode 同步,systemPrompt 引导段读取
|
|
34
34
|
feedbackCache: {}, // 学习飞轮开关缓存(rt()/set-board-config 同步)
|
|
35
|
+
epicSplitCache: {}, // 史诗拆分总开关缓存(rt()/set-board-config 同步,Team 引导段读取)
|
|
35
36
|
fileLocks: {}, // 每会话一条 promise 链,串行化所有 读-改-写
|
|
36
37
|
cyclePending: {}, // kickCycle 50ms 去抖
|
|
37
38
|
activeRuns: {}, // sid -> { taskId: { run, role, taskId, startedAt, model } }
|
|
@@ -54,10 +55,15 @@ export function apply(ctx) {
|
|
|
54
55
|
var dispatch = createDispatch(ctx, state, {
|
|
55
56
|
rt: store.rt, wt: store.wt, mutateLocked: store.mutateLocked, kickCycle: store.kickCycle,
|
|
56
57
|
rootForSession: session.rootForSession, sessionCwd: session.sessionCwd,
|
|
57
|
-
withTimeout: session.withTimeout, runsFor: session.runsFor, feedbackOn: session.feedbackOn,
|
|
58
|
+
withTimeout: session.withTimeout, runsFor: session.runsFor, feedbackOn: session.feedbackOn, epicSplitOn: session.epicSplitOn,
|
|
58
59
|
pushSysNote: notify.pushSysNote, maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
|
|
60
|
+
notifyDispatched: notify.notifyDispatched,
|
|
59
61
|
})
|
|
60
62
|
storeDeps.poolCycle = dispatch.poolCycle // 晚绑定收口
|
|
63
|
+
// 僵尸 epic 出清配套(反馈 n-mutma3mmwceq):archive-task 门禁的活性判定。
|
|
64
|
+
// 口径:runsFor 表内有记录且未 settled 即活跃。settleRun/手动终止都会摘除表项;
|
|
65
|
+
// settled=true 是 finish→settleRun 的结算过渡窗口(Worker 已结束),视为不活跃放行。
|
|
66
|
+
function hasActiveRun(sid, taskId) { var rec = session.runsFor(sid)[taskId]; return !!(rec && !rec.settled) }
|
|
61
67
|
createRpc(ctx, state, {
|
|
62
68
|
getActorId: session.getActorId, resolveRoot: session.resolveRoot, toolSessionId: session.toolSessionId, rpcSessionId: session.rpcSessionId,
|
|
63
69
|
rootForSession: session.rootForSession, deriveWorkMode: session.deriveWorkMode, runsFor: session.runsFor,
|
|
@@ -65,6 +71,7 @@ export function apply(ctx) {
|
|
|
65
71
|
maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
|
|
66
72
|
pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
|
|
67
73
|
spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
|
|
74
|
+
hasActiveRun: hasActiveRun,
|
|
68
75
|
})
|
|
69
76
|
|
|
70
77
|
console.log('[task-board] v74 loaded (pool removed: one-shot dispatch, context injected per task, dispose on settle)')
|
package/lib/client/board-list.js
CHANGED
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
function isCardHover(id, kind) { return String(state.cardHover || '') === (id + '|' + kind) }
|
|
68
68
|
function setHover(id, kind) { var v = id ? (id + '|' + kind) : ''; if (state.cardHover !== v) { state.cardHover = v; notify() } }
|
|
69
69
|
|
|
70
|
+
// 卡片键盘焦点态(无障碍,反馈 n-mut9rzpyc7p1):内联样式表达不了 :focus 伪类,
|
|
71
|
+
// 用 focus/blur 事件 + state 现算主题色 outline——样式由 state 推导,轮询重渲染也不会丢焦点框。
|
|
72
|
+
function isCardFocus(id) { return String(state.cardFocus || '') === id }
|
|
73
|
+
function setCardFocus(id) { var v = id || ''; if (state.cardFocus !== v) { state.cardFocus = v; notify() } }
|
|
74
|
+
|
|
70
75
|
// ===== 卡片「当前动作」行摘要化(client 侧提炼;host 仍下发原文,不改 host)=====
|
|
71
76
|
// host 的 activity 形如 `🔧 pwsh {"command": "Get-ChildItem …"}`(90 字符硬截,常断在 JSON 中段,
|
|
72
77
|
// 原样展示零信息量,反馈 n-mut914xana0t)或 `💬 自由文本`。这里提炼为「工具名 + 关键参数摘要」:
|
|
@@ -98,7 +103,9 @@
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
// 史诗/依赖/父子识别层(childStats 缺省兼容:host 未返回该字段时一律不渲染相关元素):
|
|
101
|
-
// 父卡 = 元信息行首位「📦 史诗 ·
|
|
106
|
+
// 父卡 = 元信息行首位「📦 史诗 · settled/total」徽章 + 3px 迷你进度条 + activeTitle 非空时「▸ 在跑」行;
|
|
107
|
+
// settled = resolved | cancelled | archived(含已归档子任务、total 也含),口径同宿主 aggregateChildStats;
|
|
108
|
+
// 老宿主只下发 resolved 时回退读它(缺省兼容)——所以归档子卡不再让徽章从 0/10 退化成 0/9;
|
|
102
109
|
// 子卡 = 标题下一行小字「↳ 父任务标题」(从 state.tasks 找父卡,找不到回退 shortId);
|
|
103
110
|
// 依赖未满足的待办卡 = 卡片底部灰字「⛓ 等待「第一个未满足依赖标题」」(多个时附「 等 N 个」)。
|
|
104
111
|
// ===== 调研门禁·UI:「⚠️ 无调研」徽章判定(纯 client 侧现算,host 不下发该字段)=====
|
|
@@ -114,8 +121,43 @@
|
|
|
114
121
|
var notes = typeof cx.notes === 'string' ? cx.notes.trim() : ''
|
|
115
122
|
return files.length === 0 && notes.length === 0
|
|
116
123
|
}
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
// ===== epic hook 相位徽章(hooks=agent run 的卡片侧可见性,task-muuw56yf)=====
|
|
125
|
+
// 与 host core.normalizeHooks 契约同源:epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 },
|
|
126
|
+
// state ∈ idle | running | done | failed(host 状态机推进,UI 只读展示,不在这里改状态)。
|
|
127
|
+
// 缺省兼容硬约束:卡上无 hooks / 该点位未启用 → 返回 null,老卡渲染逐字不变(零额外 DOM)。
|
|
128
|
+
// 优先级与文案(同一时刻只可能有一个相位有话说):
|
|
129
|
+
// pre|post failed → 「⚠️ hook 失败待裁决」(err 色):史诗已被 host 转 blocked 挂歧义,过程态文案都让位;
|
|
130
|
+
// pre running → 「⏳ 前置准备中」:串行闸门期内,该 epic 的子任务一张都不派;
|
|
131
|
+
// post running → 「🧪 收尾中」:子任务已全部了结、epic 仍 in-progress,等收口 run 归还;
|
|
132
|
+
// idle / done → 不渲染(普通进行中维持「📦 史诗 · x/y」原样,稳定态不加噪音)。
|
|
133
|
+
function hookBadgeOf(t) {
|
|
134
|
+
var h = t && t.hooks
|
|
135
|
+
if (!h) return null
|
|
136
|
+
var pre = (h.pre && h.pre.enabled) ? h.pre : null
|
|
137
|
+
var post = (h.post && h.post.enabled) ? h.post : null
|
|
138
|
+
if ((pre && pre.state === 'failed') || (post && post.state === 'failed')) {
|
|
139
|
+
var ph = (pre && pre.state === 'failed') ? 'pre' : 'post'
|
|
140
|
+
return { txt: '⚠️ hook 失败待裁决', color: C.err, tip: 'hooks.' + ph + '(' + (ph === 'pre' ? '前置准备' : '收口') + ')运行失败:史诗已转阻塞等人裁决(重试/跳过/放弃),详情页可跳裁决区' }
|
|
141
|
+
}
|
|
142
|
+
if (pre && pre.state === 'running') return { txt: '⏳ 前置准备中', color: C.brand, tip: '前置 hook run 正在跑:完成前该史诗的子任务一张都不派(串行闸门)' }
|
|
143
|
+
if (post && post.state === 'running') return { txt: '🧪 收尾中', color: C.brand, tip: '收口 hook run 正在跑:子任务已全部了结,史诗仍 in-progress,收口完成后才转验证中' }
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
function hookPhaseBadge(t) {
|
|
147
|
+
var b = hookBadgeOf(t)
|
|
148
|
+
if (!b) return null
|
|
149
|
+
return React.createElement('span', { title: b.tip, style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + b.color + ' 14%, transparent)', color: b.color, fontWeight: 600, whiteSpace: 'nowrap' } }, b.txt)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function Card(props) { var t = props.task; var pc = prioColor[t.priority] || prioColor.low; var dragging = state.dragTask === t.id; var preview = (t.deliverable && t.deliverable.summary) || t.resolution; var critGlow = t.priority === 'critical' && !t.escalation; var sel = !!state.selected[t.id]; var pm = pipeOf(t); var depBlock = t.status === 'pending' && depsBlocked(t); var delOk = !state.selectMode && canDelete(t); var cs = (state.childStats && state.childStats[t.id]) || null; var csDone = cs ? (typeof cs.settled === 'number' ? cs.settled : cs.resolved) : 0; var epic = !!(cs && cs.total > 0); var parentT = t.parentId ? getTask(t.parentId) : null; var depWaitTitle = ''; var depWaitN = 0; if (depBlock) { t.dependsOn.forEach(function (id) { var d = getTask(id); if (!d || (d.status !== 'resolved' && d.status !== 'archived')) { depWaitN++; if (!depWaitTitle) depWaitTitle = d && d.title ? d.title : id } }) } var durB = cardDur(t);
|
|
153
|
+
// 无障碍(反馈 n-mut9rzpyc7p1):卡片根以 button 角色进 Tab 序(aria-label=标题+状态),
|
|
154
|
+
// Enter/Space 触发与点击相同的激活行为(多选=切换选中,否则开详情);
|
|
155
|
+
// focus 态用主题色 C.brand 2px outline(不用浏览器默认蓝框,保持主题一致)
|
|
156
|
+
function activate() { if (state.selectMode) { if (state.selected[t.id]) delete state.selected[t.id]; else state.selected[t.id] = true; notify() } else { state.detailId = t.id; notify() } }
|
|
157
|
+
return React.createElement('div', { role: 'button', tabIndex: 0, 'aria-label': t.title + '(' + (statusLabels[t.status] || t.status) + ')', draggable: !state.selectMode, onDragStart: function (e) { onDragStart(e, t) }, onDragEnd: onDragEnd, onMouseEnter: function () { if (delOk) setHover(t.id, 'del') }, onMouseLeave: function () { if (delOk) setHover('', '') }, onFocus: function () { setCardFocus(t.id) }, onBlur: function () { setCardFocus('') }, onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() } }, onClick: activate, style: { outline: isCardFocus(t.id) ? '2px solid ' + C.brand : 'none', outlineOffset: 2, border: '1px solid ' + (sel ? C.brand : (t.escalation ? C.err : (critGlow ? C.err : C.border))), borderRadius: 6, padding: '6px 8px', marginBottom: 6, background: sel ? C.nested : C.card, borderLeft: '3px solid ' + (t.escalation ? C.err : pc), cursor: state.selectMode ? 'pointer' : 'grab', fontSize: 12, opacity: dragging ? 0.4 : (depBlock ? 0.65 : 1), transition: 'opacity .15s', animation: critGlow ? 'tskb-crit 2s ease-in-out infinite' : 'none' } }, React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 4 } }, state.selectMode ? React.createElement('span', { style: { color: sel ? C.brand : C.text2, flexShrink: 0, marginTop: 1, display: 'inline-flex' } }, ic(sel ? 'square-check-big' : 'square', 12)) : null, React.createElement('div', { style: { fontWeight: 600, color: C.text, marginBottom: 2, wordBreak: 'break-word', flex: 1 } }, t.title), React.createElement('span', { style: { flexShrink: 0, marginTop: 1, display: 'inline-flex', color: C.text2 }, title: pm.label }, ic(pm.icon, 10)), React.createElement('span', { style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + pc + ' 20%, transparent)', color: pc, flexShrink: 0, marginTop: 1 } }, prioLabel[t.priority] || '中'), noResearch(t) ? React.createElement('span', { title: '本任务未附调研上下文,Worker 需自行定位——建议补 contextFiles/contextNotes', style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + C.warn + ' 18%, transparent)', color: C.warn, fontWeight: 600, flexShrink: 0, marginTop: 1, whiteSpace: 'nowrap' } }, '⚠️ 无调研') : null, (t.usage && t.usage.total) ?React.createElement('span', { style: { fontSize: 9, color: C.text2, flexShrink: 0, marginTop: 1 }, title: '本任务累计 token:' + String(t.usage.total) + '(' + (t.usage.runs || 0) + ' 次 run)' }, '⛁ ' + fmtTokens(t.usage.total)) : null), t.parentId ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: parentT ? '父任务:' + parentT.title + ' (' + t.parentId + ')' : t.parentId }, '↳ ' + (parentT && parentT.title ? parentT.title : shortId(t.parentId))) : null, t.escalation ? React.createElement('div', { style: { fontSize: 10, color: C.err, fontWeight: 600, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 3 } }, ic('alert-triangle', 10), '待裁决 — 点击查看疑问') : null, t.stuckSince ? React.createElement('div', { style: { fontSize: 10, color: C.warn, fontWeight: 600, marginBottom: 2, animation: 'tskb-pulse 1.5s ease-in-out infinite' } }, '⏱ 疑似卡死 · ' + ago(t.stuckSince) + ' — 点击处理') : null, React.createElement('div', { style: { fontSize: 10, color: C.text2, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' } },
|
|
158
|
+
epic ? React.createElement('span', { title: '史诗父卡:' + csDone + '/' + cs.total + ' 个子任务已了结(完成/取消/归档)', style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.brand + ' 14%, transparent)', color: C.brand, fontWeight: 600 } }, '📦 史诗 · ' + csDone + '/' + cs.total) : null,
|
|
159
|
+
// hook 相位徽章:与 📦 徽章**并存**(相位是 epic 的附加过程态,不是替代进度)
|
|
160
|
+
epic ? hookPhaseBadge(t) : null,
|
|
119
161
|
t.frozen ? React.createElement('span', { title: '已冻结:不参与自动派发(详情页可「解除冻结」)', style: { color: C.brand, fontWeight: 600 } }, '❄ 冻结') : null,
|
|
120
162
|
(Array.isArray(t.waitingForTouches) && t.waitingForTouches.length) ? React.createElement('span', { title: '等文件锁释放:' + t.waitingForTouches.join('、') + '(touches 冲突,详情页可 force 越权派发)', style: { color: C.warn, fontWeight: 600 } }, '🔒 等文件释放') : null,
|
|
121
163
|
React.createElement('span', { title: pm.label, style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: C.nested, border: '1px solid ' + C.border } }, pm.short),
|
|
@@ -130,8 +172,8 @@
|
|
|
130
172
|
title: '删除任务(不可恢复;执行中请先终止,已落定请用归档)',
|
|
131
173
|
style: { flexShrink: 0, marginTop: 1, display: 'inline-flex', cursor: 'pointer', color: C.err, opacity: isCardHover(t.id, 'del') ? 1 : 0, transition: 'opacity .15s' }
|
|
132
174
|
}, ic('trash-2', 11)) : null),
|
|
133
|
-
// 史诗迷你进度条(3px 高,
|
|
134
|
-
epic ? React.createElement('div', { style: { height: 3, borderRadius: 2, background: C.nested, marginTop: 4, overflow: 'hidden' }, title: '子任务进度 ' +
|
|
175
|
+
// 史诗迷你进度条(3px 高,settled/total 比例,ok 色填充)+ 在跑子任务行(activeTitle 非空才渲染)
|
|
176
|
+
epic ? React.createElement('div', { style: { height: 3, borderRadius: 2, background: C.nested, marginTop: 4, overflow: 'hidden' }, title: '子任务进度 ' + csDone + '/' + cs.total }, React.createElement('div', { style: { height: '100%', width: Math.max(0, Math.min(100, Math.round((csDone / cs.total) * 100))) + '%', background: C.ok, borderRadius: 2, transition: 'width .3s' } })) : null,
|
|
135
177
|
epic && cs.activeTitle ? React.createElement('div', { style: { fontSize: 10, color: C.brand, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: '在跑子任务:' + cs.activeTitle }, '▸ 在跑:' + cs.activeTitle) : null,
|
|
136
178
|
// 当前动作行:展示摘要(工具名+关键参数),悬停 title 仍是 host 原文
|
|
137
179
|
(t.status === 'in-progress' || t.status === 'verifying') && state.activity[t.id] ? React.createElement('div', { style: { fontSize: 10, color: C.brand, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: state.activity[t.id] }, '👁 ' + activitySummary(state.activity[t.id])) : null,
|
package/lib/client/dashboard.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
// dashboard —— 仪表盘域:统计(computeStats/TrendChart/StatCard/BarRow)· 范围筛选(RangeFilter)
|
|
2
|
+
// dashboard —— 仪表盘域:统计(computeStats/TrendChart/StatCard/BarRow/ResearchRoiRow)· 范围筛选(RangeFilter)
|
|
3
3
|
// · 全局总览(GlobalBoards)· 报告(buildReport/ReportButton)· Token 消耗(TokenUsage)
|
|
4
4
|
// · 架构健康(HealthHints,架构自省 L1)
|
|
5
5
|
// · 团队池(TeamView/PoolStatus)· 设置(WorkModeSwitch/PoolCfg/MinCfg/ModelCfg/PoolCfgPopover)
|
|
@@ -14,8 +14,50 @@
|
|
|
14
14
|
recentActivity.sort(function (a, b) { return (b.timestamp || '').localeCompare(a.timestamp || '') }); recentActivity = recentActivity.slice(0, 10)
|
|
15
15
|
function avgMs(arr) { if (arr.length === 0) return null; var s = arr.reduce(function (a, b) { return a + b }, 0); return Math.round(s / arr.length) }
|
|
16
16
|
function fmtMs(ms) { if (!ms) return '-'; var m = Math.floor(ms / 60000); if (m < 60) return m + ' 分钟'; var h = Math.floor(m / 60); if (h < 24) return h + ' 小时 ' + (m % 60) + ' 分'; return Math.floor(h / 24) + ' 天 ' + (h % 24) + ' 时' }
|
|
17
|
+
// 调研 ROI 分组(task-mutnjesa):resolved/archived 且有执行数据(claimedAt→resolvedAt 可算,
|
|
18
|
+
// 无 claimedAt 的手工/direct 卡跳过)的卡按「有无调研注入」分两组——有调研 = context.files /
|
|
19
|
+
// context.notes 任一非空(context 缺省按无调研,与详情页「调研注入」行同口径)。
|
|
20
|
+
// 与 usageSummary 同哲学:纯现算零存储。任一组为空或总样本 <4 时 roi=null(样本太少没说服力,不渲染)。
|
|
21
|
+
var roiYes = [], roiNo = []
|
|
22
|
+
tasks.forEach(function (t) {
|
|
23
|
+
if (t.status !== 'resolved' && t.status !== 'archived') return
|
|
24
|
+
if (!t.claimedAt || !t.resolvedAt) return
|
|
25
|
+
var execMs = new Date(t.resolvedAt) - new Date(t.claimedAt)
|
|
26
|
+
if (!(execMs >= 0)) return // 时间戳异常(NaN/负值)跳过
|
|
27
|
+
var cx = t.context || {}
|
|
28
|
+
var hasRes = (Array.isArray(cx.files) && cx.files.length > 0) || (typeof cx.notes === 'string' && cx.notes.trim().length > 0)
|
|
29
|
+
// token 口径与「Token 消耗区」一致化(task-muupnnq5):取**有效消耗**(输入+输出+缓存写,不含缓存读),
|
|
30
|
+
// 不再拿含缓存读的 total 冒充——本板实测缓存读占总量 ~94%,用 total 会让 ROI 行的 token 均值虚高十几倍,
|
|
31
|
+
// 与 Token 区大数字口径互相打架。分量字段缺失的老/存量卡兜底退化为 total 并打 tokFallback 标记(title 标注),
|
|
32
|
+
// 绝不把总量伪装成有效值;无任何结算记录的卡 tok=null,不参与均值(不拉低样本)。
|
|
33
|
+
var tk = roiTokenOf(t.usage)
|
|
34
|
+
;(hasRes ? roiYes : roiNo).push({ execMs: execMs, tok: tk.tok, tokFallback: tk.fallback })
|
|
35
|
+
})
|
|
36
|
+
var roi = null
|
|
37
|
+
if (roiYes.length > 0 && roiNo.length > 0 && roiYes.length + roiNo.length >= 4) {
|
|
38
|
+
var roiAgg = function (arr) {
|
|
39
|
+
var execMs = Math.round(arr.reduce(function (a, b) { return a + b.execMs }, 0) / arr.length)
|
|
40
|
+
var toks = [], fb = 0
|
|
41
|
+
arr.forEach(function (x) { if (x.tok !== null) { toks.push(x.tok); if (x.tokFallback) fb++ } })
|
|
42
|
+
return { n: arr.length, execMs: execMs, tok: toks.length ? Math.round(toks.reduce(function (a, b) { return a + b }, 0) / toks.length) : null, tokN: toks.length, tokFallbackN: fb }
|
|
43
|
+
}
|
|
44
|
+
roi = { yes: roiAgg(roiYes), no: roiAgg(roiNo) }
|
|
45
|
+
}
|
|
17
46
|
// 耗时口径三分离(task-mutdnitw):avgQueue=平均排队(创建→被领取)、avgExec=平均执行(被领取→完成),均只统计领取过的卡;avgVerify 口径不动(完成→验收)
|
|
18
|
-
return { total: total, byStatus: byStatus, byPriority: byPriority, byAgent: byAgent, avgQueue: fmtMs(avgMs(queueTimes)), avgExec: fmtMs(avgMs(execTimes)), avgVerify: fmtMs(avgMs(verifyTimes)), todayDone: todayDone, dailyDone: dailyDone, recentActivity: recentActivity }
|
|
47
|
+
return { total: total, byStatus: byStatus, byPriority: byPriority, byAgent: byAgent, avgQueue: fmtMs(avgMs(queueTimes)), avgExec: fmtMs(avgMs(execTimes)), avgVerify: fmtMs(avgMs(verifyTimes)), todayDone: todayDone, dailyDone: dailyDone, recentActivity: recentActivity, roi: roi }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 单卡 token 口径(task-muupnnq5):ROI 行与「Token 消耗区」口径同源,避免同一张卡两处两个数。
|
|
51
|
+
// 有分量(input/output/cacheWrite 任一 >0)→ 有效消耗(不含缓存读);只有 total 的老/存量卡 → 兜底 total 并标 fallback;
|
|
52
|
+
// 两者皆无 → tok=null(无结算样本,跳过而不是记 0,避免拉低均值)。
|
|
53
|
+
// 注:客户端 bundle 只注入 React(无模块系统),故此处内联,host 侧同款判定在 lib/usage.mjs taskEffectiveTokens。
|
|
54
|
+
function roiTokenOf(u) {
|
|
55
|
+
if (!u) return { tok: null, fallback: false }
|
|
56
|
+
function pos(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
|
|
57
|
+
var inp = pos(u.input), outp = pos(u.output), cw = pos(u.cacheWrite), tot = pos(u.total)
|
|
58
|
+
if (inp > 0 || outp > 0 || cw > 0) return { tok: inp + outp + cw, fallback: false }
|
|
59
|
+
if (tot > 0) return { tok: tot, fallback: true }
|
|
60
|
+
return { tok: null, fallback: false }
|
|
19
61
|
}
|
|
20
62
|
|
|
21
63
|
function TrendChart(props) {
|
|
@@ -38,6 +80,32 @@
|
|
|
38
80
|
// 双行指标卡(耗时口径三分离:原「平均完成时间」一个展示位拆为 排队/执行 两行,不挤占其他指标位)
|
|
39
81
|
function StatCardDual(props) { return React.createElement('div', { style: { flex: '1 1 0', minWidth: 80, padding: '6px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, textAlign: 'center' }, title: props.title }, React.createElement('div', { style: { fontSize: 14, fontWeight: 700, color: props.color || C.text } }, String(props.value1)), React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 1 } }, props.label1), React.createElement('div', { style: { fontSize: 14, fontWeight: 700, color: props.color || C.text, marginTop: 3 } }, String(props.value2)), React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 1 } }, props.label2)) }
|
|
40
82
|
|
|
83
|
+
// 调研 ROI 对比行(task-mutnjesa):让数据替道理说话——「有调研 vs 无调研」分组账单直接摆给主窗口看,
|
|
84
|
+
// 比任何引导文案都管用。数据源 computeStats 的 roi(null = 任一组为空或总样本 <4,整块不渲染);
|
|
85
|
+
// 无调研组明显更慢(平均执行 > 有调研组 1.2 倍,阈值写清避免把随机波动误读成结论)时其数字用 warn 色。
|
|
86
|
+
// token 口径(task-muupnnq5):**有效消耗**(输入+输出+缓存写,不含缓存读),与「Token 消耗区」大数字同源;
|
|
87
|
+
// 组内只要有一张卡走了 total 兜底(无分量字段的老/存量卡),该数字后加 '~' 并在 title 里写明张数。
|
|
88
|
+
function ResearchRoiRow(props) {
|
|
89
|
+
var roi = props.roi
|
|
90
|
+
if (!roi) return null
|
|
91
|
+
function fmtM(ms) { var m = Math.round(ms / 60000); if (m < 60) return m + 'm'; return (m / 60).toFixed(1) + 'h' }
|
|
92
|
+
var noSlower = roi.no.execMs > roi.yes.execMs * 1.2
|
|
93
|
+
function grp(icon, label, g, warnNums) {
|
|
94
|
+
var approx = (g.tokFallbackN || 0) > 0
|
|
95
|
+
var tip = g.tok === null ? label + ':无带 usage 结算记录的卡,token 均值不可计'
|
|
96
|
+
: g.n + ' 卡中 ' + g.tokN + ' 张有 usage 结算记录;均值口径 = 有效消耗(输入+输出+缓存写,不含缓存读)'
|
|
97
|
+
+ (approx ? ';其中 ' + g.tokFallbackN + ' 张无分量字段(老/存量结算),退化为含缓存读的合计值参与均值(故标 ~)' : '')
|
|
98
|
+
return React.createElement('span', { title: tip },
|
|
99
|
+
icon + ' ' + label + ' ',
|
|
100
|
+
React.createElement('span', { style: { fontWeight: 600, color: warnNums ? C.warn : C.text } }, g.n + ' 卡 平均 ' + fmtM(g.execMs) + '/约 ' + (g.tok === null ? '-' : (approx ? '~' : '') + fmtTokens(g.tok)) + ' tok'))
|
|
101
|
+
}
|
|
102
|
+
return React.createElement('div', { style: { padding: '6px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12, fontSize: 10, color: C.text2, display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }, title: 'resolved/archived 且领取过的卡按有无调研注入分组现算(执行 = 被领取→完成;token 均值只计有 usage 结算记录的卡,口径 = 有效消耗:输入+输出+缓存写,不含缓存读;老卡无分量字段时退化为合计并标 ~)' },
|
|
103
|
+
React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 4 } }, ic('scale', 11), '调研 ROI'),
|
|
104
|
+
grp('📎', '有调研', roi.yes, false),
|
|
105
|
+
React.createElement('span', null, '·'),
|
|
106
|
+
grp('⚠️', '无调研', roi.no, noSlower))
|
|
107
|
+
}
|
|
108
|
+
|
|
41
109
|
function BarRow(props) { var pct = props.total > 0 ? (props.count / props.total * 100) : 0; return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 } }, React.createElement('span', { style: { width: 40, fontSize: 10, color: C.text2, textAlign: 'right', flexShrink: 0 } }, props.label), React.createElement('div', { style: { flex: 1, height: 8, background: C.nested, borderRadius: 4, overflow: 'hidden' } }, React.createElement('div', { style: { height: '100%', width: pct + '%', background: props.color || C.brand, borderRadius: 4, transition: 'width .3s' } })), React.createElement('span', { style: { width: 24, fontSize: 10, color: C.text2, flexShrink: 0 } }, String(props.count))) }
|
|
42
110
|
|
|
43
111
|
// ===== 仪表盘:日期范围筛选(共享给报告与全局总览)=====
|
|
@@ -162,6 +230,24 @@
|
|
|
162
230
|
// usageSummary 由 host 从各任务 t.usage 现算(t.usage 来自 Worker/Verifier 会话 v4 日志的
|
|
163
231
|
// assistant/message.usage 聚合)。这里只做展示、不做计费断言;日志读不到/还没有 run 结算时
|
|
164
232
|
// usageSummary.total 为 0,一律显示「暂无数据」。
|
|
233
|
+
// 日账(usageSummary.byDay:{'YYYY-MM-DD': {t,e}})用于「今日」大数字与「近 7 天」条形。
|
|
234
|
+
// 双指标口径:t = 总量(含缓存读)、e = 有效消耗(输入+输出+缓存写,不含缓存读)。
|
|
235
|
+
// e === null 表示该日只有老形态 number 日账 / 存量兜底(没有逐 run 拆分,有效值不可知)——
|
|
236
|
+
// 展示时退化为 t 并在文案/title 上标 ~ 近似,绝不把总量冒充有效值。
|
|
237
|
+
// dayKey 口径与 host 记账完全一致——本地 getters 拼,绝不用 toISOString()(UTC 会让
|
|
238
|
+
// 晚上 8 点后的消耗落到次日,今日消耗直接错位)。
|
|
239
|
+
function localDayKey(d) {
|
|
240
|
+
var x = d || new Date()
|
|
241
|
+
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
242
|
+
return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
|
|
243
|
+
}
|
|
244
|
+
// 近 n 天的本地日 key 序列(旧 → 新,最后一个是今天);本地日期构造天然跨月/跨年正确
|
|
245
|
+
function lastNDays(n) {
|
|
246
|
+
var out = []
|
|
247
|
+
var now = new Date()
|
|
248
|
+
for (var i = n - 1; i >= 0; i--) out.push(localDayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i)))
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
165
251
|
function fmtTokens(n) {
|
|
166
252
|
var v = Number(n) || 0
|
|
167
253
|
if (v >= 1000000000) return (v / 1000000000).toFixed(1) + 'B'
|
|
@@ -188,13 +274,49 @@
|
|
|
188
274
|
var maxM = models.length ? (models[0].total || 1) : 1
|
|
189
275
|
var top = u.topTasks || []
|
|
190
276
|
var maxT = top.length ? (top[0].total || 1) : 1
|
|
277
|
+
// 日账:今日数字取本地日 key,没有日账(byDay 缺字段/老 host)时退化为 0,不炸也不误报。
|
|
278
|
+
// 读侧兼容两种单元形态:老 number(只有总量,有效值不可知)→ { t: n, e: null }。
|
|
279
|
+
var byDay = (u.byDay && typeof u.byDay === 'object') ? u.byDay : {}
|
|
280
|
+
function dayOf(v) {
|
|
281
|
+
if (v && typeof v === 'object') return { t: Number(v.t) || 0, e: (v.e === 0 || v.e) ? Number(v.e) || 0 : null }
|
|
282
|
+
return { t: Number(v) || 0, e: null }
|
|
283
|
+
}
|
|
284
|
+
var todayKey = localDayKey()
|
|
285
|
+
var todayCell = dayOf(byDay[todayKey])
|
|
286
|
+
// 有效消耗缺失(老日账/存量兜底)→ 大数字退化为总量并打 approx 标 ~,口径不伪造
|
|
287
|
+
var todayApprox = todayCell.e === null
|
|
288
|
+
var todayEff = todayApprox ? todayCell.t : todayCell.e
|
|
289
|
+
var days = lastNDays(7)
|
|
290
|
+
var maxDay = 1
|
|
291
|
+
var hasDayData = false
|
|
292
|
+
for (var di = 0; di < days.length; di++) { var dc = dayOf(byDay[days[di]]); var dv = (dc.e === null ? dc.t : dc.e); if (dv > maxDay) maxDay = dv; if (dc.t > 0 || dc.e > 0) hasDayData = true }
|
|
191
293
|
function mini(label, value) { return React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, label + ' ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(value))) }
|
|
294
|
+
// 累计三分量:有效(真实成本)+ 缓存读(占了 total 的大头,必须单列才看得出虚高来源)+ 合计
|
|
295
|
+
var effTotal = (typeof u.effective === 'number') ? u.effective : ((u.input || 0) + (u.output || 0) + (u.cacheWrite || 0))
|
|
192
296
|
return React.createElement('div', { style: box },
|
|
193
297
|
head,
|
|
194
298
|
React.createElement('div', { style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', marginBottom: 8 } },
|
|
195
|
-
React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: C.brand } }, fmtTokens(
|
|
196
|
-
React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens
|
|
197
|
-
|
|
299
|
+
React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: C.brand }, title: '今日有效消耗(本地日 ' + todayKey + ')= 输入+输出+缓存写,不含缓存读;一次 run 的消耗整笔记在结算日' + (todayApprox ? '。本条日账来自老形态/存量兜底数据,有效值不可知,此处以总量近似(标 ~)' : '') }, (todayApprox ? '~' : '') + fmtTokens(todayEff)),
|
|
300
|
+
React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens(今日有效' + (todayApprox ? ' · 近似' : '') + ')'),
|
|
301
|
+
todayCell.t > todayEff ? React.createElement('span', { style: { fontSize: 10, color: C.text2 }, title: '今日总量(含缓存读)——与有效消耗的差额就是缓存读' }, '含缓存读共 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(todayCell.t))) : null,
|
|
302
|
+
React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, '累计(本看板) 有效 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(effTotal)), ' · 缓存读 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(u.cacheRead)), ' · 合计 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(u.total))),
|
|
303
|
+
mini('输入', u.input), mini('输出', u.output), u.cacheWrite ? mini('缓存写', u.cacheWrite) : null),
|
|
304
|
+
// 近 7 天迷你条形:高按区间 max 归一(今天高亮 brand,其余浅底 + 边框),
|
|
305
|
+
// 柱高一律取**有效消耗**;e 不可知的日退化为总量(title 标 ~ 近似)。
|
|
306
|
+
// 7 天全为 0 时整块不渲染(零残留,不占版面)
|
|
307
|
+
hasDayData ? React.createElement('div', { style: { marginBottom: 8 } },
|
|
308
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '近 7 天(有效消耗)'),
|
|
309
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 4 } },
|
|
310
|
+
days.map(function (k) {
|
|
311
|
+
var c = dayOf(byDay[k])
|
|
312
|
+
var approx = c.e === null
|
|
313
|
+
var v = approx ? c.t : c.e
|
|
314
|
+
var isToday = k === todayKey
|
|
315
|
+
var h = v > 0 ? Math.max(3, Math.round(v / maxDay * 32)) : 3
|
|
316
|
+
return React.createElement('div', { key: k, title: k.slice(5) + ':有效 ' + String(v) + (approx ? '(近似:老日账只有总量)' : '') + ' tok' + (c.t > v ? ' / 含缓存读共 ' + String(c.t) + ' tok' : ''), style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 } },
|
|
317
|
+
React.createElement('div', { style: { width: '100%', height: h, background: isToday ? C.brand : C.nested, border: '1px solid ' + (isToday ? C.brand : C.border), borderRadius: 2 } }),
|
|
318
|
+
React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2, whiteSpace: 'nowrap' } }, k.slice(5)))
|
|
319
|
+
}))) : null,
|
|
198
320
|
React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
199
321
|
React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
|
|
200
322
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布'),
|
|
@@ -205,8 +327,10 @@
|
|
|
205
327
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8'),
|
|
206
328
|
top.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : top.map(function (x) {
|
|
207
329
|
var t = getTask(x.id)
|
|
208
|
-
return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.total, max: maxT, color: C.ok, labelColor: t ? C.brand : C.text2, title: x.title + '
|
|
209
|
-
})))
|
|
330
|
+
return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.total, max: maxT, color: C.ok, labelColor: t ? C.brand : C.text2, title: x.title + '(合计 ' + String(x.total) + ' tokens · 有效 ' + String((typeof x.effective === 'number') ? x.effective : (x.total - (x.cacheRead || 0))) + ' / 缓存读 ' + String(x.cacheRead || 0) + ' · ' + (x.runs || 0) + ' 次 run)' + (t ? '——点击查看详情' : ''), onClick: t ? function () { state.detailId = x.id; notify() } : undefined })
|
|
331
|
+
}))),
|
|
332
|
+
// 口径边界:本区只统计看板派发的 Worker/Verifier run,主窗口对话自身不越界纳入
|
|
333
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话;大数字与「近 7 天」为有效消耗(输入+输出+缓存写,不含缓存读),缓存读单列'))
|
|
210
334
|
}
|
|
211
335
|
|
|
212
336
|
// ===== 架构健康提示区(架构自省 L1 · 数据源:state.healthHints)=====
|
|
@@ -242,6 +366,7 @@
|
|
|
242
366
|
React.createElement('div', { style: { flex: '1 1 0', minWidth: 200, padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '按状态分布'), statusOrder.map(function (s) { return React.createElement(BarRow, { key: s, label: statusLabels[s] || s, count: stats.byStatus[s] || 0, total: stats.total, color: statusColors[s] || C.brand }) })),
|
|
243
367
|
React.createElement('div', { style: { flex: '1 1 0', minWidth: 200, padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '按优先级分布'), prioOrder.map(function (p) { return React.createElement(BarRow, { key: p, label: prioLabel[p] || p, count: stats.byPriority[p] || 0, total: stats.total, color: prioColor[p] || C.brand }) }))),
|
|
244
368
|
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' } }, React.createElement(StatCardDual, { label1: '平均排队', value1: stats.avgQueue, label2: '平均执行', value2: stats.avgExec, title: '耗时口径三分离:⏳ 排队 = 创建 → 被领取(等了多久);⏱ 执行 = 被领取 → 完成(干了多久);均不含验收时长' }), React.createElement(StatCard, { label: '平均验证时间', value: stats.avgVerify }), React.createElement(StatCard, { label: '今日完成', value: stats.todayDone, color: C.ok })),
|
|
369
|
+
React.createElement(ResearchRoiRow, { roi: stats.roi }),
|
|
245
370
|
React.createElement(TrendChart, { data: stats.dailyDone }),
|
|
246
371
|
Object.keys(stats.byAgent).length > 0 ? React.createElement('div', { style: { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, 'Agent 负载(进行中+验证中)'), Object.keys(stats.byAgent).map(function (aid) { return React.createElement('div', { key: aid, style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3, fontSize: 11 } }, React.createElement(ActorLink, { id: aid }), React.createElement('span', { style: { color: C.text2 } }, stats.byAgent[aid] + ' 个任务')) })) : null,
|
|
247
372
|
React.createElement('div', { style: { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '最近活跃'), stats.recentActivity.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无记录') : stats.recentActivity.map(function (a, i) { return React.createElement('div', { key: i, style: { fontSize: 10, color: C.text2, marginBottom: 3, display: 'flex', gap: 4, alignItems: 'center' } }, React.createElement('span', { style: { color: statusColors[a.to] || C.brand, fontWeight: 600 } }, statusLabels[a.to] || a.to), React.createElement('span', { style: { color: C.text, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, a.title), React.createElement(ActorLink, { id: a.actor }), React.createElement('span', null, ago(a.timestamp))) })))
|
|
@@ -370,7 +495,25 @@
|
|
|
370
495
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '学习反馈'),
|
|
371
496
|
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
372
497
|
React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { rpc('set-board-config', { key: 'feedbackEnabled', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
|
|
373
|
-
React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)'))
|
|
498
|
+
React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)')),
|
|
499
|
+
// 回执开关(用户要求「回执可以做一个开关,放到设置里」):派发/完成两类回执各自可关,缺省都开。
|
|
500
|
+
// 只影响回执播报,不影响派发与状态机;歧义裁决通知不在此闸门内(见下行说明文案)。
|
|
501
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '通知'),
|
|
502
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
503
|
+
React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) { rpc('set-board-config', { key: 'notifyDispatch', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
|
|
504
|
+
React.createElement('span', null, '⚡ 派发回执(任务被 Worker/Verifier 领走时播报)')),
|
|
505
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260, marginTop: 3 } },
|
|
506
|
+
React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) { rpc('set-board-config', { key: 'notifyDone', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
|
|
507
|
+
React.createElement('span', null, '✅ 完成回执(任务完成或阻塞时聚合播报)')),
|
|
508
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '歧义裁决通知不受这两个开关影响(任务等人裁决必须提醒)'),
|
|
509
|
+
// 史诗拆分总开关(板级 epicSplit,缺省 true):**只关引导,不禁机制**——关掉后 Team 提示词不再
|
|
510
|
+
// 注入「大任务必须拆分」第 6 条、create-task 响应不再附 suggestSplit 软提示;显式传 parentId 建
|
|
511
|
+
// 子卡、史诗自动收口/hooks 状态机照常(用户/主窗口明确要拆时不受阻)。勾选态缺字段=开,与 host 同口径。
|
|
512
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '功能'),
|
|
513
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
514
|
+
React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { rpc('set-board-config', { key: 'epicSplit', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
|
|
515
|
+
React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
|
|
516
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')) : null)
|
|
374
517
|
}
|
|
375
518
|
|
|
376
519
|
function TeamView() {
|