openclacky 1.2.5 → 1.2.7

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +34 -0
  4. data/README_CN.md +34 -0
  5. data/lib/clacky/agent/cost_tracker.rb +24 -10
  6. data/lib/clacky/agent/llm_caller.rb +25 -3
  7. data/lib/clacky/agent/message_compressor.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +6 -2
  9. data/lib/clacky/agent/session_serializer.rb +23 -4
  10. data/lib/clacky/agent/tool_executor.rb +14 -0
  11. data/lib/clacky/agent/tool_registry.rb +0 -7
  12. data/lib/clacky/agent.rb +43 -10
  13. data/lib/clacky/agent_config.rb +54 -6
  14. data/lib/clacky/billing/billing_store.rb +62 -4
  15. data/lib/clacky/brand_config.rb +5 -0
  16. data/lib/clacky/cli.rb +76 -24
  17. data/lib/clacky/client.rb +59 -4
  18. data/lib/clacky/default_parsers/wps_parser.rb +82 -0
  19. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  20. data/lib/clacky/json_ui_controller.rb +5 -2
  21. data/lib/clacky/message_format/anthropic.rb +13 -3
  22. data/lib/clacky/message_format/bedrock.rb +2 -2
  23. data/lib/clacky/plain_ui_controller.rb +1 -1
  24. data/lib/clacky/platform_http_client.rb +28 -1
  25. data/lib/clacky/providers.rb +11 -29
  26. data/lib/clacky/server/channel/channel_manager.rb +148 -12
  27. data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
  28. data/lib/clacky/server/http_server.rb +133 -13
  29. data/lib/clacky/server/session_registry.rb +30 -4
  30. data/lib/clacky/server/web_ui_controller.rb +6 -3
  31. data/lib/clacky/tools/browser.rb +4 -13
  32. data/lib/clacky/tools/terminal.rb +23 -27
  33. data/lib/clacky/ui2/ui_controller.rb +1 -1
  34. data/lib/clacky/ui_interface.rb +1 -1
  35. data/lib/clacky/utils/file_processor.rb +3 -0
  36. data/lib/clacky/utils/parser_manager.rb +3 -0
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +659 -75
  39. data/lib/clacky/web/app.js +0 -1
  40. data/lib/clacky/web/billing.js +371 -99
  41. data/lib/clacky/web/i18n.js +48 -2
  42. data/lib/clacky/web/index.html +34 -1
  43. data/lib/clacky/web/sessions.js +213 -82
  44. data/lib/clacky/web/settings.js +59 -17
  45. data/lib/clacky/web/workspace.js +204 -0
  46. data/lib/clacky/web/ws-dispatcher.js +19 -3
  47. data/lib/clacky.rb +9 -3
  48. metadata +4 -5
  49. data/lib/clacky/tools/list_tasks.rb +0 -54
  50. data/lib/clacky/tools/redo_task.rb +0 -41
  51. data/lib/clacky/tools/undo_task.rb +0 -35
