@dijkspicy/opencode-stats-reporter 0.1.5 → 0.1.6

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.
@@ -65,7 +65,7 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
65
65
  {
66
66
  "schema_version": 1,
67
67
  "install_id": "0b3f7e6a-....",
68
- "reporter": { "client": "opencode", "version": "0.1.5" },
68
+ "reporter": { "client": "opencode", "version": "0.1.6" },
69
69
  "sessions": [
70
70
  {
71
71
  "session_id": "ses_...",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-reporter",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "opencode 客户端插件:在会话空闲时聚合各会话(含子代理会话)的 token 用量快照,经有界队列 + 指数退避非阻塞上报;载荷不含消费者身份字段。",
@@ -46,7 +46,7 @@ import { dirname, join } from "node:path"
46
46
  // ---------------------------------------------------------------------------
47
47
 
48
48
  const SCHEMA_VERSION = 1
49
- const PLUGIN_VERSION = "0.1.5"
49
+ const PLUGIN_VERSION = "0.1.6"
50
50
  /** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
51
51
  const QUEUE_CAP = 500
52
52
  /** 单次上报请求超时(AbortSignal.timeout) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dijkspicy/opencode-stats-reporter",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
5
5
  "type": "module",
6
6
  "main": "./contrib/opencode-session-reporter/plugin-server.ts",
@@ -537,9 +537,9 @@ body {
537
537
  white-space: nowrap;
538
538
  vertical-align: bottom;
539
539
  font-weight: 600;
540
- cursor: pointer;
541
540
  }
542
- .sess-title:hover { color: var(--accent-hover); text-decoration: underline; }
541
+ .sess-title.is-link { cursor: pointer; }
542
+ .sess-title.is-link:hover { color: var(--accent-hover); text-decoration: underline; }
543
543
  .sess-sub-line {
544
544
  display: flex;
545
545
  align-items: center;
@@ -562,7 +562,7 @@ body {
562
562
  .sess-dir { max-width: 120px; }
563
563
  }
564
564
 
565
- /* 任务分组:顶层会话行充当组头(徽标 + 组间留白),子会话缩进并以左侧竖线挂在任务下 */
565
+ /* 任务行:箭头展开控件 + 「任务」徽标 + 会话数徽标;token 列为任务合计,模型/代理/消息数留 '--' */
566
566
  #session-table tr.sess-task > td { padding-top: 14px; }
567
567
  tr.sess-task .sess-title-line { display: flex; align-items: center; gap: 6px; }
568
568
  .sess-task-badge {
@@ -575,6 +575,61 @@ tr.sess-task .sess-title-line { display: flex; align-items: center; gap: 6px; }
575
575
  font-weight: 600;
576
576
  line-height: 16px;
577
577
  }
578
+ .sess-count-chip {
579
+ flex: none;
580
+ padding: 0 7px;
581
+ border: 1px solid var(--border);
582
+ border-radius: 999px;
583
+ background: var(--bg);
584
+ color: var(--text-secondary);
585
+ font-size: 10px;
586
+ line-height: 16px;
587
+ font-variant-numeric: tabular-nums;
588
+ }
589
+
590
+ /* 展开/收起箭头:独立点击区,与标题(进详情)互不误触;加载中转圈 */
591
+ .sess-expand {
592
+ flex: none;
593
+ width: 18px;
594
+ height: 18px;
595
+ padding: 0;
596
+ border: none;
597
+ background: transparent;
598
+ display: inline-flex;
599
+ align-items: center;
600
+ justify-content: center;
601
+ cursor: pointer;
602
+ }
603
+ .sess-expand:focus-visible { outline: 1px solid var(--accent); }
604
+ .sess-expand .tri {
605
+ width: 0;
606
+ height: 0;
607
+ border-left: 5px solid var(--text-secondary);
608
+ border-top: 4px solid transparent;
609
+ border-bottom: 4px solid transparent;
610
+ transition: transform .15s;
611
+ }
612
+ .sess-expand:hover .tri { border-left-color: var(--accent); }
613
+ .sess-expand .tri.is-open { transform: rotate(90deg); }
614
+ .sess-expand .spin {
615
+ width: 10px;
616
+ height: 10px;
617
+ border: 2px solid var(--border);
618
+ border-top-color: var(--accent);
619
+ border-radius: 50%;
620
+ animation: sess-spin .8s linear infinite;
621
+ }
622
+ @keyframes sess-spin { to { transform: rotate(360deg); } }
623
+
624
+ /* 任务展开区的加载 / 错误通栏行:与子会话行同缩进 */
625
+ #session-table tr.sess-expand-state > td {
626
+ font-size: 12px;
627
+ color: var(--text-secondary);
628
+ padding-left: 30px;
629
+ }
630
+ #session-table tr.sess-expand-state > td .btn { margin-left: 10px; }
631
+ #session-table tbody tr.sess-expand-state:hover { background: transparent; }
632
+
578
633
  #session-table tr.sess-child .sess-cell { position: relative; padding-left: 30px; }
579
634
  #session-table tr.sess-child .sess-cell::before {
580
635
  content: "";
@@ -586,10 +641,6 @@ tr.sess-task .sess-title-line { display: flex; align-items: center; gap: 6px; }
586
641
  background: var(--border);
587
642
  }
588
643
 
589
- /* 根会话不在已加载结果中时的合成组头:只声明归属,不虚构任务名 */
590
- tr.sess-orphan-head > td { font-size: 11px; color: var(--text-secondary); padding-top: 14px; }
591
- #session-table tbody tr.sess-orphan-head:hover { background: transparent; }
592
-
593
644
  /* 会话 ID:默认弱化(有标题时),等宽头尾截断,完整值在 title,点击进详情 */
594
645
  .sess-id-btn {
595
646
  display: inline-block;
package/panel/index.html CHANGED
@@ -280,7 +280,7 @@
280
280
  <!-- 会话列表 -->
281
281
  <section id="card-session-list" class="card">
282
282
  <div class="table-head">
283
- <h2 class="card-title">会话列表 <span class="src-badge" title="数据来自客户端上报,可能延迟或遗漏">客户端上报</span></h2>
283
+ <h2 class="card-title">任务列表 <span class="src-badge" title="数据来自客户端上报,可能延迟或遗漏">客户端上报</span></h2>
284
284
  <div class="sess-filter">
285
285
  <label class="sess-filter-item">模型
286
286
  <select id="sess-filter-model" aria-label="按模型筛选">
@@ -296,16 +296,16 @@
296
296
  <table id="session-table" class="session-table">
297
297
  <thead>
298
298
  <tr>
299
- <th title="同一顶层任务的会话归为一组:顶层会话标「任务」,子会话缩进挂在其下;组按最近活动排序,根会话不在当前结果时以灰字注明">会话</th>
300
- <th>模型</th>
301
- <th>代理</th>
302
- <th class="num" title="输入总量 = 输入 + 缓存读 + 缓存写">输入总量</th>
303
- <th class="num" title="输出总量 = 输出 + 推理">输出总量</th>
304
- <th class="num">推理</th>
305
- <th class="num">缓存读</th>
306
- <th class="num">缓存写</th>
307
- <th class="num">消息数</th>
308
- <th>最后活动</th>
299
+ <th title="每行一个顶层任务(token 为任务合计);点箭头展开/收起子会话,点标题查看根会话详情">任务</th>
300
+ <th title="会话级属性;任务行不显示,展开子会话查看">模型</th>
301
+ <th title="会话级属性;任务行不显示,展开子会话查看">代理</th>
302
+ <th class="num" title="输入总量 = 输入 + 缓存读 + 缓存写;任务行 = 该任务全部会话合计">输入总量</th>
303
+ <th class="num" title="输出总量 = 输出 + 推理;任务行 = 该任务全部会话合计">输出总量</th>
304
+ <th class="num" title="任务行 = 该任务全部会话合计">推理</th>
305
+ <th class="num" title="任务行 = 该任务全部会话合计">缓存读</th>
306
+ <th class="num" title="任务行 = 该任务全部会话合计">缓存写</th>
307
+ <th class="num" title="会话级属性;任务行不聚合消息数">消息数</th>
308
+ <th title="任务行 = 该任务最近一次会话活动">最后活动</th>
309
309
  </tr>
310
310
  </thead>
311
311
  <tbody id="session-tbody"></tbody>
package/panel/js/api.js CHANGED
@@ -89,7 +89,9 @@
89
89
  }
90
90
 
91
91
  // ---- 会话用量接口(自建会话服务,经同一网关地址访问;数据为客户端上报口径,非网关权威) ----
92
- /** 会话列表:window / model / agent / install_id / limit / cursor(游标分页) */
92
+ /** 任务聚合:/v1/tasks,按顶层会话聚合的 token 合计 + 会话数(服务端口径),window/model/agent/limit/cursor */
93
+ function tasks(cfg, params) { return get(cfg, '/v1/tasks', params || {}); }
94
+ /** 会话列表:window / model / agent / root_session_id / install_id / limit / cursor(游标分页) */
93
95
  function sessions(cfg, params) { return get(cfg, '/v1/sessions', params || {}); }
94
96
  /** 单会话详情:不存在或不属于当前消费者时返回 404 */
95
97
  function sessionDetail(cfg, id) { return get(cfg, '/v1/sessions/' + encodeURIComponent(id)); }
@@ -144,6 +146,7 @@
144
146
  byModel: byModel,
145
147
  byProvider: byProvider,
146
148
  performance: performance,
149
+ tasks: tasks,
147
150
  sessions: sessions,
148
151
  sessionDetail: sessionDetail,
149
152
  sessionsSummary: sessionsSummary,
@@ -1,8 +1,10 @@
1
1
  /**
2
- * 会话视图:客户端上报的会话级 token 用量
3
- * 数据源:/v1/sessions*(客户端上报,可能延迟/遗漏,非网关权威数据)
2
+ * 会话视图:以顶层任务为主维度的 token 用量
3
+ * 数据源:/v1/tasks(任务级聚合,含 token 合计与会话数)+ /v1/sessions?root_session_id=(展开时按需拉取子会话)
4
+ * 均为客户端上报口径,可能延迟/遗漏,非网关权威数据
4
5
  * token 口径(已实测验证):输入总量 = 输入 + 缓存读 + 缓存写;输出总量 = 输出 + 推理(推理单列,不并入输出)
5
- * 结构:来源声明 → 用量对账(网关权威总量 − 会话上报合计 = 未归属用量)→ 会话列表(按顶层任务分组 + 窗口/模型/代理筛选 + 游标分页)→ 会话详情
6
+ * 任务行直接展示服务端 tokens_input_total / tokens_output_total(与子会话行同口径),客户端不做页内相加
7
+ * 结构:来源声明 → 用量对账(网关权威总量 − 会话上报合计 = 未归属用量)→ 任务列表(子会话默认收起、点击展开 + 窗口/模型/代理筛选 + 游标分页)→ 会话详情
6
8
  * 由 app.js 初始化:BRU.sessions.init({ getCfg, getPeriod, getModelCatalog, onUnauthorized }),并经 BRU.sessions.load() 触发拉取
7
9
  */
8
10
  (function () {
@@ -20,11 +22,13 @@
20
22
  var st = {
21
23
  seq: 0, // 请求代次:响应到达时核对,过期响应(切窗/切筛选后)直接丢弃
22
24
  period: 'today',
23
- rows: [], // 已加载会话(分页累积)
24
- nextCursor: null, // 下一页游标(null = 没有更多)
25
+ tasks: [], // 已加载任务(/v1/tasks 分页累积,服务端按任务级 last_activity_at 倒序)
26
+ taskCursor: null, // 下一页游标(null = 没有更多)
27
+ expanded: {}, // root_session_id -> { status: 'loading'|'open'|'closed'|'error', rows: []|null, error, pending }
28
+ // 子会话拉取后缓存于 rows:重复展开不再请求;切窗/切筛选(load)时整体重置
25
29
  filters: { model: '', agent: '' },
26
- modelSet: {}, // 模型筛选项(模型目录 + 已见会话模型)
27
- agentSet: {}, // 代理筛选项(已见会话代理)
30
+ modelSet: {}, // 模型筛选项(模型目录 + 已展开子会话模型)
31
+ agentSet: {}, // 代理筛选项(已展开子会话代理)
28
32
  selectedId: null // 当前打开详情的会话 ID
29
33
  };
30
34
 
@@ -77,7 +81,7 @@
77
81
  }
78
82
 
79
83
  // ---------- 加载编排 ----------
80
- /** 整页加载:列表重置到第一页 + 对账聚合;seq 保证切窗/切筛选后的旧响应不回写 */
84
+ /** 整页加载:任务列表重置到第一页 + 对账聚合;seq 保证切窗/切筛选后的旧响应不回写;展开态与子会话缓存一并重置 */
81
85
  function load() {
82
86
  var cfg = deps && deps.getCfg();
83
87
  if (!cfg) return Promise.resolve();
@@ -85,34 +89,34 @@
85
89
  st.period = deps.getPeriod();
86
90
  setCardState(el.coverageCard, 'loading');
87
91
  setCardState(el.listCard, 'loading');
88
- st.rows = [];
89
- st.nextCursor = null;
92
+ st.tasks = [];
93
+ st.taskCursor = null;
94
+ st.expanded = {};
90
95
  el.tbody.innerHTML = '';
91
96
  el.count.textContent = '';
92
97
  hide(el.btnMore);
93
98
  closeDetail();
94
99
  var w = st.period;
95
100
  return Promise.all([
96
- fetchPage(cfg, w, null, true, seq),
101
+ fetchTasks(cfg, w, null, true, seq),
97
102
  fetchCoverage(cfg, w, seq)
98
103
  ]);
99
104
  }
100
105
 
101
- /** 拉取一页会话:isReset=true 首页(覆盖),false 追加(加载更多);失败走卡片错误层 + 重试 */
102
- function fetchPage(cfg, window_, cursor, isReset, seq) {
106
+ /** 拉取一页任务:isReset=true 首页(覆盖),false 追加(加载更多);失败走卡片错误层 + 重试 */
107
+ function fetchTasks(cfg, window_, cursor, isReset, seq) {
103
108
  var params = { window: window_, limit: PAGE_SIZE };
104
109
  if (st.filters.model) params.model = st.filters.model;
105
110
  if (st.filters.agent) params.agent = st.filters.agent;
106
111
  if (cursor) params.cursor = cursor;
107
- return API.sessions(cfg, params).then(function (res) {
112
+ return API.tasks(cfg, params).then(function (res) {
108
113
  if (seq !== st.seq) return;
109
114
  var rows = (res && res.data) || [];
110
- st.rows = isReset ? rows : st.rows.concat(rows);
111
- st.nextCursor = (res && res.next_cursor) || null;
112
- absorbOptions(rows);
113
- renderRows();
114
- el.count.textContent = '已加载 ' + st.rows.length + ' 个会话' + (st.nextCursor ? '(还有更多)' : '');
115
- if (st.nextCursor) show(el.btnMore); else hide(el.btnMore);
115
+ st.tasks = isReset ? rows : st.tasks.concat(rows);
116
+ st.taskCursor = (res && res.next_cursor) || null;
117
+ renderTasks();
118
+ el.count.textContent = '已加载 ' + st.tasks.length + ' 个任务' + (st.taskCursor ? '(还有更多)' : '');
119
+ if (st.taskCursor) show(el.btnMore); else hide(el.btnMore);
116
120
  if (isReset && !rows.length) {
117
121
  setCardState(el.listCard, 'empty', null, null,
118
122
  (st.filters.model || st.filters.agent) ? '当前筛选条件下暂无会话上报' : null);
@@ -124,7 +128,7 @@
124
128
  if (seq !== st.seq) return;
125
129
  if (err && err.code === 'unauthorized') { deps.onUnauthorized(UNAUTH_MSG); return; }
126
130
  setCardState(el.listCard, 'error', (err && err.message) || '加载失败', function () {
127
- fetchPage(deps.getCfg(), deps.getPeriod(), cursor, isReset, st.seq);
131
+ fetchTasks(deps.getCfg(), deps.getPeriod(), cursor, isReset, st.seq);
128
132
  });
129
133
  });
130
134
  }
@@ -189,60 +193,191 @@
189
193
  el.covNote.textContent = note;
190
194
  }
191
195
 
192
- // ---------- 会话列表 ----------
196
+ // ---------- 任务列表 ----------
193
197
  /**
194
- * 按顶层任务组织页内呈现(不改变拉取与游标逻辑,服务端排序保持 last_activity_at 倒序):
195
- * - root_session_id 为空或等于自身 → 独立任务行(契约:顶层会话 root 等于自身;旧数据为 null 时同样按顶层处理)
196
- * - 子会话按 root_session_id 归组;根已加载则根行充当组头,根不在已加载行中则落合成组头(orphanHeadRow)
197
- * - 组间按组内最近活动倒序;组内根在前,子会话保持服务端返回顺序(即最近活动倒序)
198
+ * 默认只渲染任务行(/v1/tasks,服务端已按任务级 last_activity_at 倒序,客户端不再重排)。
199
+ * 子会话默认收起:展开态的任务行下方按需插入「加载中 / 错误可重试 / 子会话行」。
200
+ * root_session_id 为空的任务(旧数据无血缘)按单会话任务降级:无展开控件、标题缺失时降级 '--'。
198
201
  */
199
- function renderRows() {
202
+ function renderTasks() {
200
203
  var tbody = el.tbody;
201
204
  tbody.innerHTML = '';
202
- buildGroups(st.rows).forEach(function (g) {
203
- if (g.rootRow) tbody.appendChild(sessionRow(g.rootRow, 'task'));
204
- else tbody.appendChild(orphanHeadRow(g.key));
205
- g.children.forEach(function (r) { tbody.appendChild(sessionRow(r, 'child')); });
205
+ st.tasks.forEach(function (t) {
206
+ var rootId = nonEmpty(t.root_session_id);
207
+ tbody.appendChild(taskRow(t, rootId));
208
+ var s = rootId ? st.expanded[rootId] : null;
209
+ if (!s) return;
210
+ if (s.status === 'loading') tbody.appendChild(stateRow('正在加载子会话…'));
211
+ else if (s.status === 'error') tbody.appendChild(stateRow('子会话加载失败:' + (s.error || '加载失败'), rootId));
212
+ else if (s.status === 'open') (s.rows || []).forEach(function (r) { tbody.appendChild(sessionRow(r)); });
206
213
  });
207
214
  }
208
215
 
209
- function buildGroups(rows) {
210
- var map = {};
211
- var list = [];
212
- function group(key, idx) {
213
- if (!map[key]) { map[key] = { key: key, rootRow: null, children: [], newest: -Infinity, idx: idx }; list.push(map[key]); }
214
- return map[key];
215
- }
216
- rows.forEach(function (r, i) {
217
- var sid = nonEmpty(r.session_id);
218
- var rootId = nonEmpty(r.root_session_id);
219
- var isTop = !sid || !rootId || rootId === sid;
220
- var g = group(isTop ? (sid || '\u0000' + i) : rootId, i);
221
- if (isTop) g.rootRow = r;
222
- else g.children.push(r);
223
- var act = Number(r.last_activity_at);
224
- if (isFinite(act) && act > g.newest) g.newest = act;
216
+ /** 展开/收起:已拉取的子会话缓存复用(重复展开不再请求);加载中点击 = 收起并丢弃未返回的结果 */
217
+ function toggleTask(rootId) {
218
+ var s = st.expanded[rootId];
219
+ if (s && s.status === 'open') { s.status = 'closed'; renderTasks(); return; }
220
+ if (s && s.status === 'loading') { s.pending = false; s.status = 'closed'; renderTasks(); return; }
221
+ if (s && s.rows) { s.status = 'open'; renderTasks(); return; } // 收起后再展开:用缓存
222
+ st.expanded[rootId] = { status: 'loading', rows: null, pending: true };
223
+ renderTasks();
224
+ fetchChildren(rootId);
225
+ }
226
+
227
+ /** 拉取单个任务的全部会话(根自身 + 全部子会话,limit=200 上限);pending 防止已收起后迟到响应回写 */
228
+ function fetchChildren(rootId) {
229
+ var cfg = deps.getCfg();
230
+ if (!cfg) return;
231
+ var seq = st.seq;
232
+ API.sessions(cfg, { root_session_id: rootId, limit: 200 }).then(function (res) {
233
+ if (seq !== st.seq) return;
234
+ var s = st.expanded[rootId];
235
+ if (!s || !s.pending) return;
236
+ s.rows = (res && res.data) || [];
237
+ s.status = 'open';
238
+ s.pending = false;
239
+ absorbOptions(s.rows); // 子会话的模型/代理补进筛选项
240
+ renderTasks();
241
+ }, function (err) {
242
+ if (seq !== st.seq) return;
243
+ var s = st.expanded[rootId];
244
+ if (!s || !s.pending) return;
245
+ s.pending = false;
246
+ if (err && err.code === 'unauthorized') { deps.onUnauthorized(UNAUTH_MSG); return; }
247
+ s.status = 'error';
248
+ s.error = (err && err.message) || '加载失败';
249
+ renderTasks();
225
250
  });
226
- list.sort(function (a, b) { return b.newest - a.newest || a.idx - b.idx; });
227
- return list;
228
251
  }
229
252
 
230
- /** 根会话不在已加载行中(分页未到 / 窗口或筛选未含根 / 旧数据)时的合成组头:只声明归属,不虚构任务名 */
231
- function orphanHeadRow(rootId) {
253
+ /** 展开控件:箭头按钮,独立于标题的点击区;状态(开/合/加载中)每次渲染时按 st 重建 */
254
+ function expandButton(rootId) {
255
+ var b = document.createElement('button');
256
+ b.type = 'button';
257
+ b.className = 'sess-expand';
258
+ var tri = document.createElement('span');
259
+ var s = st.expanded[rootId];
260
+ if (s && s.status === 'open') { tri.className = 'tri is-open'; b.title = '收起子会话'; b.setAttribute('aria-label', '收起子会话'); }
261
+ else if (s && s.status === 'loading') { tri.className = 'spin'; b.title = '正在加载子会话'; b.setAttribute('aria-label', '正在加载子会话'); }
262
+ else { tri.className = 'tri'; b.title = '展开子会话'; b.setAttribute('aria-label', '展开子会话'); }
263
+ b.appendChild(tri);
264
+ b.addEventListener('click', function () { toggleTask(rootId); });
265
+ return b;
266
+ }
267
+
268
+ /** 任务行展开区的状态行:colspan 通栏;error 态带重试按钮 */
269
+ function stateRow(text, errorRootId) {
232
270
  var tr = document.createElement('tr');
233
- tr.className = 'sess-orphan-head';
271
+ tr.className = 'sess-expand-state';
234
272
  var c = document.createElement('td');
235
273
  c.colSpan = 10;
236
- c.textContent = '所属顶层会话不在当前列表 · ';
237
- var id = document.createElement('span');
238
- id.className = 'mono';
239
- id.textContent = shortId(rootId);
240
- id.title = rootId + '\n根会话 ID(未在当前结果中)';
241
- c.appendChild(id);
274
+ c.textContent = text;
275
+ if (errorRootId) {
276
+ var btn = document.createElement('button');
277
+ btn.className = 'btn btn-sm';
278
+ btn.textContent = '重试';
279
+ btn.addEventListener('click', function () {
280
+ st.expanded[errorRootId] = { status: 'loading', rows: null, pending: true };
281
+ renderTasks();
282
+ fetchChildren(errorRootId);
283
+ });
284
+ c.appendChild(btn);
285
+ }
242
286
  tr.appendChild(c);
243
287
  return tr;
244
288
  }
245
289
 
290
+ /**
291
+ * 任务行:与子会话行共用同一套列(token 各列同口径),仅「模型 / 代理 / 消息数」为会话级属性,
292
+ * 任务行显示 '--' 并在悬浮说明,不用某个子会话的值冒充任务值。
293
+ * token 合计取服务端 tokens_input_total / tokens_output_total(契约与客户端派生公式一致),缺失时才退回客户端派生。
294
+ */
295
+ function taskRow(t, rootId) {
296
+ var tr = document.createElement('tr');
297
+ tr.className = 'sess-task';
298
+ var rawTitle = nonEmpty(t.session_title);
299
+ var dir = nonEmpty(t.session_directory);
300
+
301
+ var cell = document.createElement('td');
302
+ cell.className = 'sess-cell';
303
+
304
+ var line1 = document.createElement('div');
305
+ line1.className = 'sess-title-line';
306
+ var cnt = Number(t.session_count);
307
+ var hasCount = isFinite(cnt) && cnt > 0;
308
+ // 单会话任务(session_count<=1)没有可展开的子会话,不提供展开控件,避免展开后空组
309
+ var expandable = rootId && (!hasCount || cnt > 1);
310
+ if (expandable) line1.appendChild(expandButton(rootId));
311
+
312
+ var badge = document.createElement('span');
313
+ badge.className = 'sess-task-badge';
314
+ badge.textContent = '任务';
315
+ line1.appendChild(badge);
316
+
317
+ var titleEl = document.createElement('span');
318
+ titleEl.className = 'sess-title' + (rootId ? ' is-link' : '');
319
+ titleEl.textContent = rawTitle || (rootId ? shortId(rootId) : '--');
320
+ if (rootId) {
321
+ titleEl.title = (rawTitle || rootId) + '\n点击查看根会话详情';
322
+ titleEl.addEventListener('click', function () { openDetail(rootId); });
323
+ } else if (rawTitle) {
324
+ titleEl.title = rawTitle;
325
+ }
326
+ line1.appendChild(titleEl);
327
+
328
+ if (hasCount) {
329
+ var chip = document.createElement('span');
330
+ chip.className = 'sess-count-chip';
331
+ chip.textContent = F.fullNumber(cnt) + ' 个会话';
332
+ chip.title = '该任务共 ' + F.fullNumber(cnt) + ' 个会话(含顶层会话与子会话)';
333
+ line1.appendChild(chip);
334
+ }
335
+ cell.appendChild(line1);
336
+
337
+ var line2 = document.createElement('div');
338
+ line2.className = 'sess-sub-line';
339
+ if (dir) {
340
+ var d = document.createElement('span');
341
+ d.className = 'sess-dir mono';
342
+ d.textContent = shortDir(dir);
343
+ d.title = '目录:' + dir;
344
+ line2.appendChild(d);
345
+ }
346
+ if (rootId) {
347
+ if (dir) {
348
+ var sep = document.createElement('span');
349
+ sep.className = 'sess-sub-sep';
350
+ sep.textContent = '·';
351
+ line2.appendChild(sep);
352
+ }
353
+ line2.appendChild(idButton(rootId, !rawTitle));
354
+ }
355
+ if (line2.childNodes.length) cell.appendChild(line2);
356
+
357
+ tr.appendChild(cell);
358
+
359
+ tr.appendChild(td('--', null, '任务行不显示会话级模型,展开查看各子会话'));
360
+ tr.appendChild(td('--', null, '任务行不显示会话级代理,展开查看各子会话'));
361
+ tr.appendChild(td(F.fullNumber(taskTotal(t, 'tokens_input_total', inputTotal)), 'num', '输入总量 = 输入 + 缓存读 + 缓存写'));
362
+ tr.appendChild(td(F.fullNumber(taskTotal(t, 'tokens_output_total', outputTotal)), 'num', '输出总量 = 输出 + 推理'));
363
+ tr.appendChild(td(F.fullNumber(t.tokens_reasoning), 'num'));
364
+ tr.appendChild(td(F.fullNumber(t.tokens_cache_read), 'num'));
365
+ tr.appendChild(td(F.fullNumber(t.tokens_cache_write), 'num'));
366
+ tr.appendChild(td('--', null, '任务级不聚合消息数,展开查看各子会话'));
367
+ var lastFull = F.dateTime(t.last_activity_at);
368
+ var firstFull = F.dateTime(t.first_activity_at);
369
+ var tip = lastFull === '--' ? '' : '最后活动:' + lastFull;
370
+ if (tip && firstFull !== '--') tip += '\n首次活动:' + firstFull;
371
+ tr.appendChild(td(F.shortDateTime(t.last_activity_at), null, tip));
372
+ return tr;
373
+ }
374
+
375
+ /** 任务级合计:优先服务端聚合字段,缺失时退回与子会话行相同的客户端派生口径 */
376
+ function taskTotal(t, serverField, derive) {
377
+ var v = t[serverField];
378
+ return (typeof v === 'number' && isFinite(v)) ? v : derive(t);
379
+ }
380
+
246
381
  /** 空值守卫:非字符串或空白串返回 null,避免把 null/undefined/空串渲染到页面 */
247
382
  function nonEmpty(v) {
248
383
  return (typeof v === 'string' && v.trim()) ? v.trim() : null;
@@ -283,15 +418,14 @@
283
418
  }
284
419
 
285
420
  /**
286
- * 首列「会话」:两行结构
421
+ * 子会话行(任务展开后插入):两行结构
287
422
  * 主行:会话标题(加粗);无标题时降级为会话 ID(等宽、高亮、可点)
288
423
  * 次行:项目目录(末两级压缩,悬浮显全路径)· 会话 ID(弱化;ID 已作主标识时不重复)
289
- * role:task = 顶层任务行(带「任务」徽标,作为组头);child = 子会话(CSS 缩进 + 挂线);其余 = 独立行
424
+ * token 各列与任务行同列同口径(会话级分量)。
290
425
  */
291
- function sessionRow(r, role) {
426
+ function sessionRow(r) {
292
427
  var tr = document.createElement('tr');
293
- if (role === 'task') tr.className = 'sess-task';
294
- else if (role === 'child') tr.className = 'sess-child';
428
+ tr.className = 'sess-child';
295
429
  var title = nonEmpty(r.session_title);
296
430
  var dir = nonEmpty(r.session_directory);
297
431
  var sid = nonEmpty(r.session_id) || '--';
@@ -301,15 +435,9 @@
301
435
 
302
436
  var line1 = document.createElement('div');
303
437
  line1.className = 'sess-title-line';
304
- if (role === 'task') {
305
- var badge = document.createElement('span');
306
- badge.className = 'sess-task-badge';
307
- badge.textContent = '任务';
308
- line1.appendChild(badge);
309
- }
310
438
  if (title) {
311
439
  var t = document.createElement('span');
312
- t.className = 'sess-title';
440
+ t.className = 'sess-title is-link';
313
441
  t.textContent = displayTitle(title, nonEmpty(r.agent));
314
442
  t.title = title + '\n点击查看详情';
315
443
  t.addEventListener('click', function () { openDetail(sid); });
@@ -518,11 +646,11 @@
518
646
  };
519
647
 
520
648
  el.btnMore.addEventListener('click', function () {
521
- if (!st.nextCursor) return;
649
+ if (!st.taskCursor) return;
522
650
  el.btnMore.disabled = true;
523
651
  el.btnMore.textContent = '加载中…';
524
- // fetchPage 内部消化错误,此处仅恢复按钮状态
525
- fetchPage(deps.getCfg(), deps.getPeriod(), st.nextCursor, false, st.seq)
652
+ // fetchTasks 内部消化错误,此处仅恢复按钮状态
653
+ fetchTasks(deps.getCfg(), deps.getPeriod(), st.taskCursor, false, st.seq)
526
654
  .then(function () {
527
655
  el.btnMore.disabled = false;
528
656
  el.btnMore.textContent = '加载更多';
package/src/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import http from "node:http"
2
2
  import fs from "node:fs"
3
3
  import path from "node:path"
4
- import { ApiError, report, listSessions, sessionDetail, sessionsSummary } from "./session-api.js"
4
+ import { ApiError, report, listSessions, sessionDetail, sessionsSummary, listTasks } from "./session-api.js"
5
5
  import { createProxy } from "./proxy.js"
6
6
  import { createIdentityResolver } from "./identity.js"
7
7
 
@@ -102,7 +102,11 @@ export function createServer({ config, store, panelDir, mode = "local", consumer
102
102
  }
103
103
 
104
104
  // 会话类 → 本地存储;托管模式需网关注入身份,本地模式归入单一本地用户
105
- const isSessionRoute = p === "/v1/session-usage/report" || p === "/v1/sessions" || p.startsWith("/v1/sessions/")
105
+ const isSessionRoute =
106
+ p === "/v1/session-usage/report" ||
107
+ p === "/v1/sessions" ||
108
+ p === "/v1/tasks" ||
109
+ p.startsWith("/v1/sessions/")
106
110
  if (isSessionRoute) {
107
111
  const consumer = resolveConsumer(req)
108
112
  if (!consumer) {
@@ -132,6 +136,11 @@ export function createServer({ config, store, panelDir, mode = "local", consumer
132
136
  sendJson(res, 200, sessionsSummary(store, consumer, url.searchParams))
133
137
  return
134
138
  }
139
+ // /v1/tasks 必须先于任何 :id 形式的捕获
140
+ if (p === "/v1/tasks" && req.method === "GET") {
141
+ sendJson(res, 200, listTasks(store, consumer, url.searchParams))
142
+ return
143
+ }
135
144
  if (p.startsWith("/v1/sessions/") && req.method === "GET") {
136
145
  const id = decodeURIComponent(p.slice("/v1/sessions/".length))
137
146
  if (!id || id.includes("/")) {
@@ -80,6 +80,22 @@ export function listSessions(store, consumer, params) {
80
80
  return { object: "list", window: window || null, as_of: iso(), data, next_cursor: nextCursor ? encodeCursor(nextCursor) : null }
81
81
  }
82
82
 
83
+ /** GET /v1/tasks —— 顶层任务聚合(分组键 COALESCE(root_session_id, session_id)) */
84
+ export function listTasks(store, consumer, params) {
85
+ const window = params.get("window") || undefined
86
+ if (window && !WINDOWS.includes(window)) throw new ApiError(400, "window 必须是: today, week, month", "invalid_window")
87
+ const limit = boundedInt(params.get("limit"), "limit", 50, 1, 200)
88
+ const cursorRaw = params.get("cursor")
89
+ const { data, nextCursor } = store.listTasks(consumer, {
90
+ window,
91
+ model: params.get("model") || undefined,
92
+ agent: params.get("agent") || undefined,
93
+ limit,
94
+ cursor: cursorRaw ? decodeCursor(cursorRaw) : null,
95
+ })
96
+ return { object: "list", window: window || null, as_of: iso(), data, next_cursor: nextCursor ? encodeCursor(nextCursor) : null }
97
+ }
98
+
83
99
  /** GET /v1/sessions/{id} */
84
100
  export function sessionDetail(store, consumer, sessionId, params) {
85
101
  const row = store.getSession(consumer, sessionId, params.get("install_id") || undefined)
package/src/store.js CHANGED
@@ -246,9 +246,14 @@ export class Store {
246
246
  args.push(installId)
247
247
  }
248
248
  if (rootSessionId) {
249
- // 按顶层任务筛选:同一 root 下的全部会话(含根自身)
250
- where.push("root_session_id=?")
251
- args.push(rootSessionId)
249
+ // 顶层任务标识的定义 = COALESCE(root_session_id, session_id)(与 listTasks 的分组表达式严格一致)。
250
+ // 这里写成语义等价的 OR 展开:root 非空 → root_session_id = X;root 为 NULL(旧数据/孤儿)→
251
+ // 回退到 session_id = X。若直接写 COALESCE(...) = ? 则该谓词不可走索引;OR 展开后
252
+ // 第二个分支可用 idx_su_consumer_session(consumer_id, session_id),第一个分支
253
+ // (root_session_id = X)无专用索引,但 consumer_id 前置条件仍可借
254
+ // idx_su_consumer_activity 收敛范围——当前单机个人数据量(千级行)下成本可忽略。
255
+ where.push("(root_session_id=? OR (root_session_id IS NULL AND session_id=?))")
256
+ args.push(rootSessionId, rootSessionId)
252
257
  }
253
258
  if (cursor) {
254
259
  where.push("(last_activity_at < ? OR (last_activity_at = ? AND session_id < ?))")
@@ -262,6 +267,80 @@ export class Store {
262
267
  return { data, nextCursor }
263
268
  }
264
269
 
270
+ /**
271
+ * 顶层任务聚合:按 COALESCE(root_session_id, session_id) 分组(无血缘的行各自成任务)。
272
+ *
273
+ * 语义选择:window / model / agent 过滤施加在「会话行」上(先过滤、后聚合)——
274
+ * 任务内只计入匹配的会话行,任务级 last_activity_at 为组内匹配行的 MAX。
275
+ *
276
+ * 根会话元数据(title/directory)用「相关子查询 + LIMIT 1」取标量:同一 consumer 下同一
277
+ * session_id 可能来自多个 install_id,JOIN 会造成行数膨胀;标量子查询每组恰好贡献一个值。
278
+ * 子查询额外要求 r.root_session_id = 根 id(即该行确实是根行),孤儿组(无血缘/根行缺失)
279
+ * 因此得到 null,与契约一致。
280
+ *
281
+ * 游标 [last_activity_at, root_session_id] 作用在聚合结果之后(外层 WHERE),排序键为
282
+ * 任务级 MAX(last_activity_at) DESC, root_session_id DESC。
283
+ */
284
+ listTasks(consumerId, { window, model, agent, limit = 50, cursor = null } = {}) {
285
+ const where = ["consumer_id=?"]
286
+ const args = [consumerId]
287
+ const start = windowStartMs(window)
288
+ if (start !== null) {
289
+ where.push("last_activity_at>=?")
290
+ args.push(start)
291
+ }
292
+ if (model) {
293
+ where.push("model=?")
294
+ args.push(model)
295
+ }
296
+ if (agent) {
297
+ where.push("agent=?")
298
+ args.push(agent)
299
+ }
300
+ const cursorWhere = []
301
+ const cursorArgs = []
302
+ if (cursor) {
303
+ cursorWhere.push("(t.last_activity_at < ? OR (t.last_activity_at = ? AND t.root_session_id < ?))")
304
+ cursorArgs.push(cursor[0], cursor[0], cursor[1])
305
+ }
306
+ const sql = `SELECT
307
+ t.root_session_id,
308
+ (SELECT r.session_title FROM session_usage r
309
+ WHERE r.consumer_id = t.consumer_id AND r.session_id = t.root_session_id AND r.root_session_id = t.root_session_id
310
+ ORDER BY r.last_reported_at DESC LIMIT 1) AS session_title,
311
+ (SELECT r.session_directory FROM session_usage r
312
+ WHERE r.consumer_id = t.consumer_id AND r.session_id = t.root_session_id AND r.root_session_id = t.root_session_id
313
+ ORDER BY r.last_reported_at DESC LIMIT 1) AS session_directory,
314
+ t.session_count, t.tokens_input, t.tokens_output, t.tokens_reasoning,
315
+ t.tokens_cache_read, t.tokens_cache_write,
316
+ t.first_activity_at, t.last_activity_at
317
+ FROM (
318
+ SELECT consumer_id,
319
+ COALESCE(root_session_id, session_id) AS root_session_id,
320
+ COUNT(*) AS session_count,
321
+ SUM(tokens_input) AS tokens_input,
322
+ SUM(tokens_output) AS tokens_output,
323
+ SUM(tokens_reasoning) AS tokens_reasoning,
324
+ SUM(tokens_cache_read) AS tokens_cache_read,
325
+ SUM(tokens_cache_write) AS tokens_cache_write,
326
+ MIN(last_activity_at) AS first_activity_at,
327
+ MAX(last_activity_at) AS last_activity_at
328
+ FROM session_usage
329
+ WHERE ${where.join(" AND ")}
330
+ GROUP BY consumer_id, COALESCE(root_session_id, session_id)
331
+ ) AS t
332
+ ${cursorWhere.length ? "WHERE " + cursorWhere.join(" AND ") : ""}
333
+ ORDER BY t.last_activity_at DESC, t.root_session_id DESC
334
+ LIMIT ?`
335
+ const rows = this.db.prepare(sql).all(...args, ...cursorArgs, limit + 1)
336
+ const hasNext = rows.length > limit
337
+ const data = rows.slice(0, limit).map(withDerived)
338
+ const nextCursor = hasNext && data.length
339
+ ? [data[data.length - 1].last_activity_at, data[data.length - 1].root_session_id]
340
+ : null
341
+ return { data, nextCursor }
342
+ }
343
+
265
344
  getSession(consumerId, sessionId, installId) {
266
345
  const where = ["consumer_id=?", "session_id=?"]
267
346
  const args = [consumerId, sessionId]