opencode-tokenwatch 0.3.1 → 0.3.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.
@@ -43,6 +43,11 @@ class PerfTracker {
43
43
  const cacheRead = tokens?.cache?.read ?? 0;
44
44
  const cacheWrite = tokens?.cache?.write ?? 0;
45
45
  const cost = info.cost ?? 0;
46
+ // 过滤全零 token 的失败请求,不写入日志和统计,防止污染数据
47
+ if (inputTokens + outputTokens + cacheRead + cacheWrite === 0) {
48
+ this.firstPartTimes.delete(messageID);
49
+ return;
50
+ }
46
51
  const firstPart = this.firstPartTimes.get(messageID) ?? null;
47
52
  const latencyMs = completed - created;
48
53
  const ttftMs = firstPart !== null ? firstPart - created : null;
package/dist/queries.js CHANGED
@@ -90,41 +90,41 @@ export async function getCurrentSessionStats(sessionId) {
90
90
  return getSummary(filters);
91
91
  }
92
92
  export async function getSummary(filters = {}) {
93
- const sql = `
94
- SELECT
95
- group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
96
- group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
97
- count(*) as request_count,
98
- sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
99
- sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
100
- sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
101
- sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
102
- sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
103
- sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
104
- sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
105
- FROM message m
106
- WHERE ${messageWhere(filters)}
93
+ const sql = `
94
+ SELECT
95
+ group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
96
+ group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
97
+ count(*) as request_count,
98
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
99
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
100
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
101
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
102
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
103
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
104
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
105
+ FROM message m
106
+ WHERE ${messageWhere(filters)}
107
107
  `.trim();
108
108
  const rows = await queryDb(sql);
109
109
  return toSessionTokenData(rows[0]);
110
110
  }
111
111
  export async function getModelBreakdown(filters = {}) {
112
- const sql = `
113
- SELECT
114
- coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
115
- coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
116
- count(*) as requests,
117
- count(distinct m.session_id) as sessions,
118
- sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
119
- sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
120
- sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
121
- sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
122
- sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
123
- sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
124
- FROM message m
125
- WHERE ${messageWhere(filters)}
126
- GROUP BY provider, model
127
- ORDER BY total_tokens DESC
112
+ const sql = `
113
+ SELECT
114
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
115
+ coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
116
+ count(*) as requests,
117
+ count(distinct m.session_id) as sessions,
118
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
119
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
120
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
121
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
122
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
123
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
124
+ FROM message m
125
+ WHERE ${messageWhere(filters)}
126
+ GROUP BY provider, model
127
+ ORDER BY total_tokens DESC
128
128
  `.trim();
129
129
  const rows = await queryDb(sql);
130
130
  return rows.map((row) => ({
@@ -141,21 +141,21 @@ ORDER BY total_tokens DESC
141
141
  }));
142
142
  }
143
143
  export async function getProviderBreakdown(filters = {}) {
144
- const sql = `
145
- SELECT
146
- coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
147
- count(*) as requests,
148
- count(distinct m.session_id) as sessions,
149
- sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
150
- sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
151
- sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
152
- sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
153
- sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
154
- sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
155
- FROM message m
156
- WHERE ${messageWhere(filters)}
157
- GROUP BY provider
158
- ORDER BY total_tokens DESC
144
+ const sql = `
145
+ SELECT
146
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
147
+ count(*) as requests,
148
+ count(distinct m.session_id) as sessions,
149
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
150
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
151
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
152
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
153
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
154
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
155
+ FROM message m
156
+ WHERE ${messageWhere(filters)}
157
+ GROUP BY provider
158
+ ORDER BY total_tokens DESC
159
159
  `.trim();
160
160
  const rows = await queryDb(sql);
161
161
  return rows.map((row) => ({
@@ -172,22 +172,22 @@ ORDER BY total_tokens DESC
172
172
  }
173
173
  export async function getDailyBreakdown(filters = {}) {
174
174
  const limit = filters.limit ?? 30;
175
- const sql = `
176
- SELECT
177
- date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
178
- count(*) as requests,
179
- count(distinct m.session_id) as sessions,
180
- sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
181
- sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
182
- sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
183
- sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
184
- sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
185
- sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
186
- FROM message m
187
- WHERE ${messageWhere(filters)}
188
- GROUP BY day
189
- ORDER BY day DESC
190
- LIMIT ${Math.max(1, limit)}
175
+ const sql = `
176
+ SELECT
177
+ date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
178
+ count(*) as requests,
179
+ count(distinct m.session_id) as sessions,
180
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
181
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
182
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
183
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
184
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
185
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
186
+ FROM message m
187
+ WHERE ${messageWhere(filters)}
188
+ GROUP BY day
189
+ ORDER BY day DESC
190
+ LIMIT ${Math.max(1, limit)}
191
191
  `.trim();
192
192
  const rows = await queryDb(sql);
193
193
  return rows.map((row) => ({
@@ -204,26 +204,26 @@ LIMIT ${Math.max(1, limit)}
204
204
  }
205
205
  export async function getSessionBreakdown(filters = {}) {
206
206
  const limit = filters.limit ?? 15;
207
- const sql = `
208
- SELECT
209
- s.id as session_id,
210
- s.title as title,
211
- coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
212
- coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
213
- count(*) as requests,
214
- sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
215
- sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
216
- sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
217
- sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
218
- sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
219
- sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
220
- date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
221
- FROM message m
222
- JOIN session s ON s.id = m.session_id
223
- WHERE ${messageWhere(filters)}
224
- GROUP BY s.id, s.title, provider, model
225
- ORDER BY max(m.time_created) DESC
226
- LIMIT ${Math.max(1, limit)}
207
+ const sql = `
208
+ SELECT
209
+ s.id as session_id,
210
+ s.title as title,
211
+ coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
212
+ coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
213
+ count(*) as requests,
214
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
215
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
216
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
217
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
218
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
219
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
220
+ date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
221
+ FROM message m
222
+ JOIN session s ON s.id = m.session_id
223
+ WHERE ${messageWhere(filters)}
224
+ GROUP BY s.id, s.title, provider, model
225
+ ORDER BY max(m.time_created) DESC
226
+ LIMIT ${Math.max(1, limit)}
227
227
  `.trim();
228
228
  const rows = await queryDb(sql);
229
229
  return rows.map((row) => ({
@@ -242,21 +242,21 @@ LIMIT ${Math.max(1, limit)}
242
242
  }));
243
243
  }
244
244
  export async function getAvailableModels() {
245
- const sql = `
246
- SELECT distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown') as value
247
- FROM message m
248
- WHERE ${messageWhere({})}
249
- ORDER BY value ASC
245
+ const sql = `
246
+ SELECT distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown') as value
247
+ FROM message m
248
+ WHERE ${messageWhere({})}
249
+ ORDER BY value ASC
250
250
  `.trim();
251
251
  const rows = await queryDb(sql);
252
252
  return rows.map((row) => row.value ?? "unknown");
253
253
  }
254
254
  export async function getAvailableProviders() {
255
- const sql = `
256
- SELECT distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown') as value
257
- FROM message m
258
- WHERE ${messageWhere({})}
259
- ORDER BY value ASC
255
+ const sql = `
256
+ SELECT distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown') as value
257
+ FROM message m
258
+ WHERE ${messageWhere({})}
259
+ ORDER BY value ASC
260
260
  `.trim();
261
261
  const rows = await queryDb(sql);
262
262
  return rows.map((row) => row.value ?? "unknown");
@@ -281,16 +281,16 @@ export async function getErrorStats(filters = {}) {
281
281
  }
282
282
  const baseWhere = baseConds.join(" AND ");
283
283
  // 按模型细化:同时统计成功和失败请求
284
- const sql = `
285
- SELECT
286
- coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
287
- coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
288
- count(*) as total,
289
- sum(CASE WHEN coalesce(json_extract(m.data, '$.tokens.total'), 0) = 0 THEN 1 ELSE 0 END) as failed
290
- FROM message m
291
- WHERE ${baseWhere}
292
- GROUP BY provider, model
293
- ORDER BY failed DESC
284
+ const sql = `
285
+ SELECT
286
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
287
+ coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
288
+ count(*) as total,
289
+ sum(CASE WHEN coalesce(json_extract(m.data, '$.tokens.total'), 0) = 0 THEN 1 ELSE 0 END) as failed
290
+ FROM message m
291
+ WHERE ${baseWhere}
292
+ GROUP BY provider, model
293
+ ORDER BY failed DESC
294
294
  `.trim();
295
295
  try {
296
296
  const rows = await queryDb(sql);
package/dist/sidebar.jsx CHANGED
@@ -149,11 +149,13 @@ export function TokenWatchPanel(props) {
149
149
  // ── 数据聚合 ──
150
150
  const modelStats = createMemo(() => {
151
151
  const map = new Map();
152
- for (const msg of props.allTokenMessages()) {
152
+ const msgs = props.allTokenMessages();
153
+ for (let i = 0; i < msgs.length; i++) {
154
+ const msg = msgs[i];
153
155
  const key = `${msg.providerID}/${msg.modelID}`;
154
156
  let e = map.get(key);
155
157
  if (!e) {
156
- e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
158
+ e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0, lastMessageIndex: -1 };
157
159
  map.set(key, e);
158
160
  }
159
161
  e.totalInput += msg.inputTokens;
@@ -163,8 +165,13 @@ export function TokenWatchPanel(props) {
163
165
  e.cacheWrite += msg.cacheWrite;
164
166
  e.totalCost += msg.cost;
165
167
  e.requestCount++;
168
+ e.lastMessageIndex = i; // 追踪该模型最后一条消息的索引(越大 = 越近调用)
166
169
  }
167
- return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
170
+ return Array.from(map.entries())
171
+ // 过滤掉全零无效模型(如会话失败导致 token 全为 0 的记录)
172
+ .filter(([, s]) => s.totalInput + s.totalOutput + s.totalReasoning + s.cacheRead + s.cacheWrite > 0)
173
+ // 按最近调用时间降序:最后一条消息在数组中索引越大越靠前
174
+ .sort((a, b) => b[1].lastMessageIndex - a[1].lastMessageIndex);
168
175
  });
169
176
  const sessionTotals = createMemo(() => {
170
177
  let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
@@ -353,75 +360,75 @@ export function TokenWatchPanel(props) {
353
360
  ref={(el) => { outerBoxRef = el; }} onSizeChange={() => {
354
361
  if (outerBoxRef)
355
362
  setPanelWidth(outerBoxRef.width);
356
- }} flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
357
-
363
+ }} flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
364
+
358
365
  {/* ══════════════════════════════════════
359
366
  面板 Header:▾ TokenWatch 89.1% hit
360
367
  justifyContent="space-between" 左右分布
361
- ══════════════════════════════════════ */}
362
- <box flexDirection="row" justifyContent="space-between" onMouseDown={toggle.global} paddingX={1}>
363
- <text fg={primaryColor()}>
364
- {collapse().global ? "▶" : "▾"} {t("panelTitle")}
365
- </text>
366
- <text fg={mutedColor()}>
367
- {collapse().global ? (<>
368
- {formatTokens(sessionTotals().totalTokens)}
369
- {globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
370
- {` (${globalHitRate().toFixed(1)}% hit)`}
371
- </span>) : ""}
372
- </>) : (globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
373
- {`${globalHitRate().toFixed(1)}% hit`}
374
- </span>) : "")}
375
- </text>
376
- </box>
377
-
378
- <Show when={!collapse().global}>
379
-
380
- {/* 标题下分隔线 */}
381
- <text fg={borderColor()}>{divider()}</text>
382
-
368
+ ══════════════════════════════════════ */}
369
+ <box flexDirection="row" justifyContent="space-between" onMouseDown={toggle.global} paddingX={1}>
370
+ <text fg={primaryColor()}>
371
+ {collapse().global ? "▶" : "▾"} {t("panelTitle")}
372
+ </text>
373
+ <text fg={mutedColor()}>
374
+ {collapse().global ? (<>
375
+ {formatTokens(sessionTotals().totalTokens)}
376
+ {globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
377
+ {` (${globalHitRate().toFixed(1)}% hit)`}
378
+ </span>) : ""}
379
+ </>) : (globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
380
+ {`${globalHitRate().toFixed(1)}% hit`}
381
+ </span>) : "")}
382
+ </text>
383
+ </box>
384
+
385
+ <Show when={!collapse().global}>
386
+
387
+ {/* 标题下分隔线 */}
388
+ <text fg={borderColor()}>{divider()}</text>
389
+
383
390
  {/* ══════════════════════════════════════
384
391
  全局统计:Total / Req / Input / Output 均匀分行排布
385
- ══════════════════════════════════════ */}
386
- <box flexDirection="row" paddingX={1}>
392
+ ══════════════════════════════════════ */}
393
+ <box flexDirection="row" paddingX={1}>
387
394
  <For each={[
388
395
  { val: formatTokens(sessionTotals().totalTokens), lbl: t("total") },
389
396
  { val: sessionTotals().totalRequests.toString(), lbl: t("requests") },
390
397
  { val: formatTokens(sessionTotals().totalInput), lbl: t("input") },
391
398
  { val: formatTokens(sessionTotals().totalOutput), lbl: t("output") }
392
- ]}>
399
+ ]}>
393
400
  {(item, idx) => {
394
401
  const colW = () => {
395
402
  const totalW = panelWidth() - 4;
396
403
  const base = Math.floor(totalW / 4);
397
404
  return idx() === 3 ? totalW - base * 3 : base;
398
405
  };
399
- return (<box width={colW()} flexDirection="column">
400
- <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
401
- <text fg={dimColor()}>
402
- {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
403
- </text>
406
+ return (<box width={colW()} flexDirection="column">
407
+ <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
408
+ <text fg={dimColor()}>
409
+ {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
410
+ </text>
404
411
  </box>);
405
- }}
406
- </For>
407
- </box>
408
-
409
- {/* 成本展示 */}
410
- <Show when={config().sidebar.showPricing && sessionTotals().totalCost > 0}>
411
- <box flexDirection="row" justifyContent="center" marginTop={1}>
412
- <text fg={mutedColor()}>
413
- {t("cost")}:{" "}
414
- <span style={{ fg: greenColor() }}>
415
- {formatCost(sessionTotals().totalCost)}
416
- </span>
417
- </text>
418
- </box>
419
- </Show>
420
-
412
+ }}
413
+ </For>
414
+ </box>
415
+
416
+ {/* 成本展示 */}
417
+ <Show when={config().sidebar.showPricing && sessionTotals().totalCost > 0}>
418
+ <box flexDirection="row" justifyContent="center" marginTop={1}>
419
+ <text fg={mutedColor()}>
420
+ {t("cost")}:{" "}
421
+ <span style={{ fg: greenColor() }}>
422
+ {formatCost(sessionTotals().totalCost)}
423
+ </span>
424
+ </text>
425
+ </box>
426
+ </Show>
427
+
421
428
  {/* ══════════════════════════════════════
422
429
  各模型块:无分隔线,用 marginTop=1 隔开
423
- ══════════════════════════════════════ */}
424
- <For each={modelStats()}>
430
+ ══════════════════════════════════════ */}
431
+ <For each={modelStats()}>
425
432
  {([key, stat]) => {
426
433
  const isExpanded = () => collapse().models[key] !== true;
427
434
  const hitDenom = stat.totalInput + stat.cacheRead;
@@ -442,8 +449,14 @@ export function TokenWatchPanel(props) {
442
449
  ? RGBA.fromInts(63, 185, 80, 255)
443
450
  : RGBA.fromInts(244, 67, 54, 255);
444
451
  };
445
- // 模型名处理:假如总长超过22字符,且格式为 厂商/模型厂商/模型名称,去除中间模型厂商,只留 provider/modelname 格式
446
- let fullTitle = `${stat.providerID}/${stat.modelID}`;
452
+ // 供应商名称截断:超过12字符则省略
453
+ const MAX_PROVIDER_LEN = 12;
454
+ let providerDisplay = stat.providerID;
455
+ if (providerDisplay.length > MAX_PROVIDER_LEN) {
456
+ providerDisplay = providerDisplay.slice(0, MAX_PROVIDER_LEN - 1) + "…";
457
+ }
458
+ let fullTitle = `${providerDisplay}/${stat.modelID}`;
459
+ // 去除中间段:格式为 厂商/模型厂商/模型名称 时只留 provider/modelname
447
460
  if (fullTitle.length > 22) {
448
461
  const parts = fullTitle.split("/");
449
462
  if (parts.length >= 3) {
@@ -483,109 +496,109 @@ export function TokenWatchPanel(props) {
483
496
  const modelBarWidth = () => Math.max(8, (panelWidth() - 4) - targetW() - 11);
484
497
  return (
485
498
  // marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
486
- <box flexDirection="column" marginTop={1}>
487
-
488
- {/* 模型 Header:左侧 ● 名称,右侧 统计+箭头 */}
489
- <box flexDirection="row" justifyContent="space-between" onMouseDown={() => toggle.model(key)} paddingX={1}>
490
- <text fg={mutedColor()}>
491
- <span style={{ fg: hitRateColor(hitRate) }}>●</span>
492
- {" "}
493
- <span style={{ fg: primaryColor() }}>{shortTitle}</span>
494
- </text>
495
- <text fg={mutedColor()}>{modelHeaderRight()}</text>
496
- </box>
497
-
498
- <Show when={isExpanded()}>
499
- <box flexDirection="column" paddingX={1}>
500
-
501
- {/* 模型指标三列网格,外带圆角边框 (取消上下间距) */}
502
- <box flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
503
- <box flexDirection="row">
499
+ <box flexDirection="column" marginTop={1}>
500
+
501
+ {/* 模型 Header:左侧 ● 名称,右侧 统计+箭头 */}
502
+ <box flexDirection="row" justifyContent="space-between" onMouseDown={() => toggle.model(key)} paddingX={1}>
503
+ <text fg={mutedColor()}>
504
+ <span style={{ fg: hitRateColor(hitRate) }}>●</span>
505
+ {" "}
506
+ <span style={{ fg: primaryColor() }}>{shortTitle}</span>
507
+ </text>
508
+ <text fg={mutedColor()}>{modelHeaderRight()}</text>
509
+ </box>
510
+
511
+ <Show when={isExpanded()}>
512
+ <box flexDirection="column" paddingX={1}>
513
+
514
+ {/* 模型指标三列网格,外带圆角边框 (取消上下间距) */}
515
+ <box flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
516
+ <box flexDirection="row">
504
517
  <For each={[
505
518
  { val: formatTokens(modelTotalTokens), lbl: t("total") },
506
519
  { val: formatTokens(stat.totalInput), lbl: t("input") },
507
520
  { val: formatTokens(stat.totalOutput), lbl: t("output") }
508
- ]}>
521
+ ]}>
509
522
  {(item, idx) => {
510
523
  const colW = () => {
511
524
  const totalW = panelWidth() - 6; // 边框占用 2 列
512
525
  const base = Math.floor(totalW / 3);
513
526
  return idx() === 2 ? totalW - base * 2 : base;
514
527
  };
515
- return (<box width={colW()} flexDirection="column">
516
- <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
517
- <text fg={dimColor()}>
518
- {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
519
- </text>
528
+ return (<box width={colW()} flexDirection="column">
529
+ <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
530
+ <text fg={dimColor()}>
531
+ {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
532
+ </text>
520
533
  </box>);
521
- }}
522
- </For>
523
- </box>
524
- </box>
525
-
526
- {/* 缓存进度条 */}
527
- <text fg={mutedColor()}>
528
- {paddedCachePrefix()}
529
- <span style={{ fg: hitRateColor(hitRate) }}>
530
- {progressFilled(hitRate, modelBarWidth())}{progressRemaining(hitRate, modelBarWidth())}{" "}{hitRate.toFixed(0)}%
531
- </span>
534
+ }}
535
+ </For>
536
+ </box>
537
+ </box>
538
+
539
+ {/* 缓存进度条 */}
540
+ <text fg={mutedColor()}>
541
+ {paddedCachePrefix()}
542
+ <span style={{ fg: hitRateColor(hitRate) }}>
543
+ {progressFilled(hitRate, modelBarWidth())}{progressRemaining(hitRate, modelBarWidth())}{" "}{hitRate.toFixed(0)}%
544
+ </span>
532
545
  {trendStr()
533
546
  ? <span style={{ fg: trendColor() }}>{trendStr()}</span>
534
- : null}
535
- </text>
536
-
537
- {/* 性能指标 */}
538
- <Show when={config().sidebar.showPerformance && !!perfStats().models[key]}>
539
- <text fg={mutedColor()} marginTop={1}>
540
- {t("ttft")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgTTFT ?? null)}</span>
541
- {" "}{t("tps")} <span style={{ fg: primaryColor() }}>{perfStats().models[key]?.avgTPS?.toFixed(1) ?? "—"}</span>
542
- {" "}{t("lat")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgLatency ?? null)}</span>
543
- </text>
544
- </Show>
545
-
546
- {/* 成本 */}
547
- <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
548
- <text fg={mutedColor()}>{paddedCostPrefix()}{formatCost(stat.totalCost)}</text>
549
- </Show>
550
-
551
- </box>
552
- </Show>
547
+ : null}
548
+ </text>
549
+
550
+ {/* 性能指标 */}
551
+ <Show when={config().sidebar.showPerformance && !!perfStats().models[key]}>
552
+ <text fg={mutedColor()} marginTop={1}>
553
+ {t("ttft")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgTTFT ?? null)}</span>
554
+ {" "}{t("tps")} <span style={{ fg: primaryColor() }}>{perfStats().models[key]?.avgTPS?.toFixed(1) ?? "—"}</span>
555
+ {" "}{t("lat")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgLatency ?? null)}</span>
556
+ </text>
557
+ </Show>
558
+
559
+ {/* 成本 */}
560
+ <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
561
+ <text fg={mutedColor()}>{paddedCostPrefix()}{formatCost(stat.totalCost)}</text>
562
+ </Show>
563
+
564
+ </box>
565
+ </Show>
553
566
  </box>);