@@ -158,7 +158,6 @@ const Router = (() => {
158
158
  }
159
159
  _setHash(`session/${id}`);
160
160
  $("chat-panel").style.display = "flex";
161
- $("chat-panel").style.flexDirection = "column";
162
161
  Sessions.updateChatHeader(s);
163
162
  Sessions.updateStatusBar(s.status);
164
163
  Sessions.updateInfoBar(s);
@@ -4,7 +4,10 @@
4
4
  const Billing = (() => {
5
5
  let _summary = null;
6
6
  let _daily = [];
7
- let _currentPeriod = "month";
7
+ let _allModels = []; // 保存完整的模型列表
8
+ let _currentPeriod = "day";
9
+ let _currentModel = "all";
10
+ let _clearPopupVisible = false;
8
11
 
9
12
  // ── Currency Settings ─────────────────────────────────────────────────────
10
13
  const CURRENCY_STORAGE_KEY = "clacky-currency";
@@ -69,15 +72,21 @@ const Billing = (() => {
69
72
  container.innerHTML = `<div class="billing-loading">${I18n.t("billing.loading") || "Loading billing data..."}</div>`;
70
73
 
71
74
  try {
75
+ const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
72
76
  const [summaryRes, dailyRes] = await Promise.all([
73
- fetch(`/api/billing/summary?period=${_currentPeriod}`),
74
- fetch("/api/billing/daily?days=30")
77
+ fetch(`/api/billing/summary?period=${_currentPeriod}${modelParam}`),
78
+ fetch(`/api/billing/daily?days=30${modelParam}`)
75
79
  ]);
76
80
 
77
81
  _summary = await summaryRes.json();
78
82
  const dailyData = await dailyRes.json();
79
83
  _daily = dailyData.days || [];
80
84
 
85
+ // 保存完整模型列表(仅在未筛选时更新)
86
+ if (!_currentModel || _currentModel === "all") {
87
+ _allModels = _summary.by_model ? Object.keys(_summary.by_model) : [];
88
+ }
89
+
81
90
  _render();
82
91
  } catch (e) {
83
92
  container.innerHTML = `<div class="billing-error">${I18n.t("billing.error") || "Failed to load billing data"}: ${e.message}</div>`;
@@ -90,145 +99,397 @@ const Billing = (() => {
90
99
  const container = document.getElementById("billing-content");
91
100
  if (!container || !_summary) return;
92
101
 
93
- const periodOptions = ["day", "week", "month", "year", "all"].map(p =>
94
- `<option value="${p}" ${p === _currentPeriod ? "selected" : ""}>${_periodLabel(p)}</option>`
102
+ // Period button group
103
+ const periods = ["day", "week", "month", "year", "all"];
104
+ const periodBtns = periods.map(p =>
105
+ `<button class="billing-period-btn ${p === _currentPeriod ? 'active' : ''}" data-period="${p}">${_periodLabel(p)}</button>`
95
106
  ).join("");
96
107
 
108
+ // Model filter options (使用完整模型列表)
109
+ const models = _allModels.length > 0 ? _allModels : (_summary.by_model ? Object.keys(_summary.by_model) : []);
110
+ const modelOptions = [`<option value="all">${I18n.t("billing.allModels") || "All Models"}</option>`]
111
+ .concat(models.map(m => `<option value="${_esc(m)}" ${m === _currentModel ? "selected" : ""}>${_esc(m)}</option>`))
112
+ .join("");
113
+
97
114
  container.innerHTML = `
98
- <div class="billing-header">
99
- <h2>${I18n.t("billing.title") || "💰 Billing"}</h2>
100
- <select id="billing-period-select" class="billing-period-select">
101
- ${periodOptions}
102
- </select>
103
- </div>
115
+ <div class="billing-dashboard">
116
+ <div class="billing-top-bar">
117
+ <div class="billing-title-row">
118
+ <h2>${I18n.t("billing.title") || "Usage"}</h2>
119
+ <span class="billing-subtitle">${_getSummaryHint()}</span>
120
+ </div>
121
+ <div class="billing-controls">
122
+ <div class="billing-period-group">${periodBtns}</div>
123
+ <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
124
+ <div class="billing-clear-container">
125
+ <button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
126
+ 🗑️
127
+ </button>
128
+ <div id="billing-clear-popup" class="billing-clear-popup" style="display: none;">
129
+ <button id="billing-clear-today" class="billing-clear-option">${I18n.t('billing.clearToday') || 'Clear Today'}</button>
130
+ <button id="billing-clear-all" class="billing-clear-option billing-clear-danger">${I18n.t('billing.clearAll') || 'Clear All'}</button>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
104
135
 
105
- <div class="billing-summary-cards">
106
- <div class="billing-card billing-card-primary">
107
- <div class="billing-card-label">${I18n.t("billing.totalCost") || "Total Cost"}</div>
108
- <div class="billing-card-value">${_getCurrencySymbol()}${_formatCost(_convertCost(_summary.total_cost))}</div>
136
+ <div class="billing-stats-row">
137
+ <div class="billing-stat-card billing-stat-primary">
138
+ <div class="billing-stat-icon">💰</div>
139
+ <div class="billing-stat-content">
140
+ <div class="billing-stat-value">${_getCurrencySymbol()}${_formatCost(_convertCost(_summary.total_cost))}</div>
141
+ <div class="billing-stat-label">${I18n.t("billing.totalCost") || "Total Cost"}</div>
142
+ </div>
143
+ </div>
144
+ <div class="billing-stat-card">
145
+ <div class="billing-stat-icon">📊</div>
146
+ <div class="billing-stat-content">
147
+ <div class="billing-stat-value">${_formatCompact(_summary.total_tokens)}</div>
148
+ <div class="billing-stat-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</div>
149
+ </div>
150
+ </div>
151
+ <div class="billing-stat-card">
152
+ <div class="billing-stat-icon">🔄</div>
153
+ <div class="billing-stat-content">
154
+ <div class="billing-stat-value">${_formatNumber(_summary.record_count)}</div>
155
+ <div class="billing-stat-label">${I18n.t("billing.requests") || "Requests"}</div>
156
+ </div>
157
+ </div>
158
+ <div class="billing-stat-card">
159
+ <div class="billing-stat-icon">⚡</div>
160
+ <div class="billing-stat-content">
161
+ <div class="billing-stat-value">${_getCacheHitRate()}%</div>
162
+ <div class="billing-stat-label">${I18n.t("billing.cacheHit") || "Cache Hit"}</div>
163
+ </div>
164
+ </div>
109
165
  </div>
110
- <div class="billing-card">
111
- <div class="billing-card-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</div>
112
- <div class="billing-card-value">${_formatNumber(_summary.total_tokens)}</div>
166
+
167
+ <div class="billing-bottom-grid">
168
+ ${_renderTokenBreakdown()}
169
+ ${_renderModelBreakdown()}
113
170
  </div>
114
- <div class="billing-card">
115
- <div class="billing-card-label">${I18n.t("billing.requests") || "API Requests"}</div>
116
- <div class="billing-card-value">${_formatNumber(_summary.record_count)}</div>
171
+
172
+ <div class="billing-chart-row">
173
+ ${_renderCombinedChart()}
117
174
  </div>
118
175
  </div>
176
+ `;
177
+
178
+ // Bind period button handlers
179
+ document.querySelectorAll(".billing-period-btn").forEach(btn => {
180
+ btn.addEventListener("click", (e) => {
181
+ _currentPeriod = e.target.dataset.period;
182
+ _load();
183
+ });
184
+ });
185
+
186
+ // Bind model filter handler
187
+ document.getElementById("billing-model-filter")?.addEventListener("change", (e) => {
188
+ _currentModel = e.target.value;
189
+ _load();
190
+ });
191
+
192
+ // Bind clear button handlers
193
+ _bindClearHandlers();
194
+
195
+ // Bind chart tooltip handlers
196
+ _bindChartTooltip();
197
+ }
198
+
199
+ function _bindChartTooltip() {
200
+ const container = document.getElementById("billing-chart-container");
201
+ const tooltip = document.getElementById("billing-tooltip");
202
+ if (!container || !tooltip) return;
203
+
204
+ container.addEventListener("mousemove", (e) => {
205
+ const group = e.target.closest(".billing-bar-group");
206
+ if (!group) {
207
+ tooltip.style.display = "none";
208
+ return;
209
+ }
210
+
211
+ const date = group.dataset.date;
212
+ const total = group.dataset.total;
213
+ const cacheHit = group.dataset.cacheHit;
214
+ const cacheMiss = group.dataset.cacheMiss;
215
+ const output = group.dataset.output;
216
+
217
+ tooltip.innerHTML = `
218
+ <div class="tooltip-header">
219
+ <span class="tooltip-date">${date}</span>
220
+ <span class="tooltip-total">${total} tokens</span>
221
+ </div>
222
+ <div class="tooltip-row">
223
+ <span class="tooltip-dot tooltip-cache-hit"></span>
224
+ <span class="tooltip-label">${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}</span>
225
+ <span class="tooltip-value">${cacheHit}</span>
226
+ </div>
227
+ <div class="tooltip-row">
228
+ <span class="tooltip-dot tooltip-cache-miss"></span>
229
+ <span class="tooltip-label">${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}</span>
230
+ <span class="tooltip-value">${cacheMiss}</span>
231
+ </div>
232
+ <div class="tooltip-row">
233
+ <span class="tooltip-dot tooltip-output"></span>
234
+ <span class="tooltip-label">${I18n.t("billing.output") || "Output"}</span>
235
+ <span class="tooltip-value">${output}</span>
236
+ </div>
237
+ `;
238
+ tooltip.style.display = "block";
239
+
240
+ // Position tooltip following mouse
241
+ tooltip.style.left = `${e.clientX + 15}px`;
242
+ tooltip.style.top = `${e.clientY - 10}px`;
243
+ });
244
+
245
+ container.addEventListener("mouseleave", () => {
246
+ tooltip.style.display = "none";
247
+ });
248
+ }
249
+
250
+ function _bindClearHandlers() {
251
+ const clearBtn = document.getElementById("billing-clear-btn");
252
+ const clearPopup = document.getElementById("billing-clear-popup");
253
+ const clearToday = document.getElementById("billing-clear-today");
254
+ const clearAll = document.getElementById("billing-clear-all");
255
+
256
+ if (!clearBtn || !clearPopup) return;
257
+
258
+ // Toggle popup on button click
259
+ clearBtn.addEventListener("click", (e) => {
260
+ e.stopPropagation();
261
+ _clearPopupVisible = !_clearPopupVisible;
262
+ clearPopup.style.display = _clearPopupVisible ? "flex" : "none";
263
+ });
264
+
265
+ // Clear today
266
+ clearToday?.addEventListener("click", async (e) => {
267
+ e.stopPropagation();
268
+ await _clearData("today");
269
+ });
270
+
271
+ // Clear all
272
+ clearAll?.addEventListener("click", async (e) => {
273
+ e.stopPropagation();
274
+ await _clearData("all");
275
+ });
276
+
277
+ // Close popup when clicking outside
278
+ document.addEventListener("click", _closeClearPopup);
279
+ }
119
280
 
120
- <div class="billing-details">
121
- <div class="billing-section">
122
- <h3>${I18n.t("billing.tokenBreakdown") || "Token Breakdown"}</h3>
123
- <div class="billing-token-grid">
124
- <div class="billing-token-item">
125
- <span class="billing-token-label">📥 ${I18n.t("billing.promptTokens") || "Prompt"}</span>
126
- <span class="billing-token-value">${_formatNumber(_summary.prompt_tokens)}</span>
281
+ function _closeClearPopup(e) {
282
+ const clearPopup = document.getElementById("billing-clear-popup");
283
+ const clearBtn = document.getElementById("billing-clear-btn");
284
+ if (!clearPopup || !clearBtn) return;
285
+
286
+ // Check if click is outside popup and button
287
+ if (!clearPopup.contains(e.target) && !clearBtn.contains(e.target)) {
288
+ _clearPopupVisible = false;
289
+ clearPopup.style.display = "none";
290
+ }
291
+ }
292
+
293
+ async function _clearData(scope) {
294
+ const clearPopup = document.getElementById("billing-clear-popup");
295
+ if (clearPopup) {
296
+ clearPopup.style.display = "none";
297
+ _clearPopupVisible = false;
298
+ }
299
+
300
+ try {
301
+ const res = await fetch(`/api/billing/clear?scope=${scope}`, { method: "DELETE" });
302
+ const data = await res.json();
303
+ if (res.ok) {
304
+ // Reload data after clearing
305
+ _load();
306
+ } else {
307
+ alert(data.error || "Failed to clear data");
308
+ }
309
+ } catch (e) {
310
+ alert(`Error clearing data: ${e.message}`);
311
+ }
312
+ }
313
+
314
+ // Helper functions for new UI
315
+ function _getSummaryHint() {
316
+ const cost = _convertCost(_summary.total_cost || 0);
317
+ const tokens = _summary.total_tokens || 0;
318
+ return `${_formatCompact(tokens)} tokens · ${_getCurrencySymbol()}${_formatCost(cost)}`;
319
+ }
320
+
321
+ function _getCacheHitRate() {
322
+ const prompt = _summary.prompt_tokens || 0;
323
+ const cacheRead = _summary.cache_read_tokens || 0;
324
+ if (prompt === 0) return "0";
325
+ return ((cacheRead / prompt) * 100).toFixed(1);
326
+ }
327
+
328
+ function _formatCompact(num) {
329
+ if (num == null || num === 0) return "0";
330
+ if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
331
+ if (num >= 1000) return (num / 1000).toFixed(1) + "K";
332
+ return num.toLocaleString();
333
+ }
334
+
335
+ function _renderTokenBreakdown() {
336
+ return `
337
+ <div class="billing-section billing-token-section">
338
+ <h3>${I18n.t("billing.tokenBreakdown") || "Token Breakdown"}</h3>
339
+ <div class="billing-token-bars">
340
+ <div class="billing-token-bar-item">
341
+ <div class="billing-token-bar-header">
342
+ <span class="billing-token-bar-label">${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}</span>
343
+ <span class="billing-token-bar-value">${_formatCompact(_summary.cache_read_tokens)}</span>
344
+ </div>
345
+ <div class="billing-token-bar-track">
346
+ <div class="billing-token-bar-fill billing-bar-cache-read" style="width: ${_getTokenPercent('cache_read')}%"></div>
347
+ </div>
348
+ </div>
349
+ <div class="billing-token-bar-item">
350
+ <div class="billing-token-bar-header">
351
+ <span class="billing-token-bar-label">${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}</span>
352
+ <span class="billing-token-bar-value">${_formatCompact(_summary.prompt_tokens)}</span>
127
353
  </div>
128
- <div class="billing-token-item">
129
- <span class="billing-token-label">📤 ${I18n.t("billing.completionTokens") || "Completion"}</span>
130
- <span class="billing-token-value">${_formatNumber(_summary.completion_tokens)}</span>
354
+ <div class="billing-token-bar-track">
355
+ <div class="billing-token-bar-fill billing-bar-prompt" style="width: ${_getTokenPercent('prompt')}%"></div>
131
356
  </div>
132
- <div class="billing-token-item">
133
- <span class="billing-token-label">🗄️ ${I18n.t("billing.cacheRead") || "Cache Read"}</span>
134
- <span class="billing-token-value">${_formatNumber(_summary.cache_read_tokens)}</span>
357
+ </div>
358
+ <div class="billing-token-bar-item">
359
+ <div class="billing-token-bar-header">
360
+ <span class="billing-token-bar-label">${I18n.t("billing.output") || "Output"}</span>
361
+ <span class="billing-token-bar-value">${_formatCompact(_summary.completion_tokens)}</span>
135
362
  </div>
136
- <div class="billing-token-item">
137
- <span class="billing-token-label">📝 ${I18n.t("billing.cacheWrite") || "Cache Write"}</span>
138
- <span class="billing-token-value">${_formatNumber(_summary.cache_write_tokens)}</span>
363
+ <div class="billing-token-bar-track">
364
+ <div class="billing-token-bar-fill billing-bar-completion" style="width: ${_getTokenPercent('completion')}%"></div>
139
365
  </div>
140
366
  </div>
141
367
  </div>
142
-
143
- ${_renderModelBreakdown()}
144
- ${_renderDailyChart()}
145
368
  </div>
146
369
  `;
370
+ }
147
371
 
148
- // Bind period change handler
149
- document.getElementById("billing-period-select")?.addEventListener("change", (e) => {
150
- _currentPeriod = e.target.value;
151
- _load();
152
- });
372
+ function _getTokenPercent(type) {
373
+ const total = _summary.total_tokens || 1;
374
+ const value = _summary[type + '_tokens'] || 0;
375
+ return Math.min((value / total) * 100, 100).toFixed(1);
153
376
  }
154
377
 
155
378
  function _renderModelBreakdown() {
156
- if (!_summary.by_model || Object.keys(_summary.by_model).length === 0) {
157
- return "";
379
+ const hasData = _summary.by_model && Object.keys(_summary.by_model).length > 0;
380
+
381
+ if (!hasData) {
382
+ return `
383
+ <div class="billing-section billing-model-section">
384
+ <h3>${I18n.t("billing.byModel") || "By Model"}</h3>
385
+ <div class="billing-model-empty">${I18n.t("billing.noData") || "No data"}</div>
386
+ </div>
387
+ `;
158
388
  }
159
389
 
160
- const rows = Object.entries(_summary.by_model)
161
- .sort((a, b) => (b[1].cost || b[1]) - (a[1].cost || a[1]))
162
- .map(([model, data]) => {
163
- const cost = typeof data === "object" ? data.cost : data;
164
- const requests = typeof data === "object" ? data.requests : "—";
165
- return `
166
- <tr>
167
- <td class="billing-model-name">${_esc(model)}</td>
168
- <td class="billing-model-cost">${_getCurrencySymbol()}${_formatCost(_convertCost(cost))}</td>
169
- <td class="billing-model-requests">${requests}</td>
170
- </tr>
171
- `;
172
- }).join("");
390
+ const entries = Object.entries(_summary.by_model)
391
+ .sort((a, b) => (b[1].cost || b[1]) - (a[1].cost || a[1]));
392
+
393
+ const totalCost = entries.reduce((sum, [, data]) => sum + (typeof data === "object" ? data.cost : data), 0) || 1;
394
+
395
+ const rows = entries.map(([model, data]) => {
396
+ const cost = typeof data === "object" ? data.cost : data;
397
+ const requests = typeof data === "object" ? data.requests : 0;
398
+ const percent = ((cost / totalCost) * 100).toFixed(1);
399
+ return `
400
+ <div class="billing-model-row">
401
+ <div class="billing-model-info">
402
+ <span class="billing-model-name">${_esc(model)}</span>
403
+ <span class="billing-model-meta">${requests} ${I18n.t("billing.requests") || "requests"}</span>
404
+ </div>
405
+ <div class="billing-model-bar-track">
406
+ <div class="billing-model-bar-fill" style="width: ${percent}%"></div>
407
+ </div>
408
+ <div class="billing-model-cost">${_getCurrencySymbol()}${_formatCost(_convertCost(cost))}</div>
409
+ </div>
410
+ `;
411
+ }).join("");
173
412
 
174
413
  return `
175
- <div class="billing-section">
414
+ <div class="billing-section billing-model-section">
176
415
  <h3>${I18n.t("billing.byModel") || "By Model"}</h3>
177
- <table class="billing-model-table">
178
- <thead>
179
- <tr>
180
- <th>${I18n.t("billing.model") || "Model"}</th>
181
- <th>${I18n.t("billing.cost") || "Cost"}</th>
182
- <th>${I18n.t("billing.requests") || "Requests"}</th>
183
- </tr>
184
- </thead>
185
- <tbody>
186
- ${rows}
187
- </tbody>
188
- </table>
416
+ <div class="billing-model-list">
417
+ ${rows}
418
+ </div>
189
419
  </div>
190
420
  `;
191
421
  }
192
422
 
193
- function _renderDailyChart() {
423
+ function _renderCombinedChart() {
194
424
  if (!_daily || _daily.length === 0) {
195
- return "";
425
+ return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
196
426
  }
197
427
 
198
- // Get last 14 days (show all days, not just those with activity)
199
428
  const recentDays = _daily.slice(-14);
200
- if (recentDays.length === 0) {
201
- return "";
202
- }
429
+ // Max values for scaling
430
+ const maxInput = Math.max(...recentDays.map(d => (d.prompt_tokens || 0) + (d.cache_read_tokens || 0)), 1);
431
+ const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1);
432
+ const maxVal = Math.max(maxInput, maxOutput);
433
+
434
+ // Chart height in pixels
435
+ const chartHeight = 120;
436
+
437
+ // Generate bars: each date has Input (stacked: cache hit + cache miss) and Output
438
+ const chartBars = recentDays.map((d, i) => {
439
+ const cacheHit = d.cache_read_tokens || 0; // 命中缓存
440
+ const cacheMiss = d.prompt_tokens || 0; // 未命中缓存(实际发送的prompt)
441
+ const output = d.completion_tokens || 0;
442
+ const totalInput = cacheHit + cacheMiss;
443
+ const totalTokens = totalInput + output;
444
+
445
+ // Calculate heights in pixels
446
+ const cacheHitPx = Math.max((cacheHit / maxVal) * chartHeight, cacheHit > 0 ? 2 : 0);
447
+ const cacheMissPx = Math.max((cacheMiss / maxVal) * chartHeight, cacheMiss > 0 ? 2 : 0);
448
+ const outputPx = Math.max((output / maxVal) * chartHeight, output > 0 ? 2 : 0);
449
+ const date = d.date.slice(5);
450
+ const showLabel = i % 2 === 0 || i === recentDays.length - 1;
451
+
452
+ // Tooltip data attributes for custom tooltip
453
+ const tooltipData = `data-date="${d.date}" data-total="${_formatCompact(totalTokens)}" data-cache-hit="${_formatCompact(cacheHit)}" data-cache-miss="${_formatCompact(cacheMiss)}" data-output="${_formatCompact(output)}"`;
203
454
 
204
- // Find max cost for scaling (minimum 0.0001 to avoid division by zero)
205
- const maxCost = Math.max(...recentDays.map(d => d.cost || 0), 0.0001);
206
-
207
- const bars = recentDays.map(d => {
208
- const cost = d.cost || 0;
209
- const height = cost > 0 ? Math.max((cost / maxCost) * 100, 5) : 0;
210
- const date = d.date.slice(5); // MM-DD
211
- const promptTokens = d.prompt_tokens || 0;
212
- const completionTokens = d.completion_tokens || 0;
213
- const cacheRead = d.cache_read_tokens || 0;
214
- const cacheWrite = d.cache_write_tokens || 0;
215
- const cacheHitRate = promptTokens > 0 ? ((cacheRead / promptTokens) * 100).toFixed(1) : 0;
216
- const tooltip = `${d.date}\n${I18n.t("billing.totalCost") || "Cost"}: ${_getCurrencySymbol()}${_formatCost(_convertCost(cost))}\n${I18n.t("billing.promptTokens") || "Input"}: ${_formatNumber(promptTokens)}\n${I18n.t("billing.completionTokens") || "Output"}: ${_formatNumber(completionTokens)}\n${I18n.t("billing.cacheRead") || "Cache Read"}: ${_formatNumber(cacheRead)} (${cacheHitRate}%)\n${I18n.t("billing.cacheWrite") || "Cache Write"}: ${_formatNumber(cacheWrite)}\n${I18n.t("billing.requests") || "Requests"}: ${d.requests || 0}`;
217
455
  return `
218
- <div class="billing-chart-bar-wrapper" title="${tooltip}">
219
- <div class="billing-chart-bar" style="height: ${height}%"></div>
220
- <div class="billing-chart-label">${date}</div>
456
+ <div class="billing-bar-group" ${tooltipData}>
457
+ <div class="billing-bar-pair">
458
+ <div class="billing-input-stack">
459
+ <div class="billing-cache-hit" style="height: ${cacheHitPx}px"></div>
460
+ <div class="billing-cache-miss" style="height: ${cacheMissPx}px"></div>
461
+ </div>
462
+ <div class="billing-output-bar" style="height: ${outputPx}px"></div>
463
+ </div>
464
+ ${showLabel ? `<span class="billing-bar-date">${date}</span>` : '<span class="billing-bar-date"></span>'}
221
465
  </div>
222
466
  `;
223
467
  }).join("");
224
468
 
225
469
  return `
226
- <div class="billing-section">
227
- <h3>${I18n.t("billing.dailyUsage") || "Daily Usage"}</h3>
228
- <div class="billing-chart">
229
- ${bars}
470
+ <div class="billing-chart-card billing-chart-wide">
471
+ <div class="billing-chart-header">
472
+ <h4>${I18n.t("billing.dailyUsage") || "Usage Details"}</h4>
473
+ <div class="billing-chart-legends">
474
+ <span class="billing-chart-legend">
475
+ <span class="billing-legend-dot billing-legend-cache-hit"></span>
476
+ ${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}
477
+ </span>
478
+ <span class="billing-chart-legend">
479
+ <span class="billing-legend-dot billing-legend-cache-miss"></span>
480
+ ${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}
481
+ </span>
482
+ <span class="billing-chart-legend">
483
+ <span class="billing-legend-dot billing-legend-output"></span>
484
+ ${I18n.t("billing.output") || "Output"}
485
+ </span>
486
+ </div>
487
+ </div>
488
+ <div class="billing-combined-chart" id="billing-chart-container">
489
+ ${chartBars}
230
490
  </div>
231
491
  </div>
492
+ <div class="billing-chart-tooltip" id="billing-tooltip"></div>
232
493
  `;
233
494
  }
234
495
 
@@ -244,6 +505,17 @@ const Billing = (() => {
244
505
  return num.toLocaleString();
245
506
  }
246
507
 
508
+ function _periodLabelShort(period) {
509
+ const labels = {
510
+ day: I18n.t("billing.period.day") || "Today",
511
+ week: I18n.t("billing.period.weekShort") || "Week",
512
+ month: I18n.t("billing.period.monthShort") || "Month",
513
+ year: I18n.t("billing.period.yearShort") || "Year",
514
+ all: I18n.t("billing.period.allShort") || "All"
515
+ };
516
+ return labels[period] || period;
517
+ }
518
+
247
519
  function _periodLabel(period) {
248
520
  const labels = {
249
521
  day: I18n.t("billing.period.day") || "Today",