openclacky 1.3.2 → 1.3.3

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -1,114 +1,45 @@
1
- // billing.jsBilling panel logic
2
- // Handles displaying billing summary, daily breakdown, and usage statistics.
3
-
4
- const Billing = (() => {
5
- let _summary = null;
6
- let _daily = [];
7
- let _sessions = []; // 会话列表
8
- let _allModels = []; // 保存完整的模型列表
9
- let _currentPeriod = "day";
10
- let _currentModel = "all";
1
+ // ── Billing · view dashboard rendering, charts, tooltips, DOM wiring ─────
2
+ //
3
+ // Owns every render function (stats, heatmap, trend, breakdowns, sessions),
4
+ // tooltip binding, and the clear-data popup. Reads through BillingStore.state
5
+ // and currency helpers; period / model / clear / share go through store actions.
6
+ //
7
+ // Augments the `Billing` facade with onPanelShow.
8
+ //
9
+ // Depends on: BillingStore, I18n, Share (optional).
10
+ // ───────────────────────────────────────────────────────────────────────────
11
+
12
+ const BillingView = (() => {
11
13
  let _clearPopupVisible = false;
12
14
 
13
- // ── Currency Settings ─────────────────────────────────────────────────────
14
- const CURRENCY_STORAGE_KEY = "clacky-currency";
15
- const EXCHANGE_RATE_STORAGE_KEY = "clacky-exchange-rate";
16
- const DEFAULT_USD_TO_CNY_RATE = 6.7944; // Default exchange rate: 1 USD ≈ 6.7944 CNY
15
+ // ── Loading / error ───────────────────────────────────────────────────────
17
16
 
18
- function _getCurrency() {
19
- try { return localStorage.getItem(CURRENCY_STORAGE_KEY) || "USD"; } catch (_) { return "USD"; }
20
- }
21
-
22
- function _getExchangeRate() {
23
- try {
24
- const rate = localStorage.getItem(EXCHANGE_RATE_STORAGE_KEY);
25
- if (rate) {
26
- const parsed = parseFloat(rate);
27
- if (!isNaN(parsed) && parsed > 0) return parsed;
28
- }
29
- } catch (_) {}
30
- return DEFAULT_USD_TO_CNY_RATE;
31
- }
32
-
33
- function _setExchangeRate(rate) {
34
- try {
35
- if (rate && !isNaN(rate) && rate > 0) {
36
- localStorage.setItem(EXCHANGE_RATE_STORAGE_KEY, rate.toString());
37
- document.dispatchEvent(new CustomEvent("currencychange"));
38
- }
39
- } catch (_) {}
40
- }
41
-
42
- function _convertCost(usdCost) {
43
- const currency = _getCurrency();
44
- if (currency === "CNY") {
45
- return usdCost * _getExchangeRate();
46
- }
47
- return usdCost;
48
- }
49
-
50
- function _getCurrencySymbol() {
51
- return _getCurrency() === "CNY" ? "¥" : "$";
52
- }
53
-
54
- // ── Public API ──────────────────────────────────────────────────────────────
55
-
56
- function open() {
57
- _load();
58
- // Listen for currency changes
59
- document.removeEventListener("currencychange", _onCurrencyChange);
60
- document.addEventListener("currencychange", _onCurrencyChange);
61
- }
62
-
63
- function _onCurrencyChange() {
64
- if (_summary) _render();
65
- }
66
-
67
- // ── Data Loading ────────────────────────────────────────────────────────────
68
-
69
- async function _load() {
17
+ function _onLoading(payload) {
70
18
  const container = document.getElementById("billing-content");
71
19
  if (!container) return;
72
20
 
73
- const isFirstLoad = !_summary;
74
- if (isFirstLoad) {
21
+ if (payload.isFirstLoad) {
75
22
  container.innerHTML = _renderSkeleton();
76
- } else {
77
- const existing = container.querySelector(".billing-dashboard");
78
- if (existing && !existing.querySelector(".billing-skel-overlay")) {
79
- const topBar = existing.querySelector(".billing-top-bar");
80
- const topBarH = topBar ? topBar.offsetHeight + 20 : 0;
81
- existing.insertAdjacentHTML("beforeend", `<div class="billing-skel-overlay" style="top:${topBarH}px">${_renderSkeletonBody()}</div>`);
82
- }
23
+ return;
83
24
  }
25
+ const existing = container.querySelector(".billing-dashboard");
26
+ if (existing && !existing.querySelector(".billing-skel-overlay")) {
27
+ const topBar = existing.querySelector(".billing-top-bar");
28
+ const topBarH = topBar ? topBar.offsetHeight + 20 : 0;
29
+ existing.insertAdjacentHTML("beforeend", `<div class="billing-skel-overlay" style="top:${topBarH}px">${_renderSkeletonBody()}</div>`);
30
+ }
31
+ }
84
32
 
85
- try {
86
- const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
87
- const [summaryRes, dailyRes, sessionsRes] = await Promise.all([
88
- fetch(`/api/billing/summary?period=${_currentPeriod}${modelParam}`),
89
- fetch(`/api/billing/daily?days=30${modelParam}`),
90
- fetch(`/api/billing/sessions?period=${_currentPeriod}${modelParam}&limit=100`)
91
- ]);
92
-
93
- _summary = await summaryRes.json();
94
- const dailyData = await dailyRes.json();
95
- _daily = dailyData.days || [];
96
-
97
- const sessionsData = await sessionsRes.json();
98
- _sessions = sessionsData.sessions || [];
99
-
100
- // 保存完整模型列表(仅在未筛选时更新)
101
- if (!_currentModel || _currentModel === "all") {
102
- _allModels = _summary.by_model ? Object.keys(_summary.by_model) : [];
103
- }
33
+ function _onError(payload) {
34
+ const container = document.getElementById("billing-content");
35
+ if (container) container.innerHTML = `<div class="billing-error">${I18n.t("billing.error") || "Failed to load billing data"}: ${payload.message}</div>`;
36
+ }
104
37
 
105
- _render();
106
- } catch (e) {
107
- container.innerHTML = `<div class="billing-error">${I18n.t("billing.error") || "Failed to load billing data"}: ${e.message}</div>`;
108
- }
38
+ function _onActionError(payload) {
39
+ alert(payload.message);
109
40
  }
110
41
 
111
- // ── Rendering ───────────────────────────────────────────────────────────────
42
+ // ── Skeletons ─────────────────────────────────────────────────────────────
112
43
 
113
44
  function _renderSkeletonBody() {
114
45
  return `
@@ -178,20 +109,22 @@ const Billing = (() => {
178
109
  `;
179
110
  }
180
111
 
112
+ // ── Main render ─────────────────────────────────────────────────────────────
113
+
181
114
  function _render() {
182
115
  const container = document.getElementById("billing-content");
183
- if (!container || !_summary) return;
116
+ const summary = BillingStore.state.summary;
117
+ if (!container || !summary) return;
184
118
 
185
- // Period button group
186
119
  const periods = ["day", "week", "month", "year", "all"];
187
- const periodBtns = periods.map(p =>
188
- `<button class="billing-period-btn ${p === _currentPeriod ? 'active' : ''}" data-period="${p}">${_periodLabel(p)}</button>`
120
+ const periodBtns = periods.map(p =>
121
+ `<button class="billing-period-btn ${p === BillingStore.state.currentPeriod ? 'active' : ''}" data-period="${p}">${_periodLabel(p)}</button>`
189
122
  ).join("");
190
123
 
191
- // Model filter options (使用完整模型列表)
192
- const models = _allModels.length > 0 ? _allModels : (_summary.by_model ? Object.keys(_summary.by_model) : []);
124
+ const allModels = BillingStore.state.allModels;
125
+ const models = allModels.length > 0 ? allModels : (summary.by_model ? Object.keys(summary.by_model) : []);
193
126
  const modelOptions = [`<option value="all">${I18n.t("billing.allModels") || "All Models"}</option>`]
194
- .concat(models.filter(m => m).map(m => `<option value="${_esc(m)}" ${m === _currentModel ? "selected" : ""}>${_esc(m)}</option>`))
127
+ .concat(models.filter(m => m).map(m => `<option value="${_esc(m)}" ${m === BillingStore.state.currentModel ? "selected" : ""}>${_esc(m)}</option>`))
195
128
  .join("");
196
129
 
197
130
  container.innerHTML = `
@@ -225,7 +158,7 @@ const Billing = (() => {
225
158
  <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/><path d="M10 6v1m0 6v1M7.5 10a2.5 2.5 0 0 0 2.5 2.5c1.38 0 2.5-.56 2.5-1.25S11.38 10 10 10c-1.38 0-2.5-.56-2.5-1.25S8.62 7.5 10 7.5A2.5 2.5 0 0 1 12.5 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
226
159
  </div>
227
160
  <div class="billing-stat-content">
228
- <div class="billing-stat-value">${_getCurrencySymbol()}${_formatCost(_convertCost(_summary.total_cost))}</div>
161
+ <div class="billing-stat-value">${Billing.getCurrencySymbol()}${_formatCost(Billing.convertCost(summary.total_cost))}</div>
229
162
  <div class="billing-stat-label">${I18n.t("billing.totalCost") || "Total Cost"}</div>
230
163
  </div>
231
164
  </div>
@@ -234,7 +167,7 @@ const Billing = (() => {
234
167
  <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="11" width="3" height="6" rx="1" fill="currentColor" opacity=".4"/><rect x="8.5" y="7" width="3" height="10" rx="1" fill="currentColor" opacity=".7"/><rect x="14" y="3" width="3" height="14" rx="1" fill="currentColor"/></svg>
235
168
  </div>
236
169
  <div class="billing-stat-content">
237
- <div class="billing-stat-value">${_formatCompact(_summary.total_tokens)}</div>
170
+ <div class="billing-stat-value">${_formatCompact(summary.total_tokens)}</div>
238
171
  <div class="billing-stat-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</div>
239
172
  </div>
240
173
  </div>
@@ -243,7 +176,7 @@ const Billing = (() => {
243
176
  <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 3a7 7 0 1 1 0 14A7 7 0 0 1 10 3Z" stroke="currentColor" stroke-width="1.5"/><path d="M10 7v3l2 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
244
177
  </div>
245
178
  <div class="billing-stat-content">
246
- <div class="billing-stat-value">${_formatNumber(_summary.record_count)}</div>
179
+ <div class="billing-stat-value">${_formatNumber(summary.record_count)}</div>
247
180
  <div class="billing-stat-label">${I18n.t("billing.requests") || "Requests"}</div>
248
181
  </div>
249
182
  </div>
@@ -278,52 +211,43 @@ const Billing = (() => {
278
211
  </div>
279
212
  `;
280
213
 
281
- // Bind period button handlers
282
214
  document.querySelectorAll(".billing-period-btn").forEach(btn => {
283
- btn.addEventListener("click", (e) => {
284
- _currentPeriod = e.target.dataset.period;
285
- _load();
286
- });
215
+ btn.addEventListener("click", (e) => Billing.setPeriod(e.target.dataset.period));
287
216
  });
288
217
 
289
- // Bind model filter handler
290
218
  document.getElementById("billing-model-filter")?.addEventListener("change", (e) => {
291
- _currentModel = e.target.value;
292
- _load();
219
+ Billing.setModel(e.target.value);
293
220
  });
294
221
 
295
- // Bind clear button handlers
296
222
  _bindClearHandlers();
297
-
298
- // Bind scorecard share button
299
223
  document.getElementById("billing-share-btn")?.addEventListener("click", _openScorecardShare);
300
224
 
301
- // Bind chart tooltip handlers
302
225
  _bindChartTooltip();
303
226
  _bindHeatmapTooltip();
304
227
  _bindTrendTooltip();
305
228
  }
306
229
 
307
- // Builds the per-period scorecard numbers from a raw summary object, using
308
- // the same currency / formatting conventions as the billing dashboard.
230
+ // ── Scorecard ───────────────────────────────────────────────────────────────
231
+
309
232
  function _scorecardStatsFor(summary, periodKey) {
310
- const prompt = summary.prompt_tokens || 0;
311
- const cacheRead = summary.cache_read_tokens || 0;
312
- const rate = prompt === 0 ? "0" : ((cacheRead / prompt) * 100).toFixed(1);
313
233
  return {
314
234
  key: periodKey,
315
235
  period: _periodLabel(periodKey),
316
- cacheHitRate: rate,
317
- costStr: `${_getCurrencySymbol()}${_formatCost(_convertCost(summary.total_cost || 0))}`,
236
+ cacheHitRate: _cacheHitRateOf(summary),
237
+ costStr: `${Billing.getCurrencySymbol()}${_formatCost(Billing.convertCost(summary.total_cost || 0))}`,
318
238
  tokensStr: _formatCompact(summary.total_tokens || 0),
319
239
  requests: _formatNumber(summary.record_count || 0)
320
240
  };
321
241
  }
322
242
 
323
- // Daily token totals for the heatmap (GitHub-contribution style), oldest →
324
- // newest. Each entry: { date: "YYYY-MM-DD", tokens: <total> }.
243
+ function _cacheHitRateOf(summary) {
244
+ const prompt = summary.prompt_tokens || 0;
245
+ const cacheRead = summary.cache_read_tokens || 0;
246
+ return prompt === 0 ? "0" : ((cacheRead / prompt) * 100).toFixed(1);
247
+ }
248
+
325
249
  function _heatmapDays() {
326
- return (_daily || []).map((d) => ({
250
+ return (BillingStore.state.daily || []).map((d) => ({
327
251
  date: d.date,
328
252
  tokens: (d.prompt_tokens || 0) + (d.completion_tokens || 0),
329
253
  cost: d.cost || 0
@@ -331,29 +255,27 @@ const Billing = (() => {
331
255
  }
332
256
 
333
257
  function _openScorecardShare() {
334
- if (!_summary || typeof Share === "undefined" || !Share.openScorecard) return;
335
- const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
258
+ const summary = BillingStore.state.summary;
259
+ if (!summary || typeof Share === "undefined" || !Share.openScorecard) return;
260
+ const currentPeriod = BillingStore.state.currentPeriod;
336
261
 
337
- // Open instantly with the period the dashboard already has, then fetch the
338
- // other periods in the background and hot-swap them in (no blocking await).
339
262
  const periods = {};
340
- periods[_currentPeriod] = _scorecardStatsFor(_summary, _currentPeriod);
263
+ periods[currentPeriod] = _scorecardStatsFor(summary, currentPeriod);
341
264
 
342
265
  Share.openScorecard({
343
266
  periods: periods,
344
- defaultPeriod: _currentPeriod,
267
+ defaultPeriod: currentPeriod,
345
268
  heatmap: _heatmapDays(),
346
- period: periods[_currentPeriod].period,
347
- cacheHitRate: periods[_currentPeriod].cacheHitRate,
348
- costStr: periods[_currentPeriod].costStr,
349
- tokensStr: periods[_currentPeriod].tokensStr,
350
- requests: periods[_currentPeriod].requests
269
+ period: periods[currentPeriod].period,
270
+ cacheHitRate: periods[currentPeriod].cacheHitRate,
271
+ costStr: periods[currentPeriod].costStr,
272
+ tokensStr: periods[currentPeriod].tokensStr,
273
+ requests: periods[currentPeriod].requests
351
274
  });
352
275
 
353
- const others = ["day", "week", "month"].filter((p) => p !== _currentPeriod);
276
+ const others = ["day", "week", "month"].filter((p) => p !== currentPeriod);
354
277
  others.forEach((p) => {
355
- fetch(`/api/billing/summary?period=${p}${modelParam}`)
356
- .then((r) => r.json())
278
+ Billing.fetchSummary(p)
357
279
  .then((summary) => {
358
280
  if (Share.addScorecardPeriod) Share.addScorecardPeriod(p, _scorecardStatsFor(summary, p));
359
281
  })
@@ -361,6 +283,8 @@ const Billing = (() => {
361
283
  });
362
284
  }
363
285
 
286
+ // ── Tooltips ─────────────────────────────────────────────────────────────────
287
+
364
288
  function _bindChartTooltip() {
365
289
  const container = document.getElementById("billing-chart-container");
366
290
  const tooltip = document.getElementById("billing-tooltip");
@@ -406,8 +330,6 @@ const Billing = (() => {
406
330
  </div>
407
331
  `;
408
332
  tooltip.style.display = "block";
409
-
410
- // Position tooltip following mouse
411
333
  tooltip.style.left = `${e.clientX + 15}px`;
412
334
  tooltip.style.top = `${e.clientY - 10}px`;
413
335
  });
@@ -488,6 +410,8 @@ const Billing = (() => {
488
410
  });
489
411
  }
490
412
 
413
+ // ── Clear popup ────────────────────────────────────────────────────────────
414
+
491
415
  function _bindClearHandlers() {
492
416
  const clearBtn = document.getElementById("billing-clear-btn");
493
417
  const clearPopup = document.getElementById("billing-clear-popup");
@@ -496,74 +420,51 @@ const Billing = (() => {
496
420
 
497
421
  if (!clearBtn || !clearPopup) return;
498
422
 
499
- // Toggle popup on button click
500
423
  clearBtn.addEventListener("click", (e) => {
501
424
  e.stopPropagation();
502
425
  _clearPopupVisible = !_clearPopupVisible;
503
426
  clearPopup.style.display = _clearPopupVisible ? "flex" : "none";
504
427
  });
505
428
 
506
- // Clear today
507
- clearToday?.addEventListener("click", async (e) => {
429
+ clearToday?.addEventListener("click", (e) => {
508
430
  e.stopPropagation();
509
- await _clearData("today");
431
+ _hideClearPopup();
432
+ Billing.clearData("today");
510
433
  });
511
434
 
512
- // Clear all
513
- clearAll?.addEventListener("click", async (e) => {
435
+ clearAll?.addEventListener("click", (e) => {
514
436
  e.stopPropagation();
515
- await _clearData("all");
437
+ _hideClearPopup();
438
+ Billing.clearData("all");
516
439
  });
517
440
 
518
- // Close popup when clicking outside
519
441
  document.addEventListener("click", _closeClearPopup);
520
442
  }
521
443
 
444
+ function _hideClearPopup() {
445
+ const clearPopup = document.getElementById("billing-clear-popup");
446
+ if (clearPopup) clearPopup.style.display = "none";
447
+ _clearPopupVisible = false;
448
+ }
449
+
522
450
  function _closeClearPopup(e) {
523
451
  const clearPopup = document.getElementById("billing-clear-popup");
524
452
  const clearBtn = document.getElementById("billing-clear-btn");
525
453
  if (!clearPopup || !clearBtn) return;
526
-
527
- // Check if click is outside popup and button
528
- if (!clearPopup.contains(e.target) && !clearBtn.contains(e.target)) {
529
- _clearPopupVisible = false;
530
- clearPopup.style.display = "none";
531
- }
454
+ if (!clearPopup.contains(e.target) && !clearBtn.contains(e.target)) _hideClearPopup();
532
455
  }
533
456
 
534
- async function _clearData(scope) {
535
- const clearPopup = document.getElementById("billing-clear-popup");
536
- if (clearPopup) {
537
- clearPopup.style.display = "none";
538
- _clearPopupVisible = false;
539
- }
540
-
541
- try {
542
- const res = await fetch(`/api/billing/clear?scope=${scope}`, { method: "DELETE" });
543
- const data = await res.json();
544
- if (res.ok) {
545
- // Reload data after clearing
546
- _load();
547
- } else {
548
- alert(data.error || "Failed to clear data");
549
- }
550
- } catch (e) {
551
- alert(`Error clearing data: ${e.message}`);
552
- }
553
- }
457
+ // ── Section renderers ────────────────────────────────────────────────────────
554
458
 
555
- // Helper functions for new UI
556
459
  function _getSummaryHint() {
557
- const cost = _convertCost(_summary.total_cost || 0);
558
- const tokens = _summary.total_tokens || 0;
559
- return `${_formatCompact(tokens)} tokens · ${_getCurrencySymbol()}${_formatCost(cost)}`;
460
+ const summary = BillingStore.state.summary;
461
+ const cost = Billing.convertCost(summary.total_cost || 0);
462
+ const tokens = summary.total_tokens || 0;
463
+ return `${_formatCompact(tokens)} tokens · ${Billing.getCurrencySymbol()}${_formatCost(cost)}`;
560
464
  }
561
465
 
562
466
  function _getCacheHitRate() {
563
- const prompt = _summary.prompt_tokens || 0;
564
- const cacheRead = _summary.cache_read_tokens || 0;
565
- if (prompt === 0) return "0";
566
- return ((cacheRead / prompt) * 100).toFixed(1);
467
+ return _cacheHitRateOf(BillingStore.state.summary);
567
468
  }
568
469
 
569
470
  function _formatCompact(num) {
@@ -574,10 +475,11 @@ const Billing = (() => {
574
475
  }
575
476
 
576
477
  function _renderTokenBreakdown() {
577
- const totalTokens = _summary.total_tokens || 0;
578
- const promptTokens = _summary.prompt_tokens || 0;
579
- const completionTokens = _summary.completion_tokens || 0;
580
- const cacheReadTokens = _summary.cache_read_tokens || 0;
478
+ const summary = BillingStore.state.summary;
479
+ const totalTokens = summary.total_tokens || 0;
480
+ const promptTokens = summary.prompt_tokens || 0;
481
+ const completionTokens = summary.completion_tokens || 0;
482
+ const cacheReadTokens = summary.cache_read_tokens || 0;
581
483
  const cacheMissTokens = promptTokens - cacheReadTokens;
582
484
 
583
485
  return `
@@ -631,8 +533,9 @@ const Billing = (() => {
631
533
  }
632
534
 
633
535
  function _renderModelBreakdown() {
634
- const hasData = _summary.by_model && Object.keys(_summary.by_model).length > 0;
635
-
536
+ const summary = BillingStore.state.summary;
537
+ const hasData = summary.by_model && Object.keys(summary.by_model).length > 0;
538
+
636
539
  if (!hasData) {
637
540
  return `
638
541
  <div class="billing-section billing-model-section">
@@ -642,10 +545,10 @@ const Billing = (() => {
642
545
  `;
643
546
  }
644
547
 
645
- const entries = Object.entries(_summary.by_model)
548
+ const entries = Object.entries(summary.by_model)
646
549
  .filter(([_, data]) => (typeof data === "object" ? data.cost : data) > 0)
647
550
  .sort((a, b) => (b[1].cost || b[1]) - (a[1].cost || a[1]));
648
-
551
+
649
552
  const totalCost = entries.reduce((sum, [, data]) => sum + (typeof data === "object" ? data.cost : data), 0) || 1;
650
553
 
651
554
  const rows = entries.map(([model, data]) => {
@@ -661,7 +564,7 @@ const Billing = (() => {
661
564
  <div class="billing-model-bar-track">
662
565
  <div class="billing-model-bar-fill" style="width: ${percent}%"></div>
663
566
  </div>
664
- <div class="billing-model-cost">${_getCurrencySymbol()}${_formatCost(_convertCost(cost))}</div>
567
+ <div class="billing-model-cost">${Billing.getCurrencySymbol()}${_formatCost(Billing.convertCost(cost))}</div>
665
568
  </div>
666
569
  `;
667
570
  }).join("");
@@ -689,7 +592,7 @@ const Billing = (() => {
689
592
  days.forEach((d) => {
690
593
  const ratio = d.tokens / maxTok;
691
594
  const lvl = d.tokens === 0 ? 0 : ratio >= 0.75 ? 5 : ratio >= 0.5 ? 4 : ratio >= 0.25 ? 3 : ratio >= 0.08 ? 2 : 1;
692
- const costStr = `${_getCurrencySymbol()}${_formatCost(_convertCost(d.cost))}`;
595
+ const costStr = `${Billing.getCurrencySymbol()}${_formatCost(Billing.convertCost(d.cost))}`;
693
596
  cells.push(`<div class="billing-heat-cell" data-level="${lvl}" data-date="${d.date}" data-tokens="${_formatCompact(d.tokens)}" data-cost="${costStr}"></div>`);
694
597
  });
695
598
 
@@ -717,12 +620,13 @@ const Billing = (() => {
717
620
  }
718
621
 
719
622
  function _renderCostTrend() {
720
- if (!_daily || _daily.length < 2) {
623
+ const daily = BillingStore.state.daily;
624
+ if (!daily || daily.length < 2) {
721
625
  return `<div class="billing-chart-card billing-trend-card"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
722
626
  }
723
627
 
724
- const days = _daily.slice(-30);
725
- const costs = days.map(d => _convertCost(d.cost || 0));
628
+ const days = daily.slice(-30);
629
+ const costs = days.map(d => Billing.convertCost(d.cost || 0));
726
630
  const maxCost = Math.max(...costs, 0.0001);
727
631
  const minCost = Math.min(...costs);
728
632
 
@@ -762,7 +666,7 @@ const Billing = (() => {
762
666
  xLabels.push({ date: d.date.slice(5), x });
763
667
  });
764
668
 
765
- const currencySymbol = _getCurrencySymbol();
669
+ const currencySymbol = Billing.getCurrencySymbol();
766
670
 
767
671
  return `
768
672
  <div class="billing-chart-card billing-trend-card">
@@ -799,35 +703,32 @@ const Billing = (() => {
799
703
  }
800
704
 
801
705
  function _renderCombinedChart() {
802
- if (!_daily || _daily.length === 0) {
706
+ const daily = BillingStore.state.daily;
707
+ if (!daily || daily.length === 0) {
803
708
  return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
804
709
  }
805
710
 
806
- const recentDays = _daily.slice(-14);
807
- // Max values for scaling
711
+ const recentDays = daily.slice(-14);
808
712
  const maxInput = Math.max(...recentDays.map(d => d.prompt_tokens || 0), 1);
809
- const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1); const maxVal = Math.max(maxInput, maxOutput);
713
+ const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1);
714
+ const maxVal = Math.max(maxInput, maxOutput);
810
715
 
811
- // Chart height in pixels
812
716
  const chartHeight = 120;
813
717
 
814
- // Generate bars: each date has Input (stacked: cache hit + cache miss) and Output
815
718
  const chartBars = recentDays.map((d, i) => {
816
- const cacheHit = d.cache_read_tokens || 0; // 命中缓存
817
- const totalPrompt = d.prompt_tokens || 0; // 全部输入token
818
- const cacheMiss = totalPrompt - cacheHit; // 未命中缓存 = 全部输入 - 命中
719
+ const cacheHit = d.cache_read_tokens || 0;
720
+ const totalPrompt = d.prompt_tokens || 0;
721
+ const cacheMiss = totalPrompt - cacheHit;
819
722
  const output = d.completion_tokens || 0;
820
723
  const totalInput = totalPrompt;
821
724
  const totalTokens = totalInput + output;
822
725
 
823
- // Calculate heights in pixels
824
726
  const cacheHitPx = Math.max((cacheHit / maxVal) * chartHeight, cacheHit > 0 ? 2 : 0);
825
727
  const cacheMissPx = Math.max((cacheMiss / maxVal) * chartHeight, cacheMiss > 0 ? 2 : 0);
826
728
  const outputPx = Math.max((output / maxVal) * chartHeight, output > 0 ? 2 : 0);
827
729
  const date = d.date.slice(5);
828
730
  const showLabel = i % 2 === 0 || i === recentDays.length - 1;
829
731
 
830
- // Tooltip data attributes for custom tooltip
831
732
  const tooltipData = `data-date="${d.date}" data-total="${_formatCompact(totalTokens)}" data-cache-hit="${_formatCompact(cacheHit)}" data-cache-miss="${_formatCompact(cacheMiss)}" data-output="${_formatCompact(output)}"`;
832
733
 
833
734
  return `
@@ -876,7 +777,8 @@ const Billing = (() => {
876
777
  }
877
778
 
878
779
  function _renderSessionList() {
879
- if (!_sessions || _sessions.length === 0) {
780
+ const sessions = BillingStore.state.sessions;
781
+ if (!sessions || sessions.length === 0) {
880
782
  return `
881
783
  <div class="billing-sessions-section">
882
784
  <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
@@ -885,12 +787,12 @@ const Billing = (() => {
885
787
  `;
886
788
  }
887
789
 
888
- const rows = _sessions.map((s, index) => {
790
+ const rows = sessions.map((s, index) => {
889
791
  const sessionId = s.session_id || "unknown";
890
792
  const isDeleted = s.is_deleted;
891
793
  const sessionName = s.session_name || sessionId;
892
794
  const displayName = isDeleted ? (I18n.t("billing.deletedSessions") || "已删除会话") : (sessionName.length > 25 ? sessionName.slice(0, 25) + "..." : sessionName);
893
- const totalCost = _convertCost(s.total_cost || 0);
795
+ const totalCost = Billing.convertCost(s.total_cost || 0);
894
796
  const totalTokens = s.total_tokens || 0;
895
797
  const promptTokens = s.prompt_tokens || 0;
896
798
  const cacheHit = s.cache_read_tokens || 0;
@@ -912,13 +814,13 @@ const Billing = (() => {
912
814
  <div class="billing-cell billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)}</div>
913
815
  <div class="billing-cell billing-cell-number billing-cell-miss">${_formatCompact(cacheMiss)}</div>
914
816
  <div class="billing-cell billing-cell-number">${_formatCompact(completionTokens)}</div>
915
- <div class="billing-cell billing-cell-cost">${_getCurrencySymbol()}${_formatCost(totalCost)}</div>
817
+ <div class="billing-cell billing-cell-cost">${Billing.getCurrencySymbol()}${_formatCost(totalCost)}</div>
916
818
  <div class="billing-cell billing-cell-time">${lastRequest}</div>
917
819
  <div class="billing-session-numbers-row">
918
820
  <span class="billing-cell-number">${_formatCompact(totalTokens)} tok</span>
919
821
  <span class="billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)} hit</span>
920
822
  <span class="billing-cell-number">${_formatCompact(completionTokens)} out</span>
921
- <span class="billing-cell-cost">${_getCurrencySymbol()}${_formatCost(totalCost)}</span>
823
+ <span class="billing-cell-cost">${Billing.getCurrencySymbol()}${_formatCost(totalCost)}</span>
922
824
  <span class="billing-cell-time">${lastRequest}</span>
923
825
  </div>
924
826
  </div>
@@ -945,7 +847,7 @@ const Billing = (() => {
945
847
  `;
946
848
  }
947
849
 
948
- // ── Helpers ─────────────────────────────────────────────────────────────────
850
+ // ── Helpers ───────────────────────────────────────────────────────────────
949
851
 
950
852
  function _formatCost(cost) {
951
853
  if (cost == null || cost === 0) return "0.0000";
@@ -957,17 +859,6 @@ const Billing = (() => {
957
859
  return num.toLocaleString();
958
860
  }
959
861
 
960
- function _periodLabelShort(period) {
961
- const labels = {
962
- day: I18n.t("billing.period.day") || "Today",
963
- week: I18n.t("billing.period.weekShort") || "Week",
964
- month: I18n.t("billing.period.monthShort") || "Month",
965
- year: I18n.t("billing.period.yearShort") || "Year",
966
- all: I18n.t("billing.period.allShort") || "All"
967
- };
968
- return labels[period] || period;
969
- }
970
-
971
862
  function _periodLabel(period) {
972
863
  const labels = {
973
864
  day: I18n.t("billing.period.day") || "Today",
@@ -986,14 +877,15 @@ const Billing = (() => {
986
877
  return div.innerHTML;
987
878
  }
988
879
 
989
- // ── Expose Public API ───────────────────────────────────────────────────────
880
+ function _subscribe() {
881
+ Billing.on("billing:loading", _onLoading);
882
+ Billing.on("billing:changed", _render);
883
+ Billing.on("billing:error", _onError);
884
+ Billing.on("billing:actionError", _onActionError);
885
+ document.addEventListener("currencychange", () => Billing.refreshView());
886
+ }
990
887
 
991
- return {
992
- open,
993
- // Expose currency utilities for other modules
994
- getCurrency: _getCurrency,
995
- convertCost: _convertCost,
996
- getCurrencySymbol: _getCurrencySymbol,
997
- getExchangeRate: _getExchangeRate
998
- };
888
+ return { init: _subscribe };
999
889
  })();
890
+
891
+ BillingView.init();