openclacky 1.5.21 → 1.5.22

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -8,12 +8,15 @@
8
8
  // chat stream calls Clacky.Preview.open(), which opens the aside, activates
9
9
  // this tab and (re)loads the page.
10
10
  //
11
- // Host-origin pages (proxied workspace files and proxied dev servers) are
12
- // sandboxed WITHOUT allow-same-origin so AI-generated pages can run
13
- // scripts/forms/popups but never touch the host origin (cookies, localStorage,
14
- // DOM). Third-party URLs loaded directly (e.g. a Vite dev server) get
15
- // allow-same-origin added, otherwise their ES-module imports fail and the
16
- // frame stays blank. Because the sandbox hides load errors, the panel HEADs
11
+ // Proxied pages (workspace files and dev servers) are served from
12
+ // `preview.localhost` instead of the host origin: same server, different
13
+ // origin. That keeps them unable to read the host's cookies, localStorage or
14
+ // DOM while still giving them a real (non-opaque) origin — an opaque origin
15
+ // turns every <script type="module"> into a CORS request that fails, and makes
16
+ // localStorage/sessionStorage/cookie throw SecurityError, so any framework
17
+ // (Rails+Stimulus, Turbo, React) would render but never become interactive.
18
+ // Third-party URLs are loaded directly and only get allow-same-origin when
19
+ // they are cross-origin. Because the sandbox hides load errors, the panel HEADs
17
20
  // the URL first and shows a friendly 404 card instead of a blank frame.
18
21
  // ───────────────────────────────────────────────────────────────────────────
19
22
 
@@ -21,7 +24,13 @@
21
24
  if (!window.Clacky || !Clacky.ext) return;
22
25
 
23
26
  const PREVIEW_ASIDE_WIDTH = 620;
27
+ // Chrome resolves every *.localhost name to loopback, so the preview proxy
28
+ // is reachable under a second origin without a second listener.
29
+ const ISOLATED_PREVIEW_HOST = "preview.localhost";
24
30
  const SANDBOX = "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals";
31
+ const ZOOM_MIN = 50;
32
+ const ZOOM_MAX = 200;
33
+ const ZOOM_STEP = 10;
25
34
 
26
35
  const ICON_GLOBE = '<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>';
27
36
  const ICON_BACK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>';
@@ -29,6 +38,8 @@
29
38
  const ICON_RELOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/></svg>';
30
39
  const ICON_GO = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>';
31
40
  const ICON_EXTERNAL = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg>';
41
+ const ICON_MORE = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>';
42
+ const ICON_ZOOM_RESET = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>';
32
43
 
