openclacky 1.5.20 → 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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +8 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  21. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  22. data/lib/clacky/extension/loader.rb +5 -3
  23. data/lib/clacky/locales/en.rb +5 -5
  24. data/lib/clacky/locales/zh.rb +5 -5
  25. data/lib/clacky/message_format/anthropic.rb +20 -0
  26. data/lib/clacky/message_format/open_ai.rb +31 -14
  27. data/lib/clacky/message_history.rb +21 -3
  28. data/lib/clacky/platform_http_client.rb +6 -2
  29. data/lib/clacky/providers.rb +66 -31
  30. data/lib/clacky/search_config.rb +71 -15
  31. data/lib/clacky/server/browser_manager.rb +35 -5
  32. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  33. data/lib/clacky/server/discover.rb +5 -4
  34. data/lib/clacky/server/git_panel.rb +360 -34
  35. data/lib/clacky/server/http_server.rb +334 -89
  36. data/lib/clacky/server/model_prices.rb +5 -3
  37. data/lib/clacky/server/pid_files.rb +25 -0
  38. data/lib/clacky/server/preview.rb +118 -17
  39. data/lib/clacky/server/scheduler.rb +81 -2
  40. data/lib/clacky/server/server_master.rb +9 -3
  41. data/lib/clacky/server/session_registry.rb +19 -7
  42. data/lib/clacky/server/web_ui_controller.rb +78 -47
  43. data/lib/clacky/session_manager.rb +58 -5
  44. data/lib/clacky/skill.rb +1 -1
  45. data/lib/clacky/tools/ask_user.rb +4 -1
  46. data/lib/clacky/tools/base.rb +3 -2
  47. data/lib/clacky/tools/browser.rb +18 -2
  48. data/lib/clacky/tools/file_reader.rb +1 -1
  49. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  50. data/lib/clacky/tools/terminal.rb +65 -23
  51. data/lib/clacky/tools/trash_manager.rb +5 -3
  52. data/lib/clacky/tools/web_fetch.rb +81 -22
  53. data/lib/clacky/tools/web_search.rb +45 -4
  54. data/lib/clacky/ui2/ui_controller.rb +1 -1
  55. data/lib/clacky/ui_interface.rb +4 -3
  56. data/lib/clacky/utils/file_processor.rb +51 -0
  57. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  58. data/lib/clacky/utils/model_pricing.rb +192 -15
  59. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  60. data/lib/clacky/version.rb +1 -1
  61. data/lib/clacky/web/app.css +1019 -204
  62. data/lib/clacky/web/app.js +59 -6
  63. data/lib/clacky/web/auth.js +1 -1
  64. data/lib/clacky/web/components/chat-navigator.js +8 -1
  65. data/lib/clacky/web/components/composer.js +107 -8
  66. data/lib/clacky/web/components/image-zoom.js +165 -0
  67. data/lib/clacky/web/components/mentions.js +2 -13
  68. data/lib/clacky/web/components/model-picker.js +9 -5
  69. data/lib/clacky/web/components/notify.js +2 -2
  70. data/lib/clacky/web/components/onboard.js +13 -10
  71. data/lib/clacky/web/core/aside.js +60 -3
  72. data/lib/clacky/web/features/archive/store.js +30 -16
  73. data/lib/clacky/web/features/archive/view.js +20 -10
  74. data/lib/clacky/web/features/backup/view.js +5 -5
  75. data/lib/clacky/web/features/billing/view.js +2 -2
  76. data/lib/clacky/web/features/channels/view.js +5 -2
  77. data/lib/clacky/web/features/extensions/view.js +2 -2
  78. data/lib/clacky/web/features/new-session/store.js +82 -5
  79. data/lib/clacky/web/features/new-session/view.js +496 -58
  80. data/lib/clacky/web/features/skills/store.js +1 -1
  81. data/lib/clacky/web/features/tasks/view.js +162 -51
  82. data/lib/clacky/web/features/trash/store.js +10 -7
  83. data/lib/clacky/web/features/trash/view.js +9 -4
  84. data/lib/clacky/web/features/workspace/store.js +13 -6
  85. data/lib/clacky/web/features/workspace/view.js +153 -163
  86. data/lib/clacky/web/i18n.js +342 -84
  87. data/lib/clacky/web/index.html +262 -216
  88. data/lib/clacky/web/projects.js +93 -5
  89. data/lib/clacky/web/sessions.js +423 -234
  90. data/lib/clacky/web/settings.js +169 -16
  91. data/lib/clacky/web/skills.js +2 -2
  92. data/lib/clacky/web/theme.js +17 -2
  93. data/lib/clacky/web/utils.js +17 -2
  94. data/lib/clacky/web/weixin-qr.html +2 -1
  95. data/lib/clacky/web/ws-dispatcher.js +17 -42
  96. data/lib/clacky.rb +1 -0
  97. metadata +5 -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;
