openclacky 1.5.19 → 1.5.21

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +19 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/chunk_index.rb +8 -1
  7. data/lib/clacky/agent/feedback.rb +30 -0
  8. data/lib/clacky/agent/history_navigation.rb +17 -3
  9. data/lib/clacky/agent/hook_manager.rb +6 -1
  10. data/lib/clacky/agent/llm_caller.rb +17 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +36 -2
  12. data/lib/clacky/agent/session_serializer.rb +45 -4
  13. data/lib/clacky/agent.rb +135 -55
  14. data/lib/clacky/agent_config.rb +82 -4
  15. data/lib/clacky/billing/platform_billing.rb +10 -5
  16. data/lib/clacky/brand_config.rb +1 -1
  17. data/lib/clacky/cli.rb +5 -4
  18. data/lib/clacky/client.rb +12 -5
  19. data/lib/clacky/deepseek_web_tools.rb +156 -0
  20. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  21. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
  22. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  23. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  24. data/lib/clacky/extension/loader.rb +5 -3
  25. data/lib/clacky/locales/en.rb +5 -5
  26. data/lib/clacky/locales/zh.rb +5 -5
  27. data/lib/clacky/message_format/anthropic.rb +20 -0
  28. data/lib/clacky/message_format/open_ai.rb +31 -14
  29. data/lib/clacky/message_history.rb +45 -4
  30. data/lib/clacky/platform_http_client.rb +6 -2
  31. data/lib/clacky/providers.rb +38 -138
  32. data/lib/clacky/search_config.rb +47 -13
  33. data/lib/clacky/server/browser_manager.rb +35 -5
  34. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  35. data/lib/clacky/server/discover.rb +5 -4
  36. data/lib/clacky/server/http_server.rb +285 -53
  37. data/lib/clacky/server/model_prices.rb +10 -5
  38. data/lib/clacky/server/pid_files.rb +25 -0
  39. data/lib/clacky/server/server_master.rb +9 -3
  40. data/lib/clacky/server/session_registry.rb +37 -7
  41. data/lib/clacky/server/web_ui_controller.rb +72 -0
  42. data/lib/clacky/session_manager.rb +28 -6
  43. data/lib/clacky/skill.rb +1 -1
  44. data/lib/clacky/tools/ask_user.rb +4 -1
  45. data/lib/clacky/tools/base.rb +3 -2
  46. data/lib/clacky/tools/browser.rb +18 -2
  47. data/lib/clacky/tools/file_reader.rb +1 -1
  48. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  49. data/lib/clacky/tools/terminal.rb +65 -23
  50. data/lib/clacky/tools/trash_manager.rb +5 -3
  51. data/lib/clacky/tools/web_fetch.rb +81 -22
  52. data/lib/clacky/tools/web_search.rb +45 -4
  53. data/lib/clacky/ui2/ui_controller.rb +1 -1
  54. data/lib/clacky/ui_interface.rb +4 -3
  55. data/lib/clacky/utils/file_processor.rb +51 -0
  56. data/lib/clacky/utils/model_pricing.rb +242 -39
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +1158 -249
  59. data/lib/clacky/web/app.js +82 -13
  60. data/lib/clacky/web/components/chat-navigator.js +8 -1
  61. data/lib/clacky/web/components/code-editor.js +6 -2
  62. data/lib/clacky/web/components/mentions.js +2 -13
  63. data/lib/clacky/web/components/model-picker.js +92 -97
  64. data/lib/clacky/web/components/onboard.js +13 -10
  65. data/lib/clacky/web/components/sidebar.js +6 -2
  66. data/lib/clacky/web/features/archive/store.js +196 -0
  67. data/lib/clacky/web/features/archive/view.js +409 -0
  68. data/lib/clacky/web/features/backup/view.js +17 -7
  69. data/lib/clacky/web/features/billing/view.js +84 -10
  70. data/lib/clacky/web/features/channels/view.js +75 -29
  71. data/lib/clacky/web/features/new-session/store.js +3 -3
  72. data/lib/clacky/web/features/new-session/view.js +1 -0
  73. data/lib/clacky/web/features/trash/store.js +14 -11
  74. data/lib/clacky/web/features/trash/view.js +66 -13
  75. data/lib/clacky/web/features/workspace/view.js +421 -16
  76. data/lib/clacky/web/i18n.js +242 -70
  77. data/lib/clacky/web/index.html +270 -117
  78. data/lib/clacky/web/projects.js +56 -1
  79. data/lib/clacky/web/sessions.js +609 -267
  80. data/lib/clacky/web/settings.js +186 -32
  81. data/lib/clacky/web/ws-dispatcher.js +33 -21
  82. data/lib/clacky.rb +4 -2
  83. metadata +6 -1