33
44
  const t = (k, fallback) => {
34
45
  const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
@@ -54,20 +65,30 @@
54
65
  style.id = "preview-panel-style";
55
66
  style.textContent = `
56
67
  .preview-root { display: flex; flex-direction: column; flex: 1; min-height: 0; }
57
- .preview-bar { flex: none; display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--color-border-secondary); }
58
- .preview-nav-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
68
+ .preview-bar { flex: none; position: relative; z-index: 10; display: flex; align-items: center; gap: 2px; padding: 7px 8px; border-bottom: 1px solid var(--color-border-secondary); }
69
+ .preview-nav-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: 50%; background: transparent; color: var(--color-text-secondary); cursor: pointer; transition: background var(--transition-fast), color var(--transition-fast); }
59
70
  .preview-nav-btn:hover:not(:disabled) { background: var(--color-bg-hover); color: var(--color-text-primary); }
60
- .preview-nav-btn:disabled { opacity: .35; cursor: default; }
61
- .preview-address-wrap { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; }
62
- .preview-address { width: 100%; height: 27px; padding: 0 32px 0 14px; border: none; border-radius: 14px; background: var(--color-bg-secondary); color: var(--color-text-primary); font-size: 12px; font-family: ui-monospace, monospace; text-align: center; transition: box-shadow var(--transition-fast), background var(--transition-fast); }
71
+ .preview-nav-btn:disabled { opacity: .3; cursor: default; }
72
+ .preview-address-wrap { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; margin: 0 6px; }
73
+ .preview-address { width: 100%; height: 30px; padding: 0 30px 0 16px; border: 1px solid transparent; border-radius: var(--radius-pill); background: var(--color-bg-primary); color: var(--color-text-primary); font-size: 12px; font-family: ui-monospace, monospace; text-align: center; transition: background var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast); }
63
74
  .preview-address::placeholder { color: var(--color-text-tertiary); font-family: -apple-system, sans-serif; }
64
- .preview-address:hover:not(:focus) { box-shadow: 0 0 0 1px var(--color-border-strong); }
65
- .preview-address:focus { outline: none; box-shadow: 0 0 0 1px var(--color-accent-primary); background: var(--color-bg-primary); }
66
- .preview-go { position: absolute; right: 5px; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
75
+ .preview-address:hover:not(:focus) { background: var(--color-bg-hover); }
76
+ .preview-address:focus { outline: none; background: var(--color-bg-secondary); border-color: var(--color-border-strong); box-shadow: var(--shadow-sm); }
77
+ .preview-go { position: absolute; right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
67
78
  .preview-go:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
68
- .preview-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
79
+ .preview-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: 50%; background: transparent; color: var(--color-text-secondary); cursor: pointer; transition: background var(--transition-fast), color var(--transition-fast); }
69
80
  .preview-icon-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
70
- .preview-frame-wrap { flex: 1; min-height: 0; overflow: hidden; }
81
+ .preview-menu { position: absolute; top: calc(100% - 2px); right: 10px; z-index: 20; width: 236px; padding: 5px; background: var(--color-bg-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); }
82
+ .preview-menu[hidden] { display: none; }
83
+ .preview-menu-label { flex: 1; }
84
+ .preview-menu-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 3px 6px 3px 10px; font-size: 12.5px; color: var(--color-text-primary); }
85
+ .preview-zoom { display: flex; align-items: center; gap: 1px; padding: 1px; border-radius: var(--radius-pill); background: var(--color-bg-hover); }
86
+ .preview-zoom-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--color-text-secondary); font-size: 13px; line-height: 1; cursor: pointer; }
87
+ .preview-zoom-btn:hover:not(:disabled) { background: var(--color-bg-secondary); color: var(--color-text-primary); }
88
+ .preview-zoom-btn:disabled { opacity: .35; cursor: default; }
89
+ .preview-zoom-value { min-width: 46px; height: 22px; padding: 0 4px; border: none; border-radius: var(--radius-pill); background: transparent; color: var(--color-text-primary); font-size: 11.5px; font-variant-numeric: tabular-nums; cursor: pointer; }
90
+ .preview-zoom-value:hover { background: var(--color-bg-secondary); }
91
+ .preview-frame-wrap { flex: 1; min-height: 0; overflow: auto; }
71
92
  .preview-frame { display: block; width: 100%; height: 100%; border: none; background: #fff; }
72
93
  .preview-empty, .preview-notfound { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; text-align: center; }
73
94
  .preview-empty-icon { color: var(--color-text-tertiary); opacity: .5; margin-bottom: 4px; }
@@ -91,6 +112,7 @@
91
112
  let forwardStack = []; // rels reachable via forward, newest → oldest
92
113
  let pending = null; // { sid, rel } arrived before that body rendered
93
114
  let releaseAsideWidth = null; // set while the aside is widened for the preview
115
+ let zoom = 100; // preview zoom, in percent; survives tab re-renders
94
116
 
95
117
  function isExternal(rel) {
96
118
  return /^https?:\/\//i.test(rel);
@@ -197,21 +219,39 @@
197
219
  els.forwardBtn.disabled = forwardStack.length === 0;
198
220
  }
199
221
 
200
- // Follows in-frame navigation. A sandboxed frame without allow-same-origin
201
- // cannot be inspected: contentWindow.location is sealed and the src property
202
- // does not follow internal navigations. Instead the server injects a
203
- // reporter into every preview HTML page that announces its URL on load, so
204
- // link clicks inside the frame keep the address bar and the back/forward
205
- // stacks in sync with what is actually on screen.
222
+ // Follows in-frame navigation. The frame is cross-origin to the host page,
223
+ // so contentWindow.location is unreadable and the src property does not
224
+ // follow internal navigations. Instead the server injects a reporter into
225
+ // every preview HTML page that announces its URL whenever it changes, so
226
+ // link clicks and SPA/Turbo transitions keep the address bar and the
227
+ // back/forward stacks in sync with what is actually on screen.
206
228
  function relFromUrl(url) {
207
229
  try {
208
230
  const u = new URL(url, location.href);
209
- const m = u.pathname.match(/^\/preview\/s\/[^/]+\/(.*)$/);
210
- if (!m || !m[1]) return null;
211
- return m[1].split("/").map(decodeURIComponent).join("/");
231
+
232
+ const file = u.pathname.match(/^\/preview\/f(\/.*)$/);
233
+ if (file) return file[1].split("/").map(decodeURIComponent).join("/");
234
+
235
+ const dev = u.pathname.match(/^\/preview\/p\/(\d+)(\/.*)?$/);
236
+ if (dev) return `http://${devHostFor(dev[1])}:${dev[1]}${dev[2] || "/"}${u.search}`;
237
+
238
+ const ws = u.pathname.match(/^\/preview\/s\/([^/]+)\/(.*)$/);
239
+ if (!ws || !ws[2]) return null;
240
+ if (activeSessionId && ws[1] !== encodeURIComponent(activeSessionId)) return null;
241
+ return ws[2].split("/").map(decodeURIComponent).join("/");
212
242
  } catch (_e) { return null; }
