openclacky 1.2.18 → 1.3.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/clacky/agent/time_machine.rb +256 -74
  4. data/lib/clacky/agent/tool_executor.rb +12 -0
  5. data/lib/clacky/agent.rb +15 -20
  6. data/lib/clacky/agent_config.rb +18 -0
  7. data/lib/clacky/cli.rb +55 -3
  8. data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
  9. data/lib/clacky/media/base.rb +93 -0
  10. data/lib/clacky/media/gemini.rb +10 -0
  11. data/lib/clacky/media/generator.rb +57 -0
  12. data/lib/clacky/media/openai_compat.rb +160 -0
  13. data/lib/clacky/message_history.rb +12 -7
  14. data/lib/clacky/providers.rb +29 -1
  15. data/lib/clacky/rich_ui_controller.rb +3 -1
  16. data/lib/clacky/server/backup_manager.rb +200 -0
  17. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  18. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  19. data/lib/clacky/server/channel/channel_manager.rb +65 -50
  20. data/lib/clacky/server/http_server.rb +356 -14
  21. data/lib/clacky/server/scheduler.rb +19 -0
  22. data/lib/clacky/server/session_registry.rb +8 -4
  23. data/lib/clacky/session_manager.rb +40 -2
  24. data/lib/clacky/tools/trash_manager.rb +14 -0
  25. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  26. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  27. data/lib/clacky/ui2/ui_controller.rb +150 -19
  28. data/lib/clacky/utils/file_processor.rb +75 -4
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +2283 -1277
  31. data/lib/clacky/web/app.js +73 -1
  32. data/lib/clacky/web/backup.js +119 -0
  33. data/lib/clacky/web/billing.js +224 -11
  34. data/lib/clacky/web/channels.js +81 -11
  35. data/lib/clacky/web/design-sample.css +247 -0
  36. data/lib/clacky/web/design-sample.html +127 -0
  37. data/lib/clacky/web/favicon.svg +16 -0
  38. data/lib/clacky/web/i18n.js +167 -31
  39. data/lib/clacky/web/index.html +176 -55
  40. data/lib/clacky/web/logo_nav_dark.png +0 -0
  41. data/lib/clacky/web/onboard.js +121 -28
  42. data/lib/clacky/web/sessions.js +447 -192
  43. data/lib/clacky/web/settings.js +21 -1
  44. data/lib/clacky/web/skills.js +34 -1
  45. data/lib/clacky/web/tasks.js +129 -61
  46. data/lib/clacky/web/utils.js +72 -0
  47. data/lib/clacky/web/ws-dispatcher.js +6 -0
  48. data/lib/clacky.rb +1 -0
  49. metadata +9 -8
  50. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
