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
@@ -13,6 +13,7 @@ const Settings = (() => {
13
13
  _load();
14
14
  _loadBrand();
15
15
  _loadBrowserStatus();
16
+ _applyAboutTabVisibility();
16
17
  }
17
18
 
18
19
  // ── Data Loading ────────────────────────────────────────────────────────────
@@ -50,112 +51,499 @@ const Settings = (() => {
50
51
  _models.forEach((m, i) => _renderCard(container, m, i));
51
52
  }
52
53
 
54
+ function _getProviderName(model) {
55
+ if (!model.base_url) return I18n.t("settings.models.provider.custom");
56
+ const url = model.base_url.trim().replace(/\/+$/, "");
57
+ const provider = _providers.find(p => {
58
+ const candidates = [p.base_url].concat(
59
+ Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
60
+ ).filter(Boolean);
61
+ return candidates.some(c => {
62
+ const norm = String(c).replace(/\/+$/, "");
63
+ return url === norm || url.startsWith(norm + "/");
64
+ });
65
+ });
66
+ return provider ? provider.name : I18n.t("settings.models.provider.custom");
67
+ }
68
+
53
69
  function _renderCard(container, model, index) {
54
70
  const isDefault = model.type === "default";
55
71
  const isLite = model.type === "lite";
72
+ const providerName = _getProviderName(model);
73
+ const displayName = model.model || I18n.t("settings.models.unnamed");
56
74
 
57
75
  const card = document.createElement("div");
58
- card.className = "model-card";
76
+ card.className = "model-card-grid" + (isDefault ? " model-card-grid-default" : "");
59
77
  card.dataset.index = index;
60
78
 
61
- // Build provider options
62
- const providerOptions = _providers.map(p =>
63
- `<option value="${p.id}">${p.name}</option>`
64
- ).join("");
65
-
66
79
  card.innerHTML = `
67
- <div class="model-card-header">
68
- <div class="model-card-badges">
80
+ <div class="model-card-grid-info">
81
+ <div class="model-card-grid-name-row">
82
+ <span class="model-card-grid-name">${_esc(displayName)}</span>
69
83
  ${isDefault ? `<span class="badge badge-default">${I18n.t("settings.models.badge.default")}</span>` : ""}
70
- ${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
71
- ${!isDefault && !isLite ? `<span class="badge badge-secondary">${I18n.t("settings.models.badge.model", { n: index + 1 })}</span>` : ""}
84
+ ${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
72
85
  </div>
73
- <div class="model-card-actions">
74
- ${_models.length > 1
75
- ? `<button class="btn-model-remove" data-index="${index}" title="Remove this model">×</button>`
76
- : ""}
86
+ <div class="model-card-grid-provider">${_esc(providerName)}</div>
87
+ ${model.model ? `<div class="model-card-grid-model">${_esc(model.model)}</div>` : ""}
88
+ <div class="model-card-grid-status">
89
+ <span class="model-test-result" data-index="${index}"></span>
77
90
  </div>
78
91
  </div>
79
-
80
- <div class="model-fields">
81
- <label class="model-field quick-setup-field" ${(model.model || model.base_url) ? 'style="display:none"' : ''}>
82
- <span class="field-label">${I18n.t("settings.models.field.quicksetup")}</span>
83
- <div class="custom-select-wrapper" data-index="${index}">
84
- <div class="custom-select-trigger" tabindex="0">
85
- <span class="custom-select-value placeholder">${I18n.t("settings.models.placeholder.provider")}</span>
86
- <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
88
- </svg>
89
- </div>
90
- <div class="custom-select-dropdown">
91
- <div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
92
- ${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(p.name)}">${_esc(p.name)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`).join("")}
93
- <div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
94
- </div>
95
- </div>
96
- <div class="provider-promo-hint" data-index="${index}"></div>
97
- </label>
98
- <label class="model-field">
99
- <span class="field-label">${I18n.t("settings.models.field.model")}</span>
100
- <div class="model-name-combobox" data-index="${index}">
101
- <input type="text" class="field-input model-name-input" data-key="model" data-index="${index}"
102
- placeholder="${I18n.t("settings.models.placeholder.model")}" value="${_esc(model.model)}"
103
- autocomplete="off">
104
- <button class="model-name-dropdown-btn" type="button" title="Select from presets">
105
- <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
106
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
107
- </svg>
108
- </button>
109
- <div class="model-name-dropdown" style="display:none"></div>
110
- </div>
111
- </label>
112
- <label class="model-field">
113
- <span class="field-label">${I18n.t("settings.models.field.baseurl")}</span>
114
- <div class="base-url-combobox" data-index="${index}">
115
- <input type="text" class="field-input base-url-input" data-key="base_url" data-index="${index}"
116
- placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}"
117
- autocomplete="off">
118
- <button class="base-url-dropdown-btn" type="button" title="Select preset endpoint">
119
- <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
120
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
121
- </svg>
122
- </button>
123
- <div class="base-url-dropdown" style="display:none"></div>
124
- </div>
125
- </label>
126
- <label class="model-field">
127
- <span class="field-label">
128
- ${I18n.t("settings.models.field.apikey")}
129
- <a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:0.5rem;font-size:0.75rem;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;">${I18n.t("settings.models.field.getApiKey")}</a>
130
- </span>
131
- <div class="field-input-row">
132
- <input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
133
- placeholder="${I18n.t("settings.models.placeholder.apikey")}" value="${_esc(model.api_key_masked)}">
134
- <button class="btn-toggle-key" data-index="${index}" title="Show/hide key">
135
- <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
136
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
137
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
138
- </svg>
139
- </button>
140
- </div>
141
- </label>
142
- </div>
143
-
144
- <div class="model-card-footer">
145
- ${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:0.75rem;">
146
- <span style="color:var(--muted,#6b7280);">${I18n.t("settings.models.field.docsGuide.question")}</span>
147
- <a href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:0.25rem;color:var(--accent,#6366f1);text-decoration:none;">${I18n.t("settings.models.field.docsGuide.cta")}</a>
148
- </span>` : ""}
149
- <span class="model-test-result" data-index="${index}"></span>
150
- <div class="model-card-actions-row">
151
- ${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="${I18n.t("settings.models.btn.setDefault")}">${I18n.t("settings.models.btn.setDefault")}</button>` : ""}
152
- <button class="btn-save-model btn-primary" data-index="${index}">${I18n.t("settings.models.btn.save")}</button>
153
- </div>
92
+ <div class="model-card-grid-actions">
93
+ ${!isDefault ? `<button class="btn-card-grid-action" data-index="${index}" data-action="default" title="${I18n.t("settings.models.btn.setDefault")}">
94
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
95
+ <span>${I18n.t("settings.models.btn.setDefault")}</span>
96
+ </button>` : ""}
97
+ <button class="btn-card-grid-action" data-index="${index}" data-action="test" title="${I18n.t("settings.models.btn.test")}">
98
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
99
+ <span>${I18n.t("settings.models.btn.test")}</span>
100
+ </button>
101
+ <button class="btn-card-grid-action" data-index="${index}" data-action="edit" title="${I18n.t("settings.models.btn.edit")}">
102
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
103
+ <span>${I18n.t("settings.models.btn.edit")}</span>
104
+ </button>
105
+ ${_models.length > 1 ? `<button class="btn-card-grid-action btn-card-grid-action-danger" data-index="${index}" data-action="delete" title="${I18n.t("settings.models.btn.delete")}">
106
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
107
+ <span>${I18n.t("settings.models.btn.delete")}</span>
108
+ </button>` : ""}
154
109
  </div>
155
110
  `;
156
111
 
157
112
  container.appendChild(card);
158
- _bindCardEvents(card, index);
113
+ _bindCompactCardEvents(card, index);
114
+ }
115
+
116
+ function _bindCompactCardEvents(card, index) {
117
+ // Bind all action buttons using data-action attribute
118
+ card.querySelectorAll(".btn-card-grid-action").forEach(btn => {
119
+ btn.addEventListener("click", () => {
120
+ const action = btn.dataset.action;
121
+ switch (action) {
122
+ case "edit": _openModal(index); break;
123
+ case "test": _testModel(index); break;
124
+ case "delete": _removeModel(index); break;
125
+ case "default": _setAsDefault(index); break;
126
+ }
127
+ });
128
+ });
129
+ }
130
+
131
+ // ── Modal Functions ────────────────────────────────────────────────────────
132
+
133
+ function _openModal(index = -1) {
134
+ const modal = document.getElementById("model-edit-modal");
135
+ const titleEl = document.getElementById("model-modal-title");
136
+ const indexInput = document.getElementById("model-modal-index");
137
+
138
+ indexInput.value = index;
139
+
140
+ // Populate provider dropdown
141
+ _populateModalProviderDropdown();
142
+
143
+ if (index >= 0 && _models[index]) {
144
+ // Edit mode
145
+ const model = _models[index];
146
+ titleEl.textContent = I18n.t("settings.models.modal.edit");
147
+ document.getElementById("model-modal-model").value = model.model || "";
148
+ document.getElementById("model-modal-baseurl").value = model.base_url || "";
149
+ document.getElementById("model-modal-apikey").value = model.api_key_masked || "";
150
+ document.getElementById("model-modal-default-field").style.display = "none";
151
+
152
+ // Set provider dropdown value
153
+ const providerName = _getProviderName(model);
154
+ const providerValue = document.getElementById("model-modal-provider-value");
155
+ providerValue.textContent = providerName;
156
+ providerValue.classList.remove("placeholder");
157
+ } else {
158
+ // Add mode
159
+ titleEl.textContent = I18n.t("settings.models.modal.add");
160
+ document.getElementById("model-modal-model").value = "";
161
+ document.getElementById("model-modal-baseurl").value = "";
162
+ document.getElementById("model-modal-apikey").value = "";
163
+ document.getElementById("model-modal-default-field").style.display = "";
164
+ document.getElementById("model-modal-set-default").checked = true;
165
+
166
+ // Reset provider dropdown
167
+ const providerValue = document.getElementById("model-modal-provider-value");
168
+ providerValue.textContent = I18n.t("settings.models.placeholder.provider");
169
+ providerValue.classList.add("placeholder");
170
+ }
171
+
172
+ // Clear test result
173
+ document.getElementById("model-modal-test-result").textContent = "";
174
+ document.getElementById("model-modal-test-result").className = "model-test-result";
175
+
176
+ // Show promo hint by default for new models
177
+ const promoHint = document.getElementById("model-modal-promo-hint");
178
+ _showPromoHint(promoHint);
179
+
180
+ modal.style.display = "";
181
+ document.body.style.overflow = "hidden";
182
+ document.getElementById("model-modal-provider-trigger").focus();
183
+ }
184
+
185
+ function _closeModal() {
186
+ const modal = document.getElementById("model-edit-modal");
187
+ modal.style.display = "none";
188
+ document.body.style.overflow = "";
189
+ }
190
+
191
+ function _populateModalProviderDropdown() {
192
+ const dropdown = document.getElementById("model-modal-provider-dropdown");
193
+ dropdown.innerHTML = `
194
+ <div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
195
+ ${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(p.name)}">${_esc(p.name)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`).join("")}
196
+ <div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
197
+ `;
198
+
199
+ // Bind click events for options
200
+ dropdown.querySelectorAll(".custom-select-option").forEach(option => {
201
+ option.addEventListener("click", (e) => {
202
+ e.stopPropagation();
203
+ const value = option.dataset.value;
204
+ const text = option.dataset.label || option.textContent.trim();
205
+
206
+ const providerValue = document.getElementById("model-modal-provider-value");
207
+ providerValue.textContent = text;
208
+ providerValue.classList.toggle("placeholder", !value);
209
+
210
+ dropdown.classList.remove("open");
211
+ document.getElementById("model-modal-provider-trigger").classList.remove("open");
212
+
213
+ // Show/hide promo hint
214
+ const promoHint = document.getElementById("model-modal-promo-hint");
215
+ if (value === "openclacky" || !value) {
216
+ _showPromoHint(promoHint);
217
+ } else {
218
+ promoHint.classList.remove("visible");
219
+ }
220
+
221
+ // Auto-fill if provider selected
222
+ if (value && value !== "custom") {
223
+ const preset = _providers.find(p => p.id === value);
224
+ if (preset) {
225
+ document.getElementById("model-modal-model").value = preset.default_model || "";
226
+ document.getElementById("model-modal-baseurl").value = preset.base_url || "";
227
+
228
+ const apikeyLink = document.getElementById("model-modal-apikey-link");
229
+ if (preset.website_url) {
230
+ apikeyLink.href = preset.website_url;
231
+ apikeyLink.style.display = "";
232
+ } else {
233
+ apikeyLink.style.display = "none";
234
+ }
235
+
236
+ // Update model dropdown with provider's models
237
+ setTimeout(() => _updateModalModelDropdown(), 0);
238
+ }
239
+ }
240
+ });
241
+ });
242
+ }
243
+
244
+ async function _testModel(index) {
245
+ const model = _models[index];
246
+ if (!model) return;
247
+
248
+ const testBtn = document.querySelector(`.btn-test-model[data-index="${index}"]`);
249
+ if (testBtn) testBtn.disabled = true;
250
+
251
+ _showTestResult(index, null, "");
252
+
253
+ try {
254
+ const testRes = await fetch("/api/config/test", {
255
+ method: "POST",
256
+ headers: { "Content-Type": "application/json" },
257
+ body: JSON.stringify({
258
+ model: model.model,
259
+ base_url: model.base_url,
260
+ api_key: model.api_key_masked,
261
+ index
262
+ })
263
+ });
264
+ const testData = await testRes.json();
265
+ _showTestResult(index, testData.ok, testData.message);
266
+ } catch (e) {
267
+ _showTestResult(index, false, e.message);
268
+ } finally {
269
+ if (testBtn) testBtn.disabled = false;
270
+ }
271
+ }
272
+
273
+ async function _saveModalModel() {
274
+ const saveBtn = document.getElementById("model-modal-save");
275
+ const index = parseInt(document.getElementById("model-modal-index").value, 10);
276
+
277
+ const model = document.getElementById("model-modal-model").value.trim();
278
+ const base_url = document.getElementById("model-modal-baseurl").value.trim();
279
+ const api_key = document.getElementById("model-modal-apikey").value.trim();
280
+
281
+ saveBtn.disabled = true;
282
+
283
+ // Step 1: Test first
284
+ saveBtn.textContent = I18n.t("settings.models.btn.testing");
285
+ _showModalTestResult(null, "");
286
+
287
+ try {
288
+ const testRes = await fetch("/api/config/test", {
289
+ method: "POST",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({ model, base_url, api_key, index })
292
+ });
293
+ const testData = await testRes.json();
294
+ _showModalTestResult(testData.ok, testData.message);
295
+
296
+ if (!testData.ok) {
297
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
298
+ saveBtn.disabled = false;
299
+ return;
300
+ }
301
+ } catch (e) {
302
+ _showModalTestResult(false, e.message);
303
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
304
+ saveBtn.disabled = false;
305
+ return;
306
+ }
307
+
308
+ // Step 2: Save
309
+ saveBtn.textContent = I18n.t("settings.models.btn.saving");
310
+
311
+ const isNew = index < 0;
312
+ const existing = isNew ? {} : (_models[index] || {});
313
+ const hasId = !!existing.id;
314
+
315
+ const payload = { model, base_url, anthropic_format: false };
316
+ if (isNew) {
317
+ if (document.getElementById("model-modal-set-default").checked) {
318
+ payload.type = "default";
319
+ _models.forEach(m => { if (m.type === "default") m.type = null; });
320
+ } else {
321
+ payload.type = null;
322
+ }
323
+ } else {
324
+ payload.type = existing.type;
325
+ }
326
+
327
+ if (api_key && !api_key.includes("****")) {
328
+ payload.api_key = api_key;
329
+ }
330
+
331
+ try {
332
+ let res, data;
333
+ if (hasId) {
334
+ res = await fetch(`/api/config/models/${encodeURIComponent(existing.id)}`, {
335
+ method: "PATCH",
336
+ headers: { "Content-Type": "application/json" },
337
+ body: JSON.stringify(payload)
338
+ });
339
+ data = await res.json();
340
+ } else {
341
+ if (!payload.api_key) {
342
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
343
+ saveBtn.disabled = false;
344
+ _showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
345
+ return;
346
+ }
347
+ res = await fetch(`/api/config/models`, {
348
+ method: "POST",
349
+ headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify(payload)
351
+ });
352
+ data = await res.json();
353
+ }
354
+
355
+ if (data.ok) {
356
+ saveBtn.textContent = I18n.t("settings.models.btn.saved");
357
+ setTimeout(() => {
358
+ _closeModal();
359
+ _load();
360
+ }, 800);
361
+ } else {
362
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
363
+ saveBtn.disabled = false;
364
+ _showModalTestResult(false, data.error || I18n.t("settings.models.saveFailed"));
365
+ }
366
+ } catch (e) {
367
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
368
+ saveBtn.disabled = false;
369
+ _showModalTestResult(false, e.message);
370
+ }
371
+ }
372
+
373
+ function _showModalTestResult(ok, message) {
374
+ const el = document.getElementById("model-modal-test-result");
375
+ if (!el) return;
376
+ if (ok === null) { el.textContent = I18n.t("settings.models.btn.testing"); el.className = "model-test-result result-testing"; return; }
377
+ el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
378
+ el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
379
+ }
380
+
381
+ function _initModal() {
382
+ // Close button
383
+ document.getElementById("model-modal-close").addEventListener("click", _closeModal);
384
+ document.getElementById("model-modal-cancel").addEventListener("click", _closeModal);
385
+
386
+ // Save button
387
+ document.getElementById("model-modal-save").addEventListener("click", _saveModalModel);
388
+
389
+ // Click overlay to close
390
+ document.getElementById("model-edit-modal").addEventListener("click", (e) => {
391
+ if (e.target.id === "model-edit-modal") _closeModal();
392
+ });
393
+
394
+ // ESC to close
395
+ document.addEventListener("keydown", (e) => {
396
+ if (e.key === "Escape" && document.getElementById("model-edit-modal").style.display !== "none") {
397
+ _closeModal();
398
+ }
399
+ });
400
+
401
+ // Provider dropdown toggle
402
+ const providerTrigger = document.getElementById("model-modal-provider-trigger");
403
+ const providerDropdown = document.getElementById("model-modal-provider-dropdown");
404
+ providerTrigger.addEventListener("click", (e) => {
405
+ e.stopPropagation();
406
+ const isOpen = providerDropdown.classList.contains("open");
407
+ document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
408
+ d.classList.remove("open");
409
+ });
410
+ if (!isOpen) {
411
+ providerDropdown.classList.add("open");
412
+ providerTrigger.classList.add("open");
413
+ } else {
414
+ providerDropdown.classList.remove("open");
415
+ providerTrigger.classList.remove("open");
416
+ }
417
+ });
418
+
419
+ // Close dropdowns on outside click
420
+ document.addEventListener("click", () => {
421
+ providerDropdown.classList.remove("open");
422
+ providerTrigger.classList.remove("open");
423
+ });
424
+
425
+ // Toggle API key visibility
426
+ document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
427
+ const input = document.getElementById("model-modal-apikey");
428
+ input.type = input.type === "password" ? "text" : "password";
429
+ });
430
+
431
+ // Model dropdown functionality
432
+ const modelDropdownBtn = document.getElementById("model-modal-model-dropdown-btn");
433
+ const modelDropdown = document.getElementById("model-modal-model-dropdown");
434
+ const modelInput = document.getElementById("model-modal-model");
435
+
436
+ modelDropdownBtn.addEventListener("click", (e) => {
437
+ e.stopPropagation();
438
+ const isOpen = modelDropdown.style.display === "block";
439
+ document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
440
+ d.style.display = "none";
441
+ });
442
+ if (!isOpen) {
443
+ _updateModalModelDropdown();
444
+ modelDropdown.style.display = "block";
445
+ }
446
+ });
447
+
448
+ // Base URL dropdown functionality
449
+ const baseUrlDropdownBtn = document.getElementById("model-modal-baseurl-dropdown-btn");
450
+ const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
451
+ const baseUrlInput = document.getElementById("model-modal-baseurl");
452
+
453
+ baseUrlDropdownBtn.addEventListener("click", (e) => {
454
+ e.stopPropagation();
455
+ const isOpen = baseUrlDropdown.style.display === "block";
456
+ document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
457
+ d.style.display = "none";
458
+ });
459
+ if (!isOpen) {
460
+ _updateModalBaseUrlDropdown();
461
+ baseUrlDropdown.style.display = "block";
462
+ }
463
+ });
464
+
465
+ // Update model dropdown when base_url changes
466
+ baseUrlInput.addEventListener("blur", () => {
467
+ _updateModalModelDropdown();
468
+ });
469
+
470
+ // Close all modal dropdowns on document click
471
+ document.addEventListener("click", () => {
472
+ modelDropdown.style.display = "none";
473
+ baseUrlDropdown.style.display = "none";
474
+ });
475
+ }
476
+
477
+ function _getModalCurrentProvider() {
478
+ const baseUrlInput = document.getElementById("model-modal-baseurl");
479
+ const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
480
+ if (!url) return null;
481
+ return _providers.find(p => {
482
+ const candidates = [p.base_url].concat(
483
+ Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
484
+ ).filter(Boolean);
485
+ return candidates.some(c => {
486
+ const norm = String(c).replace(/\/+$/, "");
487
+ return url === norm || url.startsWith(norm + "/");
488
+ });
489
+ }) || null;
490
+ }
491
+
492
+ function _updateModalModelDropdown() {
493
+ const modelDropdown = document.getElementById("model-modal-model-dropdown");
494
+ const modelInput = document.getElementById("model-modal-model");
495
+ const provider = _getModalCurrentProvider();
496
+ const models = provider?.models || [];
497
+
498
+ if (models.length === 0) {
499
+ modelDropdown.innerHTML = `<div class="model-dropdown-empty">${I18n.t("settings.models.noModels") || "No preset models available"}</div>`;
500
+ return;
501
+ }
502
+
503
+ modelDropdown.innerHTML = models.map(m =>
504
+ `<div class="model-dropdown-option" data-value="${_esc(m)}">${_esc(m)}</div>`
505
+ ).join("");
506
+
507
+ modelDropdown.querySelectorAll(".model-dropdown-option").forEach(opt => {
508
+ opt.addEventListener("click", (e) => {
509
+ e.stopPropagation();
510
+ modelInput.value = opt.dataset.value;
511
+ modelDropdown.style.display = "none";
512
+ });
513
+ });
514
+ }
515
+
516
+ function _updateModalBaseUrlDropdown() {
517
+ const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
518
+ const baseUrlInput = document.getElementById("model-modal-baseurl");
519
+ const provider = _getModalCurrentProvider();
520
+ const variants = provider && Array.isArray(provider.endpoint_variants) ? provider.endpoint_variants : [];
521
+
522
+ if (variants.length === 0) {
523
+ baseUrlDropdown.innerHTML = `<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
524
+ return;
525
+ }
526
+
527
+ baseUrlDropdown.innerHTML = variants.map(v => {
528
+ const translated = v.label_key ? I18n.t(v.label_key) : null;
529
+ const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
530
+ const label = _esc(labelText);
531
+ const url = _esc(v.base_url);
532
+ return `
533
+ <div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
534
+ <div class="base-url-dropdown-label">${label}</div>
535
+ <div class="base-url-dropdown-url">${url}</div>
536
+ </div>`;
537
+ }).join("");
538
+
539
+ baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
540
+ opt.addEventListener("click", (e) => {
541
+ e.stopPropagation();
542
+ baseUrlInput.value = opt.dataset.value;
543
+ baseUrlDropdown.style.display = "none";
544
+ _updateModalModelDropdown();
545
+ });
546
+ });
159
547
  }