213
243
  }
214
244
 
245
+ // A dev server is reachable as either localhost or 127.0.0.1; keep whichever
246
+ // the address bar was already showing so the URL doesn't flip on every click.
247
+ function devHostFor(port) {
248
+ try {
249
+ const u = new URL(currentRel);
250
+ if (u.port === String(port) && u.hostname) return u.hostname;
251
+ } catch (_e) { /* currentRel is a workspace path */ }
252
+ return "localhost";
253
+ }
254
+
215
255
  if (!window.__clackyPreviewNav) {
216
256
  window.__clackyPreviewNav = true;
217
257
  window.addEventListener("message", (e) => {
@@ -231,17 +271,69 @@
231
271
  // A brand-new element is the only reload primitive that works uniformly:
232
272
  // re-assigning an identical src is a no-op, and contentWindow is sealed by
233
273
  // the sandbox.
234
- function freshFrame(url, opaque) {
235
- const sandbox = opaque ? SANDBOX : (SANDBOX + " allow-same-origin");
274
+ function freshFrame(url, isolated) {
275
+ const src = isolated ? isolateOrigin(url) : url;
236
276
  const f = el("iframe", {
237
277
  class: "preview-frame",
238
- sandbox: sandbox,
278
+ sandbox: sandboxFor(src),
239
279
  referrerpolicy: "no-referrer",
240
280
  title: t("preview.tab", "预览"),
241
281
  });
242
282
  els.frame.replaceChildren(f);
243
283
  els.iframe = f;
244
- f.src = url;
284
+ applyZoom();
285
+ f.src = src;
286
+ }
287
+
288
+ // Swaps the loopback host for `preview.localhost`, keeping port and path so
289
+ // the same server answers. Non-loopback hosts (a remote clacky UI) have no
290
+ // second name to use and are left alone.
291
+ function isolateOrigin(url) {
292
+ try {
293
+ const u = new URL(url, location.href);
294
+ if (!isLoopbackHost(location.hostname) || !isLoopbackHost(u.hostname)) return url;
295
+ if (u.hostname === ISOLATED_PREVIEW_HOST) return u.href;
296
+ u.hostname = ISOLATED_PREVIEW_HOST;
297
+ return u.href;
298
+ } catch (_e) { return url; }
299
+ }
300
+
301
+ function isLoopbackHost(host) {
302
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
303
+ }
304
+
305
+ // allow-same-origin is only withheld when the frame would share the host's
306
+ // origin, which is the one case where it would escape the sandbox.
307
+ function sandboxFor(url) {
308
+ try {
309
+ return new URL(url, location.href).origin === location.origin ? SANDBOX : (SANDBOX + " allow-same-origin");
310
+ } catch (_e) { return SANDBOX; }
311
+ }
312
+
313
+ // CSS zoom on an iframe keeps the element box at its laid-out size while
314
+ // shrinking the layout viewport inside it, so the page reflows at
315
+ // (width / scale) and is then drawn scaled — exactly what browser page zoom
316
+ // does. The box must stay at 100% or the frame detaches from the panel edge.
317
+ function applyZoom() {
318
+ if (!els) return;
319
+ if (els.iframe) els.iframe.style.zoom = String(zoom / 100);
320
+ if (els.zoomValue) els.zoomValue.textContent = zoom + "%";
321
+ if (els.zoomOutBtn) els.zoomOutBtn.disabled = zoom <= ZOOM_MIN;
322
+ if (els.zoomInBtn) els.zoomInBtn.disabled = zoom >= ZOOM_MAX;
323
+ if (els.zoomResetBtn) els.zoomResetBtn.disabled = zoom === 100;
324
+ }
325
+
326
+ function stepZoom(delta) {
327
+ const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom + delta));
328
+ if (next === zoom) return;
329
+ zoom = next;
330
+ applyZoom();
331
+ }
332
+
333
+ function resetZoom() {
334
+ if (zoom === 100) return;
335
+ zoom = 100;
336
+ applyZoom();
245
337
  }
246
338
 
247
339
  async function openRel(sid, rel, record) {
@@ -290,10 +382,8 @@
290
382
  showOverlay(null);
291
383
  syncAddress(rel);
292
384
  ensureAsideWidth();
293
- // Only third-party origins may self-identify: Vite-style dev servers
294
- // need allow-same-origin for their ES-module graph to load. Proxy URLs
295
- // (/preview/p/, /preview/s/) stay on the host origin and must remain
296
- // opaque so AI pages can never touch the host's cookies or localStorage.
385
+ // Proxy URLs move to the isolated preview origin; direct third-party URLs
386
+ // stay as-is and are already cross-origin.
297
387
  freshFrame(frameUrl, isProxied(frameUrl));
298
388
  }
299
389
 
@@ -331,6 +421,40 @@
331
421
  window.open(url, "_blank", "noopener");
332
422
  }
333
423
 
424
+ // ── More menu ──────────────────────────────────────────────────────────
425
+ // Menu state is read from the DOM rather than the module-level `els` ref:
426
+ // when the aside re-renders (session switch, layout restore) the old `els`
427
+ // points at detached nodes and a click on the fresh toolbar would silently
428
+ // toggle a menu nobody can see.
429
+ function closeMenu() {
430
+ document.querySelectorAll(".preview-menu:not([hidden])").forEach((menu) => {
431
+ menu.hidden = true;
432
+ const btn = menu.parentElement && menu.parentElement.querySelector('[aria-haspopup="true"]');
433
+ if (btn) btn.setAttribute("aria-expanded", "false");
434
+ });
435
+ }
436
+
437
+ function toggleMenu(menu, btn) {
438
+ if (!menu) return;
439
+ menu.hidden = !menu.hidden;
440
+ if (btn) btn.setAttribute("aria-expanded", String(!menu.hidden));
441
+ }
442
+
443
+ if (!window.__clackyPreviewMenuDismiss) {
444
+ window.__clackyPreviewMenuDismiss = true;
445
+ document.addEventListener("mousedown", (e) => {
446
+ if (!document.querySelector(".preview-menu:not([hidden])")) return;
447
+ if (e.target.closest(".preview-menu") || e.target.closest('[aria-haspopup="true"]')) return;
448
+ closeMenu();
449
+ });
450
+ document.addEventListener("keydown", (e) => {
451
+ if (e.key === "Escape") closeMenu();
452
+ });
453
+ // Clicking into the preview frame moves focus to the iframe's document,
454
+ // which the host never sees; the window blur is the only signal available.
455
+ window.addEventListener("blur", () => closeMenu());
456
+ }
457
+
334
458
  Clacky.ext.ui.mount("session.aside", (container, ctx) => {
335
459
  if (!ctx || !ctx.sessionId) return;
336
460
 
@@ -382,7 +506,45 @@
382
506
  });
383
507
  extBtn.innerHTML = ICON_EXTERNAL;
384
508
 
385
- const bar = el("div", { class: "preview-bar" }, backBtn, forwardBtn, reloadBtn, addressWrap, extBtn);
509
+ const moreBtn = el("button", {
510
+ class: "preview-icon-btn", type: "button",
511
+ title: t("preview.more", "更多"), "aria-haspopup": "true", "aria-expanded": "false",
512
+ onclick: (e) => { e.stopPropagation(); toggleMenu(menu, moreBtn); },
513
+ });
514
+ moreBtn.innerHTML = ICON_MORE;
515
+
516
+ const zoomOutBtn = el("button", {
517
+ class: "preview-zoom-btn", type: "button",
518
+ title: t("preview.zoom.out", "缩小"), text: "−",
519
+ onclick: () => stepZoom(-ZOOM_STEP),
520
+ });
521
+ const zoomValue = el("button", {
522
+ class: "preview-zoom-value", type: "button",
523
+ title: t("preview.zoom.reset", "重置为 100%"), text: zoom + "%",
524
+ onclick: resetZoom,
525
+ });
526
+ const zoomInBtn = el("button", {
527
+ class: "preview-zoom-btn", type: "button",
528
+ title: t("preview.zoom.in", "放大"), text: "+",
529
+ onclick: () => stepZoom(ZOOM_STEP),
530
+ });
531
+ const zoomResetBtn = el("button", {
532
+ class: "preview-zoom-btn", type: "button",
533
+ title: t("preview.zoom.reset", "重置为 100%"),
534
+ onclick: resetZoom,
535
+ });
536
+ zoomResetBtn.innerHTML = ICON_ZOOM_RESET;
537
+ const zoomRow = el("div", { class: "preview-menu-row" },
538
+ el("span", { class: "preview-menu-label", text: t("preview.zoom", "缩放") }),
539
+ el("div", { class: "preview-zoom" }, zoomOutBtn, zoomValue, zoomInBtn),
540
+ zoomResetBtn);
541
+
542
+ const menu = el("div", { class: "preview-menu" },
543
+ zoomRow);
544
+ menu.hidden = true;
545
+
546
+ const bar = el("div", { class: "preview-bar" },
547
+ backBtn, forwardBtn, reloadBtn, addressWrap, extBtn, moreBtn, menu);
386
548
  const frame = el("div", { class: "preview-frame-wrap" });
387
549
 
388
550
  const notFound = el("div", { class: "preview-notfound" },
@@ -412,10 +574,13 @@
412
574
  empty: empty, notFound: notFound, unreachable: unreachable,
413
575
  address: address,
414
576
  backBtn: backBtn, forwardBtn: forwardBtn,
577
+ moreBtn: moreBtn, menu: menu,
578
+ zoomValue: zoomValue, zoomOutBtn: zoomOutBtn, zoomInBtn: zoomInBtn, zoomResetBtn: zoomResetBtn,
415
579
  };
416
580
  activeSessionId = ctx.sessionId;
417
581
  syncAddress(currentRel);
418
582
  updateNavButtons();
583
+ applyZoom();
419
584
 
420
585
  if (pending && pending.sid === ctx.sessionId) {
421
586
  const rel = pending.rel;
@@ -36,7 +36,7 @@ module Clacky
36
36
  "default_model" => "auto",
37
37
  "models" => [
38
38
  "auto",
39
- "dsk-deepseek-flash",
39
+ "dsk-deepseek-v4.1-flash",
40
40
  "dsk-deepseek-v4-pro",
41
41
  "oc-glm-5.3",
42
42
  "oc-kimi-k3",
@@ -148,9 +148,11 @@ module Clacky
148
148
  # vision=true; oc-gpt-6.1-sol, oc-gpt-6-astra, oc-gpt-6-sol and
149
149
  # oc-gpt-6-luna do too.
150
150
  "model_capabilities" => {
151
- "dsk-deepseek-flash" => { "vision" => true }.freeze,
152
- "dsk-deepseek-v4-pro" => { "vision" => false }.freeze,
153
- "oc-glm-5.3" => { "vision" => false }.freeze
151
+ "dsk-deepseek-v4.1-flash" => { "vision" => true }.freeze,
152
+ # Legacy alias still sent by sessions created before the rename.
153
+ "dsk-deepseek-flash" => { "vision" => true }.freeze,
154
+ "dsk-deepseek-v4-pro" => { "vision" => false }.freeze,
155
+ "oc-glm-5.3" => { "vision" => false }.freeze
154
156
  }.freeze,
155
157
  # Bedrock GPT models (abs-gpt-*) are served through the OpenAI
156
158
  # Responses API — their Chat Completions endpoint rejects function
@@ -166,7 +168,7 @@ module Clacky
166
168
  # themselves, so they're intentionally not listed here as keys —
167
169
  # no injection happens when the default model is already lite-class.
168
170
  "lite_models" => {
169
- "auto" => "dsk-deepseek-flash",
171
+ "auto" => "dsk-deepseek-v4.1-flash",
170
172
  "abs-claude-fable-5-1" => "abs-claude-haiku-5-5",
171
173
  "abs-claude-fable-5" => "abs-claude-haiku-5-5",
172
174
  "abs-claude-opus-5-5" => "abs-claude-haiku-5-5",
@@ -180,7 +182,7 @@ module Clacky
180
182
  "abs-gpt-6-sol" => "abs-gpt-6-luna",
181
183
  "abs-gpt-5.6-sol" => "abs-gpt-5.6-luna",
182
184
  "abs-gpt-5.6-terra" => "abs-gpt-5.6-luna",
183
- "dsk-deepseek-v4-pro" => "dsk-deepseek-flash",
185
+ "dsk-deepseek-v4-pro" => "dsk-deepseek-v4.1-flash",
184
186
  "or-gemini-3-1-pro" => "or-gemini-3-8-flash"
185
187
  },
186
188
  # Fallback chain: if a model is unavailable, try the next one in order.
@@ -213,7 +215,7 @@ module Clacky
213
215
  "base_url" => "https://api.deepseek.com",
214
216
  "api" => "openai-completions",
215
217
  "default_model" => "deepseek-v4-pro",
216
- "lite_model" => "deepseek-flash",
218
+ "lite_model" => "deepseek-v4.1-flash",
217
219
  # Note: deepseek-chat and deepseek-reasoner are legacy aliases being
218
220
  # deprecated on 2026-07-24; they map to V4.1 Flash's non-thinking and
219
221
  # thinking modes respectively.
@@ -221,14 +223,21 @@ module Clacky
221
223
  # longer offered upstream, so they are out of this list: only their
222
224
  # pricing entries remain, to let already-billed history resolve.
223
225
  "models" => [
224
- "deepseek-flash",
226
+ "deepseek-v4.1-flash",
225
227
  "deepseek-v4-pro",
226
228
  ],
227
229
  # DeepSeek V4 API is text-only across all models, except V4.1 Flash
228
- # (deepseek-flash), which is natively multimodal.
230
+ # (deepseek-v4.1-flash), which is natively multimodal.
229
231
  "capabilities" => { "vision" => false }.freeze,
230
232
  "model_capabilities" => {
231
- "deepseek-flash" => { "vision" => true }.freeze
233
+ "deepseek-v4.1-flash" => { "vision" => true }.freeze,
234
+ # Legacy id kept for configs saved before the rename.
235
+ "deepseek-flash" => { "vision" => true }.freeze
236
+ }.freeze,
237
+ # The upstream API still exposes V4.1 Flash as "deepseek-flash"; the
238
+ # picker shows the generation name.
239
+ "api_model_aliases" => {
240
+ "deepseek-v4.1-flash" => "deepseek-flash"
232
241
  }.freeze,
233
242
  "deepseek_web_tools" => true,
234
243
  "website_url" => "https://platform.deepseek.com/api_keys"
@@ -271,8 +280,8 @@ module Clacky
271
280
  "name" => "Kimi (Moonshot)",
272
281
  "base_url" => "https://api.moonshot.cn/v1",
273
282
  "api" => "openai-completions",
274
- "default_model" => "kimi-k3",
275
- "models" => ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
283
+ "default_model" => "kimi-k3.1-pro",
284
+ "models" => ["kimi-k3.1-pro", "kimi-k3.1-pro-highspeed", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
276
285
  # Moonshot operates two regional endpoints with identical APIs & model
277
286
  # lineup — mainland China (.cn) and international (.ai). These are the
278
287
  # pay-as-you-go Open Platform endpoints; the subscription-billed
@@ -288,7 +297,7 @@ module Clacky
288
297
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
289
298
  { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
290
299
  ].freeze,
291
- # k3 / k2.7-code / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
300
+ # k3.1-pro / k3 / k2.7-code / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
292
301
  "capabilities" => { "vision" => true }.freeze,
293
302
  "default_ocr_model" => "kimi-k3",
294
303
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
@@ -574,17 +583,20 @@ module Clacky
574
583
  "doubao-seed-evolving",
575
584
  "doubao-seed-2.1-pro",
576
585
  "doubao-seed-2.1-turbo",
586
+ "doubao-seed-2.1-lite",
577
587
  "doubao-seed-2.0-mini",
578
- "doubao-seed-2.0-lite",
579
588
  "minimax-m3",
580
- "kimi-k2.7-code",
589
+ "kimi-k3.1-pro",
581
590
  "kimi-k3",
591
+ "kimi-k2.7-code",
582
592
  # GLM-5.2 is delisted from Ark on 2026-08-31, so new setups only
583
593
  # offer GLM-5.3. It is available on the Coding/Agent Plan endpoints;
584
594
  # the pay-as-you-go endpoint does not carry it yet (no versioned id),
585
595
  # so no payg alias is registered for it.
586
596
  "glm-5.3",
587
597
  "glm-5.3-flash",
598
+ "deepseek-v4.1-flash",
599
+ "deepseek-v4.1-flash-economy",
588
600
  "deepseek-v4-pro",
589
601
  "deepseek-v4-flash"
590
602
  ],
@@ -605,14 +617,21 @@ module Clacky
605
617
  "glm-5.2" => "glm-5-2-260617",
606
618
  "deepseek-v4-pro" => "deepseek-v4-pro-260425",
607
619
  "deepseek-v4-flash" => "deepseek-v4-flash-260425",
620
+ "deepseek-v4.1-flash" => "deepseek-v4-1-flash-261017",
621
+ "deepseek-v4.1-flash-economy" => "deepseek-v4-1-flash-economy-261017",
622
+ "kimi-k3.1-pro" => "kimi-k3-1-pro-261024",
608
623
  "doubao-seed-2.1-pro" => "doubao-seed-2-1-pro-260628",
609
624
  "doubao-seed-2.1-turbo" => "doubao-seed-2-1-turbo-260628",
625
+ "doubao-seed-2.1-lite" => "doubao-seed-2-1-lite-260915",
610
626
  "doubao-seed-2.0-mini" => "doubao-seed-2-0-mini-260428",
627
+ # doubao-seed-2.0-lite is delisted in favor of 2.1-lite; the alias
628
+ # stays so existing configs keep working.
611
629
  "doubao-seed-2.0-lite" => "doubao-seed-2-0-lite-260428"
612
630
  }.freeze
613
631
  }.freeze,
614
632
  # Most Doubao/multimodal models accept image input; GLM-5.2/5.3 and
615
- # DeepSeek-V4 on Ark are text-only. GLM-5.3-Flash inherits vision=true.
633
+ # DeepSeek-V4 on Ark are text-only. GLM-5.3-Flash and DeepSeek-V4.1
634
+ # (incl. economy) inherit vision=true.
616
635
  "capabilities" => { "vision" => true }.freeze,
617
636
  "model_capabilities" => {
618
637
  "glm-5.3" => { "vision" => false }.freeze,
@@ -621,7 +640,7 @@ module Clacky
621
640
  "deepseek-v4-flash" => { "vision" => false }.freeze,
622
641
  "minimax-m2.7" => { "vision" => false }.freeze
623
642
  }.freeze,
624
- "default_ocr_model" => "doubao-seed-2.0-lite",
643
+ "default_ocr_model" => "doubao-seed-2.1-lite",
625
644
  "website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
626
645
  }.freeze,
