openclacky 1.5.12 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -0,0 +1,527 @@
1
+ // ── ModelPicker — shared model dropdown component ─────────────────────────
2
+ // Renders the model list (vendor badge, price ratio, vision/remark/sub
3
+ // decorations, latency cell, sub-model toggle) plus the sub-model variant
4
+ // panel, benchmark button and generation footer. Session-specific actions
5
+ // (switching card/sub-model, running the benchmark request, opening media
6
+ // config) are injected as callbacks so the same component powers both the
7
+ // session info bar and the #new landing page.
8
+ const ModelPicker = (() => {
9
+ // Price-ratio badges for the submodel rows. Ratios come from
10
+ // GET /api/model_prices (backed by Clacky::ModelPricing), so the local
11
+ // pricing table stays the single source of truth - no hardcoded JS prices.
12
+ const _priceCache = new Map(); // model name -> { in, out, label } | null
13
+
14
+ function _fmtRatio(ratio) {
15
+ if (ratio >= 10) return Math.round(ratio) + "x";
16
+ const s = ratio >= 1 ? ratio.toFixed(1) : ratio.toFixed(2);
17
+ return s.replace(/\.?0+$/, "") + "x";
18
+ }
19
+
20
+ // Cache of the most recent benchmark results, keyed by model_id. Kept at
21
+ // closure scope so the numbers survive closing & reopening the dropdown.
22
+ let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
23
+ let _benchInFlight = false; // prevent double-click spam
24
+
25
+ // Vendor badge: brand-colored tile with the provider's mark, shown before
26
+ // each model name. Model family wins over provider prefix ("or-gemini-…"
27
+ // matches Gemini before the "or-" OpenRouter rule). Rules carry a real
28
+ // brand SVG where the path stays legible at badge size; the rest (and any
29
+ // unknown model) fall back to the provider initial on a brand-colored tile.
30
+ const _VENDOR_RULES = [
31
+ { re: /claude|anthropic/i, label: "A", color: "#D97757",
32
+ svg: '<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/>' },
33
+ { re: /deepseek/i, label: "D", color: "#4D6BFE", viewBox: "0 1.5 27 20.5",
34
+ svg: '<path d="M26.5174 3.39471C26.235 3.2567 26.1137 3.52006 25.9487 3.65346C25.8923 3.69659 25.8446 3.75294 25.7969 3.80469C25.3846 4.24516 24.9027 4.53439 24.2737 4.49989C23.3536 4.44814 22.5682 4.73737 21.8735 5.44119C21.7258 4.57349 21.2353 4.0554 20.4889 3.72304C20.0985 3.55054 19.7034 3.37746 19.4297 3.00197C19.2388 2.73459 19.1865 2.43673 19.091 2.14289C19.0301 1.96579 18.9697 1.78466 18.7656 1.75418C18.5442 1.71968 18.4574 1.90541 18.3705 2.06067C18.0232 2.69549 17.8887 3.39471 17.9019 4.10313C17.9324 5.6965 18.6051 6.96556 19.9421 7.86834C20.0939 7.97184 20.133 8.07535 20.0852 8.22658C19.9938 8.53766 19.8857 8.83955 19.7903 9.15063C19.7293 9.34901 19.6384 9.39271 19.4257 9.30588C18.692 8.9994 18.0583 8.54571 17.4982 7.99772C16.5477 7.07827 15.6881 6.06336 14.6162 5.26869C14.3644 5.08296 14.1125 4.91045 13.8521 4.746C12.7584 3.68394 13.9952 2.81164 14.2816 2.70814C14.5812 2.60003 14.3857 2.22857 13.4179 2.23317C12.4502 2.2372 11.5646 2.56151 10.4359 2.99335C10.2708 3.05832 10.0972 3.10547 9.91951 3.14457C8.8954 2.95022 7.83162 2.90709 6.72069 3.03245C4.62877 3.26533 2.95777 4.25436 1.72954 5.94261C0.254043 7.97184 -0.0932678 10.2777 0.33167 12.6824C0.778458 15.2171 2.07225 17.3153 4.06008 18.9558C6.12152 20.6567 8.49577 21.4905 11.2047 21.3306C12.8498 21.2358 14.6812 21.0155 16.7473 19.2669C17.2682 19.5262 17.8151 19.6297 18.7219 19.7074C19.4205 19.7723 20.0933 19.6729 20.6143 19.5648C21.4302 19.3923 21.3739 18.6367 21.0789 18.4981C18.6874 17.3843 19.2124 17.8374 18.7351 17.4706C19.9501 16.033 21.8063 13.4776 22.379 9.99821C22.4353 9.61409 22.5072 9.073 22.4986 8.76192C22.494 8.57216 22.5377 8.49856 22.7545 8.47671C23.3536 8.40771 23.935 8.24383 24.4692 7.94999C26.0188 7.10357 26.6439 5.71318 26.7911 4.04678C26.8129 3.79204 26.7865 3.52869 26.5174 3.39471ZM13.0143 18.3946C10.6964 16.5724 9.5722 15.9726 9.10816 15.9985C8.67402 16.0244 8.75222 16.5212 8.84768 16.8449C8.94773 17.1646 9.07768 17.3849 9.25996 17.6655C9.38589 17.8512 9.47272 18.1272 9.13404 18.3348C8.38766 18.7965 7.08985 18.1796 7.0289 18.1491C5.51833 17.2595 4.25559 16.0853 3.36546 14.4793C2.50581 12.9337 2.0067 11.2753 1.92447 9.50542C1.90262 9.07818 2.02855 8.92695 2.45406 8.84932C3.01413 8.74582 3.59144 8.72397 4.15093 8.80619C6.51656 9.15178 8.53027 10.2092 10.2185 11.8848C11.1822 12.8388 11.9114 13.979 12.6623 15.0929C13.461 16.2757 14.3201 17.4027 15.4144 18.3268C15.8008 18.6505 16.109 18.8966 16.404 19.0783C15.5144 19.1778 14.0297 19.1991 13.0143 18.3958V18.3946ZM14.1252 11.2489C14.1252 11.0591 14.277 10.9079 14.4679 10.9079C14.511 10.9079 14.5501 10.9165 14.5852 10.9292C14.6329 10.9464 14.6766 10.9723 14.7111 11.0114C14.7721 11.0718 14.8066 11.158 14.8066 11.2489C14.8066 11.4386 14.6548 11.5899 14.4639 11.5899C14.273 11.5899 14.1252 11.4386 14.1252 11.2489ZM17.5759 13.0188C17.3545 13.1096 17.1331 13.1873 16.9203 13.1959C16.5903 13.2131 16.2303 13.0791 16.0348 12.9153C15.7312 12.6605 15.5139 12.5179 15.423 12.0734C15.3839 11.8837 15.4057 11.5899 15.4402 11.4214C15.5185 11.0585 15.4316 10.8257 15.1757 10.614C14.9676 10.4415 14.7025 10.3938 14.4115 10.3938C14.3029 10.3938 14.2034 10.3461 14.1292 10.3076C14.0079 10.2472 13.9078 10.096 14.0033 9.91023C14.0338 9.84985 14.1815 9.70322 14.216 9.67734C14.6111 9.45251 15.0665 9.52612 15.488 9.6946C15.8784 9.85445 16.174 10.1477 16.5989 10.5623C17.033 11.0631 17.1112 11.2011 17.3585 11.5772C17.554 11.871 17.7317 12.1729 17.8536 12.5185C17.9272 12.7341 17.8317 12.9107 17.5759 13.0188Z"/>' },
35
+ { re: /glm|zhipu|bigmodel/i, label: "Z", color: "#131212", viewBox: "0 0 32 27",
36
+ svg: '<path d="M16.5376 0.0307374L14.3326 3.13223C14.1617 3.37577 13.9331 3.57415 13.6667 3.71001C13.4004 3.84586 13.1045 3.91504 12.8048 3.9115H0.787598V0.015152H16.5376V0.0307374Z"/><path d="M31.5 0.0321655L12.6 26.5273H0L18.9 0.0321655H31.5Z"/><path d="M14.9624 26.5272L17.1832 23.4101C17.3564 23.1689 17.5856 22.9723 17.8513 22.8368C18.1171 22.7012 18.4119 22.6306 18.7109 22.6308H30.7124V26.5272H14.9624Z"/>' },
37
+ { re: /gemini/i, label: "G", color: "#3B78E7",
38
+ svg: '<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>' },
39
+ { re: /gpt|openai/i, label: "O", color: "#10A37F" },
40
+ { re: /minimax/i, label: "M", color: "#F2402A",
41
+ svg: '<path style="fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round" d="M4 9.5V14.5M8 6V18M12 3V21M16 6V18M20 9.5V14.5"/>' },
42
+ { re: /qwen|tongyi/i, label: "Q", color: "#615CED" },
43
+ { re: /kimi|moonshot/i, label: "K", color: "#111827" },
44
+ { re: /grok|xai/i, label: "X", color: "#111827",
45
+ svg: '<path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"/>' },
46
+ { re: /doubao/i, label: "D", color: "#3370FF" },
47
+ { re: /ark|volces/i, label: "V", color: "#0F6BFF" },
48
+ { re: /llama/i, label: "L", color: "#0866FF" },
49
+ { re: /mistral/i, label: "M", color: "#F26B1D",
50
+ svg: '<path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/>' },
51
+ { re: /hunyuan/i, label: "H", color: "#0052FF" },
52
+ { re: /ernie|wenxin/i, label: "E", color: "#2932E1" },
53
+ { re: /^or-|openrouter/i, label: "R", color: "#5A6B7B" },
54
+ ];
55
+
56
+ function vendorBadge(name) {
57
+ const s = String(name || "").trim();
58
+ const rule = _VENDOR_RULES.find(r => r.re.test(s));
59
+ const el = document.createElement("span");
60
+ el.className = "sib-vendor-badge";
61
+ el.style.setProperty("--vendor-color", rule ? rule.color : "#7A8B99");
62
+ if (rule && rule.svg) {
63
+ el.innerHTML = '<svg viewBox="' + (rule.viewBox || "0 0 24 24") + '" aria-hidden="true">' + rule.svg + '</svg>';
64
+ } else {
65
+ el.textContent = rule ? rule.label : (s.charAt(0).toUpperCase() || "?");
66
+ }
67
+ return el;
68
+ }
69
+
70
+ // Fetches ratios for names not yet cached. Rows simply stay blank when
71
+ // the request fails (offline, unknown model).
72
+ async function loadPriceRatios(names) {
73
+ const missing = Array.from(new Set(names.filter(Boolean).filter((n) => !_priceCache.has(n))));
74
+ if (!missing.length) return;
75
+ try {
76
+ const res = await fetch("/api/model_prices?models=" + encodeURIComponent(missing.join(",")));
77
+ if (!res.ok) return;
78
+ const data = await res.json();
79
+ missing.forEach((n) => {
80
+ const p = data && data.prices && data.prices[n];
81
+ _priceCache.set(n, p ? { in: p.in, out: p.out, label: _fmtRatio(p.ratio) } : null);
82
+ });
83
+ } catch (e) {
84
+ /* network error - leave prices hidden */
85
+ }
86
+ }
87
+
88
+ function getPrice(name) {
89
+ return _priceCache.get(name) || null;
90
+ }
91
+
92
+ // Render one latency cell based on a cached result.
93
+ // undefined → empty slot
94
+ // { ok:true } → "812ms" in green/amber/red per threshold
95
+ // { ok:false } → "✕" with error in tooltip
96
+ // { pending:true } → "…" spinner-ish marker
97
+ function fillLatencyCell(el, entry) {
98
+ el.className = "sib-model-latency";
99
+ el.textContent = "";
100
+ el.removeAttribute("title");
101
+ if (!entry) return;
102
+ if (entry.pending) {
103
+ el.textContent = "…";
104
+ el.classList.add("is-pending");
105
+ return;
106
+ }
107
+ if (!entry.ok) {
108
+ el.textContent = "✕";
109
+ el.classList.add("is-err");
110
+ el.title = entry.error || "failed";
111
+ return;
112
+ }
113
+ const ms = entry.ttft_ms;
114
+ // Same thresholds as the sib-signal status bar — keep them aligned.
115
+ let cls = "is-bad";
116
+ if (ms <= 2000) cls = "is-ok";
117
+ else if (ms <= 60000) cls = "is-ok";
118
+ else if (ms <= 120000) cls = "is-warn";
119
+ el.classList.add(cls);
120
+ el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
121
+ if (typeof I18n !== "undefined") {
122
+ el.title = I18n.t("sib.bench.latencyTooltip", {
123
+ ttft: el.textContent,
124
+ time: new Date(entry.ts).toLocaleTimeString(),
125
+ });
126
+ } else {
127
+ el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
128
+ }
129
+ }
130
+
131
+ // ── Sub-model variant panel ─────────────────────────────────────────────
132
+ let _activeSubmodelAnchor = null;
133
+
134
+ function closeSubmodelPanel() {
135
+ const panel = document.getElementById("sib-submodel-panel");
136
+ if (panel) panel.style.display = "none";
137
+ if (_activeSubmodelAnchor) {
138
+ const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
139
+ if (btn) btn.setAttribute("aria-expanded", "false");
140
+ _activeSubmodelAnchor.classList.remove("submodel-open");
141
+ _activeSubmodelAnchor = null;
142
+ }
143
+ }
144
+
145
+ function _renderSubmodelPanel(panel, subInfo, onSwitchSubModel) {
146
+ panel.innerHTML = "";
147
+
148
+ const header = document.createElement("div");
149
+ header.className = "sib-submodel-panel-header";
150
+ header.textContent = I18n.t("sib.variant.header");
151
+ if (subInfo.cardModel) {
152
+ const cardName = document.createElement("span");
153
+ cardName.className = "sib-submodel-panel-model";
154
+ cardName.textContent = subInfo.cardModel;
155
+ header.appendChild(cardName);
156
+ }
157
+ panel.appendChild(header);
158
+
159
+ const cardDefault = subInfo.cardModel;
160
+ subInfo.options.forEach(name => {
161
+ const row = document.createElement("div");
162
+ row.className = "sib-submodel-row";
163
+ row.dataset.subModel = name;
164
+
165
+ const isActive = subInfo.current
166
+ ? name === subInfo.current
167
+ : name === cardDefault;
168
+ if (isActive) row.classList.add("current");
169
+
170
+ const nameEl = document.createElement("span");
171
+ nameEl.className = "sib-submodel-row-name";
172
+ nameEl.appendChild(vendorBadge(name));
173
+ const nameText = document.createElement("span");
174
+ nameText.className = "sib-submodel-row-text";
175
+ nameText.textContent = name;
176
+ nameEl.appendChild(nameText);
177
+ row.appendChild(nameEl);
178
+
179
+ const rightBox = document.createElement("span");
180
+ rightBox.style.cssText = "display:inline-flex;align-items:center;gap:0.375rem;flex-shrink:0;";
181
+
182
+ const priceEl = document.createElement("span");
183
+ priceEl.className = "sib-model-price";
184
+ rightBox.appendChild(priceEl);
185
+
186
+ if (name === cardDefault) {
187
+ const tag = document.createElement("span");
188
+ tag.className = "sib-submodel-default-tag";
189
+ tag.textContent = I18n.t("sib.variant.default");
190
+ rightBox.appendChild(tag);
191
+ }
192
+ row.appendChild(rightBox);
193
+
194
+ row.addEventListener("click", (ev) => {
195
+ ev.stopPropagation();
196
+ const passName = (name === cardDefault) ? null : name;
197
+ onSwitchSubModel(passName, name);
198
+ });
199
+ panel.appendChild(row);
200
+ });
201
+
202
+ _fillSubmodelPrices(panel);
203
+ }
204
+
205
+ // Ratio text fills in asynchronously once /api/model_prices responds
206
+ // (cached afterwards, so re-opening the panel shows prices instantly).
207
+ async function _fillSubmodelPrices(panel) {
208
+ const rows = Array.from(panel.querySelectorAll(".sib-submodel-row"));
209
+ await loadPriceRatios(rows.map((r) => r.dataset.subModel));
210
+ rows.forEach((row) => {
211
+ const price = _priceCache.get(row.dataset.subModel);
212
+ const el = row.querySelector(".sib-model-price");
213
+ if (!price || !el || el.textContent) return;
214
+ el.textContent = price.label;
215
+ el.title = I18n.t("sib.price.tip", { in: price.in, out: price.out });
216
+ });
217
+ }
218
+
219
+ function _toggleSubmodelPanel(container, anchorRow, btn, subInfo, onSwitchSubModel) {
220
+ const panel = document.getElementById("sib-submodel-panel");
221
+ if (!panel || !container) return;
222
+
223
+ if (panel.parentElement !== document.body) {
224
+ document.body.appendChild(panel);
225
+ }
226
+
227
+ const isOpen = panel.style.display !== "none" && _activeSubmodelAnchor === anchorRow;
228
+ if (isOpen) {
229
+ closeSubmodelPanel();
230
+ return;
231
+ }
232
+
233
+ _renderSubmodelPanel(panel, subInfo, onSwitchSubModel);
234
+
235
+ // Reset any prior position so measurements are accurate.
236
+ panel.style.left = "0px";
237
+ panel.style.top = "0px";
238
+ panel.style.display = "block";
239
+ panel.style.visibility = "hidden";
240
+
241
+ const dropRect = container.getBoundingClientRect();
242
+ const btnRect = btn.getBoundingClientRect();
243
+ const panelRect = panel.getBoundingClientRect();
244
+ const gap = 6;
245
+ const margin = 8;
246
+ const vw = window.innerWidth;
247
+ const vh = window.innerHeight;
248
+
249
+ // Prefer right of dropdown; flip to left if we'd overflow viewport.
250
+ let left = dropRect.right + gap;
251
+ if (left + panelRect.width > vw - margin) {
252
+ left = dropRect.left - panelRect.width - gap;
253
+ }
254
+ if (left < margin) left = margin;
255
+
256
+ let top = btnRect.top - 6;
257
+ if (top + panelRect.height > vh - margin) {
258
+ top = vh - margin - panelRect.height;
259
+ }
260
+ if (top < margin) top = margin;
261
+
262
+ panel.style.left = `${left}px`;
263
+ panel.style.top = `${top}px`;
264
+ panel.style.visibility = "";
265
+
266
+ _activeSubmodelAnchor = anchorRow;
267
+ anchorRow.classList.add("submodel-open");
268
+ btn.setAttribute("aria-expanded", "true");
269
+ }
270
+
271
+ // ── Benchmark runner ────────────────────────────────────────────────────
272
+ async function _runBenchmark(container, btn, label, hint, onBenchmark) {
273
+ if (_benchInFlight) return;
274
+ _benchInFlight = true;
275
+ btn.disabled = true;
276
+ const origLabel = label.textContent;
277
+ const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
278
+ label.textContent = _t("sib.bench.running");
279
+ hint.textContent = "";
280
+
281
+ // Mark every row as pending so the user sees instant feedback.
282
+ container.querySelectorAll(".sib-model-option").forEach(opt => {
283
+ const id = opt.dataset.modelId;
284
+ if (!id) return;
285
+ _benchCache[id] = { pending: true };
286
+ fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
287
+ });
288
+
289
+ const t0 = performance.now();
290
+ try {
291
+ const results = await onBenchmark();
292
+ const now = Date.now();
293
+ (results || []).forEach(r => {
294
+ _benchCache[r.model_id] = {
295
+ ok: !!r.ok,
296
+ ttft_ms: r.ttft_ms,
297
+ error: r.error,
298
+ ts: now,
299
+ };
300
+ const opt = container.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
301
+ if (opt) fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
302
+ });
303
+
304
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
305
+ hint.textContent = _t("sib.bench.done", { t: elapsed });
306
+ } catch (e) {
307
+ console.error("Benchmark failed:", e);
308
+ hint.textContent = _t("sib.bench.failed", { msg: e.message });
309
+ container.querySelectorAll(".sib-model-option").forEach(opt => {
310
+ const id = opt.dataset.modelId;
311
+ if (id && _benchCache[id] && _benchCache[id].pending) {
312
+ _benchCache[id] = undefined;
313
+ fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
314
+ }
315
+ });
316
+ } finally {
317
+ _benchInFlight = false;
318
+ btn.disabled = false;
319
+ label.textContent = origLabel;
320
+ }
321
+ }
322
+
323
+ // ── Generation footer ───────────────────────────────────────────────────
324
+ function _renderFooter(container, mediaCaps, onConfigureMedia) {
325
+ const kinds = ["image", "video", "audio"];
326
+ const footer = document.createElement("div");
327
+ footer.className = "sib-gen-footer";
328
+
329
+ const list = document.createElement("span");
330
+ list.className = "sib-gen-list";
331
+ kinds.forEach(k => {
332
+ const cap = mediaCaps[k] || {};
333
+ const ok = !!cap.configured;
334
+ const chip = document.createElement("span");
335
+ chip.className = "sib-gen-chip " + (ok ? "is-ok" : "is-off");
336
+ chip.textContent = (ok ? "✓ " : "") + I18n.t(`sib.gen.kind.${k}`);
337
+ chip.title = ok
338
+ ? I18n.t("sib.gen.okTip", { model: cap.model || "" })
339
+ : I18n.t("sib.gen.offTip");
340
+ list.appendChild(chip);
341
+ });
342
+ footer.appendChild(list);
343
+
344
+ const configBtn = document.createElement("button");
345
+ configBtn.type = "button";
346
+ configBtn.className = "sib-gen-config";
347
+ configBtn.textContent = I18n.t("sib.gen.config");
348
+ configBtn.title = I18n.t("sib.gen.offTip");
349
+ configBtn.addEventListener("click", (ev) => {
350
+ ev.stopPropagation();
351
+ onConfigureMedia();
352
+ });
353
+ footer.appendChild(configBtn);
354
+
355
+ container.appendChild(footer);
356
+ }
357
+
358
+ // ── Main entry point ────────────────────────────────────────────────────
359
+ // opts:
360
+ // models [{id, model, type, remark, base_url, api_key_masked, ...}]
361
+ // currentId id of the currently-selected model
362
+ // onSelect(model) selection callback (required)
363
+ // mediaCaps {vision, image, video, audio} — enables vision badge + footer
364
+ // subInfo {options, current, cardModel} — enables sub-model panel
365
+ // onSwitchSubModel (name|null, displayName) — required when subInfo given
366
+ // onBenchmark async () => [{model_id, ok, ttft_ms, error}] — enables ⚡
367
+ // onConfigureMedia () => void — required when mediaCaps given
368
+ async function populate(container, opts) {
369
+ const {
370
+ models, currentId, onSelect,
371
+ mediaCaps, subInfo, onSwitchSubModel,
372
+ onBenchmark, onConfigureMedia,
373
+ } = opts;
374
+
375
+ container.innerHTML = "";
376
+
377
+ // Benchmark floating button (top-right of dropdown).
378
+ if (onBenchmark) {
379
+ const bench = document.createElement("div");
380
+ bench.className = "sib-model-bench";
381
+ const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
382
+ const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
383
+ bench.innerHTML = `
384
+ <button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
385
+ <span class="sib-bench-hint"></span>
386
+ `;
387
+ container.appendChild(bench);
388
+
389
+ const benchBtn = bench.querySelector(".sib-bench-btn");
390
+ const benchLabel = bench.querySelector(".sib-bench-label");
391
+ const benchHint = bench.querySelector(".sib-bench-hint");
392
+ benchBtn.addEventListener("click", (ev) => {
393
+ ev.stopPropagation();
394
+ _runBenchmark(container, benchBtn, benchLabel, benchHint, onBenchmark);
395
+ });
396
+ }
397
+
398
+ // Model rows.
399
+ const _nameCounts = models.reduce((acc, m) => {
400
+ acc[m.model] = (acc[m.model] || 0) + 1;
401
+ return acc;
402
+ }, {});
403
+
404
+ models.forEach(m => {
405
+ const opt = document.createElement("div");
406
+ opt.className = "sib-model-option";
407
+ opt.dataset.modelId = m.id;
408
+ if (m.id === currentId) opt.classList.add("current");
409
+
410
+ const left = document.createElement("span");
411
+ left.className = "sib-model-name";
412
+
413
+ const nameLine = document.createElement("span");
414
+ nameLine.className = "sib-model-name-main";
415
+ // When a non-default quick-switch model is active, show only that
416
+ // model's name (avoid the long "main → quick-switch" truncated string).
417
+ const hasActiveOverride =
418
+ m.id === currentId &&
419
+ subInfo && subInfo.current &&
420
+ subInfo.current !== subInfo.cardModel;
421
+ const displayName = hasActiveOverride ? subInfo.current : m.model;
422
+ nameLine.appendChild(vendorBadge(displayName));
423
+ const nameText = document.createElement("span");
424
+ nameText.className = "sib-model-name-text";
425
+ nameText.textContent = displayName;
426
+ nameLine.appendChild(nameText);
427
+ left.appendChild(nameLine);
428
+
429
+ // Vision status for the active model only.
430
+ if (m.id === currentId && mediaCaps && mediaCaps.vision) {
431
+ const ok = !!mediaCaps.vision.configured;
432
+ const vis = document.createElement("span");
433
+ vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
434
+ vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
435
+ vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
436
+ nameLine.appendChild(vis);
437
+ }
438
+
439
+ // Distinguish rows that would otherwise look identical.
440
+ const remark = (m.remark || "").trim();
441
+ if (remark || _nameCounts[m.model] > 1) {
442
+ left.classList.add("has-sub");
443
+ const host = (() => {
444
+ try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
445
+ })();
446
+ const subBits = [remark, host, m.api_key_masked].filter(Boolean);
447
+ if (subBits.length) {
448
+ const subLine = document.createElement("span");
449
+ subLine.className = "sib-model-name-sub";
450
+ subLine.textContent = subBits.join(" · ");
451
+ left.appendChild(subLine);
452
+ opt.title = `${m.model} · ${subBits.join(" · ")}`;
453
+ }
454
+ }
455
+
456
+ opt.appendChild(left);
457
+
458
+ const right = document.createElement("span");
459
+ right.className = "sib-model-right";
460
+
461
+ if (m.id === currentId) {
462
+ const check = document.createElement("span");
463
+ check.className = "sib-model-check";
464
+ check.innerHTML =
465
+ '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' +
466
+ '<path d="M3 8.5L6.5 12L13 4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
467
+ '</svg>';
468
+ right.appendChild(check);
469
+ }
470
+
471
+ if (m.type === "default") {
472
+ const badge = document.createElement("span");
473
+ badge.className = `model-badge ${m.type}`;
474
+ badge.textContent = m.type;
475
+ right.appendChild(badge);
476
+ }
477
+
478
+ if (onBenchmark) {
479
+ const lat = document.createElement("span");
480
+ lat.className = "sib-model-latency";
481
+ fillLatencyCell(lat, _benchCache[m.id]);
482
+ right.appendChild(lat);
483
+ }
484
+
485
+ const hasSubModels =
486
+ m.id === currentId &&
487
+ subInfo && subInfo.options &&
488
+ subInfo.options.length > 1;
489
+
490
+ if (hasSubModels && onSwitchSubModel) {
491
+ const toggleBtn = document.createElement("button");
492
+ toggleBtn.type = "button";
493
+ toggleBtn.className = "sib-submodel-toggle";
494
+ toggleBtn.title = I18n.t("sib.variant.header");
495
+ toggleBtn.setAttribute("aria-expanded", "false");
496
+ toggleBtn.innerHTML =
497
+ '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' +
498
+ '<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>' +
499
+ '</svg>';
500
+ right.appendChild(toggleBtn);
501
+
502
+ toggleBtn.addEventListener("click", (ev) => {
503
+ ev.stopPropagation();
504
+ _toggleSubmodelPanel(container, opt, toggleBtn, subInfo, onSwitchSubModel);
505
+ });
506
+ }
507
+
508
+ opt.appendChild(right);
509
+
510
+ opt.addEventListener("click", () => onSelect(m));
511
+ container.appendChild(opt);
512
+ });
513
+
514
+ // Generation footer.
515
+ if (mediaCaps && onConfigureMedia) {
516
+ _renderFooter(container, mediaCaps, onConfigureMedia);
517
+ }
518
+ }
519
+
520
+ return {
521
+ vendorBadge,
522
+ loadPriceRatios,
523
+ getPrice,
524
+ populate,
525
+ closeSubmodelPanel,
526
+ };
527
+ })();
@@ -268,65 +268,39 @@ const Onboard = (() => {
268
268
  if (_dropdownBound) return; // listeners already attached
269
269
  _dropdownBound = true;
270
270
 
271
- const wrapper = $("setup-provider-wrapper");
272
- const trigger = wrapper.querySelector(".custom-select-trigger");
273
- const dropdown = wrapper.querySelector(".custom-select-dropdown");
274
- const valueSpan = trigger.querySelector(".custom-select-value");
275
-
276
- trigger.addEventListener("click", e => {
277
- e.stopPropagation();
278
- const open = dropdown.classList.toggle("open");
279
- trigger.classList.toggle("open", open);
280
- });
281
-
282
- // Use event delegation on the dropdown container — works for any option
283
- // including dynamically added ones (no need to re-bind on Back/Next).
284
- dropdown.addEventListener("click", e => {
285
- e.stopPropagation();
286
- const opt = e.target.closest(".custom-select-option");
287
- if (!opt) return;
288
-
289
- const value = opt.dataset.value;
290
- valueSpan.textContent = opt.dataset.label || opt.textContent;
291
- valueSpan.classList.toggle("placeholder", !value);
292
- dropdown.querySelectorAll(".custom-select-option").forEach(o => o.classList.remove("selected"));
293
- opt.classList.add("selected");
294
- dropdown.classList.remove("open");
295
- trigger.classList.remove("open");
296
-
297
- const getApiKeyLink = $("setup-get-apikey-link");
298
- if (value === "__custom__") {
299
- // Custom: clear presets so the user can fill in their own values
300
- $("setup-model").value = "";
301
- $("setup-base-url").value = "";
302
- _updateSetupModelDropdown([]);
303
- _updateSetupBaseUrlDropdown(null);
304
- if (getApiKeyLink) getApiKeyLink.style.display = "none";
305
- } else if (value) {
306
- const preset = _providers.find(p => p.id === value);
307
- if (preset) {
308
- $("setup-model").value = preset.default_model || "";
309
- $("setup-base-url").value = preset.base_url || "";
310
- _updateSetupModelDropdown(preset.models || []);
311
- _updateSetupBaseUrlDropdown(preset);
312
- // Show "how to get" link if provider has a website_url
313
- if (getApiKeyLink && preset.website_url) {
314
- getApiKeyLink.href = preset.website_url;
315
- getApiKeyLink.style.display = "";
316
- } else if (getApiKeyLink) {
317
- getApiKeyLink.style.display = "none";
271
+ const wrapper = $("setup-provider-wrapper");
272
+ CustomSelect.init({
273
+ trigger: wrapper.querySelector(".custom-select-trigger"),
274
+ dropdown: wrapper.querySelector(".custom-select-dropdown"),
275
+ onSelect: function (value) {
276
+ const getApiKeyLink = $("setup-get-apikey-link");
277
+ if (value === "__custom__") {
278
+ // Custom: clear presets so the user can fill in their own values
279
+ $("setup-model").value = "";
280
+ $("setup-base-url").value = "";
281
+ _updateSetupModelDropdown([]);
282
+ _updateSetupBaseUrlDropdown(null);
283
+ if (getApiKeyLink) getApiKeyLink.style.display = "none";
284
+ } else if (value) {
285
+ const preset = _providers.find(p => p.id === value);
286
+ if (preset) {
287
+ $("setup-model").value = preset.default_model || "";
288
+ $("setup-base-url").value = preset.base_url || "";
289
+ _updateSetupModelDropdown(preset.models || []);
290
+ _updateSetupBaseUrlDropdown(preset);
291
+ // Show "how to get" link if provider has a website_url
292
+ if (getApiKeyLink && preset.website_url) {
293
+ getApiKeyLink.href = preset.website_url;
294
+ getApiKeyLink.style.display = "";
295
+ } else if (getApiKeyLink) {
296
+ getApiKeyLink.style.display = "none";
297
+ }
318
298
  }
299
+ } else {
300
+ if (getApiKeyLink) getApiKeyLink.style.display = "none";
319
301
  }
320
- } else {
321
- if (getApiKeyLink) getApiKeyLink.style.display = "none";
322
302
  }
323
303
  });
324
-
325
- // Single global click-outside listener
326
- document.addEventListener("click", () => {
327
- dropdown.classList.remove("open");
328
- trigger.classList.remove("open");
329
- });
330
304
  }
331
305
 
332
306
  // Guard: key-step listeners are attached only once