openclacky 1.1.6 → 1.2.0
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 +37 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +108 -6
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +36 -5
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +14 -1
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
data/lib/clacky/web/app.js
CHANGED
|
@@ -45,8 +45,10 @@ const PANELS = [
|
|
|
45
45
|
"task-detail-panel",
|
|
46
46
|
"skills-panel",
|
|
47
47
|
"channels-panel",
|
|
48
|
+
"mcp-panel",
|
|
48
49
|
"trash-panel",
|
|
49
50
|
"profile-panel",
|
|
51
|
+
"billing-panel",
|
|
50
52
|
"settings-panel",
|
|
51
53
|
"creator-panel",
|
|
52
54
|
];
|
|
@@ -77,11 +79,13 @@ const Router = (() => {
|
|
|
77
79
|
if (h === "tasks") return { view: "tasks", params: {} };
|
|
78
80
|
if (h === "skills") return { view: "skills", params: {} };
|
|
79
81
|
if (h === "channels") return { view: "channels", params: {} };
|
|
82
|
+
if (h === "mcp") return { view: "mcp", params: {} };
|
|
80
83
|
if (h === "trash") return { view: "trash", params: {} };
|
|
81
84
|
if (h === "profile") return { view: "profile", params: {} };
|
|
82
85
|
// Legacy: #memories redirects to #profile (memories are now merged into
|
|
83
86
|
// the profile panel). Kept so bookmarks / external links don't 404.
|
|
84
87
|
if (h === "memories") return { view: "profile", params: {} };
|
|
88
|
+
if (h === "billing") return { view: "billing", params: {} };
|
|
85
89
|
if (h === "settings") return { view: "settings", params: {} };
|
|
86
90
|
if (h === "creator") return { view: "creator", params: {} };
|
|
87
91
|
const m = h.match(/^session\/(.+)$/);
|
|
@@ -96,8 +100,10 @@ const Router = (() => {
|
|
|
96
100
|
tasks: "tasks-sidebar-item",
|
|
97
101
|
skills: "skills-sidebar-item",
|
|
98
102
|
channels: "channels-sidebar-item",
|
|
103
|
+
mcp: "mcp-sidebar-item",
|
|
99
104
|
trash: "trash-sidebar-item",
|
|
100
105
|
profile: "profile-sidebar-item",
|
|
106
|
+
billing: "billing-sidebar-item",
|
|
101
107
|
creator: "creator-sidebar-item",
|
|
102
108
|
};
|
|
103
109
|
|
|
@@ -198,6 +204,13 @@ const Router = (() => {
|
|
|
198
204
|
Sessions.renderList();
|
|
199
205
|
break;
|
|
200
206
|
|
|
207
|
+
case "mcp":
|
|
208
|
+
_setHash("mcp");
|
|
209
|
+
$("mcp-panel").style.display = "flex";
|
|
210
|
+
Mcp.onPanelShow();
|
|
211
|
+
Sessions.renderList();
|
|
212
|
+
break;
|
|
213
|
+
|
|
201
214
|
case "trash":
|
|
202
215
|
_setHash("trash");
|
|
203
216
|
$("trash-panel").style.display = "flex";
|
|
@@ -212,6 +225,13 @@ const Router = (() => {
|
|
|
212
225
|
Sessions.renderList();
|
|
213
226
|
break;
|
|
214
227
|
|
|
228
|
+
case "billing":
|
|
229
|
+
_setHash("billing");
|
|
230
|
+
$("billing-panel").style.display = "flex";
|
|
231
|
+
Billing.open();
|
|
232
|
+
Sessions.renderList();
|
|
233
|
+
break;
|
|
234
|
+
|
|
215
235
|
case "creator":
|
|
216
236
|
_setHash("creator");
|
|
217
237
|
$("creator-panel").style.display = "flex";
|
|
@@ -383,6 +403,68 @@ const Modal = (() => {
|
|
|
383
403
|
return { confirm, prompt, rename };
|
|
384
404
|
})();
|
|
385
405
|
|
|
406
|
+
// ── Toast helper ──────────────────────────────────────────────────────────
|
|
407
|
+
// Non-blocking notification stack. Replaces alert() for success/error/info
|
|
408
|
+
// feedback. Supports an optional action button (e.g. "Go check").
|
|
409
|
+
//
|
|
410
|
+
// Modal.toast("Saved")
|
|
411
|
+
// Modal.toast("Failed: …", "error")
|
|
412
|
+
// Modal.toast("Restored", "success", { action: { label: "Go", onClick } })
|
|
413
|
+
Modal.toast = function (message, typeOrOptions = "info", maybeOptions = {}) {
|
|
414
|
+
const opts = typeof typeOrOptions === "object"
|
|
415
|
+
? typeOrOptions
|
|
416
|
+
: { type: typeOrOptions, ...maybeOptions };
|
|
417
|
+
const type = opts.type || "info";
|
|
418
|
+
const duration = opts.duration ?? (type === "error" ? 6000 : 3500);
|
|
419
|
+
const action = opts.action;
|
|
420
|
+
|
|
421
|
+
const stack = document.getElementById("toast-stack");
|
|
422
|
+
if (!stack) { console.warn("[toast] #toast-stack missing"); return; }
|
|
423
|
+
|
|
424
|
+
const icons = {
|
|
425
|
+
success: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
426
|
+
error: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
|
|
427
|
+
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
|
428
|
+
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const escape = s => String(s ?? "")
|
|
432
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
433
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
434
|
+
|
|
435
|
+
const el = document.createElement("div");
|
|
436
|
+
el.className = "toast toast-" + type;
|
|
437
|
+
el.setAttribute("role", type === "error" ? "alert" : "status");
|
|
438
|
+
el.innerHTML =
|
|
439
|
+
'<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' +
|
|
440
|
+
'<div class="toast-body">' +
|
|
441
|
+
'<div class="toast-message">' + escape(message) + '</div>' +
|
|
442
|
+
(action ? '<button type="button" class="toast-action">' + escape(action.label) + '</button>' : '') +
|
|
443
|
+
'</div>' +
|
|
444
|
+
'<button type="button" class="toast-close" aria-label="Close">' +
|
|
445
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
|
|
446
|
+
'</button>';
|
|
447
|
+
|
|
448
|
+
let timer = null;
|
|
449
|
+
const dismiss = () => {
|
|
450
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
451
|
+
el.classList.add("toast-leave");
|
|
452
|
+
el.addEventListener("animationend", () => el.remove(), { once: true });
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
el.querySelector(".toast-close").onclick = dismiss;
|
|
456
|
+
if (action) {
|
|
457
|
+
el.querySelector(".toast-action").onclick = () => {
|
|
458
|
+
try { action.onClick && action.onClick(); } finally { dismiss(); }
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
el.onmouseenter = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
462
|
+
el.onmouseleave = () => { if (duration > 0) timer = setTimeout(dismiss, 1500); };
|
|
463
|
+
|
|
464
|
+
stack.appendChild(el);
|
|
465
|
+
if (duration > 0) timer = setTimeout(dismiss, duration);
|
|
466
|
+
};
|
|
467
|
+
|
|
386
468
|
// ── Confirmation modal ────────────────────────────────────────────────────
|
|
387
469
|
function showConfirmModal(confId, message) {
|
|
388
470
|
$("modal-message").textContent = message;
|
|
@@ -434,20 +516,12 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
|
|
|
434
516
|
// On mobile: start with sidebar hidden
|
|
435
517
|
if (_isMobile()) _closeSidebar();
|
|
436
518
|
|
|
437
|
-
// Mobile floating back button (visible only on mobile via CSS): tap to go back to welcome.
|
|
438
|
-
const _btnBack = document.querySelector(".chat-back-btn");
|
|
439
|
-
if (_btnBack) _btnBack.addEventListener("click", () => Router.navigate("welcome"));
|
|
440
|
-
|
|
441
|
-
// On mobile: shorten textarea placeholder so it doesn't wrap
|
|
442
|
-
if (_isMobile()) {
|
|
443
|
-
const inp = $("user-input");
|
|
444
|
-
if (inp) inp.setAttribute("placeholder", inp.placeholder.replace(/\s*[\((].*[\))]/, "").trim());
|
|
445
|
-
}
|
|
446
|
-
|
|
447
519
|
// On mobile: auto-close sidebar when switching sessions/pages
|
|
448
520
|
function _mobileCloseSidebar() {
|
|
449
521
|
if (_isMobile()) _closeSidebar();
|
|
450
522
|
}
|
|
523
|
+
// Expose for use in sessions.js (rename/delete dialogs need to close sidebar first)
|
|
524
|
+
window.mobileCloseSidebar = _mobileCloseSidebar;
|
|
451
525
|
|
|
452
526
|
// ── New session controls ───────────────────────────────────────────────────
|
|
453
527
|
// Moved to sessions.js (_initNewSessionControls, called from Sessions.init()).
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// billing.js — Billing 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 _currentPeriod = "month";
|
|
8
|
+
|
|
9
|
+
// ── Currency Settings ─────────────────────────────────────────────────────
|
|
10
|
+
const CURRENCY_STORAGE_KEY = "clacky-currency";
|
|
11
|
+
const EXCHANGE_RATE_STORAGE_KEY = "clacky-exchange-rate";
|
|
12
|
+
const DEFAULT_USD_TO_CNY_RATE = 6.7944; // Default exchange rate: 1 USD ≈ 6.7944 CNY
|
|
13
|
+
|
|
14
|
+
function _getCurrency() {
|
|
15
|
+
try { return localStorage.getItem(CURRENCY_STORAGE_KEY) || "USD"; } catch (_) { return "USD"; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function _getExchangeRate() {
|
|
19
|
+
try {
|
|
20
|
+
const rate = localStorage.getItem(EXCHANGE_RATE_STORAGE_KEY);
|
|
21
|
+
if (rate) {
|
|
22
|
+
const parsed = parseFloat(rate);
|
|
23
|
+
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
24
|
+
}
|
|
25
|
+
} catch (_) {}
|
|
26
|
+
return DEFAULT_USD_TO_CNY_RATE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _setExchangeRate(rate) {
|
|
30
|
+
try {
|
|
31
|
+
if (rate && !isNaN(rate) && rate > 0) {
|
|
32
|
+
localStorage.setItem(EXCHANGE_RATE_STORAGE_KEY, rate.toString());
|
|
33
|
+
document.dispatchEvent(new CustomEvent("currencychange"));
|
|
34
|
+
}
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _convertCost(usdCost) {
|
|
39
|
+
const currency = _getCurrency();
|
|
40
|
+
if (currency === "CNY") {
|
|
41
|
+
return usdCost * _getExchangeRate();
|
|
42
|
+
}
|
|
43
|
+
return usdCost;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _getCurrencySymbol() {
|
|
47
|
+
return _getCurrency() === "CNY" ? "¥" : "$";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function open() {
|
|
53
|
+
_load();
|
|
54
|
+
// Listen for currency changes
|
|
55
|
+
document.removeEventListener("currencychange", _onCurrencyChange);
|
|
56
|
+
document.addEventListener("currencychange", _onCurrencyChange);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _onCurrencyChange() {
|
|
60
|
+
if (_summary) _render();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Data Loading ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
async function _load() {
|
|
66
|
+
const container = document.getElementById("billing-content");
|
|
67
|
+
if (!container) return;
|
|
68
|
+
|
|
69
|
+
container.innerHTML = `<div class="billing-loading">${I18n.t("billing.loading") || "Loading billing data..."}</div>`;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const [summaryRes, dailyRes] = await Promise.all([
|
|
73
|
+
fetch(`/api/billing/summary?period=${_currentPeriod}`),
|
|
74
|
+
fetch("/api/billing/daily?days=30")
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
_summary = await summaryRes.json();
|
|
78
|
+
const dailyData = await dailyRes.json();
|
|
79
|
+
_daily = dailyData.days || [];
|
|
80
|
+
|
|
81
|
+
_render();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
container.innerHTML = `<div class="billing-error">${I18n.t("billing.error") || "Failed to load billing data"}: ${e.message}</div>`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function _render() {
|
|
90
|
+
const container = document.getElementById("billing-content");
|
|
91
|
+
if (!container || !_summary) return;
|
|
92
|
+
|
|
93
|
+
const periodOptions = ["day", "week", "month", "year", "all"].map(p =>
|
|
94
|
+
`<option value="${p}" ${p === _currentPeriod ? "selected" : ""}>${_periodLabel(p)}</option>`
|
|
95
|
+
).join("");
|
|
96
|
+
|
|
97
|
+
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>
|
|
104
|
+
|
|
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>
|
|
109
|
+
</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>
|
|
113
|
+
</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>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
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>
|
|
127
|
+
</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>
|
|
131
|
+
</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>
|
|
135
|
+
</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>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
${_renderModelBreakdown()}
|
|
144
|
+
${_renderDailyChart()}
|
|
145
|
+
</div>
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
// Bind period change handler
|
|
149
|
+
document.getElementById("billing-period-select")?.addEventListener("change", (e) => {
|
|
150
|
+
_currentPeriod = e.target.value;
|
|
151
|
+
_load();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _renderModelBreakdown() {
|
|
156
|
+
if (!_summary.by_model || Object.keys(_summary.by_model).length === 0) {
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
|
|
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("");
|
|
173
|
+
|
|
174
|
+
return `
|
|
175
|
+
<div class="billing-section">
|
|
176
|
+
<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>
|
|
189
|
+
</div>
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _renderDailyChart() {
|
|
194
|
+
if (!_daily || _daily.length === 0) {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Get last 14 days (show all days, not just those with activity)
|
|
199
|
+
const recentDays = _daily.slice(-14);
|
|
200
|
+
if (recentDays.length === 0) {
|
|
201
|
+
return "";
|
|
202
|
+
}
|
|
203
|
+
|
|
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
|
+
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>
|
|
221
|
+
</div>
|
|
222
|
+
`;
|
|
223
|
+
}).join("");
|
|
224
|
+
|
|
225
|
+
return `
|
|
226
|
+
<div class="billing-section">
|
|
227
|
+
<h3>${I18n.t("billing.dailyUsage") || "Daily Usage"}</h3>
|
|
228
|
+
<div class="billing-chart">
|
|
229
|
+
${bars}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
function _formatCost(cost) {
|
|
238
|
+
if (cost == null || cost === 0) return "0.0000";
|
|
239
|
+
return cost.toFixed(4);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _formatNumber(num) {
|
|
243
|
+
if (num == null || num === 0) return "0";
|
|
244
|
+
return num.toLocaleString();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _periodLabel(period) {
|
|
248
|
+
const labels = {
|
|
249
|
+
day: I18n.t("billing.period.day") || "Today",
|
|
250
|
+
week: I18n.t("billing.period.week") || "This Week",
|
|
251
|
+
month: I18n.t("billing.period.month") || "This Month",
|
|
252
|
+
year: I18n.t("billing.period.year") || "This Year",
|
|
253
|
+
all: I18n.t("billing.period.all") || "All Time"
|
|
254
|
+
};
|
|
255
|
+
return labels[period] || period;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _esc(str) {
|
|
259
|
+
if (!str) return "";
|
|
260
|
+
const div = document.createElement("div");
|
|
261
|
+
div.textContent = str;
|
|
262
|
+
return div.innerHTML;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Expose Public API ───────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
open,
|
|
269
|
+
// Expose currency utilities for other modules
|
|
270
|
+
getCurrency: _getCurrency,
|
|
271
|
+
convertCost: _convertCost,
|
|
272
|
+
getCurrencySymbol: _getCurrencySymbol,
|
|
273
|
+
getExchangeRate: _getExchangeRate
|
|
274
|
+
};
|
|
275
|
+
})();
|
data/lib/clacky/web/brand.js
CHANGED
|
@@ -145,6 +145,9 @@ const Brand = (() => {
|
|
|
145
145
|
if (typeof Settings !== "undefined") Settings.open();
|
|
146
146
|
// Settings.open() triggers an async fetch; wait for layout to stabilise before scrolling.
|
|
147
147
|
setTimeout(() => {
|
|
148
|
+
const generalTabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="general"]');
|
|
149
|
+
if (generalTabBtn && !generalTabBtn.classList.contains("active")) generalTabBtn.click();
|
|
150
|
+
|
|
148
151
|
const section = document.getElementById("brand-license-section");
|
|
149
152
|
const input = document.getElementById("settings-license-key");
|
|
150
153
|
const scrollContainer = document.getElementById("settings-body");
|