@@ -288,10 +288,12 @@ module Clacky
288
288
  end
289
289
 
290
290
 
291
- # A layer's fingerprint is the sorted list of "<ext_id>|<mtime>" for
291
+ # A layer's fingerprint is the list of [ext_id, manifest path, mtime] for
292
292
  # every container manifest present. Cheap to compute (one stat per
293
293
  # manifest); the whole map compared as a Hash so a container appearing
294
- # or disappearing also invalidates.
294
+ # or disappearing also invalidates. The path belongs in the key: two
295
+ # roots can hold the same ext_id with manifests whose mtimes collide, and
296
+ # reusing a result across roots would hand back stale dirs.
295
297
  private def fingerprint_layers(layers)
296
298
  layers.each_with_object({}) do |(layer, root), acc|
297
299
  next unless root && Dir.exist?(root)
@@ -302,7 +304,7 @@ module Clacky
302
304
  manifest = File.join(root, ext_id, MANIFEST)
303
305
  next unless File.file?(manifest)
304
306
 
305
- entries << [ext_id, File.mtime(manifest).to_f]
307
+ entries << [ext_id, manifest, File.mtime(manifest).to_f]
306
308
  end
307
309
  acc[layer] = entries
308
310
  end
@@ -15,15 +15,15 @@ module Clacky
15
15
  "llm.error.403.access_denied" => "Access denied, please check your API key permissions",
16
16
  "llm.error.403.default" => "Access denied",
17
17
  "llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
18
- "llm.error.rate_limit_429" => "Rate limit exceeded, please wait a moment",
18
+ "llm.error.rate_limit_429" => "Rate limit exceeded. Please switch to another model and retry, or wait a moment",
19
19
  "llm.error.quota_exhausted" => "API quota exhausted (key usage limit reached). Please raise the quota or upgrade your plan in the console",
20
20
  "llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
21
- "llm.error.unexpected" => "Unexpected error (%<status>d)",
21
+ "llm.error.unexpected" => "Request failed (%<status>d). Please switch to another model and retry",
22
22
  "llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
23
23
  "llm.error.bad_request" => "Model request failed. Please switch to another model and retry",
24
- "llm.error.request_timeout" => "Request timed out after %<retries>d retries",
25
- "llm.error.network_failed" => "Network connection failed after %<retries>d retries. If the problem persists, check your account balance or API Key",
26
- "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
24
+ "llm.error.request_timeout" => "Request timed out after %<retries>d retries. Please switch to another model and retry",
25
+ "llm.error.network_failed" => "Network connection failed after %<retries>d retries. If the problem persists, switch to another model and retry, or check your account balance",
26
+ "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries. Please switch to another model and retry",
27
27
  "platform.error.invalid_proof" => "Invalid license key — please check and try again.",
28
28
  "platform.error.invalid_signature" => "Invalid request signature.",
29
29
  "platform.error.nonce_replayed" => "Duplicate request detected. Please try again.",
@@ -15,15 +15,15 @@ module Clacky
15
15
  "llm.error.403.access_denied" => "访问被拒绝,请检查 API 密钥权限",
16
16
  "llm.error.403.default" => "访问被拒绝",
17
17
  "llm.error.endpoint_not_found" => "API 端点不存在,请检查服务地址配置",
18
- "llm.error.rate_limit_429" => "请求过于频繁,请稍后重试",
18
+ "llm.error.rate_limit_429" => "请求过于频繁,请暂时切换其他模型重试或稍后重试",
19
19
  "llm.error.quota_exhausted" => "API 配额已用尽(已达 Key 的用量上限),请前往控制台提升配额或升级套餐后继续",