@@ -55,7 +55,10 @@ const PANELS = [
55
55
  "channels-panel",
56
56
  "mcp-panel",
57
57
  "extensions-panel",
58
+ "data-management-panel",
58
59
  "trash-panel",
60
+ "archive-panel",
61
+ "backup-panel",
59
62
  "profile-panel",
60
63
  "billing-panel",
61
64
  "settings-panel",
@@ -194,6 +197,8 @@ const Router = (() => {
194
197
  return { view: "extensions", params: { detailId: mExtDetail[1], source } };
195
198
  }
196
199
  if (path === "trash") return { view: "trash", params: {} };
200
+ if (path === "archive") return { view: "archive", params: {} };
201
+ if (path === "backup") return { view: "backup", params: {} };
197
202
  if (path === "profile") return { view: "profile", params: {} };
198
203
  // Legacy: #memories redirects to #profile (memories are now merged into
199
204
  // the profile panel). Kept so bookmarks / external links don't 404.
@@ -213,17 +218,41 @@ const Router = (() => {
213
218
  // Sidebar items managed by Router (keyed by view name → element id).
214
219
  // Router is the single authority for active highlight — modules must NOT
215
220
  // add/remove the "active" class on these elements themselves.
221
+ // The three data-management views share one rail entry: they are tabs of a
222
+ // single page, so any of them highlights the same sidebar item.
216
223
  const SIDEBAR_ITEMS = {
217
224
  tasks: "tasks-sidebar-item",
218
225
  skills: "skills-sidebar-item",
219
226
  channels: "channels-sidebar-item",
220
227
  mcp: "mcp-sidebar-item",
221
228
  extensions: "extensions-sidebar-item",
222
- trash: "trash-sidebar-item",
229
+ trash: "data-management-sidebar-item",
230
+ archive: "data-management-sidebar-item",
231
+ backup: "data-management-sidebar-item",
223
232
  profile: "profile-sidebar-item",
224
233
  billing: "billing-sidebar-item",
225
234
  };
226
235
 
236
+ // Tabs of the data-management page, in display order. The view name is also
237
+ // the tab's hash route and its panel id prefix.
238
+ const DATA_MANAGEMENT_TABS = ["archive", "trash", "backup"];
239
+
240
+ // Show the data-management page with `tab` selected. Every tab keeps its own
241
+ // panel element (module-owned body), only visibility is toggled here.
242
+ function _showDataManagementTab(tab) {
243
+ const page = $("data-management-panel");
244
+ if (page) page.style.display = "flex";
245
+ DATA_MANAGEMENT_TABS.forEach(name => {
246
+ const panel = $(name + "-panel");
247
+ if (panel) panel.style.display = name === tab ? "flex" : "none";
248
+ const btn = $("tab-dm-" + name);
249
+ if (btn) {
250
+ btn.classList.toggle("active", name === tab);
251
+ btn.setAttribute("aria-selected", String(name === tab));
252
+ }
253
+ });
254
+ }
255
+
227
256
  // Remove active highlight from all Router-managed sidebar items.
228
257
  function _clearSidebarActive() {
229
258
  Object.values(SIDEBAR_ITEMS).forEach(id => {
@@ -258,6 +287,7 @@ const Router = (() => {
258
287
  Sessions._cacheActiveAndDeselect();
259
288
  }
260
289
  Sessions.updateInfoBar(null); // hide info bar when leaving any session
290
+ Sessions.updateChatHeader(null); // and clear the header title
261
291
  // Clear all sidebar highlights and settings button active state
262
292
  _clearSidebarActive();
263
293
  const btnSettings = $("btn-settings");
@@ -380,11 +410,25 @@ const Router = (() => {
380
410
 
381
411
  case "trash":
382
412
  _setHash("trash");
383
- $("trash-panel").style.display = "flex";
413
+ _showDataManagementTab("trash");
384
414
  Trash.onPanelShow();
385
415
  Sessions.renderList();
386
416
  break;
387
417
 
418
+ case "archive":
419
+ _setHash("archive");
420
+ _showDataManagementTab("archive");
421
+ Archive.onPanelShow();
422
+ Sessions.renderList();
423
+ break;
424
+
425
+ case "backup":
426
+ _setHash("backup");
427
+ _showDataManagementTab("backup");
428
+ Backup.onPanelShow();
429
+ Sessions.renderList();
430
+ break;
431
+
388
432
  case "profile":
389
433
  _setHash("profile");
390
434
  $("profile-panel").style.display = "flex";
@@ -403,7 +447,7 @@ const Router = (() => {
403
447
  _setHash("settings");
404
448
  $("settings-panel").style.display = "";
405
449
  if (btnSettings) btnSettings.classList.add("active");
406
- Settings.open();
450
+ Settings.open(params);
407
451
  Sessions.renderList();
408
452
  break;
409
453
 
@@ -460,6 +504,16 @@ const Router = (() => {
460
504
  _apply(view, params).catch(err => console.error("Router._apply failed:", err));
461
505
  });
462
506
 
507
+ // The data-management tab bar is navigation, nothing more: a tab click means
508
+ // the same thing as navigating to that view, so the Router owns the handler.
509
+ const dmTabs = $("data-management-tabs");
510
+ if (dmTabs) {
511
+ dmTabs.addEventListener("click", (e) => {
512
+ const tab = e.target.closest("[data-dm-tab]");
513
+ if (tab) Router.navigate(tab.dataset.dmTab);
514
+ });
515
+ }
516
+
463
517
  // Extensions request navigation via a CustomEvent so ext.js has no direct
464
518
  // dependency on Router. Detail is { view, params } — same shape as navigate().
465
519
  document.addEventListener("clacky:ext:navigate", (e) => {
@@ -811,9 +865,13 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
811
865
  const handle = $("sidebar-resize-handle");
812
866
  if (!sidebar || !handle) return;
813
867
 
814
- const MIN_W = 12; // rem
868
+ const MIN_W = 14.5; // rem
815
869
  const MAX_W = 32; // rem
816
870
  const baseFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
871
+ // The width lives on #app so the header's session divider tracks the same
872
+ // edge as the sidebar (see the Sidebar section in app.css).
873
+ const appRoot = $("app");
874
+ const setWidth = (rem) => appRoot.style.setProperty("--sidebar-width", rem + "rem");
817
875
 
818
876
  let startX = 0;
819
877
  let startW = 0;
@@ -823,7 +881,7 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
823
881
  if (saved) {
824
882
  const w = parseFloat(saved);
825
883
  if (w >= MIN_W && w <= MAX_W) {
826
- sidebar.style.setProperty("--sidebar-width", w + "rem");
884
+ setWidth(w);
827
885
  }
828
886
  }
829
887
 
@@ -844,7 +902,7 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
844
902
  if (!handle.classList.contains("active")) return;
845
903
  const dx = (e.clientX - startX) / baseFontSize;
846
904
  const newW = Math.min(MAX_W, Math.max(MIN_W, startW + dx));
847
- sidebar.style.setProperty("--sidebar-width", newW + "rem");
905
+ setWidth(newW);
848
906
  });
849
907
 
850
908
  document.addEventListener("mouseup", () => {
@@ -962,18 +1020,28 @@ window.bootAfterBrand = async function() {
962
1020
  })();
963
1021
 
964
1022
  // ── Image Lightbox ────────────────────────────────────────────────────────────
965
- // Global lightbox: click any .msg-image-thumb to open; click backdrop or ✕ or
966
- // press ESC to close.
1023
+ // Global lightbox: click a sent image or a pending attachment thumbnail to
1024
+ // open; click backdrop or ✕ or press ESC to close.
967
1025
  (function () {
1026
+ const IMAGE_SELECTOR = ".msg-image-thumb, .img-preview-thumb";
968
1027
  let _overlay = null;
969
1028
 
970
1029
  function _open(src, alt) {
971
1030
  if (_overlay) return;
972
1031
  _overlay = document.createElement("div");
973
1032
  _overlay.className = "img-lightbox";
974
- _overlay.innerHTML =
975
- `<span class="img-lightbox-close" title="Close">✕</span>` +
976
- `<img src="${src}" alt="${alt || "image"}">`;
1033
+
1034
+ const close = document.createElement("span");
1035
+ close.className = "img-lightbox-close";
1036
+ close.title = "Close";
1037
+ close.textContent = "✕";
1038
+
1039
+ const image = document.createElement("img");
1040
+ image.src = src;
1041
+ image.alt = alt || "image";
1042
+
1043
+ _overlay.appendChild(close);
1044
+ _overlay.appendChild(image);
977
1045
 
978
1046
  // Click on backdrop or ✕ → close
979
1047
  _overlay.addEventListener("click", function (e) {
@@ -994,9 +1062,10 @@ window.bootAfterBrand = async function() {
994
1062
  if (e.key === "Escape") _close();
995
1063
  });
996
1064
 
997
- // Event delegation: any click on .msg-image-thumb anywhere in the page
1065
+ // Event delegation covers sent images and local image attachments waiting
1066
+ // in either composer. File cards and reference chips are intentionally out.
998
1067
  document.addEventListener("click", function (e) {
999
- if (e.target.classList.contains("msg-image-thumb")) {
1068
+ if (e.target.matches(IMAGE_SELECTOR)) {
1000
1069
  _open(e.target.src, e.target.alt);
1001
1070
  }
1002
1071
  });
@@ -306,18 +306,24 @@
306
306
  function syncMessages() {
307
307
  if (!messages || sessionId !== Sessions.activeId) return;
308
308
  const byId = new Map(items.map((item, index) => [item.id, index]));
309
+ const byTask = new Map();
309
310
  const liveByTime = new Map();
310
311
  items.forEach((item, index) => {
312
+ if (item.taskId) byTask.set(String(item.taskId), index);
311
313
  if (!item.archived && item.created_at) liveByTime.set(String(item.created_at), index);
312
314
  });
313
315
  loaded = [];
314
316
  messages.querySelectorAll(".msg-user-wrap").forEach(el => {
315
317
  const bubble = el.querySelector(".msg-user");
316
318
  let index = byId.get(bubble?.dataset.roundId);
319
+ // Compression re-keys rounds without re-rendering bubbles; task ids survive it.
320
+ if (index === undefined) index = byTask.get(bubble?.dataset.taskId);
317
321
  // Only optimistic live bubbles need a timestamp fallback. Archived
318
322
  // targets always use source locators, never synthetic timestamps.
319
323
  if (index === undefined && bubble?.dataset.editable !== "false") index = liveByTime.get(bubble?.dataset.createdAt);
320
- if (index !== undefined) loaded.push({ index, el });
324
+ if (index === undefined) return;
325
+ if (items[index].taskId) bubble.dataset.taskId = String(items[index].taskId);
326
+ loaded.push({ index, el });
321
327
  });
322
328
  _updateActive();
323
329
  }
@@ -379,6 +385,7 @@
379
385
  const identity = source.identities?.[offset] || null;
380
386
  return { id: JSON.stringify([source.key, offset, source.version, identity]),
381
387
  bookmarkKey: _bookmarkKey(source, offset, identity),
388
+ taskId: source.task_ids?.[offset] || identity?.[1] || null,
382
389
  archived: !source.identities, created_at: identity?.[0] };
383
390
  }));
384
391
  const validBookmarks = new Set(items.map(item => item.bookmarkKey));
@@ -73,14 +73,16 @@
73
73
 
74
74
  const IMAGE_EXTS = new Set(["png","jpg","jpeg","gif","bmp","webp","svg","ico","tiff","tif","avif"]);
75
75
  const PDF_EXTS = new Set(["pdf"]);
76
+ const VIDEO_EXTS = new Set(["mp4","webm","mov"]);
77
+ const AUDIO_EXTS = new Set(["mp3","wav","ogg","aac","flac","m4a"]);
76
78
  const BINARY_EXTS = new Set([
77
79
  // archives
78
80
  "zip","gz","7z","tar","dmg","rar","tgz","iso","jar","war",
79
81
  // office docs (previewed via the "open with app" fallback instead)
80
82
  "xls","xlsx","doc","docx","ppt","pptx","pot","pps","ppsx","dot",
81
83
  "odt","ods","odp","wps","et","dps","key","numbers","pages",
82
- // media
83
- "mov","mp4","mp3","avi","mkv","wmv","weba","flac","wav","m4a","aac","aiff",
84
+ // media formats without reliable browser-native playback
85
+ "avi","mkv","wmv","weba","aiff",
84
86
  // fonts
85
87
  "ttf","otf","woff","woff2","eot",
86
88
  // system / misc
@@ -106,6 +108,8 @@
106
108
  const ext = filename.split(".").pop().toLowerCase();
107
109
  if (IMAGE_EXTS.has(ext)) return "image";
108
110
  if (PDF_EXTS.has(ext)) return "pdf";
111
+ if (VIDEO_EXTS.has(ext)) return "video";
112
+ if (AUDIO_EXTS.has(ext)) return "audio";
109
113
  if (BINARY_EXTS.has(ext)) return "binary";
110
114
  if (TABLE_EXTS.has(ext)) return "table";
111
115
  return "text";
@@ -67,7 +67,7 @@ const MentionAC = (() => {
67
67
  async function _fetchSessions(before) {
68
68
  // Cron/scheduled-task sessions are noise here — exclude them from references.
69
69
  const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
70
- if (before) params.set("before", before);
70
+ if (before) { params.set("before", before.before); params.set("before_id", before.before_id); }
71
71
  const url = `/api/sessions?${params}`;
72
72
  const res = await fetch(url);
73
73
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -303,18 +303,7 @@ const MentionAC = (() => {
303
303
  const sessions = data.sessions || [];
304
304
  _chatsHasMore = !!data.has_more;
305
305
  _items = _items.concat(sessions.map((s) => ({ kind: "session", id: s.id, name: s.name || s.id })));
306
- if (sessions.length) {
307
- // Cursor must come from a non-pinned session: pinned sessions sit at
308
- // the top regardless of age, so an old pinned row would otherwise push
309
- // the cursor too far back and leave the next page empty.
310
- const oldest = sessions.reduce((min, s) => {
311
- if (s.pinned) return min;
312
- const t = s.updated_at || s.created_at;
313
- if (!t) return min;
314
- return (!min || t < min) ? t : min;
315
- }, null);
316
- if (oldest) _chatsCursor = oldest;
317
- }
306
+ if (data.next_cursor) _chatsCursor = data.next_cursor;
318
307
  _renderChatItems(listWrap, reset);
319
308
  } catch (err) {
320
309
  if (_mode !== "chats" || req !== _chatsReq) return;
@@ -182,14 +182,18 @@ const ModelPicker = (() => {
182
182
  ratio.textContent = price.label;
183
183
  el.appendChild(ratio);
184
184
  const vars = { in: price.in, out: price.out };
185
- let tipKey = "sib.price.tip";
185
+ // Bedrock GPT carries a 20% markup over list, so the standard-price claim
186
+ // would be wrong for these rows.
187
+ let tipKey = name.startsWith("abs-gpt-") ? "sib.price.tip.abs" : "sib.price.tip";
186
188
  if (price.discount) {
187
189
  const badge = document.createElement("span");
188
190
  badge.className = "sib-model-discount";
189
191
  badge.textContent = I18n.t("sib.discount.tag", _discountVars(price.discount));
190
192
  el.appendChild(badge);
191
193
  Object.assign(vars, _discountVars(price.discount));
192
- tipKey = "sib.price.tip.discounted";
194
+ // A discount on a vendor-owned lane (Vertex) is a promo, not evidence
195
+ // of a third-party channel feeding us below list.
196
+ tipKey = price.discount.reason === "promo" ? "sib.price.tip.promo" : "sib.price.tip.discounted";
193
197
  }
194
198
  el.setAttribute("data-tooltip", I18n.t(tipKey, vars));
195
199
  return true;
@@ -209,27 +213,6 @@ const ModelPicker = (() => {
209
213
  return m.provider_name;
210
214
  }
211
215
 
212
- // [label, value] pairs for the picker header: which provider serves the live
213
- // model, and which model that is. The list below spans every provider, so
214
- // without this the rows read as if they were choosing a provider.
215
- function pickerHeadBits(models, currentId, subInfo) {
216
- const current = (models || []).find((m) => m.id === currentId);
217
- if (!current) return [];
218
-
219
- const label = (key, fallback) =>
220
- (typeof I18n !== "undefined") ? I18n.t(key) : fallback;
221
- const overridden =
222
- subInfo && subInfo.current && subInfo.current !== subInfo.cardModel
223
- ? subInfo.current
224
- : null;
225
-
226
- const bits = [];
227
- const provider = providerLabel(current);
228
- if (provider) bits.push([label("sib.picker.provider", "Provider"), provider]);
229
- bits.push([label("sib.picker.current", "Current model"), overridden || current.model]);
230
- return bits;
231
- }
232
-
233
216
  // Render one latency cell based on a cached result.
234
217
  // undefined → empty slot
235
218
  // { ok:true } → "812ms" in green/amber/red per threshold
@@ -326,8 +309,6 @@ const ModelPicker = (() => {
326
309
  priceEl.className = "sib-model-price";
327
310
  rightBox.appendChild(priceEl);
328
311
 
329
- _decorateAutoRow(row, name, rightBox);
330
-
331
312
  if (name === cardDefault) {
332
313
  const tag = document.createElement("span");
333
314
  tag.className = "sib-submodel-default-tag";
@@ -336,6 +317,11 @@ const ModelPicker = (() => {
336
317
  }
337
318
  row.appendChild(rightBox);
338
319
 
320
+ // The variant panel is where the economy-first alias is explained: here
321
+ // the name stands alone, unlike the dropdown rows that lead with the
322
+ // provider.
323
+ if (/^auto$/i.test(name || "")) _decorateAutoRow(row, name, rightBox);
324
+
339
325
  row.addEventListener("click", (ev) => {
340
326
  ev.stopPropagation();
341
327
  const passName = (name === cardDefault) ? null : name;
@@ -555,6 +541,8 @@ const ModelPicker = (() => {
555
541
  // Attaches the economy-first tag and its hover card — or nothing at all when
556
542
  // the alias is pinned to a concrete model, where the row already reads as a
557
543
  // normal model and the routing hint would contradict what is selected.
544
+ // Settings' model rows are the only caller: dropdown rows lead with the
545
+ // provider now and leave no room for the tag.
558
546
  function _decorateAutoRow(row, modelName, box) {
559
547
  if (!/^auto$/i.test(modelName || "")) return;
560
548
  const tag = document.createElement("span");
@@ -583,9 +571,13 @@ const ModelPicker = (() => {
583
571
  // models [{id, model, type, remark, base_url, api_key_masked, ...}]
584
572
  // currentId id of the currently-selected model
585
573
  // onSelect(model) selection callback (required)
586
- // mediaCaps {vision, image, video, audio} — enables vision badge + footer
587
- // subInfo {options, current, cardModel} — enables sub-model panel
574
+ // mediaCaps {vision, image, video, audio} — enables the generation footer
575
+ // subInfo {options, current, cardModel} — the live sub-model list
576
+ // of the session's current row
588
577
  // onSwitchSubModel (name|null, displayName) — required when subInfo given
578
+ // onSwitchRowSubModel (model, name, displayName) — required to offer the
579
+ // switcher on rows other than the current one; the host
580
+ // switches the session onto that model first
589
581
  // onBenchmark async () => [{model_id, ok, ttft_ms, error}] — enables ⚡
590
582
  // onAddModel () => void — enables the "Add Model" entry
591
583
  // onConfigureMedia () => void — required when mediaCaps given
@@ -593,45 +585,12 @@ const ModelPicker = (() => {
593
585
  const {
594
586
  models, currentId, onSelect,
595
587
  mediaCaps, subInfo, onSwitchSubModel,
596
- onBenchmark, onAddModel, onConfigureMedia,
588
+ onSwitchRowSubModel, onBenchmark, onAddModel, onConfigureMedia,
597
589
  } = opts;
598
590
 
599
591
  container.innerHTML = "";
600
592
  _hideAutoTip();
601
593
 
602
- // Header: names the surface and states the live provider/model, so the
603
- // list below is not mistaken for a provider chooser.
604
- const head = document.createElement("div");
605
- head.className = "sib-picker-head";
606
-
607
- const title = document.createElement("div");
608
- title.className = "sib-picker-title";
609
- title.textContent = (typeof I18n !== "undefined") ? I18n.t("sib.picker.title") : "Choose model";
610
- head.appendChild(title);
611
-
612
- const headBits = pickerHeadBits(models, currentId, subInfo);
613
- if (headBits.length) {
614
- const sub = document.createElement("div");
615
- sub.className = "sib-picker-sub";
616
- headBits.forEach(([label, value], i) => {
617
- if (i) {
618
- const sep = document.createElement("span");
619
- sep.className = "sib-picker-sub-sep";
620
- sep.textContent = "·";
621
- sub.appendChild(sep);
622
- }
623
- const labelEl = document.createElement("span");
624
- labelEl.textContent = label + " ";
625
- const valueEl = document.createElement("span");
626
- valueEl.className = "sib-picker-sub-value";
627
- valueEl.textContent = value;
628
- sub.appendChild(labelEl);
629
- sub.appendChild(valueEl);
630
- });
631
- head.appendChild(sub);
632
- }
633
- container.appendChild(head);
634
-
635
594
  // Top action bar: "Add Model" hugs the left edge, the benchmark chip the
636
595
  // right (both pinned by the `order` rules on .sib-model-bench). The bar
637
596
  // only exists when at least one of the two actions is wired up.
@@ -693,8 +652,6 @@ const ModelPicker = (() => {
693
652
  const left = document.createElement("span");
694
653
  left.className = "sib-model-name";
695
654
 
696
- const nameLine = document.createElement("span");
697
- nameLine.className = "sib-model-name-main";
698
655
  // When a non-default quick-switch model is active, show only that
699
656
  // model's name (avoid the long "main → quick-switch" truncated string).
700
657
  const hasActiveOverride =
@@ -702,40 +659,49 @@ const ModelPicker = (() => {
702
659
  subInfo && subInfo.current &&
703
660
  subInfo.current !== subInfo.cardModel;
704
661
  const displayName = hasActiveOverride ? subInfo.current : m.model;
705
- nameLine.appendChild(vendorBadge(displayName));
706
- const nameText = document.createElement("span");
707
- nameText.className = "sib-model-name-text";
708
- nameText.textContent = displayName;
709
- nameLine.appendChild(nameText);
710
- left.appendChild(nameLine);
711
-
712
- // Vision status for the active model only.
713
- if (m.id === currentId && mediaCaps && mediaCaps.vision) {
714
- const ok = !!mediaCaps.vision.configured;
715
- const vis = document.createElement("span");
716
- vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
717
- vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
718
- vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
719
- nameLine.appendChild(vis);
720
- }
721
662
 
722
- // Provider line: which service the model runs through (plus the user's own
723
- // remark). Rendered on every row so two rows with the same model name are
724
- // never ambiguous. The endpoint host and the masked key are technical
725
- // details: they stay out of the line and live in the tooltip instead.
726
663
  const remark = (m.remark || "").trim();
727
664
  const host = (() => {
728
665
  try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
729
666
  })();
730
667
  const provider = providerLabel(m);
731
668
  // Unknown endpoints (no matching provider preset) fall back to the host
732
- // so a row never renders an empty second line.
733
- const identityBits = [provider || host, remark].filter(Boolean);
734
- if (identityBits.length) {
669
+ // so a row never leads with an empty first line.
670
+ const providerName = provider || host;
671
+
672
+ // The list spans every service, so each row leads with the provider the
673
+ // model runs through and keeps the model itself as the detail line.
674
+ // Endpoints no preset claims have no provider to show, so those rows keep
675
+ // the model on the lead line.
676
+ const headLine = document.createElement("span");
677
+ headLine.className = "sib-model-name-main";
678
+ const headText = document.createElement("span");
679
+ headText.className = "sib-model-name-text";
680
+ headText.textContent = providerName || displayName;
681
+ headLine.appendChild(headText);
682
+ if (!providerName) headLine.insertBefore(vendorBadge(displayName), headText);
683
+ left.appendChild(headLine);
684
+
685
+ // Detail line: the model badge and name, followed by the user's own
686
+ // remark. The endpoint host and the masked key are technical details:
687
+ // they stay out of the line and live in the tooltip instead.
688
+ if (providerName || remark) {
735
689
  left.classList.add("has-sub");
736
690
  const subLine = document.createElement("span");
737
691
  subLine.className = "sib-model-name-sub";
738
- subLine.textContent = identityBits.join(" · ");
692
+ if (providerName) {
693
+ subLine.appendChild(vendorBadge(displayName));
694
+ const subText = document.createElement("span");
695
+ subText.className = "sib-model-name-text";
696
+ subText.textContent = displayName;
697
+ subLine.appendChild(subText);
698
+ }
699
+ if (remark) {
700
+ const remarkEl = document.createElement("span");
701
+ remarkEl.className = "sib-model-name-remark";
702
+ remarkEl.textContent = remark;
703
+ subLine.appendChild(remarkEl);
704
+ }
739
705
  left.appendChild(subLine);
740
706
  }
741
707
  const tipBits = [provider, host, remark, m.api_key_masked].filter(Boolean);
@@ -748,9 +714,20 @@ const ModelPicker = (() => {
748
714
  const right = document.createElement("span");
749
715
  right.className = "sib-model-right";
750
716
 
751
- if (/^auto$/i.test(m.model || "") && !hasActiveOverride) {
752
- // Pinned to a concrete sub-model → no tag and no routing tooltip.
753
- _decorateAutoRow(opt, m.model, right);
717
+ // Shortcut to the provider's own console, where the balance is topped up
718
+ // and the key is managed. Hover-only (see .sib-topup-btn) so it does not
719
+ // compete with the model name, and an anchor rather than a button so
720
+ // open-in-new-tab keeps working; the row's own click is suppressed.
721
+ if (m.website_url) {
722
+ const topUp = document.createElement("a");
723
+ topUp.className = "sib-topup-btn";
724
+ topUp.href = m.website_url;
725
+ topUp.target = "_blank";
726
+ topUp.rel = "noopener";
727
+ topUp.textContent = I18n.t("sib.topup");
728
+ topUp.title = I18n.t("sib.topup.tip", { provider: providerName || displayName });
729
+ topUp.addEventListener("click", (ev) => ev.stopPropagation());
730
+ right.appendChild(topUp);
754
731
  }
755
732
 
756
733
  if (m.id === currentId) {
@@ -779,12 +756,17 @@ const ModelPicker = (() => {
779
756
  right.appendChild(lat);
780
757
  }
781
758
 
782
- const hasSubModels =
783
- m.id === currentId &&
784
- subInfo && subInfo.options &&
785
- subInfo.options.length > 1;
786
-
787
- if (hasSubModels && onSwitchSubModel) {
759
+ // Every row offers its own switcher: the list comes from the row's
760
+ // provider. The session's current row keeps the live list (it may
761
+ // already be pinned to a variant), the others use the list the server
762
+ // sends with the model.
763
+ const isCurrentRow = m.id === currentId;
764
+ const rowSubModels =
765
+ isCurrentRow && subInfo && subInfo.options && subInfo.options.length
766
+ ? subInfo.options
767
+ : (m.sub_models || []);
768
+
769
+ if (rowSubModels.length > 1 && onSwitchSubModel) {
788
770
  const toggleBtn = document.createElement("button");
789
771
  toggleBtn.type = "button";
790
772
  toggleBtn.className = "sib-submodel-toggle";
@@ -796,9 +778,22 @@ const ModelPicker = (() => {
796
778
  '</svg>';
797
779
  right.appendChild(toggleBtn);
798
780
 
781
+ const rowSubInfo = {
782
+ options: rowSubModels,
783
+ current: isCurrentRow && subInfo ? subInfo.current : null,
784
+ cardModel: m.model
785
+ };
786
+ // Picking a variant on a row other than the session's current one has
787
+ // to run the session on that model first — a sub-model is pinned under
788
+ // the card, so the host owns that order.
789
+ const switchVariant = (name, displayName) => {
790
+ if (isCurrentRow || !onSwitchRowSubModel) return onSwitchSubModel(name, displayName);
791
+ onSwitchRowSubModel(m, name, displayName);
792
+ };
793
+
799
794
  toggleBtn.addEventListener("click", (ev) => {
800
795
  ev.stopPropagation();
801
- _toggleSubmodelPanel(container, opt, toggleBtn, subInfo, onSwitchSubModel);
796
+ _toggleSubmodelPanel(container, opt, toggleBtn, rowSubInfo, switchVariant);
802
797
  });
803
798
  }
804
799
 
@@ -414,8 +414,18 @@ const Onboard = (() => {
414
414
  el.className = "setup-test-result" + (msg ? " result-fail" : "");
415
415
  }
416
416
 
417
+ // Platform returns raw RFC 8628 codes (access_denied / expired_token); never
418
+ // surface those to users — map the status to a translated sentence instead.
419
+ function _deviceErrorText(status) {
420
+ switch (status) {
421
+ case "denied": return I18n.t("onboard.device.error.denied");
422
+ case "expired":
423
+ case "consumed": return I18n.t("onboard.device.error.expired");
424
+ default: return I18n.t("onboard.device.error.failed");
425
+ }
426
+ }
427
+
417
428
  async function _startDeviceLogin() {
418
- const zh = _selectedLang === "zh";
419
429
  _setDeviceError("");
420
430
 
421
431
  const w = window.open("about:blank", "_blank");
@@ -430,13 +440,11 @@ const Onboard = (() => {
430
440
 
431
441
  if (!data || !data.ok) {
432
442
  if (w && !w.closed) w.close();
433
- _setDeviceError((data && data.error) || (zh ? "无法发起登录,请稍后重试。" : "Could not start login. Please try again."));
443
+ _setDeviceError(I18n.t("onboard.device.error.startFailed"));
434
444
  return;
435
445
  }
436
446
 
437
447
  const url = data.verification_uri_complete || data.verification_uri;
438
- const codeEl = $("setup-device-usercode");
439
- if (codeEl) codeEl.textContent = data.user_code || "—";
440
448
  const link = $("setup-device-link");
441
449
  if (link && url) link.href = url;
442
450
 
@@ -452,8 +460,6 @@ const Onboard = (() => {
452
460
  }
453
461
 
454
462
  async function _pollDevice(deviceCode, intervalMs) {
455
- const zh = _selectedLang === "zh";
456
-
457
463
  while (_devicePolling) {
458
464
  await new Promise(r => setTimeout(r, intervalMs));
459
465
  if (!_devicePolling) return;
@@ -480,10 +486,7 @@ const Onboard = (() => {
480
486
  // denied / expired / consumed / error
481
487
  _devicePolling = false;
482
488
  _showDevicePending(false);
483
- const msg = data.status === "denied"
484
- ? (zh ? "授权已被拒绝。" : "Authorization was denied.")
485
- : (zh ? "授权已过期,请重新登录。" : "Authorization expired. Please try again.");
486
- _setDeviceError(data.error || msg);
489
+ _setDeviceError(_deviceErrorText(data.status));
487
490
  return;
488
491
  }
489
492
  }