627
646
 
@@ -1083,6 +1102,10 @@ module Clacky
1083
1102
  # Plan endpoints accept the short "doubao-seed-2.1-pro"). Users always see
1084
1103
  # and pick the short name; this swaps it just before the request goes out.
1085
1104
  #
1105
+ # The map comes in two shapes:
1106
+ # - grouped by endpoint variant: { "payg" => { short => real } }
1107
+ # - flat, endpoint-independent: { short => real } (e.g. DeepSeek)
1108
+ #
1086
1109
  # Returns the original model unchanged when no alias applies (unknown
1087
1110
  # provider, endpoint without an alias group, or model not in the map).
1088
1111
  #
@@ -1100,6 +1123,9 @@ module Clacky
1100
1123
  aliases = preset && preset["api_model_aliases"]
1101
1124
  return model unless aliases.is_a?(Hash)
1102
1125
 
1126
+ flat = aliases[model.to_s]
1127
+ return flat if flat.is_a?(String)
1128
+
1103
1129
  group = alias_group_for_base_url(preset, base_url)
1104
1130
  return model unless group
1105
1131
 
@@ -16,8 +16,9 @@ module Clacky
16
16
  # provider: tavily — "deepseek_web" for DeepSeek's hosted
17
17
  # search/fetch, blank for built-in engines only,
