openclacky 1.1.5 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +113 -63
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +521 -17
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +30 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +41 -6
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +51 -40
  52. data/lib/clacky/web/i18n.js +248 -24
  53. data/lib/clacky/web/index.html +362 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +299 -18
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +15 -2
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -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, "&amp;").replace(/</g, "&lt;")
433
+ .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ })();
@@ -68,28 +68,64 @@ const Brand = (() => {
68
68
  // ── Internal ───────────────────────────────────────────────────────────────
69
69
 
70
70
  // Show a dismissible activation banner at the top of the page.
71
- // Renders immediately with a generic prompt, then asynchronously fetches
72
- // the free/paid skill counts and refines the copy.
71
+ // Defers rendering until /api/brand/skills resolves so the banner shows its
72
+ // final copy in one shot. Falls back to a generic prompt if the API fails
73
+ // or stays silent for 5s.
73
74
  function _showActivationBanner(brandName) {
74
- const existing = document.getElementById("brand-activation-banner");
75
- if (existing) return;
75
+ if (document.getElementById("brand-activation-banner")) return;
76
+
77
+ const name = brandName || I18n.t("brand.banner.defaultName");
78
+
79
+ let settled = false;
80
+ const settle = data => {
81
+ if (settled) return;
82
+ settled = true;
83
+ if (document.getElementById("brand-activation-banner")) return;
84
+ _renderActivationBanner(name, data);
85
+ };
86
+
87
+ fetch("/api/brand/skills")
88
+ .then(r => r.json())
89
+ .then(settle)
90
+ .catch(() => settle(null));
91
+
92
+ setTimeout(() => settle(null), 5000);
93
+ }
76
94
 
95
+ function _renderActivationBanner(name, countsData) {
77
96
  const bar = document.createElement("div");
78
97
  bar.id = "brand-activation-banner";
79
98
  bar.className = "brand-activation-banner";
80
99
 
81
- const name = brandName || I18n.t("brand.banner.defaultName");
82
-
83
100
  const span = document.createElement("span");
84
- span.textContent = I18n.t("brand.banner.prompt", { name });
85
- span.setAttribute("data-i18n", "brand.banner.prompt");
86
- span.setAttribute("data-i18n-vars", `name=${name}`);
87
-
88
101
  const link = document.createElement("button");
89
- link.className = "brand-activation-banner-link";
102
+ link.className = "brand-activation-banner-link";
103
+ link.addEventListener("click", () => _goToLicenseInput());
104
+
105
+ let i18nKey = "brand.banner.prompt";
106
+ let vars = { name };
107
+ let hideLink = false;
108
+
109
+ if (countsData && countsData.ok && countsData.free_mode) {
110
+ const free = (countsData.skills || []).length;
111
+ const paid = Number(countsData.paid_skills_count) || 0;
112
+ vars = { name, free, paid, freePlural: free === 1 ? "" : "s", paidPlural: paid === 1 ? "" : "s" };
113
+
114
+ if (free > 0 && paid > 0) i18nKey = "brand.banner.freePromptBoth";
115
+ else if (free > 0 && paid === 0) { i18nKey = "brand.banner.freePromptOnlyFree"; hideLink = true; }
116
+ else if (free === 0 && paid > 0) i18nKey = "brand.banner.freePromptOnlyPaid";
117
+ }
118
+
119
+ span.textContent = I18n.t(i18nKey, vars);
120
+ span.setAttribute("data-i18n", i18nKey);
121
+ span.setAttribute(
122
+ "data-i18n-vars",
123
+ Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(";")
124
+ );
125
+
90
126
  link.textContent = I18n.t("brand.banner.action");
91
127
  link.setAttribute("data-i18n", "brand.banner.action");
92
- link.addEventListener("click", () => _goToLicenseInput());
128
+ if (hideLink) link.style.display = "none";
93
129
 
94
130
  const closeBtn = document.createElement("button");
95
131
  closeBtn.className = "brand-activation-banner-close";
@@ -100,34 +136,6 @@ const Brand = (() => {
100
136
  bar.appendChild(link);
101
137
  bar.appendChild(closeBtn);
102
138
  document.getElementById("main").prepend(bar);
103
-
104
- _refineBannerWithCounts(name, span, link);
105
- }
106
-
107
- async function _refineBannerWithCounts(name, span, link) {
108
- try {
109
- const res = await fetch("/api/brand/skills");
110
- const data = await res.json();
111
- if (!data.ok || !data.free_mode) return;
112
-
113
- const free = (data.skills || []).length;
114
- const paid = Number(data.paid_skills_count) || 0;
115
-
116
- let i18nKey;
117
- if (free > 0 && paid > 0) i18nKey = "brand.banner.freePromptBoth";
118
- else if (free > 0 && paid === 0) i18nKey = "brand.banner.freePromptOnlyFree";
119
- else if (free === 0 && paid > 0) i18nKey = "brand.banner.freePromptOnlyPaid";
120
- else return;
121
-
122
- const vars = { name, free, paid, freePlural: free === 1 ? "" : "s", paidPlural: paid === 1 ? "" : "s" };
123
- span.textContent = I18n.t(i18nKey, vars);
124
- span.setAttribute("data-i18n", i18nKey);
125
- span.setAttribute("data-i18n-vars", `name=${name};free=${free};paid=${paid};freePlural=${vars.freePlural};paidPlural=${vars.paidPlural}`);
126
-
127
- if (paid === 0 && free > 0) link.style.display = "none";
128
- } catch (_) {
129
- // Network failure: leave the generic prompt in place.
130
- }
131
139
  }
132
140
 
133
141
  // Navigate to Settings, scroll to Brand & License section, flash it, then focus the input.
@@ -137,6 +145,9 @@ const Brand = (() => {
137
145
  if (typeof Settings !== "undefined") Settings.open();
138
146
  // Settings.open() triggers an async fetch; wait for layout to stabilise before scrolling.
139
147
  setTimeout(() => {
148
+ const generalTabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="general"]');
149
+ if (generalTabBtn && !generalTabBtn.classList.contains("active")) generalTabBtn.click();
150
+
140
151
  const section = document.getElementById("brand-license-section");
141
152
  const input = document.getElementById("settings-license-key");
142
153
  const scrollContainer = document.getElementById("settings-body");