20
20
  "llm.error.server_error" => "服务暂时不可用(%<status>d),正在重试...",
21
- "llm.error.unexpected" => "请求失败(%<status>d)",
21
+ "llm.error.unexpected" => "请求失败(%<status>d),请暂时切换其他模型重试",
22
22
  "llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
23
23
  "llm.error.bad_request" => "请求模型出错,请暂时切换其他模型重试",
24
- "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
25
- "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次),若持续失败,请检查账户余额或 API Key",
26
- "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)",
24
+ "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次),请暂时切换其他模型重试",
25
+ "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次),若持续失败,请暂时切换其他模型重试或检查账户余额",
26
+ "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次),请暂时切换其他模型重试",
27
27
  "platform.error.invalid_proof" => "许可证密钥无效,请检查后重试。",
28
28
  "platform.error.invalid_signature" => "请求签名无效。",
29
29
  "platform.error.nonce_replayed" => "检测到重复请求,请重试。",
@@ -19,6 +19,10 @@ module Clacky
19
19
  module Anthropic
20
20
  module_function
21
21
 
22
+ # Same "no explicit level" set the OpenAI adapter uses: the sessionbar and
23
+ # config paths only ever emit these values or a concrete low..max level.
24
+ OFF_EFFORT_VALUES = %w[off nothink disabled none].freeze
25
+
22
26
  # Normalise a /v1/messages usage block to the codebase's canonical
23
27
  # (OpenAI-style) prompt_tokens: total input including cache_read but
24
28
  # excluding cache_creation.
@@ -122,6 +126,22 @@ module Clacky
122
126
  end
123
127
  body[:thinking] = { type: "enabled" }
124
128
  body[:output_config] = { effort: k3_effort } if k3_effort
129
+ elsif model.to_s.match?(/\Aminimax-m3[.]1-flash\z/i)
130
+ # Flash always thinks; use its lightest supported effort for "off".
131
+ effort = if OFF_EFFORT_VALUES.include?(reasoning_effort.to_s)
132
+ "low"
133
+ else
134
+ normalized_effort(reasoning_effort)
135
+ end
136
+ body[:output_config] = { effort: effort } if effort
137
+ elsif model.to_s.match?(/\Aminimax-m3\z/i)
138
+ # M3 defaults to disabled on this transport and ignores effort.
139
+ effort_str = reasoning_effort.to_s
140
+ if OFF_EFFORT_VALUES.include?(effort_str)
141
+ body[:thinking] = { type: "disabled" }
142
+ elsif !effort_str.empty?
143
+ body[:thinking] = { type: "adaptive" }
144
+ end
125
145
  elsif (effort = normalized_effort(reasoning_effort))
126
146
  body[:thinking] = { type: "adaptive" }
127
147
  body[:output_config] = { effort: effort }
@@ -224,17 +224,23 @@ module Clacky
224
224
 
225
225
  # Returns the token-limit field name for the given model.
226
226
  # Most OpenAI-compatible APIs use :max_tokens; MiMo-V2.5 (Xiaomi) and
227
- # MiniMax-M3 use :max_completion_tokens to cap the combined thinking +
227
+ # MiniMax-M3 / M3.1-Flash use :max_completion_tokens to cap the combined thinking +
228
228
  # answer length (M3 documents max_completion_tokens over the legacy
229
229
  # max_tokens field).
230
230
  private_class_method def self.token_field_for(model)
231
- if model.to_s.match?(/^mimo-v2/i) || model.to_s.match?(/^minimax-m3$/i)
231
+ if model.to_s.match?(/^mimo-v2/i) || model.to_s.match?(/^minimax-m3([.]1-flash)?$/i)
232
232
  :max_completion_tokens
233
233
  else
234
234
  :max_tokens
235
235
  end
236
236
  end
237
237
 
238
+ # Values the sessionbar/config use to mean "no explicit level". They are
239
+ # model-specific on the wire (some families map them to their lightest
240
+ # effort, others to thinking disabled), so every branch below handles
241
+ # them explicitly — the generic fallback must never forward them.
242
+ OFF_EFFORT_VALUES = %w[off nothink disabled none].freeze
243
+
238
244
  # Apply model-specific reasoning / thinking parameters to the request body.
