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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -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 +108 -6
  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 +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -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 +36 -5
  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 +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  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 +14 -1
  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
+ })();
@@ -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");