openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -12,6 +12,9 @@ const Settings = (() => {
12
12
  // backend's runtime inference decide.
13
13
  let _modalSelectedProviderId = null;
14
14
  let _modalProviderIsCustom = false;
15
+ let _apiFormatSelect = null;
16
+ let _providerSelect = null;
17
+ let _providerFilterSelect = null;
15
18
  // Live filter text for the model search box; empty = show all.
16
19
  let _modelSearchQuery = "";
17
20
  // Provider id selected in the model provider filter; empty = show all.
@@ -83,8 +86,10 @@ const Settings = (() => {
83
86
  }
84
87
 
85
88
  function _renderProviderFilter() {
86
- const select = document.getElementById("model-provider-filter");
87
- if (!select) return;
89
+ const wrap = document.getElementById("model-provider-filter");
90
+ if (!wrap) return;
91
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
92
+ if (!dropdown) return;
88
93
 
89
94
  // Count models per provider bucket so the dropdown can sort by usage
90
95
  // (most-used providers first), with display name as a stable tiebreaker.
@@ -99,12 +104,13 @@ const Settings = (() => {
99
104
  }
100
105
  });
101
106
 
102
- const prev = select.value;
103
- select.innerHTML = "";
104
- const all = document.createElement("option");
105
- all.value = "";
107
+ const prev = _providerFilterSelect ? _providerFilterSelect.value : "";
108
+ dropdown.innerHTML = "";
109
+ const all = document.createElement("div");
110
+ all.className = "custom-select-option";
111
+ all.dataset.value = "";
106
112
  all.textContent = I18n.t("settings.models.filter.all");
107
- select.appendChild(all);
113
+ dropdown.appendChild(all);
108
114
 
109
115
  [...names.entries()]
110
116
  .sort((a, b) => {
@@ -112,17 +118,18 @@ const Settings = (() => {
112
118
  return diff !== 0 ? diff : a[1].localeCompare(b[1]);
113
119
  })
114
120
  .forEach(([key, name]) => {
115
- const opt = document.createElement("option");
116
- opt.value = key;
121
+ const opt = document.createElement("div");
122
+ opt.className = "custom-select-option";
123
+ opt.dataset.value = key;
117
124
  opt.textContent = name;
118
- select.appendChild(opt);
125
+ dropdown.appendChild(opt);
119
126
  });
120
127
 
121
128
  // Keep the current selection when it still exists, otherwise reset to All.
122
129
  if (prev && names.has(prev)) {
123
- select.value = prev;
130
+ if (_providerFilterSelect) _providerFilterSelect.setValue(prev);
124
131
  } else {
125
- select.value = "";
132
+ if (_providerFilterSelect) _providerFilterSelect.setValue("");
126
133
  _modelProviderFilter = "";
127
134
  }
128
135
  }
@@ -165,9 +172,14 @@ const Settings = (() => {
165
172
  function _getProvider(model) {
166
173
  // Prefer the saved provider_id (survives custom base_url edits) over
167
174
  // a reverse base_url lookup so the card always shows what the user picked.
168
- return (model.provider_id
169
- ? _providers.find(pr => pr.id === model.provider_id) || null
170
- : null) || _findProviderByBaseUrl(model.base_url);
175
+ // "custom" is the persisted marker for user-added custom entries (also
176
+ // implied by an api_format override): neither should reverse-match a
177
+ // preset — the badge and website link must reflect the user's choice.
178
+ if (model.provider_id) {
179
+ return _providers.find(pr => pr.id === model.provider_id) || null;
180
+ }
181
+ if (model.api_format) return null;
182
+ return _findProviderByBaseUrl(model.base_url);
171
183
  }
172
184
 
173
185
  // Stable key used to bucket models by provider for the filter dropdown.
@@ -192,14 +204,28 @@ const Settings = (() => {
192
204
  }) || null;
193
205
  }
194
206
 
207
+ // Shared resolution for the provider dropdown (edit + duplicate).
208
+ // Priority: the persisted "custom" marker; saved preset provider_id;
209
+ // explicit api_format override (also marks custom — the override dropdown
210
+ // only exists in custom mode, and a base_url reverse lookup must not
211
+ // re-attach a preset here, because preset mode sends api_format:null on
212
+ // save and would silently drop the override); legacy base_url lookup;
213
+ // anthropic_format self-hosted proxy hint.
214
+ function _resolveModalProviderId(model) {
215
+ if (model.provider_id === "custom") return null;
216
+ if (model.provider_id) return model.provider_id;
217
+ if (model.api_format) return null;
218
+ const matched = _findProviderByBaseUrl(model.base_url);
219
+ if (matched) return matched.id;
220
+ return model.anthropic_format ? "anthropic" : null;
221
+ }
222
+
195
223
  function _renderCard(container, model, index) {
196
224
  const isDefault = model.type === "default";
197
225
  const isLite = model.type === "lite";
198
- // Prefer saved provider_id over base_url reverse-lookup so the card
199
- // shows the correct provider even when the user has customised base_url.
200
- const provider = (model.provider_id
201
- ? _providers.find(p => p.id === model.provider_id) || null
202
- : null) || _findProviderByBaseUrl(model.base_url);
226
+ // Same resolution rule as _getProvider: saved provider_id wins, an
227
+ // api_format override marks custom, base_url lookup is legacy fallback.
228
+ const provider = _getProvider(model);
203
229
  const providerName = _providerDisplayName(provider);
204
230
  const remark = (model.remark || "").trim();
205
231
  const websiteUrl = provider && provider.website_url;
@@ -249,6 +275,7 @@ const Settings = (() => {
249
275
  </div>
250
276
  <div class="model-card-grid-footer">
251
277
  ${websiteUrl ? `<a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer" style="margin-left:auto">
278
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>
252
279
  ${I18n.t("settings.models.link.topUp")}
253
280
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>
254
281
  </a>` : ""}
@@ -294,7 +321,7 @@ const Settings = (() => {
294
321
  document.getElementById("model-modal-model").value = model.model || "";
295
322
  document.getElementById("model-modal-baseurl").value = model.base_url || "";
296
323
  document.getElementById("model-modal-apikey").value = model.api_key_masked || "";
297
- document.getElementById("model-modal-api-format").value = model.api_format || "";
324
+ if (_apiFormatSelect) _apiFormatSelect.setValue(model.api_format || "");
298
325
  document.getElementById("model-modal-default-field").style.display = "";
299
326
  // Lock the checkbox when this is the only configured model: the system
300
327
  // must always have one default (backend re-promotes on save), so
@@ -305,14 +332,10 @@ const Settings = (() => {
305
332
  setDefaultCb.checked = isOnlyModel ? true : (model.type === "default");
306
333
  setDefaultCb.disabled = isOnlyModel;
307
334
 
308
- // Set provider dropdown value.
309
- // Priority: 1) saved provider_id on the model record (explicit user choice,
310
- // survives base_url edits); 2) reverse-lookup from base_url (legacy records
311
- // without provider_id); 3) anthropic_format flag (self-hosted proxy hint).
312
- const matched = _findProviderByBaseUrl(model.base_url);
313
- const savedProviderId = model.provider_id || null;
314
- const resolvedProviderId = savedProviderId ||
315
- (matched ? matched.id : (model.anthropic_format ? "anthropic" : null));
335
+ // Set provider dropdown value (shared resolution: see
336
+ // _resolveModalProviderId). An api_format override marks custom so the
337
+ // override survives reopen+save instead of being silently dropped.
338
+ const resolvedProviderId = _resolveModalProviderId(model);
316
339
  _modalSelectedProviderId = resolvedProviderId;
317
340
  _modalProviderIsCustom = !resolvedProviderId;
318
341
  _syncApiFormatVisibility();
@@ -337,7 +360,7 @@ const Settings = (() => {
337
360
  document.getElementById("model-modal-apikey").value = "";
338
361
  document.getElementById("model-modal-remark").value = "";
339
362
  document.getElementById("model-modal-no-vision").checked = false;
340
- document.getElementById("model-modal-api-format").value = "";
363
+ if (_apiFormatSelect) _apiFormatSelect.setValue("");
341
364
  document.getElementById("model-modal-default-field").style.display = "";
342
365
  // Default to checked for new models - most users want their first/new
343
366
  // model to take over as the default.
@@ -392,12 +415,16 @@ const Settings = (() => {
392
415
  document.getElementById("model-modal-baseurl").value = source.base_url || "";
393
416
  document.getElementById("model-modal-apikey").value = source.api_key_masked || "";
394
417
  document.getElementById("model-modal-remark").value = source.remark || "";
418
+ if (_apiFormatSelect) _apiFormatSelect.setValue(source.api_format || "");
395
419
 
396
- const matched = _findProviderByBaseUrl(source.base_url);
397
- _modalSelectedProviderId = matched ? matched.id : (source.anthropic_format ? "anthropic" : null);
398
- _modalProviderIsCustom = !_modalSelectedProviderId;
420
+ const resolvedProviderId = _resolveModalProviderId(source);
421
+ _modalSelectedProviderId = resolvedProviderId;
422
+ _modalProviderIsCustom = !resolvedProviderId;
399
423
  _syncApiFormatVisibility();
400
424
  const providerValue = document.getElementById("model-modal-provider-value");
425
+ const matched = resolvedProviderId
426
+ ? _providers.find(p => p.id === resolvedProviderId) || null
427
+ : null;
401
428
  if (matched) {
402
429
  providerValue.textContent = matched.name;
403
430
  providerValue.classList.remove("placeholder");
@@ -428,68 +455,18 @@ const Settings = (() => {
428
455
 
429
456
  function _populateModalProviderDropdown() {
430
457
  const dropdown = document.getElementById("model-modal-provider-dropdown");
458
+ // The provider list scrolls on its own while the "Custom" entry sits in
459
+ // a fixed footer area outside the scroll container, so it never scrolls.
431
460
  dropdown.innerHTML = `
432
- <div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
433
- ${_providers.map(p => { const dn = _providerDisplayName(p); return `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(dn)}">${_esc(dn)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`; }).join("")}
434
- <div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
461
+ <div class="custom-select-list">
462
+ <div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
463
+ ${_providers.map(p => { const dn = _providerDisplayName(p); return `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(dn)}">${_esc(dn)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`; }).join("")}
464
+ </div>
465
+ <div class="custom-select-option custom-select-option-custom" data-value="custom" data-label="${_esc(I18n.t("settings.models.custom"))}">
466
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
467
+ <span>${I18n.t("settings.models.custom")}</span>
468
+ </div>
435
469
  `;
436
-
437
- // Bind click events for options
438
- dropdown.querySelectorAll(".custom-select-option").forEach(option => {
439
- option.addEventListener("click", (e) => {
440
- e.stopPropagation();
441
- const value = option.dataset.value;
442
- const text = option.dataset.label || option.textContent.trim();
443
-
444
- // Track the picked provider so test/save can flag anthropic_format=true
445
- // when the user explicitly picks Anthropic. Empty / "custom" → null.
446
- _modalSelectedProviderId = (value && value !== "custom") ? value : null;
447
- _modalProviderIsCustom = value === "custom";
448
- _syncApiFormatVisibility();
449
-
450
- const providerValue = document.getElementById("model-modal-provider-value");
451
- providerValue.textContent = text;
452
- providerValue.classList.toggle("placeholder", !value);
453
- _syncRemarkField();
454
-
455
- _closeProviderDropdown();
456
-
457
- // Show/hide promo hint
458
- const promoHint = document.getElementById("model-modal-promo-hint");
459
- if (value === "openclacky" || !value) {
460
- _showPromoHint(promoHint);
461
- } else {
462
- promoHint.classList.remove("visible");
463
- }
464
-
465
- // Auto-fill if provider selected
466
- if (value && value !== "custom") {
467
- const preset = _providers.find(p => p.id === value);
468
- if (preset) {
469
- document.getElementById("model-modal-model").value = preset.default_model || "";
470
- document.getElementById("model-modal-baseurl").value = preset.base_url || "";
471
-
472
- const apikeyLink = document.getElementById("model-modal-apikey-link");
473
- if (preset.website_url) {
474
- apikeyLink.href = preset.website_url;
475
- apikeyLink.style.display = "";
476
- } else {
477
- apikeyLink.style.display = "none";
478
- }
479
-
480
- // Update model dropdown with provider's models
481
- setTimeout(() => _updateModalModelDropdown(), 0);
482
- }
483
- } else {
484
- // Selecting "custom" (or the placeholder) abandons the preset:
485
- // clear leftover model/base_url so stale values never survive.
486
- document.getElementById("model-modal-model").value = "";
487
- document.getElementById("model-modal-baseurl").value = "";
488
- document.getElementById("model-modal-apikey-link").style.display = "none";
489
- setTimeout(() => _updateModalModelDropdown(), 0);
490
- }
491
- });
492
- });
493
470
  }
494
471
 
495
472
  async function _testModel(index) {
@@ -527,7 +504,7 @@ const Settings = (() => {
527
504
  // Preset providers own their protocol routing; drop any override so the
528
505
  // backend falls back to preset + per-model resolution (overrides are a
529
506
  // custom-provider feature).
530
- const api_format = _modalProviderIsCustom ? (document.getElementById("model-modal-api-format").value.trim() || null) : null;
507
+ const api_format = _modalProviderIsCustom ? ((_apiFormatSelect && _apiFormatSelect.value) || null) : null;
531
508
 
532
509
  saveBtn.disabled = true;
533
510
 
@@ -570,7 +547,10 @@ const Settings = (() => {
570
547
 
571
548
  const hasId = !!existingId;
572
549
 
573
- const payload = { model, base_url, anthropic_format, api_format, provider_id: _modalSelectedProviderId || null };
550
+ // Persist the custom choice as provider_id:"custom" so reopening the
551
+ // modal (and the card badge) stays custom even when base_url matches a
552
+ // preset endpoint; null would fall into the legacy base_url reverse lookup.
553
+ const payload = { model, base_url, anthropic_format, api_format, provider_id: _modalProviderIsCustom ? "custom" : (_modalSelectedProviderId || null) };
574
554
  payload.remark = document.getElementById("model-modal-remark").value.trim();
575
555
  // Vision override only applies to custom (non-preset) providers — preset
576
556
  // capability tables live server-side. Checked → vision:false; unchecked →
@@ -644,25 +624,11 @@ const Settings = (() => {
644
624
  }
645
625
 
646
626
  function _openProviderDropdown() {
647
- const wrapper = document.getElementById("model-modal-provider-wrapper");
648
- const trigger = document.getElementById("model-modal-provider-trigger");
649
- const dropdown = document.getElementById("model-modal-provider-dropdown");
650
- _positionDropdownFixed(dropdown, wrapper);
651
- dropdown.classList.add("open");
652
- dropdown.style.display = "block";
653
- trigger.classList.add("open");
654
- document.body.appendChild(dropdown);
627
+ if (_providerSelect) _providerSelect.open();
655
628
  }
656
629
 
657
630
  function _closeProviderDropdown() {
658
- const wrapper = document.getElementById("model-modal-provider-wrapper");
659
- const trigger = document.getElementById("model-modal-provider-trigger");
660
- const dropdown = document.getElementById("model-modal-provider-dropdown");
661
- dropdown.classList.remove("open");
662
- dropdown.style.display = "none";
663
- trigger.classList.remove("open");
664
- _resetDropdownPosition(dropdown);
665
- wrapper.appendChild(dropdown);
631
+ if (_providerSelect) _providerSelect.close();
666
632
  }
667
633
 
668
634
  function _initModal() {
@@ -686,21 +652,63 @@ const Settings = (() => {
686
652
  });
687
653
 
688
654
  // Provider dropdown toggle (fixed-positioned like the model/base-url dropdowns)
689
- const providerTrigger = document.getElementById("model-modal-provider-trigger");
690
- providerTrigger.addEventListener("click", (e) => {
691
- e.stopPropagation();
692
- const dropdown = document.getElementById("model-modal-provider-dropdown");
693
- if (dropdown.classList.contains("open")) {
694
- _closeProviderDropdown();
695
- } else {
696
- _openProviderDropdown();
655
+ _providerSelect = CustomSelect.init({
656
+ trigger: document.getElementById("model-modal-provider-trigger"),
657
+ dropdown: document.getElementById("model-modal-provider-dropdown"),
658
+ anchor: document.getElementById("model-modal-provider-wrapper"),
659
+ portal: true,
660
+ onSelect: function (value) {
661
+ // Track the picked provider so test/save can flag anthropic_format=true
662
+ // when the user explicitly picks Anthropic. Empty / "custom" → null.
663
+ _modalSelectedProviderId = (value && value !== "custom") ? value : null;
664
+ _modalProviderIsCustom = value === "custom";
665
+ _syncApiFormatVisibility();
666
+ _syncRemarkField();
667
+
668
+ // Show/hide promo hint
669
+ const promoHint = document.getElementById("model-modal-promo-hint");
670
+ if (value === "openclacky" || !value) {
671
+ _showPromoHint(promoHint);
672
+ } else {
673
+ promoHint.classList.remove("visible");
674
+ }
675
+
676
+ // Auto-fill if provider selected
677
+ if (value && value !== "custom") {
678
+ const preset = _providers.find(p => p.id === value);
679
+ if (preset) {
680
+ document.getElementById("model-modal-model").value = preset.default_model || "";
681
+ document.getElementById("model-modal-baseurl").value = preset.base_url || "";
682
+
683
+ const apikeyLink = document.getElementById("model-modal-apikey-link");
684
+ if (preset.website_url) {
685
+ apikeyLink.href = preset.website_url;
686
+ apikeyLink.style.display = "";
687
+ } else {
688
+ apikeyLink.style.display = "none";
689
+ }
690
+
691
+ // Update model dropdown with provider's models
692
+ setTimeout(() => _updateModalModelDropdown(), 0);
693
+ }
694
+ } else {
695
+ // Selecting "custom" (or the placeholder) abandons the preset:
696
+ // clear leftover model/base_url so stale values never survive.
697
+ document.getElementById("model-modal-model").value = "";
698
+ document.getElementById("model-modal-baseurl").value = "";
699
+ document.getElementById("model-modal-apikey-link").style.display = "none";
700
+ setTimeout(() => _updateModalModelDropdown(), 0);
701
+ }
697
702
  }
698
703
  });
699
704
 
700
- // Close dropdowns on outside click
701
- document.addEventListener("click", () => {
702
- _closeProviderDropdown();
705
+ const apiFormatSelect = CustomSelect.init({
706
+ trigger: document.getElementById("model-modal-api-format-trigger"),
707
+ dropdown: document.getElementById("model-modal-api-format-dropdown"),
708
+ anchor: document.getElementById("model-modal-api-format-wrapper"),
709
+ portal: true
703
710
  });
711
+ _apiFormatSelect = apiFormatSelect;
704
712
 
705
713
  // Toggle API key visibility
706
714
  document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
@@ -875,12 +883,9 @@ const Settings = (() => {
875
883
  }
876
884
 
877
885
  function _bindCardEvents(card, index) {
878
- // Custom dropdown interactions
879
886
  const customSelectWrapper = card.querySelector(".custom-select-wrapper");
880
887
  const trigger = customSelectWrapper.querySelector(".custom-select-trigger");
881
888
  const dropdown = customSelectWrapper.querySelector(".custom-select-dropdown");
882
- const valueSpan = trigger.querySelector(".custom-select-value");
883
- const options = dropdown.querySelectorAll(".custom-select-option");
884
889
 
885
890
  // Initialize promo hint: only show for new cards (no existing model config)
886
891
  const quickSetupField = card.querySelector(".quick-setup-field");
@@ -892,44 +897,10 @@ const Settings = (() => {
892
897
  _showPromoHint(initialPromoHint);
893
898
  }
894
899
 
895
- // Toggle dropdown
896
- trigger.addEventListener("click", (e) => {
897
- e.stopPropagation();
898
- const isOpen = dropdown.classList.contains("open");
899
- // Close all other dropdowns
900
- document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
901
- d.classList.remove("open");
902
- d.previousElementSibling.classList.remove("open");
903
- });
904
- if (!isOpen) {
905
- dropdown.classList.add("open");
906
- trigger.classList.add("open");
907
- }
908
- });
909
-
910
- // Select option
911
- options.forEach(option => {
912
- option.addEventListener("click", (e) => {
913
- e.stopPropagation();
914
- const value = option.dataset.value;
915
- const text = option.dataset.label || option.textContent;
916
-
917
- // Update UI
918
- valueSpan.textContent = text;
919
- if (value) {
920
- valueSpan.classList.remove("placeholder");
921
- } else {
922
- valueSpan.classList.add("placeholder");
923
- }
924
-
925
- // Update selected state
926
- options.forEach(opt => opt.classList.remove("selected"));
927
- option.classList.add("selected");
928
-
929
- // Close dropdown
930
- dropdown.classList.remove("open");
931
- trigger.classList.remove("open");
932
-
900
+ CustomSelect.init({
901
+ trigger: trigger,
902
+ dropdown: dropdown,
903
+ onSelect: function (value) {
933
904
  // Auto-fill model & base_url if a provider preset was selected
934
905
  const getApiKeyLink = card.querySelector(`.get-apikey-link[data-index="${index}"]`);
935
906
  const promoHint = card.querySelector(`.provider-promo-hint[data-index="${index}"]`);
@@ -961,13 +932,7 @@ const Settings = (() => {
961
932
  // Show promo hint when no provider selected (default state)
962
933
  if (promoHint) _showPromoHint(promoHint);
963
934
  }
964
- });
965
- });
966
-
967
- // Close dropdown when clicking outside
968
- document.addEventListener("click", () => {
969
- dropdown.classList.remove("open");
970
- trigger.classList.remove("open");
935
+ }
971
936
  });
972
937
 
973
938
  // Toggle API key visibility
@@ -1563,30 +1528,37 @@ const Settings = (() => {
1563
1528
  // ── Web Search ────────────────────────────────────────────────────────────────
1564
1529
 
1565
1530
  async function _initSearchSettings() {
1566
- const select = document.getElementById("settings-search-provider");
1531
+ const trigger = document.getElementById("settings-search-provider-trigger");
1532
+ const dropdown = document.getElementById("settings-search-provider-dropdown");
1567
1533
  const keyWrap = document.getElementById("search-key-wrap");
1568
1534
  const keyInput = document.getElementById("settings-search-key");
1569
1535
  const saveBtn = document.getElementById("btn-save-search");
1570
1536
  const testBtn = document.getElementById("btn-test-search");
1571
1537
  const status = document.getElementById("settings-search-status");
1572
- if (!select || !saveBtn) return;
1538
+ if (!dropdown || !saveBtn) return;
1539
+
1540
+ let select = dropdown.__customSelect;
1541
+ if (!select && trigger) {
1542
+ select = CustomSelect.init({
1543
+ trigger: trigger,
1544
+ dropdown: dropdown,
1545
+ onSelect: function () { _syncKeyVisibility(); }
1546
+ });
1547
+ }
1573
1548
 
1574
1549
  function _syncKeyVisibility() {
1575
- keyWrap.style.display = select.value ? "" : "none";
1550
+ keyWrap.style.display = (select && select.value) ? "" : "none";
1576
1551
  }
1577
1552
 
1578
1553
  try {
1579
1554
  const res = await fetch("/api/config/search");
1580
1555
  const data = await res.json();
1581
1556
  if (data.ok) {
1582
- while (select.options.length > 1) select.remove(1);
1583
- (data.available || []).forEach((name) => {
1584
- const opt = document.createElement("option");
1585
- opt.value = name;
1586
- opt.textContent = name;
1587
- select.appendChild(opt);
1588
- });
1589
- select.value = data.provider || "";
1557
+ const rows = (data.available || []).map(name =>
1558
+ `<div class="custom-select-option" data-value="${_esc(name)}">${_esc(name)}</div>`).join("");
1559
+ dropdown.innerHTML =
1560
+ `<div class="custom-select-option" data-value="">${I18n.t("settings.search.builtin")}</div>` + rows;
1561
+ if (select) select.setValue(data.provider || "");
1590
1562
  keyInput.value = data.key_masked || "";
1591
1563
  }
1592
1564
  } catch (_) { /* non-critical */ }
@@ -1597,11 +1569,6 @@ const Settings = (() => {
1597
1569
  status.className = `model-test-result ${ok ? "success" : "error"}`;
1598
1570
  }
1599
1571
 
1600
- if (!select.dataset.bound) {
1601
- select.dataset.bound = "1";
1602
- select.addEventListener("change", _syncKeyVisibility);
1603
- }
1604
-
1605
1572
  if (!saveBtn.dataset.bound) {
1606
1573
  saveBtn.dataset.bound = "1";
1607
1574
  saveBtn.addEventListener("click", async () => {
@@ -2579,10 +2546,17 @@ const Settings = (() => {
2579
2546
  _modelSearchQuery = e.target.value;
2580
2547
  _renderCards();
2581
2548
  });
2582
- document.getElementById("model-provider-filter").addEventListener("change", (e) => {
2583
- _modelProviderFilter = e.target.value;
2584
- _renderCards();
2585
- });
2549
+ const providerFilterWrap = document.getElementById("model-provider-filter");
2550
+ if (providerFilterWrap && window.CustomSelect) {
2551
+ _providerFilterSelect = window.CustomSelect.init({
2552
+ trigger: providerFilterWrap.querySelector(".custom-select-trigger"),
2553
+ dropdown: providerFilterWrap.querySelector(".custom-select-dropdown"),
2554
+ onSelect: (value) => {
2555
+ _modelProviderFilter = value;
2556
+ _renderCards();
2557
+ }
2558
+ });
2559
+ }
2586
2560
  document.getElementById("btn-rerun-onboard").addEventListener("click", _rerunOnboard);
2587
2561
  document.getElementById("btn-browser-setup").addEventListener("click", _setupBrowser);
2588
2562
 
@@ -377,10 +377,8 @@ const SkillAC = (() => {
377
377
  const skill = _items[idx];
378
378
  if (!skill) return;
379
379
  _mruTouch(skill.name);
380
- const input = _inputEl();
381
- input.value = "/" + skill.name + " ";
382
- input.style.height = "auto";
383
- input.style.height = Math.min(input.scrollHeight, 200) + "px";
380
+ const input = _inputEl();
381
+ Composer.setText(input, "/" + skill.name + " ");
384
382
  _hide();
385
383
  input.focus();
386
384
  }
@@ -425,10 +423,9 @@ const SkillAC = (() => {
425
423
  $(cfg.slashBtn).addEventListener("click", () => {
426
424
  _cfg = cfg;
427
425
  const input = $(cfg.input);
428
- if (input.value === "" || input.value === "/") {
429
- input.value = "/";
430
- input.style.height = "auto";
431
- input.style.height = Math.min(input.scrollHeight, 200) + "px";
426
+ const value = Composer.text(input);
427
+ if (value === "" || value === "/") {
428
+ Composer.setText(input, "/");
432
429
  }
433
430
  _toggle(); // Toggle dropdown instead of always opening
434
431
  if (_visible) {
@@ -453,22 +450,19 @@ const SkillAC = (() => {
453
450
  }
454
451
  });
455
452
 
456
- // Composer input: auto-grow textarea, normalize full-width slash, drive AC.
453
+ // Composer input: drive skill autocomplete; normalize full-width slash.
457
454
  $(cfg.input).addEventListener("input", () => {
458
455
  _cfg = cfg;
459
456
  const el = $(cfg.input);
460
- el.style.height = "auto";
461
- el.style.height = Math.min(el.scrollHeight, 200) + "px";
462
457
 
463
458
  // Replace full-width slash / or Chinese dunhao 、 with ASCII / in-place
464
- if (/^[/、]/.test(el.value)) {
465
- const pos = el.selectionStart;
466
- el.value = el.value.replace(/^[/、]/, "/");
467
- el.setSelectionRange(pos, pos);
459
+ const value = Composer.text(el);
460
+ if (/^[/、]/.test(value)) {
461
+ Composer.setText(el, value.replace(/^[/、]/, "/"));
468
462
  }
469
463
 
470
464
  // Trigger skill autocomplete
471
- _update(el.value);
465
+ _update(Composer.text(el));
472
466
  });
473
467
 
474
468
  const chk = $(cfg.systemChk);
@@ -479,7 +473,7 @@ const SkillAC = (() => {
479
473
  _showSystemSkills = chk.checked;
480
474
  localStorage.setItem(SKILL_AC_SHOW_SYSTEM_KEY, _showSystemSkills ? "true" : "false");
481
475
  if (_visible) {
482
- const query = _getSlashQuery($(cfg.input).value);
476
+ const query = _getSlashQuery(Composer.text($(cfg.input)));
483
477
  if (query !== null) await _render(query);
484
478
  }
485
479
  });