239
245
  #
240
246
  # Different model families expose extended reasoning through different API
@@ -259,7 +265,7 @@ module Clacky
259
265
  # error (Z.ai migration guide), so "off" maps to the lightest effort
260
266
  # "low". Effort accepts low/high/max only; server default is max.
261
267
  body[:thinking] = { type: "enabled" }
262
- if %w[off nothink disabled].include?(effort_str)
268
+ if OFF_EFFORT_VALUES.include?(effort_str)
263
269
  body[:reasoning_effort] = "low"
264
270
  elsif !effort_str.empty?
265
271
  body[:reasoning_effort] =
@@ -275,7 +281,7 @@ module Clacky
275
281
  # ({type: "enabled"|"disabled"}) plus a restricted reasoning_effort
276
282
  # that only accepts "max" or "high". Other effort levels collapse
277
283
  # to "high". Send both fields so GLM activates thinking correctly.
278
- if %w[off nothink disabled].include?(effort_str)
284
+ if OFF_EFFORT_VALUES.include?(effort_str)
279
285
  body[:thinking] = { type: "disabled" }
280
286
  elsif !effort_str.empty?
281
287
  glm_effort =
@@ -293,7 +299,7 @@ module Clacky
293
299
  # Thinking is always enabled and cannot be turned off; "off" maps
294
300
  # to the lowest intensity "low". Unsupported effort levels (medium,
295
301
  # xhigh) would be rejected or ignored by the server.
296
- if %w[off nothink disabled].include?(effort_str)
302
+ if OFF_EFFORT_VALUES.include?(effort_str)
297
303
  body[:reasoning_effort] = "low"
298
304
  elsif !effort_str.empty?
299
305
  body[:reasoning_effort] =
@@ -309,11 +315,19 @@ module Clacky
309
315
  # thinking:{type:...} field rather than reasoning_effort. Map the
310
316
  # internal reasoning_effort value to thinking on/off so MiMo does
311
317
  # not receive an unsupported parameter.
312
- if %w[off nothink disabled].include?(effort_str)
318
+ if OFF_EFFORT_VALUES.include?(effort_str)
313
319
  body[:thinking] = { type: "disabled" }
314
320
  elsif !effort_str.empty?
315
321
  body[:thinking] = { type: "enabled" }
316
322
  end
323
+ elsif model.to_s.match?(/^minimax-m3[.]1-flash$/i)
324
+ # M3.1-Flash always thinks and rejects thinking disabled / effort none,
325
+ # so "off" maps to the lightest effort "low".
326
+ if OFF_EFFORT_VALUES.include?(effort_str)
327
+ body[:reasoning_effort] = "low"
328
+ elsif %w[low medium high xhigh max].include?(effort_str)
329
+ body[:reasoning_effort] = effort_str
330
+ end
317
331
  elsif model.to_s.match?(/^minimax-m3$/i)
318
332
  # MiniMax-M3 controls thinking via a top-level "thinking" field with
319
333
  # { type: "adaptive" | "disabled" } — the same envelope shape MiMo
@@ -326,20 +340,23 @@ module Clacky
326
340
  # decides per request); we let the server pick the intensity.
327
341
  # M2.7 cannot disable thinking and ignores this field, so sending
328
342
  # it is harmless for the rest of the MiniMax lineup.
329
- if %w[off nothink disabled].include?(effort_str)
343
+ if OFF_EFFORT_VALUES.include?(effort_str)
330
344
  body[:thinking] = { type: "disabled" }
331
345
  elsif !effort_str.empty?
332
346
  body[:thinking] = { type: "adaptive" }
333
347
  end
334
348
  elsif model.to_s.match?(/deepseek/i)
335
349
  # DeepSeek V4 reasoning_effort only accepts low/high/max.
