openclacky 1.3.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +65 -11
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +12 -1
- data/lib/clacky/agent_config.rb +14 -2
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/media/output_dir.rb +43 -0
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +521 -13
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/utils/environment_detector.rb +16 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +512 -60
- data/lib/clacky/web/app.js +30 -7
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/core/aside.js +112 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +303 -0
- data/lib/clacky/web/features/skills/view.js +550 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +60 -6
- data/lib/clacky/web/index.html +117 -57
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +121 -25
- data/lib/clacky/web/skills.js +3 -821
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky.rb +1 -0
- metadata +45 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -365
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -212
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -1,114 +1,45 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
-
|
|
74
|
-
if (isFirstLoad) {
|
|
21
|
+
if (payload.isFirstLoad) {
|
|
75
22
|
container.innerHTML = _renderSkeleton();
|
|
76
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
192
|
-
const models =
|
|
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 ===
|
|
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">${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
-
|
|
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:
|
|
317
|
-
costStr: `${
|
|
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
|
-
|
|
324
|
-
|
|
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 (
|
|
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
|
-
|
|
335
|
-
|
|
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[
|
|
263
|
+
periods[currentPeriod] = _scorecardStatsFor(summary, currentPeriod);
|
|
341
264
|
|
|
342
265
|
Share.openScorecard({
|
|
343
266
|
periods: periods,
|
|
344
|
-
defaultPeriod:
|
|
267
|
+
defaultPeriod: currentPeriod,
|
|
345
268
|
heatmap: _heatmapDays(),
|
|
346
|
-
period: periods[
|
|
347
|
-
cacheHitRate: periods[
|
|
348
|
-
costStr: periods[
|
|
349
|
-
tokensStr: periods[
|
|
350
|
-
requests: periods[
|
|
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 !==
|
|
276
|
+
const others = ["day", "week", "month"].filter((p) => p !== currentPeriod);
|
|
354
277
|
others.forEach((p) => {
|
|
355
|
-
|
|
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
|
-
|
|
507
|
-
clearToday?.addEventListener("click", async (e) => {
|
|
429
|
+
clearToday?.addEventListener("click", (e) => {
|
|
508
430
|
e.stopPropagation();
|
|
509
|
-
|
|
431
|
+
_hideClearPopup();
|
|
432
|
+
Billing.clearData("today");
|
|
510
433
|
});
|
|
511
434
|
|
|
512
|
-
|
|
513
|
-
clearAll?.addEventListener("click", async (e) => {
|
|
435
|
+
clearAll?.addEventListener("click", (e) => {
|
|
514
436
|
e.stopPropagation();
|
|
515
|
-
|
|
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
|
-
|
|
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
|
|
558
|
-
const
|
|
559
|
-
|
|
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
|
-
|
|
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
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const
|
|
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
|
|
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(
|
|
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">${
|
|
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 = `${
|
|
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
|
-
|
|
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 =
|
|
725
|
-
const costs = days.map(d =>
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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);
|
|
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;
|
|
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,21 +777,22 @@ const Billing = (() => {
|
|
|
876
777
|
}
|
|
877
778
|
|
|
878
779
|
function _renderSessionList() {
|
|
879
|
-
|
|
780
|
+
const sessions = BillingStore.state.sessions;
|
|
781
|
+
if (!sessions || sessions.length === 0) {
|
|
880
782
|
return `
|
|
881
|
-
<div class="billing-
|
|
783
|
+
<div class="billing-sessions-section">
|
|
882
784
|
<h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
|
|
883
785
|
<div class="billing-sessions-empty">${I18n.t("billing.noSessions") || "No session data"}</div>
|
|
884
786
|
</div>
|
|
885
787
|
`;
|
|
886
788
|
}
|
|
887
789
|
|
|
888
|
-
const rows =
|
|
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 =
|
|
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">${
|
|
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">${
|
|
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>
|
|
@@ -926,7 +828,7 @@ const Billing = (() => {
|
|
|
926
828
|
}).join("");
|
|
927
829
|
|
|
928
830
|
return `
|
|
929
|
-
<div class="billing-
|
|
831
|
+
<div class="billing-sessions-section">
|
|
930
832
|
<h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
|
|
931
833
|
<div class="billing-sessions-header">
|
|
932
834
|
<span class="billing-cell billing-cell-index">#</span>
|
|
@@ -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
|
-
|
|
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();
|