@@ -171,7 +171,7 @@ const Router = (() => {
171
171
  // Input field remains usable so user can type while waiting
172
172
  $("btn-send").disabled = true;
173
173
  WS.send({ type: "subscribe", session_id: id });
174
- Sessions.renderList();
174
+ Sessions.renderList({ scrollToActive: true });
175
175
  $("user-input").focus();
176
176
 
177
177
  // Load session-scoped skill list (filtered by agent profile) for slash autocomplete
@@ -513,6 +513,57 @@ if ($("btn-toggle-sidebar")) {
513
513
  // Tap overlay to close sidebar on mobile
514
514
  $("sidebar-overlay").addEventListener("click", _closeSidebar);
515
515
 
516
+ // ── Sidebar resize ──────────────────────────────────────────────────────
517
+ (function _initSidebarResize() {
518
+ const sidebar = $("sidebar");
519
+ const handle = $("sidebar-resize-handle");
520
+ if (!sidebar || !handle) return;
521
+
522
+ const MIN_W = 12; // rem
523
+ const MAX_W = 32; // rem
524
+ const baseFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
525
+
526
+ let startX = 0;
527
+ let startW = 0;
528
+
529
+ // Restore saved width
530
+ const saved = localStorage.getItem("sidebar-width");
531
+ if (saved) {
532
+ const w = parseFloat(saved);
533
+ if (w >= MIN_W && w <= MAX_W) {
534
+ sidebar.style.setProperty("--sidebar-width", w + "rem");
535
+ }
536
+ }
537
+
538
+ function _getWidth() {
539
+ return parseFloat(getComputedStyle(sidebar).getPropertyValue("--sidebar-width"));
540
+ }
541
+
542
+ handle.addEventListener("mousedown", (e) => {
543
+ e.preventDefault();
544
+ startX = e.clientX;
545
+ startW = _getWidth();
546
+ handle.classList.add("active");
547
+ document.body.style.cursor = "col-resize";
548
+ document.body.style.userSelect = "none";
549
+ });
550
+
551
+ document.addEventListener("mousemove", (e) => {
552
+ if (!handle.classList.contains("active")) return;
553
+ const dx = (e.clientX - startX) / baseFontSize;
554
+ const newW = Math.min(MAX_W, Math.max(MIN_W, startW + dx));
555
+ sidebar.style.setProperty("--sidebar-width", newW + "rem");
556
+ });
557
+
558
+ document.addEventListener("mouseup", () => {
559
+ if (!handle.classList.contains("active")) return;
560
+ handle.classList.remove("active");
561
+ document.body.style.cursor = "";
562
+ document.body.style.userSelect = "";
563
+ localStorage.setItem("sidebar-width", _getWidth());
564
+ });
565
+ })();
566
+
516
567
  // On mobile: start with sidebar hidden
517
568
  if (_isMobile()) _closeSidebar();
518
569
 
@@ -641,3 +692,24 @@ window.bootAfterBrand = async function() {
641
692
 
642
693
  // Session Info Bar (model switcher + working-directory switcher) moved to sessions.js
643
694
 
695
+ // Logo hover shake with debounce
696
+ (function () {
697
+ const logo = document.getElementById('header-logo-img');
698
+ if (!logo) return;
699
+ let timer = null;
700
+ logo.addEventListener('mouseenter', function () {
701
+ clearTimeout(timer);
702
+ timer = setTimeout(function () {
703
+ logo.style.animation = 'none';
704
+ logo.offsetHeight;
705
+ logo.style.animation = 'logo-shake 0.5s ease';
706
+ }, 100);
707
+ });
708
+ logo.addEventListener('mouseleave', function () {
709
+ clearTimeout(timer);
710
+ });
711
+ logo.addEventListener('animationend', function () {
712
+ logo.style.animation = 'none';
713
+ });
714
+ })();
715
+
@@ -0,0 +1,119 @@
1
+ // backup.js — Backup settings panel (Settings → General → Backup).
2
+ //
3
+ // Talks to /api/backup/{status,config,download}. Lets the user toggle automatic
4
+ // backups (handled by the server-side Scheduler — fixed daily at 03:00, keeps 7)
5
+ // and download a one-off archive directly to the browser.
6
+
7
+ const Backup = (() => {
8
+
9
+ let _status = null;
10
+ let _saving = false;
11
+
12
+ async function load() {
13
+ try {
14
+ const res = await fetch("/api/backup/status");
15
+ _status = await res.json();
16
+ _render();
17
+ } catch (e) {
18
+ // Backup section is non-critical; fail quietly.
19
+ }
20
+ }
21
+
22
+ function _render() {
23
+ if (!_status) return;
24
+ const cfg = _status.config || {};
25
+
26
+ const incl = $("backup-include-sessions");
27
+ if (incl) incl.checked = cfg.include_sessions !== false;
28
+
29
+ const autoToggle = $("backup-auto-toggle");
30
+ if (autoToggle) autoToggle.checked = !!cfg.enabled;
31
+
32
+ _renderLastRun(cfg);
33
+ }
34
+
35
+ function _renderLastRun(cfg) {
36
+ const el = $("backup-status");
37
+ if (!el) return;
38
+ if (!cfg.last_run_at) { el.textContent = ""; el.className = "model-test-result"; return; }
39
+ if (cfg.last_status === "error") {
40
+ el.textContent = I18n.t("settings.backup.lastError", { msg: cfg.last_error || "" });
41
+ el.className = "model-test-result error";
42
+ } else {
43
+ el.textContent = I18n.t("settings.backup.lastOk", { time: _fmtDate(cfg.last_run_at) });
44
+ el.className = "model-test-result success";
45
+ }
46
+ }
47
+
48
+ async function _downloadNow() {
49
+ const btn = $("btn-backup-now");
50
+ const el = $("backup-status");
51
+ if (btn) btn.disabled = true;
52
+ if (el) { el.textContent = I18n.t("settings.backup.running"); el.className = "model-test-result"; }
53
+ try {
54
+ const res = await fetch("/api/backup/download");
55
+ if (!res.ok) {
56
+ let msg = "failed";
57
+ try { msg = (await res.json()).error || msg; } catch (e) {}
58
+ throw new Error(msg);
59
+ }
60
+ const blob = await res.blob();
61
+ const cd = res.headers.get("Content-Disposition") || "";
62
+ const m = cd.match(/filename="?([^"]+)"?/);
63
+ const name = (m && m[1]) || "clacky-backup.tar.gz";
64
+ const url = URL.createObjectURL(blob);
65
+ const a = document.createElement("a");
66
+ a.href = url;
67
+ a.download = name;
68
+ document.body.appendChild(a);
69
+ a.click();
70
+ a.remove();
71
+ URL.revokeObjectURL(url);
72
+ if (el) { el.textContent = I18n.t("settings.backup.downloaded"); el.className = "model-test-result success"; }
73
+ } catch (e) {
74
+ if (el) { el.textContent = I18n.t("settings.backup.lastError", { msg: e.message }); el.className = "model-test-result error"; }
75
+ } finally {
76
+ if (btn) btn.disabled = false;
77
+ }
78
+ }
79
+
80
+ async function _saveConfig(patch) {
81
+ if (_saving) return;
82
+ _saving = true;
83
+ try {
84
+ const res = await fetch("/api/backup/config", {
85
+ method: "PATCH",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify(patch)
88
+ });
89
+ const data = await res.json();
90
+ if (data.ok) { _status = data.status; _render(); }
91
+ } catch (e) {
92
+ // ignore — next load will resync
93
+ } finally {
94
+ _saving = false;
95
+ }
96
+ }
97
+
98
+ function _fmtDate(iso) {
99
+ if (!iso) return "";
100
+ try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
101
+ }
102
+
103
+ function _bind() {
104
+ const btn = $("btn-backup-now");
105
+ if (btn) btn.addEventListener("click", _downloadNow);
106
+
107
+ const autoToggle = $("backup-auto-toggle");
108
+ if (autoToggle) autoToggle.addEventListener("change", () => _saveConfig({ enabled: autoToggle.checked }));
109
+
110
+ const incl = $("backup-include-sessions");
111
+ if (incl) incl.addEventListener("change", () => _saveConfig({ include_sessions: incl.checked }));
112
+ }
113
+
114
+ document.addEventListener("DOMContentLoaded", _bind);
115
+
116
+ return { load };
117
+ })();
118
+
119
+ window.Backup = Backup;
@@ -70,7 +70,17 @@ const Billing = (() => {
70
70
  const container = document.getElementById("billing-content");
71
71
  if (!container) return;
72
72
 
73
- container.innerHTML = `<div class="billing-loading">${I18n.t("billing.loading") || "Loading billing data..."}</div>`;
73
+ const isFirstLoad = !_summary;
74
+ if (isFirstLoad) {
75
+ 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
+ }
83
+ }
74
84
 
75
85
  try {
76
86
  const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
@@ -100,6 +110,74 @@ const Billing = (() => {
100
110
 
101
111
  // ── Rendering ───────────────────────────────────────────────────────────────
102
112
 
113
+ function _renderSkeletonBody() {
114
+ return `
115
+ <div class="billing-stats-row">
116
+ ${[0,1,2,3].map(() => `
117
+ <div class="billing-stat-card">
118
+ <div class="skel skel-icon"></div>
119
+ <div class="billing-stat-content">
120
+ <div class="skel skel-value"></div>
121
+ <div class="skel skel-label"></div>
122
+ </div>
123
+ </div>
124
+ `).join("")}
125
+ </div>
126
+ <div class="billing-heatmap-row">
127
+ <div class="billing-chart-card billing-heatmap-card">
128
+ <div class="skel skel-heatmap"></div>
129
+ </div>
130
+ <div class="billing-chart-card billing-trend-card">
131
+ <div class="skel skel-block-sm"></div>
132
+ </div>
133
+ </div>
134
+ <div class="billing-bottom-grid">
135
+ <div class="billing-section"><div class="skel skel-block"></div></div>
136
+ <div class="billing-section"><div class="skel skel-block"></div></div>
137
+ </div>
138
+ `;
139
+ }
140
+
141
+ function _renderSkeleton() {
142
+ return `
143
+ <div class="billing-dashboard billing-skeleton">
144
+ <div class="billing-top-bar">
145
+ <div class="billing-title-row">
146
+ <div class="skel skel-title"></div>
147
+ <div class="skel skel-subtitle"></div>
148
+ </div>
149
+ <div class="billing-controls">
150
+ <div class="skel skel-tabs"></div>
151
+ <div class="skel skel-select"></div>
152
+ </div>
153
+ </div>
154
+ <div class="billing-stats-row">
155
+ ${[0,1,2,3].map(() => `
156
+ <div class="billing-stat-card">
157
+ <div class="skel skel-icon"></div>
158
+ <div class="billing-stat-content">
159
+ <div class="skel skel-value"></div>
160
+ <div class="skel skel-label"></div>
161
+ </div>
162
+ </div>
163
+ `).join("")}
164
+ </div>
165
+ <div class="billing-heatmap-row">
166
+ <div class="billing-chart-card billing-heatmap-card">
167
+ <div class="skel skel-heatmap"></div>
168
+ </div>
169
+ <div class="billing-chart-card billing-trend-card">
170
+ <div class="skel skel-block-sm"></div>
171
+ </div>
172
+ </div>
173
+ <div class="billing-bottom-grid">
174
+ <div class="billing-section"><div class="skel skel-block"></div></div>
175
+ <div class="billing-section"><div class="skel skel-block"></div></div>
176
+ </div>
177
+ </div>
178
+ `;
179
+ }
180
+
103
181
  function _render() {
104
182
  const container = document.getElementById("billing-content");
105
183
  if (!container || !_summary) return;
@@ -113,7 +191,7 @@ const Billing = (() => {
113
191
  // Model filter options (使用完整模型列表)
114
192
  const models = _allModels.length > 0 ? _allModels : (_summary.by_model ? Object.keys(_summary.by_model) : []);
115
193
  const modelOptions = [`<option value="all">${I18n.t("billing.allModels") || "All Models"}</option>`]
116
- .concat(models.map(m => `<option value="${_esc(m)}" ${m === _currentModel ? "selected" : ""}>${_esc(m)}</option>`))
194
+ .concat(models.filter(m => m).map(m => `<option value="${_esc(m)}" ${m === _currentModel ? "selected" : ""}>${_esc(m)}</option>`))
117
195
  .join("");
118
196
 
119
197
  container.innerHTML = `
@@ -131,7 +209,7 @@ const Billing = (() => {
131
209
  </button>
132
210
  <div class="billing-clear-container">
133
211
  <button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
134
- 🗑️
212
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
135
213
  </button>
136
214
  <div id="billing-clear-popup" class="billing-clear-popup" style="display: none;">
137
215
  <button id="billing-clear-today" class="billing-clear-option">${I18n.t('billing.clearToday') || 'Clear Today'}</button>
@@ -142,29 +220,37 @@ const Billing = (() => {
142
220
  </div>
143
221
 
144
222
  <div class="billing-stats-row">
145
- <div class="billing-stat-card billing-stat-primary">
146
- <div class="billing-stat-icon">💰</div>
223
+ <div class="billing-stat-card">
224
+ <div class="billing-stat-icon billing-stat-icon-cost">
225
+ <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
+ </div>
147
227
  <div class="billing-stat-content">
148
228
  <div class="billing-stat-value">${_getCurrencySymbol()}${_formatCost(_convertCost(_summary.total_cost))}</div>
149
229
  <div class="billing-stat-label">${I18n.t("billing.totalCost") || "Total Cost"}</div>
150
230
  </div>
151
231
  </div>
152
232
  <div class="billing-stat-card">
153
- <div class="billing-stat-icon">📊</div>
233
+ <div class="billing-stat-icon billing-stat-icon-tokens">
234
+ <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
+ </div>
154
236
  <div class="billing-stat-content">
155
237
  <div class="billing-stat-value">${_formatCompact(_summary.total_tokens)}</div>
156
238
  <div class="billing-stat-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</div>
157
239
  </div>
158
240
  </div>
159
241
  <div class="billing-stat-card">
160
- <div class="billing-stat-icon">🔄</div>
242
+ <div class="billing-stat-icon billing-stat-icon-requests">
243
+ <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
+ </div>
161
245
  <div class="billing-stat-content">
162
246
  <div class="billing-stat-value">${_formatNumber(_summary.record_count)}</div>
163
247
  <div class="billing-stat-label">${I18n.t("billing.requests") || "Requests"}</div>
164
248
  </div>
165
249
  </div>
166
250
  <div class="billing-stat-card">
167
- <div class="billing-stat-icon">⚡</div>
251
+ <div class="billing-stat-icon billing-stat-icon-cache">
252
+ <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 2L3 11h6l-1 7 8-10h-6l1-6z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
253
+ </div>
168
254
  <div class="billing-stat-content">
169
255
  <div class="billing-stat-value">${_getCacheHitRate()}%</div>
170
256
  <div class="billing-stat-label">${I18n.t("billing.cacheHit") || "Cache Hit"}</div>
@@ -174,6 +260,7 @@ const Billing = (() => {
174
260
 
175
261
  <div class="billing-heatmap-row">
176
262
  ${_renderHeatmap()}
263
+ ${_renderCostTrend()}
177
264
  </div>
178
265
 
179
266
  <div class="billing-bottom-grid">
@@ -214,6 +301,7 @@ const Billing = (() => {
214
301
  // Bind chart tooltip handlers
215
302
  _bindChartTooltip();
216
303
  _bindHeatmapTooltip();
304
+ _bindTrendTooltip();
217
305
  }
218
306
 
219
307
  // Builds the per-period scorecard numbers from a raw summary object, using
@@ -365,6 +453,41 @@ const Billing = (() => {
365
453
  });
366
454
  }
367
455
 
456
+ function _bindTrendTooltip() {
457
+ const svg = document.querySelector(".billing-trend-svg");
458
+ const tooltip = document.getElementById("billing-tooltip");
459
+ if (!svg || !tooltip) return;
460
+
461
+ svg.addEventListener("mousemove", (e) => {
462
+ const dot = e.target.closest(".billing-trend-dot");
463
+ if (!dot) {
464
+ tooltip.style.display = "none";
465
+ return;
466
+ }
467
+ tooltip.innerHTML = `
468
+ <div class="tooltip-header">
469
+ <span class="tooltip-date">${dot.dataset.date}</span>
470
+ </div>
471
+ <div class="tooltip-row">
472
+ <span class="tooltip-label">${I18n.t("billing.cost") || "Cost"}</span>
473
+ <span class="tooltip-value">${dot.dataset.cost}</span>
474
+ </div>
475
+ `;
476
+ tooltip.style.display = "block";
477
+ tooltip.style.visibility = "hidden";
478
+ const rect = tooltip.getBoundingClientRect();
479
+ const ovf = e.clientX + 15 + rect.width - window.innerWidth;
480
+ tooltip.style.left = ovf > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
481
+ tooltip.style.top = `${e.clientY - 10}px`;
482
+ tooltip.style.visibility = "visible";
483
+ });
484
+
485
+ svg.addEventListener("mouseleave", () => {
486
+ tooltip.style.display = "none";
487
+ tooltip.style.visibility = "";
488
+ });
489
+ }
490
+
368
491
  function _bindClearHandlers() {
369
492
  const clearBtn = document.getElementById("billing-clear-btn");
370
493
  const clearPopup = document.getElementById("billing-clear-popup");
@@ -520,6 +643,7 @@ const Billing = (() => {
520
643
  }
521
644
 
522
645
  const entries = Object.entries(_summary.by_model)
646
+ .filter(([_, data]) => (typeof data === "object" ? data.cost : data) > 0)
523
647
  .sort((a, b) => (b[1].cost || b[1]) - (a[1].cost || a[1]));
524
648
 
525
649
  const totalCost = entries.reduce((sum, [, data]) => sum + (typeof data === "object" ? data.cost : data), 0) || 1;
@@ -573,7 +697,7 @@ const Billing = (() => {
573
697
  const dowHeader = dowLabels.map(l => `<span class="billing-heat-dow">${_esc(l)}</span>`).join("");
574
698
 
575
699
  return `
576
- <div class="billing-chart-card billing-chart-wide billing-heatmap-card">
700
+ <div class="billing-chart-card billing-heatmap-card">
577
701
  <div class="billing-chart-header">
578
702
  <h4>${I18n.t("billing.heatmap.title") || "Activity"}</h4>
579
703
  <div class="billing-heat-legend">
@@ -592,6 +716,88 @@ const Billing = (() => {
592
716
  `;
593
717
  }
594
718
 
719
+ function _renderCostTrend() {
720
+ if (!_daily || _daily.length < 2) {
721
+ return `<div class="billing-chart-card billing-trend-card"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
722
+ }
723
+
724
+ const days = _daily.slice(-30);
725
+ const costs = days.map(d => _convertCost(d.cost || 0));
726
+ const maxCost = Math.max(...costs, 0.0001);
727
+ const minCost = Math.min(...costs);
728
+
729
+ const pad = { top: 20, right: 16, bottom: 22, left: 48 };
730
+ const w = 400;
731
+ const h = 140;
732
+ const plotW = w - pad.left - pad.right;
733
+ const plotH = h - pad.top - pad.bottom;
734
+
735
+ const range = maxCost - minCost || 1;
736
+ const xStep = days.length > 1 ? plotW / (days.length - 1) : plotW;
737
+ const points = costs.map((c, i) => {
738
+ const x = pad.left + i * xStep;
739
+ const y = pad.top + plotH - ((c - minCost) / range) * plotH;
740
+ return `${x},${y}`;
741
+ }).join(" ");
742
+
743
+ const areaPoints = costs.length > 0
744
+ ? `${pad.left},${pad.top + plotH} ${points} ${pad.left + (costs.length - 1) * xStep},${pad.top + plotH}`
745
+ : "";
746
+
747
+ const yTicks = 4;
748
+ const yLabels = Array.from({ length: yTicks + 1 }, (_, i) => {
749
+ const val = minCost + (range / yTicks) * i;
750
+ const y = pad.top + plotH - ((val - minCost) / range) * plotH;
751
+ return { val, y };
752
+ });
753
+
754
+ const showEvery = days.length > 20 ? 10 : days.length > 10 ? 5 : days.length > 5 ? 3 : 1;
755
+ const xLabels = [];
756
+ let lastX = -50;
757
+ days.forEach((d, i) => {
758
+ if (i % showEvery !== 0 && i !== days.length - 1) return;
759
+ const x = pad.left + i * xStep;
760
+ if (x - lastX < 40) return;
761
+ lastX = x;
762
+ xLabels.push({ date: d.date.slice(5), x });
763
+ });
764
+
765
+ const currencySymbol = _getCurrencySymbol();
766
+
767
+ return `
768
+ <div class="billing-chart-card billing-trend-card">
769
+ <div class="billing-chart-header">
770
+ <h4>${I18n.t("billing.costTrend") || "Cost Trend"}</h4>
771
+ <span class="billing-trend-total">${currencySymbol}${_formatCost(costs.reduce((a, b) => a + b, 0))}</span>
772
+ </div>
773
+ <div class="billing-trend-chart">
774
+ <svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg">
775
+ ${yLabels.map(l => `
776
+ <line x1="${pad.left}" y1="${l.y}" x2="${w - pad.right}" y2="${l.y}" class="billing-trend-grid-line" />
777
+ <text x="${pad.left - 6}" y="${l.y + 4}" class="billing-trend-y-label">${currencySymbol}${_formatCost(l.val)}</text>
778
+ `).join("")}
779
+ ${xLabels.map(l => `
780
+ <text x="${l.x}" y="${h - 4}" class="billing-trend-x-label">${l.date}</text>
781
+ `).join("")}
782
+ <defs>
783
+ <linearGradient id="billing-trend-grad" x1="0" y1="0" x2="0" y2="1">
784
+ <stop offset="0%" stop-color="#4f46e5" stop-opacity="0.15" />
785
+ <stop offset="100%" stop-color="#4f46e5" stop-opacity="0.02" />
786
+ </linearGradient>
787
+ </defs>
788
+ <polygon points="${areaPoints}" fill="url(#billing-trend-grad)" class="billing-trend-area" />
789
+ <polyline points="${points}" fill="none" class="billing-trend-line" />
790
+ ${costs.map((c, i) => {
791
+ const cx = pad.left + i * xStep;
792
+ const cy = pad.top + plotH - ((c - minCost) / range) * plotH;
793
+ return `<circle cx="${cx}" cy="${cy}" r="3" class="billing-trend-dot" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />`;
794
+ }).join("")}
795
+ </svg>
796
+ </div>
797
+ </div>
798
+ `;
799
+ }
800
+
595
801
  function _renderCombinedChart() {
596
802
  if (!_daily || _daily.length === 0) {
597
803
  return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
@@ -698,16 +904,23 @@ const Billing = (() => {
698
904
  return `
699
905
  <div class="${rowClass}" data-session-id="${_esc(sessionId)}">
700
906
  <div class="billing-cell billing-cell-index">${index + 1}</div>
701
- <div class="billing-cell billing-cell-session" title="${_esc(sessionName)}">
907
+ <div class="billing-cell billing-cell-session" data-tooltip="${_esc(sessionName)}" data-tooltip-pos="top">
702
908
  <span class="billing-cell-main">${_esc(displayName)}</span>
703
909
  <span class="billing-cell-sub">${requests} ${I18n.t("billing.requests") || "req"} · ${_esc(models)}</span>
704
910
  </div>
705
- <div class="billing-cell billing-cell-number">${_formatCompact(totalTokens)}</div>
911
+ <div class="billing-cell billing-cell-number billing-cell-total">${_formatCompact(totalTokens)}</div>
706
912
  <div class="billing-cell billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)}</div>
707
913
  <div class="billing-cell billing-cell-number billing-cell-miss">${_formatCompact(cacheMiss)}</div>
708
914
  <div class="billing-cell billing-cell-number">${_formatCompact(completionTokens)}</div>
709
915
  <div class="billing-cell billing-cell-cost">${_getCurrencySymbol()}${_formatCost(totalCost)}</div>
710
916
  <div class="billing-cell billing-cell-time">${lastRequest}</div>
917
+ <div class="billing-session-numbers-row">
918
+ <span class="billing-cell-number">${_formatCompact(totalTokens)} tok</span>
919
+ <span class="billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)} hit</span>
920
+ <span class="billing-cell-number">${_formatCompact(completionTokens)} out</span>
921
+ <span class="billing-cell-cost">${_getCurrencySymbol()}${_formatCost(totalCost)}</span>
922
+ <span class="billing-cell-time">${lastRequest}</span>
923
+ </div>
711
924
  </div>
712
925
  `;
713
926
  }).join("");