@songsid/agend 2.3.0-beta.12 → 2.3.0-beta.14
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/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/fleet-manager.d.ts +36 -1
- package/dist/fleet-manager.js +135 -43
- 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 +21 -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/topic-commands.d.ts +10 -0
- package/dist/topic-commands.js +27 -15
- package/dist/topic-commands.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 +51 -24
- 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 +38 -3
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/identity-card.js +26 -8
- package/dist/ui/shared/identity-color.js +11 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/web-authorize.js +47 -0
- package/dist/web-api.d.ts +0 -3
- package/dist/web-api.js +80 -14
- 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
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,8 @@ 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
|
-
.chat-card-
|
|
1619
|
+
/* The chat header's card menu button (the user, 2026-10-20: no control on the card): pressed-looking while the card shows. */
|
|
1620
|
+
.chat-card-menu > .icon-btn.active { background: var(--accent-dim); color: var(--accent); }
|
|
1586
1621
|
/* 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
1622
|
under it); the S / M / L control takes the line below. The chat header's card is trimmed further: a smaller portrait
|
|
1588
1623
|
and banner, and its tagline and bio cut to a few lines (Details and View show them whole). */
|
|
@@ -11,8 +11,10 @@ register("card", {
|
|
|
11
11
|
sizeSName: "Small", sizeMName: "Medium", sizeLName: "Large",
|
|
12
12
|
byHuman: "set by a person",
|
|
13
13
|
byAgent: "set by the agent",
|
|
14
|
+
menu: "Identity card",
|
|
14
15
|
show: "Show card",
|
|
15
16
|
hide: "Hide card",
|
|
17
|
+
showRow: "Show picture and bio",
|
|
16
18
|
bannerSafe: "Every screen",
|
|
17
19
|
bannerPhone: "Phone · M card",
|
|
18
20
|
},
|
|
@@ -24,8 +26,10 @@ register("card", {
|
|
|
24
26
|
sizeSName: "小", sizeMName: "中", sizeLName: "大",
|
|
25
27
|
byHuman: "由人設定",
|
|
26
28
|
byAgent: "由 agent 設定",
|
|
29
|
+
menu: "身分卡",
|
|
27
30
|
show: "顯示卡片",
|
|
28
31
|
hide: "隱藏卡片",
|
|
32
|
+
showRow: "顯示小圖和介紹",
|
|
29
33
|
bannerSafe: "所有螢幕",
|
|
30
34
|
bannerPhone: "手機 · 中卡片",
|
|
31
35
|
},
|
|
@@ -160,35 +160,53 @@ function SizeControl({ name, size }) {
|
|
|
160
160
|
</div>`;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* #2217 hotfix (Prism): pictures that failed to load on this page, by src — shared, so the chat's card menu judges a card
|
|
165
|
+
* as drawn (a broken banner is not drawn, and then the card cannot fold), not by its metadata alone.
|
|
166
|
+
*/
|
|
167
|
+
export const brokenPictures = createStore({});
|
|
168
|
+
const markBroken = (src) => { if (src && !brokenPictures.get()[src]) brokenPictures.set((s) => ({ ...s, [src]: true })); };
|
|
169
|
+
const drawn = (card, slot) => !!(card && card[slot] && !brokenPictures.get()[card[slot].src]);
|
|
170
|
+
/**
|
|
171
|
+
* #2217: whether a card can fold its row away and keep the banner — a banner drawn, and something on its row (a picture
|
|
172
|
+
* drawn, a tagline or a bio). The chat's card menu enables Show picture and bio only then.
|
|
173
|
+
*/
|
|
174
|
+
export const cardFoldable = (card) => !!(card && drawn(card, "banner") && (drawn(card, "portrait") || card.tagline || card.bio));
|
|
175
|
+
|
|
163
176
|
/**
|
|
164
177
|
* The card. `compact`: the chat header's own small size (no size control; the banner still follows the size choice,
|
|
165
178
|
* #2038). Nothing when the card is empty. The
|
|
166
179
|
* tagline and bio say whose they are (title), since a person's field wins over the agent's.
|
|
180
|
+
* #2217: `rowHidden` — the chat's picture and bio folded away, the banner kept. The user (2026-10-20): no control on the
|
|
181
|
+
* card at all — the chat header's two buttons show / hide the card and fold / unfold its row. Only a card with a banner
|
|
182
|
+
* and something on its row folds (cardFoldable; else it would be the card hidden).
|
|
167
183
|
*/
|
|
168
|
-
export function IdentityCard({ name, card, compact = false, sizeControl = !compact }) {
|
|
184
|
+
export function IdentityCard({ name, card, compact = false, sizeControl = !compact, rowHidden = false }) {
|
|
169
185
|
useStore(cardSizeStore);
|
|
170
|
-
const
|
|
186
|
+
const broken = useStore(brokenPictures);
|
|
171
187
|
if (!card) return null;
|
|
172
188
|
const size = compact ? "c" : cardSizeFor(name);
|
|
173
189
|
// #2038: the chat header's card is its own small size, but its banner follows the person's size choice too.
|
|
174
190
|
const bannerSize = compact ? ` bs-${cardSizeFor(name)}` : "";
|
|
175
191
|
const whose = (f) => t(f.by === "human" ? "card.byHuman" : "card.byAgent");
|
|
176
|
-
const pic = (slot, cls, alt) => card[slot] && broken[
|
|
177
|
-
? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() =>
|
|
192
|
+
const pic = (slot, cls, alt) => card[slot] && !broken[card[slot].src]
|
|
193
|
+
? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() => markBroken(card[slot].src)} />` : null;
|
|
178
194
|
// #2086: the banner at its focus; on a screen wider than BANNER_MAX_ASPECT it is centred over a blurred copy of itself.
|
|
179
195
|
const focus = card.banner_focus ? card.banner_focus.value : 50;
|
|
180
196
|
const atFocus = (el) => { if (el && el.style) el.style.setProperty("--id-focus", `${focus}%`); };
|
|
181
197
|
const banner = pic("banner", "id-banner", "");
|
|
182
|
-
|
|
198
|
+
const portrait = pic("portrait", "id-portrait", t("card.portraitAlt", name));
|
|
199
|
+
const rowShown = !(rowHidden && banner && cardFoldable(card));
|
|
200
|
+
return html`<section class=${`id-card card-${size}${bannerSize}${rowShown ? "" : " row-hidden"}`} aria-label=${t("card.label", name)}>
|
|
183
201
|
${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}</div>` : null}
|
|
184
|
-
|
|
185
|
-
${
|
|
202
|
+
${rowShown ? html`<div class="id-row">
|
|
203
|
+
${portrait}
|
|
186
204
|
<div class="id-text">
|
|
187
205
|
${card.tagline ? html`<p class="id-tagline" title=${whose(card.tagline)}>${card.tagline.text}</p>` : null}
|
|
188
206
|
${card.bio ? html`<p class="id-bio" title=${whose(card.bio)}>${card.bio.text}</p>` : null}
|
|
189
207
|
</div>
|
|
190
208
|
${sizeControl ? html`<${SizeControl} name=${name} size=${size} />` : null}
|
|
191
|
-
</div
|
|
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,7 @@ 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
|
+
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
44
|
check: () => html`<path d="M20 6L9 17l-5-5"/>`,
|
|
44
45
|
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
46
|
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"/>`,
|
|
@@ -4,21 +4,25 @@ import { html, useEffect, useRef, useState } from "./app-html.js";
|
|
|
4
4
|
import { Icon } from "./ui-icons.js";
|
|
5
5
|
import { t } from "./app-i18n.js";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* items: [{ key, label, icon?, onSelect, danger?, disabled? }] (falsy entries are skipped). `checked` makes a radio item;
|
|
9
|
+
* `checkbox: true` with `checked` a checkbox item (#2217 hotfix: the chat's card menu), which toggles and keeps the menu
|
|
10
|
+
* open. `active`: the button shows a pressed look (what it controls is on), without changing its role.
|
|
11
|
+
*/
|
|
12
|
+
export function Menu({ items, label, icon = "more", cls = "", active = false }) {
|
|
9
13
|
const [open, setOpen] = useState(false);
|
|
10
14
|
const btn = useRef(null), list = useRef(null);
|
|
11
15
|
const shown = (items || []).filter(Boolean);
|
|
12
16
|
useEffect(() => {
|
|
13
17
|
if (!open) return undefined;
|
|
14
|
-
const first = list.current && list.current.querySelector("[role=menuitem]:not([disabled])");
|
|
18
|
+
const first = list.current && list.current.querySelector("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled]), [role=menuitemcheckbox]:not([disabled])");
|
|
15
19
|
if (first) first.focus();
|
|
16
20
|
const outside = (e) => { if (list.current && !list.current.contains(e.target) && btn.current && !btn.current.contains(e.target)) setOpen(false); };
|
|
17
21
|
document.addEventListener("pointerdown", outside, true);
|
|
18
22
|
return () => document.removeEventListener("pointerdown", outside, true);
|
|
19
23
|
}, [open]);
|
|
20
24
|
function onKey(e) {
|
|
21
|
-
const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled])")];
|
|
25
|
+
const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled]), [role=menuitemcheckbox]:not([disabled])")];
|
|
22
26
|
const i = all.indexOf(document.activeElement);
|
|
23
27
|
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); btn.current && btn.current.focus(); }
|
|
24
28
|
else if (e.key === "ArrowDown") { e.preventDefault(); (all[(i + 1) % all.length] || all[0])?.focus(); }
|
|
@@ -29,11 +33,20 @@ export function Menu({ items, label, icon = "more", cls = "" }) {
|
|
|
29
33
|
}
|
|
30
34
|
function choose(item) { setOpen(false); btn.current && btn.current.focus(); item.onSelect(); }
|
|
31
35
|
return html`<div class=${`menu ${cls}`}>
|
|
32
|
-
<button ref=${btn} type="button" class
|
|
36
|
+
<button ref=${btn} type="button" class=${`icon-btn${active ? " active" : ""}`} aria-haspopup="menu" aria-expanded=${open ? "true" : "false"}
|
|
33
37
|
aria-label=${label || t("app.more")} title=${label || t("app.more")} onClick=${() => setOpen(!open)}><${Icon} name=${icon} /></button>
|
|
34
38
|
${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
|
-
|
|
39
|
+
${shown.map(item => item.heading
|
|
40
|
+
? html`<div key=${item.key} class="menu-heading" role="presentation">${item.label}</div>`
|
|
41
|
+
: item.checkbox
|
|
42
|
+
? html`<button key=${item.key} type="button" role="menuitemcheckbox" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
|
|
43
|
+
disabled=${!!item.disabled} onClick=${() => item.onSelect()}><span class="menu-tick" aria-hidden="true">${item.checked ? html`<${Icon} name="check" size=${16} />` : null}</span><span>${item.label}</span></button>`
|
|
44
|
+
: item.checked !== undefined
|
|
45
|
+
// #2196: one of a group of choices (Page colours): a radio item, the chosen one ticked.
|
|
46
|
+
? html`<button key=${item.key} type="button" role="menuitemradio" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
|
|
47
|
+
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>`
|
|
48
|
+
: html`<button key=${item.key} type="button" role="menuitem" class=${`menu-item${item.danger ? " danger" : ""}`}
|
|
49
|
+
disabled=${!!item.disabled} onClick=${() => choose(item)}>${item.icon ? html`<${Icon} name=${item.icon} size=${16} />` : null}<span>${item.label}</span></button>`)}
|
|
37
50
|
</div>` : null}
|
|
38
51
|
</div>`;
|
|
39
52
|
}
|
|
@@ -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.d.ts
CHANGED
|
@@ -371,9 +371,6 @@ export interface WebPerson {
|
|
|
371
371
|
/** Their account's name at sign-in, made safe (bound only). */
|
|
372
372
|
readonly platformName?: string;
|
|
373
373
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Handle a Web UI request. Returns true if handled, false to pass through.
|
|
376
|
-
*/
|
|
377
374
|
export declare function handleWebRequest(req: IncomingMessage, res: ServerResponse, url: URL, ctx: WebApiContext): boolean;
|
|
378
375
|
/**
|
|
379
376
|
* #1723: a public-link page's preview: where it may frame from (`frameSrc` — the quick-tunnel wildcard while its own
|
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";
|
|
@@ -968,9 +969,54 @@ function profileBody(p) {
|
|
|
968
969
|
}
|
|
969
970
|
/** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
|
|
970
971
|
function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
972
|
+
const statusTicks = new WeakMap();
|
|
973
|
+
function subscribeStatusTick(ctx, stream) {
|
|
974
|
+
let tick = statusTicks.get(ctx);
|
|
975
|
+
if (!tick)
|
|
976
|
+
statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
|
|
977
|
+
const t = tick;
|
|
978
|
+
t.streams.add(stream);
|
|
979
|
+
t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
|
|
980
|
+
return () => {
|
|
981
|
+
t.streams.delete(stream);
|
|
982
|
+
if (!t.streams.size && t.timer) {
|
|
983
|
+
clearInterval(t.timer);
|
|
984
|
+
t.timer = null;
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
function statusTick(ctx, t) {
|
|
989
|
+
const live = [...t.streams].filter((s) => s.alive());
|
|
990
|
+
if (!live.length)
|
|
991
|
+
return;
|
|
992
|
+
let status, admin = null;
|
|
993
|
+
const guests = new Map();
|
|
994
|
+
try {
|
|
995
|
+
status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return;
|
|
999
|
+
} // nothing to send this tick; the next one tries again
|
|
1000
|
+
const frameFor = (s) => {
|
|
1001
|
+
if (!s.scope)
|
|
1002
|
+
return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
|
|
1003
|
+
const scope = s.scope(), key = [...scope].sort().join("\u0000");
|
|
1004
|
+
let frame = guests.get(key);
|
|
1005
|
+
if (frame === undefined)
|
|
1006
|
+
guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
|
|
1007
|
+
return frame;
|
|
1008
|
+
};
|
|
1009
|
+
for (const s of live) {
|
|
1010
|
+
let frame;
|
|
1011
|
+
try {
|
|
1012
|
+
frame = frameFor(s);
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
s.write(frame);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
974
1020
|
export function handleWebRequest(req, res, url, ctx) {
|
|
975
1021
|
const path = url.pathname;
|
|
976
1022
|
const method = req.method ?? "GET";
|
|
@@ -1021,6 +1067,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1021
1067
|
try {
|
|
1022
1068
|
const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
|
|
1023
1069
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
1070
|
+
// #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
|
|
1071
|
+
if (path === ROUTE_WORKER_PATH)
|
|
1072
|
+
res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
|
|
1024
1073
|
res.writeHead(200);
|
|
1025
1074
|
res.end(js);
|
|
1026
1075
|
}
|
|
@@ -1029,6 +1078,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1029
1078
|
}
|
|
1030
1079
|
return true;
|
|
1031
1080
|
}
|
|
1081
|
+
// #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
|
|
1082
|
+
const vendor = method === "GET" ? libavoidFile(path) : null;
|
|
1083
|
+
if (vendor) {
|
|
1084
|
+
try {
|
|
1085
|
+
const body = readFileSync(vendor.file);
|
|
1086
|
+
res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
|
|
1087
|
+
res.end(body);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
json(res, 404, { error: "File not found" });
|
|
1091
|
+
}
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1032
1094
|
// ── Backend detection ─────────────────────────────────
|
|
1033
1095
|
// #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
|
|
1034
1096
|
if (method === "GET" && path === "/ui/models") {
|
|
@@ -1171,13 +1233,17 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1171
1233
|
if (!guest && ctx.needsYouItems)
|
|
1172
1234
|
res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
|
|
1173
1235
|
ctx.sseClients.add(res);
|
|
1174
|
-
|
|
1236
|
+
// #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
|
|
1237
|
+
// this stream's own projection; this stream's checks stay its own.
|
|
1238
|
+
const unsubscribe = subscribeStatusTick(ctx, {
|
|
1175
1239
|
// A stream authorized once must not outlive the authorization. Re-checked
|
|
1176
1240
|
// without counting as activity, or an open tab would keep an idle session
|
|
1177
1241
|
// alive forever; a revoked, expired or rotated-away session ends here.
|
|
1178
1242
|
// #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
|
|
1179
1243
|
// still authenticating does not keep a revoked device's stream (with its scope) alive.
|
|
1180
|
-
|
|
1244
|
+
alive: () => {
|
|
1245
|
+
if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
|
|
1246
|
+
return true;
|
|
1181
1247
|
// Close the connection too, not just the response: an ended response leaves the socket idle in
|
|
1182
1248
|
// keep-alive, still held open by whoever's session just ended.
|
|
1183
1249
|
const socket = req.socket;
|
|
@@ -1186,23 +1252,23 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1186
1252
|
}
|
|
1187
1253
|
catch { /* already closed */ }
|
|
1188
1254
|
cleanup();
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
res.write(
|
|
1255
|
+
return false;
|
|
1256
|
+
},
|
|
1257
|
+
scope: guest ? () => guest.scope() : null,
|
|
1258
|
+
write: (frame) => { try {
|
|
1259
|
+
res.write(frame + sseClock());
|
|
1194
1260
|
}
|
|
1195
1261
|
catch {
|
|
1196
1262
|
cleanup();
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1263
|
+
} },
|
|
1264
|
+
});
|
|
1199
1265
|
let cleanedUp = false;
|
|
1200
1266
|
const cleanup = () => {
|
|
1201
1267
|
if (cleanedUp)
|
|
1202
1268
|
return;
|
|
1203
1269
|
cleanedUp = true;
|
|
1204
1270
|
ctx.sseClients.delete(res);
|
|
1205
|
-
|
|
1271
|
+
unsubscribe();
|
|
1206
1272
|
};
|
|
1207
1273
|
// The *response's* `close` is the one that means the stream is over: it fires when the connection
|
|
1208
1274
|
// goes away or the response is finished. The request's `close` means "the request has been read"
|