@songsid/agend 2.3.0-beta.12 → 2.3.0-beta.13
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.
- package/README.md +6 -0
- package/README.zh-TW.md +6 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +13 -4
- package/dist/config.js.map +1 -1
- package/dist/fleet-manager.d.ts +36 -1
- package/dist/fleet-manager.js +128 -30
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +20 -3
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +23 -5
- package/dist/outbound-schemas.d.ts +14 -1
- package/dist/outbound-schemas.js +8 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +4 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/restart-progress.d.ts +16 -1
- package/dist/restart-progress.js +74 -15
- package/dist/restart-progress.js.map +1 -1
- package/dist/settings-api.js +4 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -1
- package/dist/settings-confirmation.js +2 -0
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +2 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +11 -0
- package/dist/settings-http-confirmation.js +10 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/ui/chat-effects.js +10 -3
- package/dist/ui/chat-strings.js +2 -0
- package/dist/ui/effects-waker.js +70 -0
- package/dist/ui/fleet-canvas-layout.js +10 -4
- package/dist/ui/fleet-canvas.js +32 -2
- package/dist/ui/fleet-route-scene.js +207 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-traffic.js +27 -11
- package/dist/ui/fleet-usage.js +35 -1
- package/dist/ui/panel-chat.js +43 -23
- package/dist/ui/panel-fleet.js +3 -3
- package/dist/ui/settings-appearance.js +2 -1
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-owner-effects.js +6 -4
- package/dist/ui/shared/app-i18n.js +4 -0
- package/dist/ui/shared/app-prefs.js +12 -0
- package/dist/ui/shared/app-shell.js +38 -2
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +47 -3
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/identity-card.js +24 -6
- package/dist/ui/shared/identity-color.js +11 -7
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/ui-menu.js +9 -3
- package/dist/ui/shared/web-authorize.js +47 -0
- package/dist/web-api.js +18 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance.js +3 -1
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-effect.d.ts +1 -1
- package/dist/web-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.js.map +1 -0
- package/dist/web-owner-css-store.d.ts +3 -2
- package/dist/web-owner-css-store.js +9 -4
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-prefs.js +13 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-settings-tools.d.ts +5 -0
- package/dist/web-settings-tools.js +34 -6
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-theme.d.ts +29 -0
- package/dist/web-theme.js +89 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- package/package.json +2 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// #1629 (first design §5, r2 (b)(d)): agents' web themes on the page. Each applies to THAT agent's own chat surface
|
|
2
|
-
// (and its avatar ring)
|
|
2
|
+
// (and its avatar ring) — and, #2196, when the agent (theme.page.on) or a person (theme.pageByInstance) says so, its
|
|
3
|
+
// colours on the whole page while that agent's Chat, View, Log or Details is open: colour tokens only, on the shell
|
|
4
|
+
// (applyPageTheme); AgEnD's status colours, fonts, pictures and layout stay the page's own. What the host sends is
|
|
3
5
|
// canonical already; this checks it again (a value that fails is dropped, never passed on) and applies it through the
|
|
4
6
|
// CSSOM (custom properties) and fixed class tokens. No style attribute, no CSS text, no url() built in CSS.
|
|
5
7
|
// Not shown: with "Show agents' themes, effects and widgets" off (fx.show, synced), and in safe mode (?safe=1).
|
|
6
8
|
import { appStore, createStore } from "./app-store.js";
|
|
9
|
+
import { navStore } from "./app-nav.js";
|
|
7
10
|
import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
|
|
8
11
|
import { safeMode } from "./owner-css.js";
|
|
9
12
|
import { t } from "./app-i18n.js";
|
|
@@ -86,9 +89,110 @@ export function themeFor(name) {
|
|
|
86
89
|
out.background = { image: bg.image, fit: bg.fit, opacity: bg.opacity };
|
|
87
90
|
}
|
|
88
91
|
if (labelOk(t.workingLabel)) out.workingLabel = t.workingLabel;
|
|
92
|
+
if (t.page && typeof t.page === "object") {
|
|
93
|
+
const sb = {};
|
|
94
|
+
if (t.page.sidebar && typeof t.page.sidebar === "object") for (const m of ["light", "dark"]) if (typeof t.page.sidebar[m] === "string" && COLOR.test(t.page.sidebar[m])) sb[m] = t.page.sidebar[m];
|
|
95
|
+
out.page = { on: t.page.on === true, sidebar: sb };
|
|
96
|
+
}
|
|
89
97
|
return out;
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
// ── #2196: an agent's colours on the whole page ──
|
|
101
|
+
|
|
102
|
+
/** The token defaults (tokens.css; web-theme.ts THEME_DEFAULTS, pinned by a test) a theme's palette goes over. */
|
|
103
|
+
const PAGE_DEFAULTS = {
|
|
104
|
+
dark: { bg: "#212121", surface: "#2f2f2f", text: "#ececec", text2: "#b4b4b4", accent: "#7ab7ff" },
|
|
105
|
+
light: { bg: "#ffffff", surface: "#f4f4f4", text: "#0d0d0d", text2: "#5d5d5d", accent: "#0a62c2" },
|
|
106
|
+
};
|
|
107
|
+
/** AgEnD's status colours (tokens.css; web-theme.ts STATUS_COLORS, pinned by a test): never themed, always readable. */
|
|
108
|
+
export const STATUS_COLORS = {
|
|
109
|
+
dark: { ok: "#4cc38a", warn: "#e3b341", danger: "#ff8a80", dangerFill: "#c9372c", focus: "#7ab7ff" },
|
|
110
|
+
light: { ok: "#1a7f37", warn: "#8a5a00", danger: "#c4222b", dangerFill: "#c4222b", focus: "#0a62c2" },
|
|
111
|
+
};
|
|
112
|
+
const PAGE_PROPS = { bg: "--bg", sidebar: "--bg-sidebar", surface: "--surface", text: "--text", text2: "--text-2", accent: "--accent" };
|
|
113
|
+
const DERIVED_PROPS = ["--text-3", "--surface-2", "--border", "--border-strong", "--accent-dim", "--on-accent"];
|
|
114
|
+
function lum(hex) {
|
|
115
|
+
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((x) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4));
|
|
116
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
117
|
+
}
|
|
118
|
+
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
|
|
119
|
+
const mix = (a, b, f) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - f) + parseInt(b.slice(i, i + 2), 16) * f).toString(16).padStart(2, "0")).join("")}`;
|
|
120
|
+
|
|
121
|
+
/** The instance a page is about — Chat, View, Log or Details of it (app-shell.js openInstanceOf) — or null. */
|
|
122
|
+
function openOf(route) {
|
|
123
|
+
return route && ["chat", "view", "log", "details"].includes(route.panel) && typeof route.instance === "string" ? route.instance : null;
|
|
124
|
+
}
|
|
125
|
+
/** A person's choice for `name`: "page" (the whole page), "chat" (only its chat) or null (the agent's own). */
|
|
126
|
+
export function pageChoice(name) {
|
|
127
|
+
const f = prefInForce("theme.pageByInstance");
|
|
128
|
+
const m = f && f.value;
|
|
129
|
+
return m && typeof m === "object" && own(m, name) && (m[name] === "page" || m[name] === "chat") ? m[name] : null;
|
|
130
|
+
}
|
|
131
|
+
/** Set it ("agent" clears it), kept with the host's preferences like the identity colour a person picks. */
|
|
132
|
+
export function setPageChoice(name, choice) {
|
|
133
|
+
const f = prefInForce("theme.pageByInstance");
|
|
134
|
+
const cur = f && f.value && typeof f.value === "object" ? f.value : {};
|
|
135
|
+
const keep = Object.keys(cur).filter((k) => own(cur, k) && k !== name && (cur[k] === "page" || cur[k] === "chat")).map((k) => [k, cur[k]]);
|
|
136
|
+
if (choice === "page" || choice === "chat") keep.push([name, choice]);
|
|
137
|
+
savePref("theme.pageByInstance", keep.length ? Object.fromEntries(keep) : null);
|
|
138
|
+
// In force on this page at once: what draws a theme follows this store (another device's change: onPrefsChanged).
|
|
139
|
+
themesStore.set({ pageRev: (themesStore.get().pageRev || 0) + 1 });
|
|
140
|
+
}
|
|
141
|
+
/** `name`'s theme when its colours go on the whole page (the person's choice, else the agent's), else null. */
|
|
142
|
+
export function pageThemeFor(name) {
|
|
143
|
+
if (!name) return null;
|
|
144
|
+
const theme = themeFor(name);
|
|
145
|
+
if (!theme) return null;
|
|
146
|
+
const choice = pageChoice(name);
|
|
147
|
+
return choice === "page" || (choice !== "chat" && theme.page && theme.page.on) ? theme : null;
|
|
148
|
+
}
|
|
149
|
+
/** The page's colours for `mode`: the theme's palette over the defaults, and its sidebar (else its background). */
|
|
150
|
+
export function pagePalette(theme, mode) {
|
|
151
|
+
const p = { ...PAGE_DEFAULTS[mode], ...(theme.accent ? { accent: theme.accent } : {}), ...((theme.colors && theme.colors[mode]) || {}) };
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const k of Object.keys(PAGE_DEFAULTS[mode])) out[k] = p[k];
|
|
154
|
+
out.sidebar = (theme.page && theme.page.sidebar && theme.page.sidebar[mode]) || out.bg;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether a page palette reads (web-theme.ts's checks, again here: a person may put an agent's colours on the page
|
|
159
|
+
* though the agent never asked): text and secondary text 4.5:1 and the accent 3:1 on the background, surface and
|
|
160
|
+
* sidebar; every status colour 3:1 on the sidebar and the background.
|
|
161
|
+
*/
|
|
162
|
+
export function pageReadable(p, mode) {
|
|
163
|
+
if (!p || !Object.values(p).every((c) => typeof c === "string" && COLOR.test(c))) return false;
|
|
164
|
+
for (const bg of [p.bg, p.surface, p.sidebar]) {
|
|
165
|
+
if (ratio(p.text, bg) < 4.5 - 1e-9 || ratio(p.text2, bg) < 4.5 - 1e-9) return false;
|
|
166
|
+
}
|
|
167
|
+
if (ratio(p.accent, p.bg) < 3 - 1e-9 || ratio(p.accent, p.sidebar) < 3 - 1e-9) return false;
|
|
168
|
+
return Object.values(STATUS_COLORS[mode]).every((c) => ratio(c, p.sidebar) >= 3 - 1e-9 && ratio(c, p.bg) >= 3 - 1e-9);
|
|
169
|
+
}
|
|
170
|
+
/** The page colours in force now for `mode` (the instance the route is about), or null: the page's own. */
|
|
171
|
+
export function pageNow(mode, route = navStore.get().route) {
|
|
172
|
+
const theme = pageThemeFor(openOf(route));
|
|
173
|
+
const p = theme && pagePalette(theme, mode);
|
|
174
|
+
return p && pageReadable(p, mode) ? p : null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Draw page colours `p` (pageNow) on the shell `el`, or clear them (null): the palette's tokens and the few derived
|
|
178
|
+
* from it (borders, raised rows, the accent's tint and the colour on it), through the CSSOM; class th-page.
|
|
179
|
+
*/
|
|
180
|
+
export function applyPageTheme(el, p, mode) {
|
|
181
|
+
if (!el || !el.style) return;
|
|
182
|
+
for (const prop of [...Object.values(PAGE_PROPS), ...DERIVED_PROPS]) el.style.removeProperty(prop);
|
|
183
|
+
el.classList.remove("th-page");
|
|
184
|
+
if (!p) return;
|
|
185
|
+
for (const [k, prop] of Object.entries(PAGE_PROPS)) el.style.setProperty(prop, p[k]);
|
|
186
|
+
el.style.setProperty("--text-3", p.text2);
|
|
187
|
+
el.style.setProperty("--surface-2", mix(p.surface, p.text, 0.08));
|
|
188
|
+
el.style.setProperty("--border", mix(p.bg, p.text, 0.14));
|
|
189
|
+
el.style.setProperty("--border-strong", mix(p.bg, p.text, 0.45));
|
|
190
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(p.accent.slice(i, i + 2), 16));
|
|
191
|
+
el.style.setProperty("--accent-dim", `rgba(${r}, ${g}, ${b}, ${mode === "dark" ? 0.14 : 0.08})`);
|
|
192
|
+
el.style.setProperty("--on-accent", ratio("#ffffff", p.accent) >= ratio("#0d0d0d", p.accent) ? "#ffffff" : "#0d0d0d");
|
|
193
|
+
el.classList.add("th-page");
|
|
194
|
+
}
|
|
195
|
+
|
|
92
196
|
export function isThemeLocked(name) { return (themesStore.get().locked || []).includes(name); }
|
|
93
197
|
|
|
94
198
|
const TOKENS = ["th", "th-font-inter", "th-font-system", "th-font-mono", "th-bubble-none", "th-bubble-thin", "th-bubble-accent", "th-agent-bubble"];
|
|
@@ -193,7 +297,18 @@ export function themeMenuItems(name) {
|
|
|
193
297
|
const locked = isThemeLocked(name);
|
|
194
298
|
const w = themesStore.get().widgets;
|
|
195
299
|
const placed = w && own(w, name) && w[name] && typeof w[name] === "object" ? w[name] : {};
|
|
300
|
+
// #2196: where this agent's colours go — its own choice, the whole page, or only its chat (the person's wins).
|
|
301
|
+
const choice = pageChoice(name), theme = themeFor(name), mode = resolvedMode();
|
|
302
|
+
const agentsOwn = theme && theme.page && theme.page.on ? "chat.pageAgentOn" : "chat.pageAgentOff";
|
|
303
|
+
const readable = !!theme && pageReadable(pagePalette(theme, mode), mode);
|
|
304
|
+
const page = theme ? [
|
|
305
|
+
{ key: "page-heading", heading: true, label: t("chat.pageColours") },
|
|
306
|
+
{ key: "page-agent", checked: choice === null, label: t(agentsOwn), onSelect: () => setPageChoice(name, "agent") },
|
|
307
|
+
{ key: "page-page", checked: choice === "page", label: t("chat.pageWhole"), disabled: !readable && choice !== "page", onSelect: () => setPageChoice(name, "page") },
|
|
308
|
+
{ key: "page-chat", checked: choice === "chat", label: t("chat.pageChatOnly"), onSelect: () => setPageChoice(name, "chat") },
|
|
309
|
+
] : [];
|
|
196
310
|
return [
|
|
311
|
+
...page,
|
|
197
312
|
...["chat_header", "details"].filter((slot) => own(placed, slot)).map((slot) => ({
|
|
198
313
|
key: `widget-remove-${slot}`, label: t(slot === "chat_header" ? "chat.widgetRemoveHeader" : "chat.widgetRemoveDetails"), icon: "trash",
|
|
199
314
|
onSelect: () => removeWidget(name, slot),
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -350,6 +350,10 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
|
|
|
350
350
|
.menu-item:hover:not(:disabled), .menu-item:focus-visible { background: var(--surface-2); outline: none; }
|
|
351
351
|
.menu-item.danger { color: var(--danger); }
|
|
352
352
|
.menu-item:disabled { opacity: 0.45; }
|
|
353
|
+
/* #2196: a group's heading in a menu (Page colours) and its radio items (the chosen one ticked). */
|
|
354
|
+
.menu-heading { padding: var(--s-2) var(--s-3) var(--s-1); font-size: var(--fs-xs); font-weight: var(--fw-semibold); color: var(--text-2); border-top: 1px solid var(--border); margin-top: var(--s-1); }
|
|
355
|
+
.menu-heading:first-child { border-top: 0; margin-top: 0; }
|
|
356
|
+
.menu-radio .menu-tick { width: 16px; display: inline-flex; color: var(--accent); }
|
|
353
357
|
|
|
354
358
|
/* ── Dialog: centred on a desktop, a bottom sheet on a phone ── */
|
|
355
359
|
.dlg { border: 0; padding: 0; border-radius: var(--r-lg); background: var(--bg); color: var(--text); box-shadow: var(--shadow-2); width: min(480px, calc(100vw - 32px)); max-height: 85vh; }
|
|
@@ -560,6 +564,14 @@ html.lb-open .scroller { overflow: hidden; }
|
|
|
560
564
|
.chat-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
561
565
|
/* #1629: an agent's theme on its own chat surface — custom properties set on .p-chat through the CSSOM, choices as
|
|
562
566
|
these tokens. The surface paints its own background (the page's body is not its). */
|
|
567
|
+
/*
|
|
568
|
+
* #2196: an agent's colours on the whole page (app-themes.js applyPageTheme, on .shell): the shell paints its own
|
|
569
|
+
* background then, under the sidebar, the header, the usage rail and a phone's tabs. The header follows the page, not
|
|
570
|
+
* the chat: it takes the shell's colours back (--chrome-*: resolved on .shell, so the page's theme or the page's own).
|
|
571
|
+
*/
|
|
572
|
+
.shell.th-page { background: var(--bg); color: var(--text); }
|
|
573
|
+
.shell { --chrome-bg: var(--bg); --chrome-surface: var(--surface); --chrome-text: var(--text); --chrome-text-2: var(--text-2); --chrome-accent: var(--accent); }
|
|
574
|
+
.p-chat > .panel-head { --bg: var(--chrome-bg); --surface: var(--chrome-surface); --text: var(--chrome-text); --text-2: var(--chrome-text-2); --accent: var(--chrome-accent); background: var(--bg); color: var(--text); }
|
|
563
575
|
.p-chat.th { background: var(--bg); color: var(--text); }
|
|
564
576
|
.p-chat.th .msg.user .body { border-radius: var(--bubble-radius, var(--r-xl)); }
|
|
565
577
|
.p-chat.th-font-system .thread, .p-chat.th-font-system .composer textarea { font-family: system-ui, -apple-system, "Segoe UI", sans-serif, var(--font-emoji); }
|
|
@@ -684,7 +696,30 @@ img.amb-p { object-fit: contain; }
|
|
|
684
696
|
/* A narrow phone: Stop reply beside Send leaves the message box no room — the label is for screen readers there. */
|
|
685
697
|
@media (max-width: 479px) { .btn-stop .stop-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } }
|
|
686
698
|
.btn-stop { background: var(--bg); }
|
|
687
|
-
.hint { font-size: var(--fs-xs); color: var(--text-3);
|
|
699
|
+
.hint { font-size: var(--fs-xs); color: var(--text-3); }
|
|
700
|
+
/* #2215 (the user's request, 2026-10-19): one row under the composer, at the bottom left — this browser's web authority, then
|
|
701
|
+
the key hint. Its height is kept even before the authority is read (#2184: switching chats never moves the composer). */
|
|
702
|
+
.composer-row { position: relative; display: flex; align-items: center; gap: var(--s-2); min-height: 20px; margin-top: calc(-1 * var(--s-1));
|
|
703
|
+
font-size: var(--fs-xs); line-height: 1.4; color: var(--text-3); white-space: nowrap; }
|
|
704
|
+
.composer-row > * { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
705
|
+
.composer-row > .hint { flex-shrink: 1000; }
|
|
706
|
+
.composer-row > :not(.hint) + .hint::before { content: "·"; margin-right: var(--s-2); }
|
|
707
|
+
/* A guest's note and the public link's sentence are kept whole: on a narrow screen they wrap rather than lose words. */
|
|
708
|
+
.composer-row .guest-note, .composer-row .webauth-row[data-state="public"] { margin: 0; text-align: left; white-space: normal; flex-shrink: 1; }
|
|
709
|
+
.webauth-row { display: inline-flex; align-items: center; gap: var(--s-1); }
|
|
710
|
+
.webauth-row .webauth-ok, .webauth-row .webauth-ask { font: inherit; padding: 0 2px; border: 0; background: none; cursor: pointer; border-radius: var(--r-sm); }
|
|
711
|
+
.webauth-row .webauth-ok { color: var(--ok); }
|
|
712
|
+
.webauth-row .webauth-ask { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
713
|
+
.webauth-row .webauth-ok:focus-visible, .webauth-row .webauth-ask:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
714
|
+
/* What pressing Authorize answered is said in the row, in the hint's place: nothing is laid over the composer. */
|
|
715
|
+
.webauth-row .webauth-notice { white-space: normal; color: var(--text-2); }
|
|
716
|
+
.webauth-row .webauth-notice::before { content: "·"; margin-right: var(--s-2); color: var(--text-3); }
|
|
717
|
+
.composer-row:has(.webauth-notice) > .hint { display: none; }
|
|
718
|
+
/* When "✓ Verified" ends, on a tap (touch has no hover): a bubble above the row while it has focus. */
|
|
719
|
+
.webauth-tip { position: absolute; left: 0; bottom: calc(100% + 4px); z-index: 5; max-width: min(420px, calc(100vw - 32px)); white-space: normal;
|
|
720
|
+
padding: var(--s-1) var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text);
|
|
721
|
+
box-shadow: var(--shadow-2); }
|
|
722
|
+
.webauth-tip[hidden] { display: none; }
|
|
688
723
|
/* #1920 phase 3: Settings → Web → Guests. */
|
|
689
724
|
.s-guests .guest-names { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 2px var(--s-3); max-height: 220px; overflow: auto;
|
|
690
725
|
padding: var(--s-1) var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); }
|
|
@@ -1581,8 +1616,16 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
|
|
|
1581
1616
|
.side-widget { padding: 0 var(--s-3) var(--s-2); }
|
|
1582
1617
|
.aw-sidebar { max-height: 240px; overflow: auto; }
|
|
1583
1618
|
|
|
1584
|
-
|
|
1585
|
-
|
|
1619
|
+
/* The card's own controls (the chat's Hide card, Hide picture and bio): small and round. On the banner they sit on its
|
|
1620
|
+
corners over a dark disc, so they read on any picture; on the row, Hide picture and bio takes the row's bottom-right. */
|
|
1621
|
+
.id-ctl { flex-shrink: 0; display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-full); background: var(--surface); color: var(--text-2); cursor: pointer; }
|
|
1622
|
+
.id-ctl:hover { color: var(--text); border-color: var(--border-strong); }
|
|
1623
|
+
.id-row .id-ctl { margin-left: auto; border-color: var(--border-strong); background: var(--surface-2); color: var(--text); }
|
|
1624
|
+
.id-row .id-fold { align-self: flex-end; }
|
|
1625
|
+
.id-banner-box .id-ctl { position: absolute; z-index: 1; right: var(--s-2); border-color: rgba(255, 255, 255, .35); background: rgba(0, 0, 0, .5); color: #fff; }
|
|
1626
|
+
.id-banner-box .id-ctl:hover { background: rgba(0, 0, 0, .7); color: #fff; }
|
|
1627
|
+
.id-banner-box .id-hide { top: var(--s-2); }
|
|
1628
|
+
.id-banner-box .id-unfold { bottom: var(--s-2); }
|
|
1586
1629
|
/* A phone: the portrait stays on the text's row, smaller (the user's ask: never a portrait alone on a line with the text
|
|
1587
1630
|
under it); the S / M / L control takes the line below. The chat header's card is trimmed further: a smaller portrait
|
|
1588
1631
|
and banner, and its tagline and bio cut to a few lines (Details and View show them whole). */
|
|
@@ -1595,6 +1638,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
|
|
|
1595
1638
|
.id-card.card-c.bs-l { --id-bmin: 96px; --id-bmax: 130px; }
|
|
1596
1639
|
.id-row { flex-wrap: wrap; gap: var(--s-2) var(--s-3); padding: var(--s-2) var(--s-3); }
|
|
1597
1640
|
.card-size { flex-basis: 100%; justify-content: flex-end; }
|
|
1641
|
+
.id-ctl { width: 32px; height: 32px; }
|
|
1598
1642
|
.id-card.card-c .id-tagline, .id-card.card-c .id-bio { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
|
|
1599
1643
|
.id-card.card-c .id-tagline { -webkit-line-clamp: 2; line-clamp: 2; }
|
|
1600
1644
|
.id-card.card-c .id-bio { -webkit-line-clamp: 3; line-clamp: 3; }
|
|
@@ -13,6 +13,8 @@ register("card", {
|
|
|
13
13
|
byAgent: "set by the agent",
|
|
14
14
|
show: "Show card",
|
|
15
15
|
hide: "Hide card",
|
|
16
|
+
showRow: "Show picture and bio",
|
|
17
|
+
hideRow: "Hide picture and bio",
|
|
16
18
|
bannerSafe: "Every screen",
|
|
17
19
|
bannerPhone: "Phone · M card",
|
|
18
20
|
},
|
|
@@ -26,6 +28,8 @@ register("card", {
|
|
|
26
28
|
byAgent: "由 agent 設定",
|
|
27
29
|
show: "顯示卡片",
|
|
28
30
|
hide: "隱藏卡片",
|
|
31
|
+
showRow: "顯示小圖與介紹",
|
|
32
|
+
hideRow: "隱藏小圖與介紹",
|
|
29
33
|
bannerSafe: "所有螢幕",
|
|
30
34
|
bannerPhone: "手機 · 中卡片",
|
|
31
35
|
},
|
|
@@ -11,6 +11,7 @@ import { html, useEffect, useState } from "./app-html.js";
|
|
|
11
11
|
import { t } from "./app-i18n.js";
|
|
12
12
|
import { createStore, useStore } from "./app-store.js";
|
|
13
13
|
import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
|
|
14
|
+
import { Icon } from "./ui-icons.js";
|
|
14
15
|
import "./card-strings.js";
|
|
15
16
|
|
|
16
17
|
export const CARD_SIZES = ["s", "m", "l"];
|
|
@@ -160,12 +161,22 @@ function SizeControl({ name, size }) {
|
|
|
160
161
|
</div>`;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/** A small round control on the card (the chat's Hide card and Hide picture and bio). */
|
|
165
|
+
function CardControl({ cls, icon, label, onClick, expanded }) {
|
|
166
|
+
return html`<button type="button" class=${`id-ctl ${cls}`} aria-label=${label} title=${label}
|
|
167
|
+
aria-expanded=${expanded === undefined ? undefined : expanded ? "true" : "false"} onClick=${onClick}><${Icon} name=${icon} size=${16} /></button>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
/**
|
|
164
171
|
* The card. `compact`: the chat header's own small size (no size control; the banner still follows the size choice,
|
|
165
172
|
* #2038). Nothing when the card is empty. The
|
|
166
173
|
* tagline and bio say whose they are (title), since a person's field wins over the agent's.
|
|
174
|
+
* #2217: the chat's controls, on the card itself (the user, 2026-10-19): `onHide` — Hide card, on the banner's top-right corner
|
|
175
|
+
* (on the row when there is no banner); `onToggleRow` — hide the picture, tagline and bio and keep the banner, from the
|
|
176
|
+
* row's bottom-right corner, and back from the banner's bottom-right while they are hidden (`rowHidden`). Only a card
|
|
177
|
+
* with a banner and something on its row can fold its row (else it would be the card hidden).
|
|
167
178
|
*/
|
|
168
|
-
export function IdentityCard({ name, card, compact = false, sizeControl = !compact }) {
|
|
179
|
+
export function IdentityCard({ name, card, compact = false, sizeControl = !compact, onHide = null, rowHidden = false, onToggleRow = null }) {
|
|
169
180
|
useStore(cardSizeStore);
|
|
170
181
|
const [broken, setBroken] = useState({});
|
|
171
182
|
if (!card) return null;
|
|
@@ -179,16 +190,23 @@ export function IdentityCard({ name, card, compact = false, sizeControl = !compa
|
|
|
179
190
|
const focus = card.banner_focus ? card.banner_focus.value : 50;
|
|
180
191
|
const atFocus = (el) => { if (el && el.style) el.style.setProperty("--id-focus", `${focus}%`); };
|
|
181
192
|
const banner = pic("banner", "id-banner", "");
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
const portrait = pic("portrait", "id-portrait", t("card.portraitAlt", name));
|
|
194
|
+
const foldable = !!(onToggleRow && banner && (portrait || card.tagline || card.bio));
|
|
195
|
+
const rowShown = !(foldable && rowHidden);
|
|
196
|
+
const hide = onHide ? html`<${CardControl} cls="id-hide" icon="eyeOff" label=${t("card.hide")} onClick=${onHide} />` : null;
|
|
197
|
+
const fold = foldable ? html`<${CardControl} cls=${rowShown ? "id-fold" : "id-fold id-unfold"} icon=${rowShown ? "up" : "user"}
|
|
198
|
+
label=${t(rowShown ? "card.hideRow" : "card.showRow")} expanded=${rowShown} onClick=${onToggleRow} />` : null;
|
|
199
|
+
return html`<section class=${`id-card card-${size}${bannerSize}${rowShown ? "" : " row-hidden"}`} aria-label=${t("card.label", name)}>
|
|
200
|
+
${banner ? html`<div class="id-banner-box" ref=${atFocus}><img class="id-banner-fill" src=${card.banner.src} alt="" aria-hidden="true" decoding="async" />${banner}${hide}${rowShown ? null : fold}</div>` : null}
|
|
201
|
+
${rowShown ? html`<div class="id-row">
|
|
202
|
+
${portrait}
|
|
186
203
|
<div class="id-text">
|
|
187
204
|
${card.tagline ? html`<p class="id-tagline" title=${whose(card.tagline)}>${card.tagline.text}</p>` : null}
|
|
188
205
|
${card.bio ? html`<p class="id-bio" title=${whose(card.bio)}>${card.bio.text}</p>` : null}
|
|
189
206
|
</div>
|
|
190
207
|
${sizeControl ? html`<${SizeControl} name=${name} size=${size} />` : null}
|
|
191
|
-
|
|
208
|
+
${banner ? null : hide}${fold}
|
|
209
|
+
</div>` : null}
|
|
192
210
|
</section>`;
|
|
193
211
|
}
|
|
194
212
|
|
|
@@ -9,7 +9,7 @@ import { html, useEffect, useState } from "./app-html.js";
|
|
|
9
9
|
import { createStore, useStore } from "./app-store.js";
|
|
10
10
|
import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
|
|
11
11
|
import { register, t } from "./app-i18n.js";
|
|
12
|
-
import { themesShown, themesStore } from "./app-themes.js";
|
|
12
|
+
import { pageNow, themesShown, themesStore } from "./app-themes.js";
|
|
13
13
|
import { Dialog } from "./ui-dialog.js";
|
|
14
14
|
import { toast } from "./ui-toast.js";
|
|
15
15
|
|
|
@@ -63,16 +63,20 @@ export function contrast(a, b) {
|
|
|
63
63
|
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
64
64
|
return (l1 + 0.05) / (l2 + 0.05);
|
|
65
65
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The shade of `hex` name text uses: itself if readable, else the nearest step toward black (light) or white (dark).
|
|
68
|
+
* `page` (#2196): an agent's colours on the page now — names must read on its background and sidebar instead.
|
|
69
|
+
*/
|
|
70
|
+
export function readableShade(hex, mode, page = null) {
|
|
71
|
+
const backgrounds = page ? [page.bg, page.sidebar] : BG[mode], text = page ? page.text : TEXT[mode];
|
|
72
|
+
if (!HEX.test(hex)) return text;
|
|
69
73
|
const toward = mode === "light" ? 0 : 255;
|
|
70
74
|
const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
71
75
|
for (let step = 0; step <= 20; step++) {
|
|
72
76
|
const shade = `#${ch.map((c) => Math.round(c + (toward - c) * (step / 20)).toString(16).padStart(2, "0")).join("")}`;
|
|
73
|
-
if (
|
|
77
|
+
if (backgrounds.every((bg) => contrast(shade, bg) >= 4.5)) return shade;
|
|
74
78
|
}
|
|
75
|
-
return
|
|
79
|
+
return text;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
// ── Resolution ──
|
|
@@ -135,7 +139,7 @@ export function identityOf(it, opts = {}) {
|
|
|
135
139
|
const light = colorValue(c.color, "light"), dark = colorValue(c.color, "dark");
|
|
136
140
|
return {
|
|
137
141
|
color: c.color, source: c.source, show: p.show, showSource: p.source, off: opts.off ?? colorsOff(),
|
|
138
|
-
light, dark, nameLight: readableShade(light, "light"), nameDark: readableShade(dark, "dark"),
|
|
142
|
+
light, dark, nameLight: readableShade(light, "light", pageNow("light")), nameDark: readableShade(dark, "dark", pageNow("dark")),
|
|
139
143
|
};
|
|
140
144
|
}
|
|
141
145
|
/** The row's classes for its identity colour ("" when colours are off or shown nowhere). */
|
|
@@ -40,6 +40,8 @@ const P = {
|
|
|
40
40
|
sort: () => html`<path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/>`, // #2179: the sidebar's sort
|
|
41
41
|
star: () => html`<path d="M12 2.5l2.94 5.96 6.56.95-4.75 4.63 1.12 6.54L12 17.5l-5.87 3.08 1.12-6.54L2.5 9.41l6.56-.95L12 2.5z"/>`,
|
|
42
42
|
eye: () => html`<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>`,
|
|
43
|
+
eyeOff: () => html`<path d="M3 3l18 18M10.6 5.1A10.9 10.9 0 0 1 12 5c7 0 11 7 11 7a18.6 18.6 0 0 1-3.2 4.2M6.6 6.6C3.5 8.6 1 12 1 12s4 7 11 7a10.6 10.6 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/>`,
|
|
44
|
+
idcard: () => html`<rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="8" cy="11" r="2"/><path d="M5 16a3 3 0 0 1 6 0M14 10h5M14 14h3"/>`,
|
|
43
45
|
check: () => html`<path d="M20 6L9 17l-5-5"/>`,
|
|
44
46
|
inbox: () => html`<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>`,
|
|
45
47
|
bell: () => html`<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>`,
|
|
@@ -18,7 +18,7 @@ export function Menu({ items, label, icon = "more", cls = "" }) {
|
|
|
18
18
|
return () => document.removeEventListener("pointerdown", outside, true);
|
|
19
19
|
}, [open]);
|
|
20
20
|
function onKey(e) {
|
|
21
|
-
const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled])")];
|
|
21
|
+
const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled])")];
|
|
22
22
|
const i = all.indexOf(document.activeElement);
|
|
23
23
|
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); btn.current && btn.current.focus(); }
|
|
24
24
|
else if (e.key === "ArrowDown") { e.preventDefault(); (all[(i + 1) % all.length] || all[0])?.focus(); }
|
|
@@ -32,8 +32,14 @@ export function Menu({ items, label, icon = "more", cls = "" }) {
|
|
|
32
32
|
<button ref=${btn} type="button" class="icon-btn" aria-haspopup="menu" aria-expanded=${open ? "true" : "false"}
|
|
33
33
|
aria-label=${label || t("app.more")} title=${label || t("app.more")} onClick=${() => setOpen(!open)}><${Icon} name=${icon} /></button>
|
|
34
34
|
${open ? html`<div data-trust="menu" ref=${list} class="menu-list" role="menu" aria-label=${label || t("app.more")} onKeyDown=${onKey}>
|
|
35
|
-
${shown.map(item =>
|
|
36
|
-
|
|
35
|
+
${shown.map(item => item.heading
|
|
36
|
+
? html`<div key=${item.key} class="menu-heading" role="presentation">${item.label}</div>`
|
|
37
|
+
: item.checked !== undefined
|
|
38
|
+
// #2196: one of a group of choices (Page colours): a radio item, the chosen one ticked.
|
|
39
|
+
? html`<button key=${item.key} type="button" role="menuitemradio" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
|
|
40
|
+
disabled=${!!item.disabled} onClick=${() => choose(item)}><span class="menu-tick" aria-hidden="true">${item.checked ? html`<${Icon} name="check" size=${16} />` : null}</span><span>${item.label}</span></button>`
|
|
41
|
+
: html`<button key=${item.key} type="button" role="menuitem" class=${`menu-item${item.danger ? " danger" : ""}`}
|
|
42
|
+
disabled=${!!item.disabled} onClick=${() => choose(item)}>${item.icon ? html`<${Icon} name=${item.icon} size=${16} />` : null}<span>${item.label}</span></button>`)}
|
|
37
43
|
</div>` : null}
|
|
38
44
|
</div>`;
|
|
39
45
|
}
|
|
@@ -27,6 +27,10 @@ register("webauth", {
|
|
|
27
27
|
refusal_dm_unavailable: "Couldn't send you a DM. Open your DMs with the bot, then try again.",
|
|
28
28
|
refusal_other: "That didn't work: {0}",
|
|
29
29
|
settings: "Settings",
|
|
30
|
+
verifiedShort: "Verified",
|
|
31
|
+
authorizeShort: "Authorize",
|
|
32
|
+
authorizeWhy: "Not verified: authorize this browser to speak as you",
|
|
33
|
+
waitingShort: "Waiting for your Confirm ({0})",
|
|
30
34
|
},
|
|
31
35
|
"zh-TW": {
|
|
32
36
|
verified: "已驗證 · 以你的身分發言到 {0}",
|
|
@@ -49,6 +53,10 @@ register("webauth", {
|
|
|
49
53
|
refusal_dm_unavailable: "無法私訊給你。請開放與 bot 的私訊後再試一次。",
|
|
50
54
|
refusal_other: "沒有成功:{0}",
|
|
51
55
|
settings: "設定",
|
|
56
|
+
verifiedShort: "已驗證",
|
|
57
|
+
authorizeShort: "授權",
|
|
58
|
+
authorizeWhy: "未驗證:授權這個瀏覽器以你的身分發言",
|
|
59
|
+
waitingShort: "等待確認({0})",
|
|
52
60
|
},
|
|
53
61
|
});
|
|
54
62
|
const tn = (k, ...v) => t(`webauth.${k}`, ...v);
|
|
@@ -139,6 +147,45 @@ export function AuthorizeChip() {
|
|
|
139
147
|
return html`<div class="webauth-chip" data-state=${a.st.state}>${body(a)}${a.notice ? html` <span class="note" role="status">${a.notice}</span>` : null}</div>`;
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The composer's row (#2215, the user's request, 2026-10-19): the same states as AuthorizeChip, each kept to a few words so
|
|
152
|
+
* they share one row with the key hint. What a state would have said at length is in its tooltip and its accessible
|
|
153
|
+
* name: when "Verified" ends, why it can't be authorized, the countdown's ask. A tap on "✓ Verified" shows when it
|
|
154
|
+
* ends (touch has no hover) in a bubble above the row, until the next tap, a tap elsewhere or Escape. What pressing
|
|
155
|
+
* Authorize answered (sent, refused) is said in the row itself, in the hint's place (#2216 review: a bubble left over the
|
|
156
|
+
* composer covered Attach and what was being typed, with nothing to close it).
|
|
157
|
+
*/
|
|
158
|
+
export function ComposerAuth() {
|
|
159
|
+
const a = useWebAuthorization();
|
|
160
|
+
const [open, setOpen] = useState(false);
|
|
161
|
+
const tip = useRef("wa-tip-" + Math.random().toString(36).slice(2, 8));
|
|
162
|
+
useEffect(() => { setOpen(false); }, [a.st && a.st.state]); // a new state closes the bubble
|
|
163
|
+
if (guestPage()) return null;
|
|
164
|
+
if (publicLink()) return html`<span class="webauth-row note" data-state="public" title=${tn("publicLink")}>${tn("publicLink")}</span>`;
|
|
165
|
+
if (!a.st) return null;
|
|
166
|
+
const st = a.st, id = tip.current;
|
|
167
|
+
const said = a.notice ? html`<span class="webauth-notice" role="status">${a.notice}</span>` : null;
|
|
168
|
+
if (st.state === "verified") {
|
|
169
|
+
const until = tn("verified", clock(st.until));
|
|
170
|
+
return html`<span class="webauth-row" data-state="verified">
|
|
171
|
+
<button type="button" class="webauth-ok" title=${until} aria-label=${`✓ ${until}`} aria-expanded=${open ? "true" : "false"}
|
|
172
|
+
aria-controls=${id} onClick=${() => setOpen((v) => !v)} onBlur=${() => setOpen(false)}
|
|
173
|
+
onKeyDown=${(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); setOpen(false); } }}>✓ ${tn("verifiedShort")}</button><span
|
|
174
|
+
id=${id} class="webauth-tip" role="tooltip" hidden=${!open}>${until}</span>${said}</span>`;
|
|
175
|
+
}
|
|
176
|
+
if (st.state === "pending") {
|
|
177
|
+
const asks = tn("waiting", mmss(a.left));
|
|
178
|
+
return html`<span class="webauth-row" data-state="pending"><span class="note" title=${a.notice ? `${asks} — ${a.notice}` : asks}>${tn("waitingShort", mmss(a.left))}</span>${said}</span>`;
|
|
179
|
+
}
|
|
180
|
+
if (st.reason === "not_authorized" || st.reason === "authorization_expired") {
|
|
181
|
+
return html`<span class="webauth-row" data-state="none"><span class="note" aria-hidden="true">${tn("notVerified")} ·</span><button type="button" class="webauth-ask"
|
|
182
|
+
title=${tn("authorizeWhy")} aria-label=${tn("authorizeWhy")} onClick=${a.ask}>${tn("authorizeShort")}</button>${said}</span>`;
|
|
183
|
+
}
|
|
184
|
+
const why = REASONS.includes(st.reason) ? tn(`reason_${st.reason}`) : tn("reason_other");
|
|
185
|
+
return html`<span class="webauth-row" data-state="none"><span class="note" title=${why}>${tn("notVerified")}<span class="sr-only">: ${why}</span></span>${st.reason === "disabled" || st.reason === "loopback_only"
|
|
186
|
+
? html` · <a href="/settings/web" title=${why}>${tn("settings")}</a>` : null}</span>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
142
189
|
function body(a) {
|
|
143
190
|
const st = a.st;
|
|
144
191
|
if (st.state === "verified") return html`<span class="webauth-ok">✓ ${tn("verified", clock(st.until))}</span>`;
|
package/dist/web-api.js
CHANGED
|
@@ -21,7 +21,8 @@ import { formatWebChannelEcho } from "./web-channel-echo.js";
|
|
|
21
21
|
import { t } from "./locale.js";
|
|
22
22
|
import { isSafeInstanceName } from "./web-shell-routes.js";
|
|
23
23
|
import { MAX_COMMAND_ARGS } from "./web-commands.js";
|
|
24
|
-
import { sendPanelHtml } from "./web-host-guard.js";
|
|
24
|
+
import { ROUTE_WORKER_CSP, ROUTE_WORKER_PATH, sendPanelHtml } from "./web-host-guard.js";
|
|
25
|
+
import { libavoidFile } from "./web-libavoid.js";
|
|
25
26
|
import { shellRoute } from "./web-shell-routes.js";
|
|
26
27
|
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
27
28
|
import { join, dirname } from "node:path";
|
|
@@ -1021,6 +1022,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1021
1022
|
try {
|
|
1022
1023
|
const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
|
|
1023
1024
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
1025
|
+
// #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
|
|
1026
|
+
if (path === ROUTE_WORKER_PATH)
|
|
1027
|
+
res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
|
|
1024
1028
|
res.writeHead(200);
|
|
1025
1029
|
res.end(js);
|
|
1026
1030
|
}
|
|
@@ -1029,6 +1033,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1029
1033
|
}
|
|
1030
1034
|
return true;
|
|
1031
1035
|
}
|
|
1036
|
+
// #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
|
|
1037
|
+
const vendor = method === "GET" ? libavoidFile(path) : null;
|
|
1038
|
+
if (vendor) {
|
|
1039
|
+
try {
|
|
1040
|
+
const body = readFileSync(vendor.file);
|
|
1041
|
+
res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
|
|
1042
|
+
res.end(body);
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
json(res, 404, { error: "File not found" });
|
|
1046
|
+
}
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1032
1049
|
// ── Backend detection ─────────────────────────────────
|
|
1033
1050
|
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1034
1051
|
if (method === "GET" && path === "/ui/models") {
|