336
- # xhigh is not recognized — map it to max.
337
- effort = case effort_str
338
- when "xhigh" then "max"
339
- else effort_str
340
- end
341
- assign_effort(body, effort, responses_api)
342
- elsif reasoning_effort && !effort_str.empty?
350
+ # xhigh is not recognized — map it to max. Off-like values are not
351
+ # valid either, so they keep the provider default untouched.
352
+ unless OFF_EFFORT_VALUES.include?(effort_str)
353
+ effort = case effort_str
354
+ when "xhigh" then "max"
355
+ else effort_str
356
+ end
357
+ assign_effort(body, effort, responses_api)
358
+ end
359
+ elsif reasoning_effort && !effort_str.empty? && !OFF_EFFORT_VALUES.include?(effort_str)
343
360
  assign_effort(body, effort_str, responses_api)
344
361
  end
345
362
  end
@@ -15,7 +15,7 @@ module Clacky
15
15
  subagent_instructions subagent_result subagent_transcript token_usage
16
16
  compressed_summary chunk_path truncated transient
17
17
  chunk_index chunk_count ext_events skill_command skill_command_display
18
- display_files display_references
18
+ display_files display_references feedback_requests
19
19
  ].freeze
20
20
 
21
21
  INTERNAL_CONTENT_BLOCK_FIELDS = %i[image_path image_name].freeze
@@ -37,7 +37,8 @@ module Clacky
37
37
  # Write operations
38
38
  # ─────────────────────────────────────────────
39
39
 
40
- # Append a single message hash to the history.
40
+ # Append a single message hash to the history. An optional block receives
41
+ # the stored, sanitized message so callers can retain its identity.
41
42
  #
42
43
  # When appending a user message, automatically drop any trailing assistant
43
44
  # message that has unanswered tool_calls (no tool_result follows it).
@@ -48,7 +49,9 @@ module Clacky
48
49
  if message[:role] == "user"
49
50
  drop_dangling_tool_calls!
50
51
  end
51
- @messages << deep_sanitize_utf8(message)
52
+ stored_message = deep_sanitize_utf8(message)
53
+ @messages << stored_message
54
+ yield stored_message if block_given?
52
55
  self
53
56
  end
54
57
 
@@ -111,6 +114,16 @@ module Clacky
111
114
  self
112
115
  end
113
116
 
117
+ # Save the effective request before waiting, including while no tool result
118
+ # exists yet. The original model-authored call remains unchanged.
119
+ def attach_feedback_request(message:, tool_call_id:, request:)
120
+ return false unless message
121
+
122
+ message[:feedback_requests] ||= {}
123
+ message[:feedback_requests][tool_call_id.to_s] = deep_sanitize_utf8(request)
124
+ true
125
+ end
126
+
114
127
  # Settle an interrupted fan-out whose assistant.tool_calls turn is dangling
115
128
  # because the batch was cancelled before any tool result was written. Mirrors
116
129
  # repair_tool_call_pairing (same helpers, same scan) but PERSISTS the result:
@@ -315,6 +328,11 @@ module Clacky
315
328
  @messages
316
329
  end
317
330
  msgs = source.map { |m| strip_for_api(m) }
331
+ # A tool message with a blank id answers no announced call, and one of
332
+ # them makes every later request fail (422 "invalid type: null"). Drop
333
+ # these before pairing: a blank id is not recognized as a tool result,
334
+ # so pairing would otherwise insert a placeholder and orphan the real one.
335
+ msgs.reject! { |m| m[:role] == "tool" && m[:tool_call_id].to_s.strip.empty? }
318
336
  msgs = repair_tool_call_pairing(msgs)
319
337
  ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
320
338
  end
@@ -50,9 +50,13 @@ module Clacky
50
50
  # Auto-detects the platform endpoint:
51
51
  # - When CLACKY_LICENSE_SERVER is set → that URL (dev override)
52
52
  # - Otherwise → PRIMARY_HOST
53
+ def self.base_url
54
+ override = ENV["CLACKY_LICENSE_SERVER"]
55
+ override && !override.empty? ? override.chomp("/") : PRIMARY_HOST
56
+ end
57
+
53
58
  def initialize
54
- override = ENV["CLACKY_LICENSE_SERVER"]
55
- @base_url = override && !override.empty? ? override : PRIMARY_HOST
59
+ @base_url = self.class.base_url
56
60
  end
57
61
 
58
62
  # Send a POST request with a JSON body and return a normalised result hash.