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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +19 -1
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +17 -3
- data/lib/clacky/agent/message_compressor_helper.rb +36 -2
- data/lib/clacky/agent/session_serializer.rb +45 -4
- data/lib/clacky/agent.rb +135 -55
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +10 -5
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +5 -4
- data/lib/clacky/client.rb +12 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +45 -4
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +38 -138
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +285 -53
- data/lib/clacky/server/model_prices.rb +10 -5
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +37 -7
- data/lib/clacky/server/web_ui_controller.rb +72 -0
- data/lib/clacky/session_manager.rb +28 -6
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +242 -39
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1158 -249
- data/lib/clacky/web/app.js +82 -13
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/code-editor.js +6 -2
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +92 -97
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/components/sidebar.js +6 -2
- data/lib/clacky/web/features/archive/store.js +196 -0
- data/lib/clacky/web/features/archive/view.js +409 -0
- data/lib/clacky/web/features/backup/view.js +17 -7
- data/lib/clacky/web/features/billing/view.js +84 -10
- data/lib/clacky/web/features/channels/view.js +75 -29
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/new-session/view.js +1 -0
- data/lib/clacky/web/features/trash/store.js +14 -11
- data/lib/clacky/web/features/trash/view.js +66 -13
- data/lib/clacky/web/features/workspace/view.js +421 -16
- data/lib/clacky/web/i18n.js +242 -70
- data/lib/clacky/web/index.html +270 -117
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +609 -267
- data/lib/clacky/web/settings.js +186 -32
- data/lib/clacky/web/ws-dispatcher.js +33 -21
- data/lib/clacky.rb +4 -2
- metadata +6 -1
data/lib/clacky/web/app.js
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
587
|
-
// subInfo {options, current, cardModel} —
|
|
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
|
|
733
|
-
const
|
|
734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
}
|