160
548
 
161
549
  function _showPromoHint(promoHint) {
@@ -610,7 +998,7 @@ const Settings = (() => {
610
998
  function _showTestResult(index, ok, message) {
611
999
  const el = document.querySelector(`.model-test-result[data-index="${index}"]`);
612
1000
  if (!el) return;
613
- if (ok === null) { el.textContent = ""; el.className = "model-test-result"; return; }
1001
+ if (ok === null) { el.textContent = I18n.t("settings.models.btn.testing"); el.className = "model-test-result result-testing"; return; }
614
1002
  el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
615
1003
  el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
616
1004
  }
@@ -618,19 +1006,18 @@ const Settings = (() => {
618
1006
  // ── Set as Default Model ───────────────────────────────────────────────────
619
1007
 
620
1008
  async function _setAsDefault(index) {
621
- const btn = document.querySelector(`.btn-set-default[data-index="${index}"]`);
622
- if (!btn) return;
623
-
1009
+ const btn = document.querySelector(`.btn-card-grid-action[data-index="${index}"][data-action="default"]`);
624
1010
  const target = _models[index];
625
1011
  if (!target || !target.id) {
626
- // Can only promote saved models. Locally-added unsaved cards have
627
- // no id yet — user must save them first.
628
1012
  alert(I18n.t("settings.models.setDefaultFailed"));
629
1013
  return;
630
1014
  }
631
1015
 
632
- btn.disabled = true;
633
- btn.textContent = I18n.t("settings.models.btn.setting");
1016
+ if (btn) {
1017
+ btn.disabled = true;
1018
+ const span = btn.querySelector("span");
1019
+ if (span) span.textContent = I18n.t("settings.models.btn.setting");
1020
+ }
634
1021
 
635
1022
  try {
636
1023
  const res = await fetch(`/api/config/models/${encodeURIComponent(target.id)}/default`, {
@@ -639,17 +1026,25 @@ const Settings = (() => {
639
1026
  const data = await res.json();
640
1027
 
641
1028
  if (data.ok) {
642
- btn.textContent = I18n.t("settings.models.btn.done");
643
- // Reload to refresh the UI
1029
+ if (btn) {
1030
+ const span = btn.querySelector("span");
1031
+ if (span) span.textContent = I18n.t("settings.models.btn.done");
1032
+ }
644
1033
  setTimeout(_load, 800);
645
1034
  } else {
646
- btn.textContent = I18n.t("settings.models.btn.setDefault");
647
- btn.disabled = false;
1035
+ if (btn) {
1036
+ btn.disabled = false;
1037
+ const span = btn.querySelector("span");
1038
+ if (span) span.textContent = I18n.t("settings.models.btn.setDefault");
1039
+ }
648
1040
  alert(data.error || I18n.t("settings.models.setDefaultFailed"));
649
1041
  }
650
1042
  } catch (e) {
651
- btn.textContent = I18n.t("settings.models.btn.setDefault");
652
- btn.disabled = false;
1043
+ if (btn) {
1044
+ btn.disabled = false;
1045
+ const span = btn.querySelector("span");
1046
+ if (span) span.textContent = I18n.t("settings.models.btn.setDefault");
1047
+ }
653
1048
  alert(I18n.t("settings.models.errorPrefix") + e.message);
654
1049
  }
655
1050
  }
@@ -657,76 +1052,8 @@ const Settings = (() => {
657
1052
  // ── Add / Remove model ───────────────────────────────────────────────────────
658
1053
 
659
1054
  function _addModel() {
660
- // Locally append an empty card. No server call here — the new entry
661
- // is persisted via POST /api/config/models only when the user clicks
662
- // "Save" (inside _saveModel). This means typing into a half-filled
663
- // new card can never mutate the real config on the backend.
664
- //
665
- // UX convention: a newly added model becomes the "default" once
666
- // saved — users overwhelmingly add a new model because they want to
667
- // use it. We mark it default locally for the preview, and flip the
668
- // other cards' type to null so the UI only shows one default badge.
669
- // The backend enforces the single-default invariant on save anyway.
670
- _models.forEach(m => { if (m.type === "default") m.type = null; });
671
- _models.push({
672
- id: null, // will be assigned by server on first save
673
- index: _models.length,
674
- model: "",
675
- base_url: "",
676
- api_key_masked: "",
677
- anthropic_format: false,
678
- type: "default"
679
- });
680
- _renderCards();
681
-
682
- // Scroll the new (last) card into view.
683
- //
684
- // Why not just scrollIntoView: the settings panel uses #settings-body
685
- // as a flex scroll container. When the page hasn't overflowed yet,
686
- // scrollIntoView is a no-op; and across browsers the "smooth" path
687
- // has been flaky when the scrolling element is a flex child.
688
- //
689
- // Two rAFs ensure the freshly rendered card has been laid out before
690
- // we compute its position.
691
- requestAnimationFrame(() => {
692
- requestAnimationFrame(() => {
693
- const container = document.getElementById("settings-body");
694
- if (!container) return;
695
- const cards = container.querySelectorAll(".model-card");
696
- const last = cards[cards.length - 1];
697
- if (!last) return;
698
-
699
- // Align the new card's top with the container's top, minus a
700
- // small breathing-room offset so it doesn't touch the edge.
701
- // offsetTop is relative to the nearest positioned ancestor, so
702
- // we compute position via getBoundingClientRect delta — robust
703
- // regardless of the card's offsetParent chain.
704
- const OFFSET = 16;
705
- const delta = last.getBoundingClientRect().top
706
- - container.getBoundingClientRect().top;
707
- container.scrollTo({
708
- top: container.scrollTop + delta - OFFSET,
709
- behavior: "smooth"
710
- });
711
-
712
- // Put focus on the new card so the user can start configuring.
713
- // Priority order:
714
- // 1. The provider `.custom-select-trigger` — nudges the user to
715
- // pick a provider first (step 1 of the 3-field form). It's a
716
- // div with tabindex="0", so it gets the accent border via
717
- // `:focus` without expanding the dropdown.
718
- // 2. Fall back to the first form input (used when the quick-setup
719
- // field is hidden, e.g. on a model card that already has values).
720
- const providerTrigger = last.querySelector(".custom-select-wrapper .custom-select-trigger");
721
- const isVisible = el => el && el.offsetParent !== null;
722
- if (isVisible(providerTrigger)) {
723
- providerTrigger.focus({ preventScroll: true });
724
- } else {
725
- const firstInput = last.querySelector("input, select, textarea");
726
- if (firstInput) firstInput.focus({ preventScroll: true });
727
- }
728
- });
729
- });
1055
+ // Open modal in add mode (index = -1)
1056
+ _openModal(-1);
730
1057
  }
731
1058
 
732
1059
  async function _removeModel(index) {
@@ -1058,6 +1385,52 @@ const Settings = (() => {
1058
1385
 
1059
1386
  // ── Init ──────────────────────────────────────────────────────────────────────
1060
1387
 
1388
+ function _initTabs() {
1389
+ const tabs = document.querySelectorAll("#settings-tabs .settings-tab");
1390
+ const contents = document.querySelectorAll("#settings-body .settings-tab-content");
1391
+
1392
+ tabs.forEach(tab => {
1393
+ tab.addEventListener("click", () => {
1394
+ const targetTab = tab.dataset.tab;
1395
+
1396
+ // Update tab buttons
1397
+ tabs.forEach(t => t.classList.toggle("active", t.dataset.tab === targetTab));
1398
+
1399
+ // Update tab content panels
1400
+ contents.forEach(c => {
1401
+ const isActive = c.dataset.tabContent === targetTab;
1402
+ c.classList.toggle("active", isActive);
1403
+ c.style.display = isActive ? "" : "none";
1404
+ });
1405
+ });
1406
+ });
1407
+ }
1408
+
1409
+ function _applyAboutTabVisibility() {
1410
+ const branded = typeof Brand !== "undefined" && Brand.branded;
1411
+ const tabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="about"]');
1412
+ const tabPanel = document.querySelector('#settings-body .settings-tab-content[data-tab-content="about"]');
1413
+ if (!tabBtn || !tabPanel) return;
1414
+
1415
+ if (branded) {
1416
+ tabBtn.style.display = "none";
1417
+ if (tabBtn.classList.contains("active")) {
1418
+ tabBtn.classList.remove("active");
1419
+ tabPanel.classList.remove("active");
1420
+ tabPanel.style.display = "none";
1421
+ const fallback = document.querySelector('#settings-tabs .settings-tab[data-tab="models"]');
1422
+ const fallbackPanel = document.querySelector('#settings-body .settings-tab-content[data-tab-content="models"]');
1423
+ if (fallback) fallback.classList.add("active");
1424
+ if (fallbackPanel) {
1425
+ fallbackPanel.classList.add("active");
1426
+ fallbackPanel.style.display = "";
1427
+ }
1428
+ }
1429
+ } else {
1430
+ tabBtn.style.display = "";
1431
+ }
1432
+ }
1433
+
1061
1434
  function _initLangBtns() {
1062
1435
  // Highlight the active language button on open
1063
1436
  document.querySelectorAll("#language-section .settings-lang-btn").forEach(btn => {
@@ -1071,7 +1444,70 @@ const Settings = (() => {
1071
1444
  });
1072
1445
  }
1073
1446
 
1447
+ // ── Advanced Settings ────────────────────────────────────────────────────────
1448
+
1449
+ async function _loadAdvancedSettings() {
1450
+ try {
1451
+ const res = await fetch("/api/config/settings");
1452
+ const data = await res.json();
1453
+ if (data.ok) {
1454
+ const comp = document.getElementById("settings-compression-toggle");
1455
+ const cache = document.getElementById("settings-prompt-caching-toggle");
1456
+ const mem = document.getElementById("settings-memory-update-toggle");
1457
+ if (comp) comp.checked = data.enable_compression !== false;
1458
+ if (cache) cache.checked = data.enable_prompt_caching !== false;
1459
+ if (mem) mem.checked = data.memory_update_enabled !== false;
1460
+ }
1461
+ } catch (e) {
1462
+ console.error("Failed to load advanced settings:", e);
1463
+ }
1464
+ }
1465
+
1466
+ async function _saveAdvancedSetting(key, value) {
1467
+ try {
1468
+ await fetch("/api/config/settings", {
1469
+ method: "PATCH",
1470
+ headers: { "Content-Type": "application/json" },
1471
+ body: JSON.stringify({ [key]: value })
1472
+ });
1473
+ } catch (e) {
1474
+ console.error("Failed to save setting:", e);
1475
+ }
1476
+ }
1477
+
1478
+ function _initAdvancedSettings() {
1479
+ _loadAdvancedSettings();
1480
+ document.getElementById("settings-compression-toggle")?.addEventListener("change", (e) => {
1481
+ _saveAdvancedSetting("enable_compression", e.target.checked);
1482
+ });
1483
+ document.getElementById("settings-prompt-caching-toggle")?.addEventListener("change", (e) => {
1484
+ _saveAdvancedSetting("enable_prompt_caching", e.target.checked);
1485
+ });
1486
+ document.getElementById("settings-memory-update-toggle")?.addEventListener("change", (e) => {
1487
+ _saveAdvancedSetting("memory_update_enabled", e.target.checked);
1488
+ });
1489
+ }
1490
+
1491
+ // ── About Tab ───────────────────────────────────────────────────────────────
1492
+
1493
+ async function _loadAboutInfo() {
1494
+ try {
1495
+ const res = await fetch("/api/version");
1496
+ const data = await res.json();
1497
+ if (data.current) {
1498
+ const el = document.getElementById("about-version");
1499
+ if (el) el.textContent = `v${data.current}`;
1500
+ }
1501
+ } catch (e) {
1502
+ console.error("Failed to load version info:", e);
1503
+ }
1504
+ }
1505
+
1074
1506
  function init() {
1507
+ _initTabs();
1508
+ _initModal();
1509
+ _initAdvancedSettings();
1510
+ _loadAboutInfo();
1075
1511
  document.getElementById("btn-add-model").addEventListener("click", _addModel);
1076
1512
  document.getElementById("btn-rerun-onboard").addEventListener("click", _rerunOnboard);
1077
1513
  document.getElementById("btn-browser-setup").addEventListener("click", _setupBrowser);
@@ -1142,11 +1578,87 @@ const Settings = (() => {
1142
1578
 
1143
1579
  _initLangBtns();
1144
1580
  _initFontBtns();
1581
+ _initCurrencyBtns();
1145
1582
 
1146
1583
  // Re-render model cards when language changes (dynamic HTML, not data-i18n)
1147
1584
  document.addEventListener("langchange", () => _renderCards());
1148
1585
  }
1149
1586
 
1587
+ // ── Currency ──────────────────────────────────────────────────────────
1588
+ const CURRENCY_STORAGE_KEY = "clacky-currency";
1589
+ const EXCHANGE_RATE_STORAGE_KEY = "clacky-exchange-rate";
1590
+ const CURRENCY_DEFAULT = "USD";
1591
+ const DEFAULT_EXCHANGE_RATE = 6.7944;
1592
+
1593
+ function _applyCurrency(currency) {
1594
+ try { localStorage.setItem(CURRENCY_STORAGE_KEY, currency); } catch (_) {}
1595
+ // Update active state on all currency buttons
1596
+ document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
1597
+ btn.classList.toggle("active", btn.dataset.currency === currency);
1598
+ });
1599
+ // Show/hide exchange rate input based on currency
1600
+ const exchangeRateSection = document.getElementById("exchange-rate-section");
1601
+ if (exchangeRateSection) {
1602
+ exchangeRateSection.style.display = currency === "CNY" ? "block" : "none";
1603
+ }
1604
+ // Dispatch event for billing panel to update
1605
+ document.dispatchEvent(new CustomEvent("currencychange", { detail: { currency } }));
1606
+ }
1607
+
1608
+ function _getExchangeRate() {
1609
+ try {
1610
+ const rate = localStorage.getItem(EXCHANGE_RATE_STORAGE_KEY);
1611
+ if (rate) {
1612
+ const parsed = parseFloat(rate);
1613
+ if (!isNaN(parsed) && parsed > 0) return parsed;
1614
+ }
1615
+ } catch (_) {}
1616
+ return DEFAULT_EXCHANGE_RATE;
1617
+ }
1618
+
1619
+ function _setExchangeRate(rate) {
1620
+ try {
1621
+ if (rate && !isNaN(rate) && rate > 0) {
1622
+ localStorage.setItem(EXCHANGE_RATE_STORAGE_KEY, rate.toString());
1623
+ document.dispatchEvent(new CustomEvent("currencychange"));
1624
+ }
1625
+ } catch (_) {}
1626
+ }
1627
+
1628
+ function _initCurrencyBtns() {
1629
+ // Apply saved preference (or default) on page load
1630
+ let saved = null;
1631
+ try { saved = localStorage.getItem(CURRENCY_STORAGE_KEY); } catch (_) {}
1632
+ const current = saved || CURRENCY_DEFAULT;
1633
+
1634
+ // Wire up button clicks
1635
+ document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
1636
+ btn.classList.toggle("active", btn.dataset.currency === current);
1637
+ btn.addEventListener("click", () => {
1638
+ _applyCurrency(btn.dataset.currency);
1639
+ });
1640
+ });
1641
+
1642
+ // Initialize exchange rate input
1643
+ const exchangeRateInput = document.getElementById("settings-exchange-rate");
1644
+ const exchangeRateSection = document.getElementById("exchange-rate-section");
1645
+ if (exchangeRateInput && exchangeRateSection) {
1646
+ // Set initial value
1647
+ exchangeRateInput.value = _getExchangeRate();
1648
+ // Show/hide based on current currency
1649
+ exchangeRateSection.style.display = current === "CNY" ? "block" : "none";
1650
+ // Handle input changes
1651
+ exchangeRateInput.addEventListener("change", () => {
1652
+ const rate = parseFloat(exchangeRateInput.value);
1653
+ if (!isNaN(rate) && rate > 0) {
1654
+ _setExchangeRate(rate);
1655
+ } else {
1656
+ exchangeRateInput.value = _getExchangeRate();
1657
+ }
1658
+ });
1659
+ }
1660
+ }
1661
+
1150
1662
  // ── Font Size ──────────────────────────────────────────────────────────
1151
1663
  const FONT_STORAGE_KEY = "clacky-font-size";
1152
1664
  const FONT_DEFAULT = "medium";