18
18
  # otherwise the basename of a script in ~/.clacky/searchers/
19
- # key: tvly-xxx — credential for the selected provider (optional for
20
- # deepseek_web, which can reuse a configured model key)
19
+ # keys: — credentials per provider, so switching providers never
20
+ # tavily: tvly-xxx hands one provider's key to another (optional for
21
+ # deepseek_web, which can reuse a configured model key)
21
22
  #
22
23
  # A missing file, or one without a `provider` key, means "never chosen" and
23
24
  # resolves to the free chain.
@@ -33,27 +34,43 @@ module Clacky
33
34
  DEEPSEEK_WEB = "deepseek_web"
34
35
 
35
36
  class << self
36
- # @return [Hash] { "provider" => String, "key" => String }
37
+ # @return [Hash] { "provider" => String, "keys" => { provider => String } }
37
38
  def load
38
39
  raw = File.exist?(CONFIG_PATH) ? YAML.load_file(CONFIG_PATH) : nil
39
40
  return default_config unless raw.is_a?(Hash)
40
41
 
41
- {
42
- "provider" => raw["provider"].to_s.strip,
43
- "key" => raw["key"].to_s.strip
44
- }
42
+ provider = raw["provider"].to_s.strip
43
+ keys = {}
44
+ (raw["keys"].is_a?(Hash) ? raw["keys"] : {}).each do |name, value|
45
+ value = value.to_s.strip
46
+ keys[name.to_s] = value unless value.empty?
47
+ end
48
+ # Files written before keys were split hold one key for the provider then selected.
49
+ legacy = raw["key"].to_s.strip
50
+ keys[provider] ||= legacy unless provider.empty? || legacy.empty?
51
+
52
+ { "provider" => provider, "keys" => keys }
45
53
  rescue StandardError
