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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- 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/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -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/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- 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 +79 -2
- 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/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- 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 +8 -29
- 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
|
-
//
|
|
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;
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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"
|
|
152
|
-
|
|
153
|
-
"
|
|
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-
|
|
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
|
|
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.
|
|
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
|
|
data/lib/clacky/search_config.rb
CHANGED
|
@@ -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
|
-
#
|
|
20
|
-
#
|
|
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, "
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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" => "", "
|
|
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
|
|
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
|