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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +18 -0
- 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 +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +8 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -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 +21 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- 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 +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +66 -31
- data/lib/clacky/search_config.rb +71 -15
- 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/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +334 -89
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +78 -47
- data/lib/clacky/session_manager.rb +58 -5
- 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/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +192 -15
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1019 -204
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +9 -5
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +82 -5
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +342 -84
- data/lib/clacky/web/index.html +262 -216
- data/lib/clacky/web/projects.js +93 -5
- data/lib/clacky/web/sessions.js +423 -234
- data/lib/clacky/web/settings.js +169 -16
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +17 -42
- data/lib/clacky.rb +1 -0
- 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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// DOM
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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:
|
|
58
|
-
.preview-nav-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width:
|
|
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: .
|
|
61
|
-
.preview-address-wrap { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; }
|
|
62
|
-
.preview-address { width: 100%; height:
|
|
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) {
|
|
65
|
-
.preview-address:focus { outline: none;
|
|
66
|
-
.preview-go { position: absolute; right:
|
|
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:
|
|
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-
|
|
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.
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// link clicks
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
return
|
|
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,
|
|
235
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
294
|
-
//
|
|
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
|
|
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
|
|
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
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -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,
|
|
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" => "
|
|
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
|
|
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.",
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|