46
54
  default_config
47
55
  end
48
56
 
57
+ # The key is stored under the resulting provider; a blank key removes it.
49
58
  def save(provider: nil, key: nil)
50
59
  current = load
51
60
  new_provider = provider.nil? ? current["provider"] : provider.to_s.strip
52
- new_key = key.nil? ? current["key"] : key.to_s.strip
61
+ keys = current["keys"]
62
+ unless key.nil? || new_provider.empty?
63
+ key = key.to_s.strip
64
+ if key.empty?
65
+ keys.delete(new_provider)
66
+ else
67
+ keys[new_provider] = key
68
+ end
69
+ end
53
70
 
54
71
  payload = {
55
72
  "provider" => new_provider,
56
- "key" => new_key
73
+ "keys" => keys
57
74
  }
58
75
 
59
76
  FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
@@ -70,7 +87,7 @@ module Clacky
70
87
  end
71
88
 
72
89
  private def default_config
73
- { "provider" => "", "key" => "" }
90
+ { "provider" => "", "keys" => {} }
74
91
  end
75
92
 
76
93
  # Absolute path of the configured searcher, or nil when unset/missing.
@@ -84,7 +101,12 @@ module Clacky
84
101
  end
85
102
 
86
103
  def key
87
- load["key"]
104
+ config = load
105
+ config["keys"][config["provider"]].to_s
106
+ end
107
+
108
+ def key_for(provider)
109
+ load["keys"][provider.to_s].to_s
88
110
  end
89
111
  end
90
112
  end