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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +15 -20
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
- data/lib/clacky/media/base.rb +93 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +57 -0
- data/lib/clacky/media/openai_compat.rb +160 -0
- data/lib/clacky/message_history.rb +12 -7
- data/lib/clacky/providers.rb +29 -1
- data/lib/clacky/rich_ui_controller.rb +3 -1
- data/lib/clacky/server/backup_manager.rb +200 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
- data/lib/clacky/server/channel/channel_manager.rb +65 -50
- data/lib/clacky/server/http_server.rb +356 -14
- data/lib/clacky/server/scheduler.rb +19 -0
- data/lib/clacky/server/session_registry.rb +8 -4
- data/lib/clacky/session_manager.rb +40 -2
- data/lib/clacky/tools/trash_manager.rb +14 -0
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +34 -7
- data/lib/clacky/ui2/ui_controller.rb +150 -19
- data/lib/clacky/utils/file_processor.rb +75 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2283 -1277
- data/lib/clacky/web/app.js +73 -1
- data/lib/clacky/web/backup.js +119 -0
- data/lib/clacky/web/billing.js +224 -11
- data/lib/clacky/web/channels.js +81 -11
- data/lib/clacky/web/design-sample.css +247 -0
- data/lib/clacky/web/design-sample.html +127 -0
- data/lib/clacky/web/favicon.svg +16 -0
- data/lib/clacky/web/i18n.js +167 -31
- data/lib/clacky/web/index.html +176 -55
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/onboard.js +121 -28
- data/lib/clacky/web/sessions.js +447 -192
- data/lib/clacky/web/settings.js +21 -1
- data/lib/clacky/web/skills.js +34 -1
- data/lib/clacky/web/tasks.js +129 -61
- data/lib/clacky/web/utils.js +72 -0
- data/lib/clacky/web/ws-dispatcher.js +6 -0
- data/lib/clacky.rb +1 -0
- metadata +9 -8
- data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
data/lib/clacky/web/app.js
CHANGED
|
@@ -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;
|
data/lib/clacky/web/billing.js
CHANGED
|
@@ -70,7 +70,17 @@ const Billing = (() => {
|
|
|
70
70
|
const container = document.getElementById("billing-content");
|
|
71
71
|
if (!container) return;
|
|
72
72
|
|
|
73
|
-
|
|
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
|
|
146
|
-
<div class="billing-stat-icon"
|
|
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"
|
|
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"
|
|
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"
|
|
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-
|
|
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"
|
|
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("");
|