554
- }}
555
- </For>
556
-
567
+ }}
568
+ </For>
569
+
557
570
  {/* ══════════════════════════════════════
558
571
  Token 分布区块:左右 space-between 对齐布局 (取消进度条)
559
- ══════════════════════════════════════ */}
560
- <Show when={config().sidebar.showTokenDistribution}>
561
- <box flexDirection="column" marginTop={1}>
562
-
563
- {/* 分隔线 */}
564
- <text fg={borderColor()}>{divider()}</text>
565
-
566
- {/* Header */}
567
- <box flexDirection="row" onMouseDown={() => toggle.sub("token-dist")} paddingX={1}>
568
- <text fg={greenColor()}>
569
- {!collapse().subBlocks["token-dist"] ? "▾" : "▶"} {t("tokenDistribution")}
570
- </text>
571
- </box>
572
-
573
- <Show when={!collapse().subBlocks["token-dist"]}>
574
- <box flexDirection="column" paddingX={1} marginTop={1}>
575
- <For each={Object.entries(tokenDistribution()).filter(([_, val]) => val > 0)}>
576
- {([role, val]) => (<box flexDirection="row" justifyContent="space-between">
577
- <box flexDirection="row">
578
- <text fg={distRoleColor(role)}>█ </text>
579
- <text fg={mutedColor()}>{t(role)}</text>
580
- </box>
581
- <text fg={mutedColor()}>{formatTokens(val)}</text>
582
- </box>)}
583
- </For>
584
- </box>
585
- </Show>
586
- </box>
587
- </Show>
588
-
589
- </Show>
572
+ ══════════════════════════════════════ */}
573
+ <Show when={config().sidebar.showTokenDistribution}>
574
+ <box flexDirection="column" marginTop={1}>
575
+
576
+ {/* 分隔线 */}
577
+ <text fg={borderColor()}>{divider()}</text>
578
+
579
+ {/* Header */}
580
+ <box flexDirection="row" onMouseDown={() => toggle.sub("token-dist")} paddingX={1}>
581
+ <text fg={greenColor()}>
582
+ {!collapse().subBlocks["token-dist"] ? "▾" : "▶"} {t("tokenDistribution")}
583
+ </text>
584
+ </box>
585
+
586
+ <Show when={!collapse().subBlocks["token-dist"]}>
587
+ <box flexDirection="column" paddingX={1} marginTop={1}>
588
+ <For each={Object.entries(tokenDistribution()).filter(([_, val]) => val > 0)}>
589
+ {([role, val]) => (<box flexDirection="row" justifyContent="space-between">
590
+ <box flexDirection="row">
591
+ <text fg={distRoleColor(role)}>█ </text>
592
+ <text fg={mutedColor()}>{t(role)}</text>
593
+ </box>
594
+ <text fg={mutedColor()}>{formatTokens(val)}</text>
595
+ </box>)}
596
+ </For>
597
+ </box>
598
+ </Show>
599
+ </box>
600
+ </Show>
601
+
602
+ </Show>
590
603
  </box>);
591
604
  }
package/dist/tui.jsx CHANGED
@@ -108,7 +108,8 @@ const tui = async (api) => {
108
108
  if (msg.role !== "assistant")
109
109
  continue;
110
110
  const tokens = msg.tokens;
111
- if (!tokens)
111
+ // 与 message.updated 处理器保持一致:过滤 total=0 的失败请求
112
+ if (!tokens || (tokens.total ?? 0) === 0)
112
113
  continue;
113
114
  const id = msg.id;
114
115
  const idx = next.findIndex(m => m.id === id);