@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.4
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/dist/agent-cli.js +6 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/auth-api.js +2 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/claude-code.d.ts +39 -2
- package/dist/backend/claude-code.js +115 -31
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +33 -3
- package/dist/backend/credential-profile.js +107 -5
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/types.d.ts +1 -1
- package/dist/cache-service.d.ts +2 -0
- package/dist/cache-service.js +2 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -1
- package/dist/channel/adapters/discord.js +42 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +18 -3
- package/dist/channel/adapters/telegram.js +66 -10
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.js +5 -4
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +27 -4
- package/dist/config-validator.js +10 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +108 -0
- package/dist/daemon.js +432 -32
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +29 -7
- package/dist/delivery-outbox.js +134 -16
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-context.d.ts +1 -0
- package/dist/fleet-manager.d.ts +81 -4
- package/dist/fleet-manager.js +452 -85
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
- package/dist/host-memory.d.ts +10 -0
- package/dist/host-memory.js +33 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +25 -6
- package/dist/instance-lifecycle.js +73 -26
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +48 -18
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +20 -3
- package/dist/login-controller.js +71 -19
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +5 -2
- package/dist/login-flows.js +5 -4
- package/dist/login-flows.js.map +1 -1
- package/dist/memory-pressure.d.ts +13 -1
- package/dist/memory-pressure.js +50 -10
- package/dist/memory-pressure.js.map +1 -1
- package/dist/outbound-handlers.d.ts +63 -0
- package/dist/outbound-handlers.js +121 -46
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -2
- package/dist/outbound-schemas.js +3 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +8 -5
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.js +10 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +2 -2
- package/dist/spawn-gate.js +5 -1
- package/dist/spawn-gate.js.map +1 -1
- package/dist/spawn-hold-reply.d.ts +12 -0
- package/dist/spawn-hold-reply.js +39 -0
- package/dist/spawn-hold-reply.js.map +1 -0
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/topic-commands.js +9 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/turn-resume.d.ts +2 -0
- package/dist/turn-resume.js +1 -1
- package/dist/turn-resume.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +25 -0
- package/dist/ui/chat-store.js +1 -1
- package/dist/ui/chat-strings.js +12 -12
- package/dist/ui/chat-thread.js +48 -11
- package/dist/ui/panel-chat.js +20 -9
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +51 -22
- package/dist/ui/panel-settings.js +18 -9
- package/dist/ui/preview-panel.js +48 -14
- package/dist/ui/preview.js +5 -1
- package/dist/ui/settings-strings.js +6 -2
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-i18n.js +35 -13
- package/dist/ui/shared/app-prefs.js +249 -0
- package/dist/ui/shared/app-shell.js +17 -4
- package/dist/ui/shared/app-store.js +5 -1
- package/dist/ui/shared/app.css +41 -7
- package/dist/ui/shared/app.js +6 -1
- package/dist/ui/shared/header-tools.js +55 -5
- package/dist/ui/shared/instance-nav.js +131 -38
- package/dist/ui/shared/panel-view.js +6 -0
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +13 -4
- package/dist/ui/shared/view-strings.js +26 -2
- package/dist/unrecognised-prompt.d.ts +23 -0
- package/dist/unrecognised-prompt.js +74 -0
- package/dist/unrecognised-prompt.js.map +1 -0
- package/dist/view-api.js +5 -0
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +13 -1
- package/dist/web-api.js +218 -18
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +3 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-mentions.d.ts +44 -0
- package/dist/web-mentions.js +75 -0
- package/dist/web-mentions.js.map +1 -0
- package/dist/web-prefs.d.ts +63 -0
- package/dist/web-prefs.js +263 -0
- package/dist/web-prefs.js.map +1 -0
- package/dist/web-session.js +1 -1
- package/dist/web-session.js.map +1 -1
- package/package.json +1 -1
|
@@ -258,9 +258,10 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
/**
|
|
261
|
-
* #1604:
|
|
262
|
-
*
|
|
263
|
-
* the chat tour (replayed on the
|
|
261
|
+
* #1604, #1665: Preferences. Synced across the person's devices (kept on the host, app-prefs.js): theme (theme.js),
|
|
262
|
+
* language (app-i18n.js) and HTML previews (preview.js, through the app's confirm). On the public link they are
|
|
263
|
+
* read-only: a change there stays in that browser, and it says so. This device only: the chat tour (replayed on the
|
|
264
|
+
* chat it points at), and the layout choices named there (their controls are where they always were).
|
|
264
265
|
*/
|
|
265
266
|
export function DeviceSection() {
|
|
266
267
|
const P = () => globalThis.AgendPreview;
|
|
@@ -291,11 +292,15 @@ export function DeviceSection() {
|
|
|
291
292
|
};
|
|
292
293
|
setTimeout(go, 0);
|
|
293
294
|
};
|
|
294
|
-
|
|
295
|
-
|
|
295
|
+
const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
|
|
296
|
+
return html`<section class="card s-device s-synced"><h3>${tn("syncedTitle")}</h3>
|
|
297
|
+
<p class="sub">${publicLink ? tn("syncedPublicLink") : tn("syncedLead")}</p>
|
|
296
298
|
<${DevicePrefs} labels=${true} />
|
|
297
299
|
${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
298
300
|
<span>${t("chat.previews")}</span></label>` : null}
|
|
301
|
+
</section>
|
|
302
|
+
<section class="card s-device s-local"><h3>${tn("deviceTitle")}</h3>
|
|
303
|
+
<p class="sub">${tn("deviceLead")}</p>
|
|
299
304
|
<div class="s-device-row"><button type="button" id="tourBtn" class="btn" title=${t("chat.tourBtnTitle")} onClick=${replay}><${Icon} name="info" size=${16} />${t("chat.tour")}</button></div>
|
|
300
305
|
</section>`;
|
|
301
306
|
}
|
|
@@ -367,11 +372,13 @@ function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
|
|
|
367
372
|
async pauseWake(name, action) {
|
|
368
373
|
const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
|
|
369
374
|
if (!res.ok) toast((res.body && res.body.error) || tn("actionFailed", action), false);
|
|
375
|
+
else if (res.body && res.body.held) toast(res.body.message);
|
|
370
376
|
await reloadLive();
|
|
371
377
|
},
|
|
372
378
|
async startStop(name, running) {
|
|
373
379
|
const res = await api(running ? `/stop/${encodeURIComponent(name)}` : `/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
|
|
374
380
|
if (!res.ok) toast(`${tn("actionFailed", running ? tn("stop") : tn("start"))}: ${(res.body && res.body.error) || res.status || ""}`, false);
|
|
381
|
+
else if (res.body && res.body.held) toast(res.body.message);
|
|
375
382
|
await new Promise((r) => setTimeout(r, 800));
|
|
376
383
|
await reloadLive();
|
|
377
384
|
},
|
|
@@ -726,7 +733,9 @@ function WebSection({ ctx, staged }) {
|
|
|
726
733
|
// Empty = the default (null in the write: the key is removed from fleet.yaml).
|
|
727
734
|
const value = (v) => (v.trim() === "" ? null : Number(v));
|
|
728
735
|
const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
|
|
729
|
-
|
|
736
|
+
// The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY); an idle time left blank follows a shorter session, as
|
|
737
|
+
// the store and the validator apply it — so only an idle time that is typed can exceed the session length.
|
|
738
|
+
const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
|
|
730
739
|
const errors = {
|
|
731
740
|
ttl: !Number.isInteger(Number(f.ttl)) || Number(f.ttl) < 1 || Number(f.ttl) > 480 ? "publicTtl" : "",
|
|
732
741
|
abs: hours(f.abs),
|
|
@@ -771,9 +780,9 @@ function WebSection({ ctx, staged }) {
|
|
|
771
780
|
<section class="card"><h3>${tn("webSessionTitle")}</h3>
|
|
772
781
|
<p class="note">${tn("webSessionHint")}</p>
|
|
773
782
|
<div class="grid2">
|
|
774
|
-
<div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours",
|
|
783
|
+
<div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
|
|
775
784
|
class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} /></div>
|
|
776
|
-
<div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours",
|
|
785
|
+
<div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
|
|
777
786
|
class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} /></div>
|
|
778
787
|
</div>
|
|
779
788
|
</section>
|
|
@@ -840,7 +849,7 @@ function Developer({ ctx }) {
|
|
|
840
849
|
<button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
|
|
841
850
|
<button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
|
|
842
851
|
</div>
|
|
843
|
-
${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck
|
|
852
|
+
${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck=${false} wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
|
|
844
853
|
<div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
|
|
845
854
|
: html`<pre class="s-yaml">${text}</pre>`}
|
|
846
855
|
${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
|
package/dist/ui/preview-panel.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// #1481: an agent's HTML preview (#1306) in a panel beside the conversation — a split with a draggable divider on a
|
|
2
2
|
// wide screen, a full-screen sheet on a narrow one. Nothing about the preview itself changes here: the frame is the one
|
|
3
3
|
// preview.js makes (mountPreview: the preview origin, sandbox allow-scripts only), started by AgendPreview.start like a
|
|
4
|
-
// card's, only told to fill the panel. It runs on a click, as a card's does:
|
|
5
|
-
// -
|
|
4
|
+
// card's, only told to fill the panel. It runs on a person's click, as a card's does — and "Open in panel" is that click:
|
|
5
|
+
// - on an idle card it opens the panel and starts the preview there, as if Preview were clicked in the panel (#1704);
|
|
6
|
+
// a device that has not allowed previews gets the "Allow previews" offer instead, and allowing it starts the run;
|
|
7
|
+
// where previews cannot run (web.preview: false, a public link), the panel opens with nothing running;
|
|
6
8
|
// - on a card whose preview is running, the same button moves that run to the panel (stopped in the card, started in
|
|
7
9
|
// the panel: a moved frame reloads anyway);
|
|
8
|
-
// -
|
|
10
|
+
// - nothing runs by itself: not when the page loads, not when the panel is shown again, and a newer version of the
|
|
11
|
+
// block in a later reply is offered, never swapped in, and shown with Preview to click.
|
|
9
12
|
// Closing the panel or leaving the chat stops its frame; one preview per page still holds (start() stops any other).
|
|
10
13
|
import { html, useEffect, useRef, useState } from "/assets/app-html.js";
|
|
11
14
|
import { t } from "/assets/app-i18n.js";
|
|
@@ -19,18 +22,29 @@ const P = () => globalThis.AgendPreview;
|
|
|
19
22
|
|
|
20
23
|
// ── The panel's state, one per page (the chat shows one instance at a time) ──
|
|
21
24
|
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4);
|
|
27
|
+
* run: start it on mount — the card's click, as a token no later open ever reuses (0: nothing to run).
|
|
28
|
+
*/
|
|
23
29
|
let state = { open: null, run: 0 };
|
|
30
|
+
let runs = 0, version = 0, claims = 0;
|
|
24
31
|
const subs = new Set();
|
|
25
|
-
function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
|
|
32
|
+
function set(next) { state = next; version++; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
|
|
26
33
|
export const panelStore = { get: () => state, subscribe(fn) { subs.add(fn); return () => { subs.delete(fn); }; } };
|
|
34
|
+
/** Moves on every open and close, and on every claim: what a card's click compares when its HTML arrives late. */
|
|
35
|
+
export const panelVersion = () => version;
|
|
36
|
+
/** A click that will show something in the panel (an attachment's HTML still to read): from now on it is the newest —
|
|
37
|
+
* any earlier click still reading is dropped when its answer comes. Returns the version the click owns. */
|
|
38
|
+
export function claimPanel() { claims++; return ++version; }
|
|
39
|
+
/** How many clicks have claimed: an inline Preview still reading asks only whether a newer click came (not a close). */
|
|
40
|
+
export const panelClaims = () => claims;
|
|
27
41
|
|
|
28
42
|
/** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
|
|
29
43
|
export const frameKey = (key) => `panel:${key}`;
|
|
30
|
-
/** Show `spec` in the panel; `run`: start it there (
|
|
44
|
+
/** Show `spec` in the panel; `run`: start it there (the card's "Open in panel" click). The block shown before stops. */
|
|
31
45
|
export function openPanel(spec, { run = false } = {}) {
|
|
32
46
|
if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
|
|
33
|
-
set({ open: { ...spec }, run: run ?
|
|
47
|
+
set({ open: { ...spec }, run: run ? ++runs : 0 });
|
|
34
48
|
}
|
|
35
49
|
/** Close the panel: its frame stops first. */
|
|
36
50
|
export function closePanel() {
|
|
@@ -105,7 +119,7 @@ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digi
|
|
|
105
119
|
* props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
|
|
106
120
|
* reveal(msgKey) (the thread shows that message), download(code).
|
|
107
121
|
*/
|
|
108
|
-
/** allow(): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then setOptIn) → the choice. */
|
|
122
|
+
/** allow(commit): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then commit(setOptIn)) → the choice. */
|
|
109
123
|
export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download, allow }) {
|
|
110
124
|
const [st, setSt] = useState(state);
|
|
111
125
|
const [view, setView] = useState("preview");
|
|
@@ -113,13 +127,17 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
|
|
|
113
127
|
const [newer, setNewer] = useState(null);
|
|
114
128
|
const [newerNote, setNewerNote] = useState("");
|
|
115
129
|
const reading = useRef(null); // the newer version whose file is being read (one at a time)
|
|
130
|
+
const pending = useRef(0); // #1704: the card's click, waiting for this device's Allow (the st.run it came with)
|
|
131
|
+
const ownAllow = useRef(0); // while this panel's own Allow commits its choice: the click it answers (not cancelled by it)
|
|
116
132
|
const holder = useRef(null), divider = useRef(null), panel = useRef(null);
|
|
117
133
|
const open = st.open && st.open.instance === name ? st.open : null;
|
|
118
134
|
|
|
119
135
|
useEffect(() => panelStore.subscribe(setSt), []);
|
|
120
136
|
// This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
|
|
137
|
+
// A click waiting for Allow is cancelled by any other change of that choice (Never, a card's menu, another tab):
|
|
138
|
+
// after it, running needs a new click — only this panel's own Allow runs the click it was offered for.
|
|
121
139
|
const [, choice] = useState(0);
|
|
122
|
-
useEffect(() => (P() ? P().onChange(() => choice(n => n + 1)) : undefined), []);
|
|
140
|
+
useEffect(() => (P() ? P().onChange(() => { if (pending.current && ownAllow.current !== pending.current) pending.current = 0; choice(n => n + 1); }) : undefined), []);
|
|
123
141
|
// Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
|
|
124
142
|
useEffect(() => () => closePanel(), []);
|
|
125
143
|
|
|
@@ -130,11 +148,17 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
|
|
|
130
148
|
P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
|
|
131
149
|
};
|
|
132
150
|
const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
|
|
133
|
-
// A new block in the panel: what ran before is gone (openPanel stopped it);
|
|
151
|
+
// A new block in the panel: what ran before is gone (openPanel stopped it); the card's click starts it here — or,
|
|
152
|
+
// where it cannot run, waits as `pending`: only the Allow below can use it (offered to a device that has not opted
|
|
153
|
+
// in), only while the panel shows what that click opened (st.run is a token no later open reuses), and any other
|
|
154
|
+
// change of this device's choice cancels it.
|
|
134
155
|
useEffect(() => {
|
|
135
156
|
setRun({ state: "idle", reason: "" });
|
|
136
157
|
setView("preview");
|
|
137
|
-
if (open && st.run)
|
|
158
|
+
if (open && st.run) {
|
|
159
|
+
if (P().availability().ok) start();
|
|
160
|
+
else pending.current = st.run;
|
|
161
|
+
}
|
|
138
162
|
// Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
|
|
139
163
|
if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
|
|
140
164
|
}, [open && open.key, st.run]);
|
|
@@ -204,11 +228,21 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
|
|
|
204
228
|
if (!open) return null;
|
|
205
229
|
const going = run.state === "starting" || run.state === "running";
|
|
206
230
|
const a = P().availability();
|
|
207
|
-
// #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm)
|
|
208
|
-
// is then enabled — still a click. Why a run stopped
|
|
231
|
+
// #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm). Allowing
|
|
232
|
+
// runs the block the card's click opened (#1704); otherwise Preview is then enabled — still a click. Why a run stopped
|
|
233
|
+
// (e.g. switched off in another tab) is still said first.
|
|
209
234
|
const askOptIn = !going && !a.ok && a.why === "optin" && typeof allow === "function";
|
|
210
235
|
const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : askOptIn ? t("chat.pvWhyOptinPanel") : a.reason);
|
|
211
|
-
|
|
236
|
+
// Allowed here: the click that opened the panel runs now — if it is still waiting (no other change of the choice
|
|
237
|
+
// cancelled it, even while the confirm was open: only the change this Allow commits is its own) and the panel still
|
|
238
|
+
// shows what it opened (the confirm is a wait: the panel may have moved on meanwhile).
|
|
239
|
+
const allowHere = async () => {
|
|
240
|
+
const want = pending.current;
|
|
241
|
+
const yes = await allow((apply) => { ownAllow.current = want; try { apply(); } finally { ownAllow.current = 0; } });
|
|
242
|
+
if (!yes) return;
|
|
243
|
+
setRun({ state: "idle", reason: "" });
|
|
244
|
+
if (want && pending.current === want && panelStore.get().run === want) start();
|
|
245
|
+
};
|
|
212
246
|
const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
|
|
213
247
|
return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
|
|
214
248
|
aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
|
package/dist/ui/preview.js
CHANGED
|
@@ -197,11 +197,15 @@
|
|
|
197
197
|
* opts.fill (#1481, the side panel): the frame fills its holder — the same frame from mountPreview, only sized by the
|
|
198
198
|
* panel instead of by the frame's height messages, which it then ignores.
|
|
199
199
|
*/
|
|
200
|
+
/** How many previews this page has started: a click whose HTML arrives late asks whether another started meanwhile. */
|
|
201
|
+
var starts = 0;
|
|
202
|
+
function startCount() { return starts; }
|
|
200
203
|
function start(key, holder, html, ui, opts) {
|
|
201
204
|
var a = availability();
|
|
202
205
|
if (!a.ok) { ui.state("unavailable", a.reason); return false; }
|
|
203
206
|
if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", say("pvHtmlOver", "This HTML is over 1 MiB; it is not previewed.")); return false; }
|
|
204
207
|
stopAll("another");
|
|
208
|
+
starts++;
|
|
205
209
|
var doc = holder.ownerDocument;
|
|
206
210
|
var iframe = mountPreview(doc);
|
|
207
211
|
var card = {
|
|
@@ -330,7 +334,7 @@
|
|
|
330
334
|
|
|
331
335
|
return {
|
|
332
336
|
init: init, availability: availability, optedIn: optedIn, setOptIn: setOptIn, never: never, setNever: setNever, onChange: onChange, listeners: listeners, onStorage: onStorage,
|
|
333
|
-
start: start, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
|
|
337
|
+
start: start, startCount: startCount, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
|
|
334
338
|
mountPreview: mountPreview, onMessage: onMessage, BANNER: BANNER, LIMITS: LIMITS,
|
|
335
339
|
/** The banner every running preview carries, in the page's language (#1554); BANNER is its English. */
|
|
336
340
|
banner: function () { return say("pvBanner", BANNER); },
|
|
@@ -217,7 +217,9 @@ register("settings", {
|
|
|
217
217
|
concurrencyWarning: "May exceed CPU capacity on smaller hosts",
|
|
218
218
|
staggerRange: "Must be between 0 and 30000",
|
|
219
219
|
staggerWarning: "Very low values can cause startup spikes",
|
|
220
|
-
sec_device: "
|
|
220
|
+
sec_device: "Preferences", syncedTitle: "Synced across your devices", syncedLead: "Changes here apply on every device where you are signed in.",
|
|
221
|
+
syncedPublicLink: "On the public link these are read-only: a change here stays in this browser.",
|
|
222
|
+
deviceTitle: "This device only", deviceLead: "The tour, text size, view fit, the sidebar shown or hidden, panel widths and desktop notifications stay on each device.",
|
|
221
223
|
sec_agents: "Agents",
|
|
222
224
|
sec_bots: "Connections",
|
|
223
225
|
sec_classic: "ClassicBot",
|
|
@@ -580,7 +582,9 @@ register("settings", {
|
|
|
580
582
|
concurrencyWarning: "在較小的主機上可能超出 CPU 負荷",
|
|
581
583
|
staggerRange: "必須介於 0 到 30000",
|
|
582
584
|
staggerWarning: "數值太低可能造成啟動尖峰",
|
|
583
|
-
sec_device: "
|
|
585
|
+
sec_device: "偏好設定", syncedTitle: "在你所有的裝置同步", syncedLead: "這裡的變更會套用到你登入的每一台裝置。",
|
|
586
|
+
syncedPublicLink: "公開連結只能讀取這些設定:在這裡變更只會留在這個瀏覽器。",
|
|
587
|
+
deviceTitle: "只限這台裝置", deviceLead: "導覽、文字大小、視圖寬度、側欄顯示或隱藏、面板寬度與桌面通知,每台裝置各自保留。",
|
|
584
588
|
sec_agents: "Agent",
|
|
585
589
|
sec_bots: "連線",
|
|
586
590
|
sec_classic: "ClassicBot",
|
|
@@ -31,7 +31,7 @@ export function TokenField({ id, platform, value, onInput, identity, busy, onVer
|
|
|
31
31
|
const [shown, setShown] = useState(false);
|
|
32
32
|
return html`<div class="field token-field"><label for=${id}>${tn("wizardToken")}</label>
|
|
33
33
|
<div class="token-row">
|
|
34
|
-
<input id=${id} type=${shown ? "text" : "password"} autocomplete="off" autocapitalize="off" spellcheck
|
|
34
|
+
<input id=${id} type=${shown ? "text" : "password"} autocomplete="off" autocapitalize="off" spellcheck=${false} disabled=${!!busy}
|
|
35
35
|
placeholder=${platform === "discord" ? "MTA…" : "123456:ABC-DEF…"} value=${value} onInput=${(e) => onInput(e.target.value.trim())} />
|
|
36
36
|
<button type="button" class="icon-btn" aria-pressed=${shown ? "true" : "false"} title=${shown ? tn("tokenHide") : tn("tokenShow")}
|
|
37
37
|
aria-label=${shown ? tn("tokenHide") : tn("tokenShow")} onClick=${() => setShown(!shown)}><${Icon} name="eye" size=${16} /></button>
|
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
// #1408 §4: one dictionary for the app, namespaced (app.*, chat.*, fleet.*, …). A panel registers its own namespace
|
|
2
2
|
// when it loads; t("chat.send") resolves it in the chosen language and falls back to English, then to the key.
|
|
3
|
-
// The language is
|
|
3
|
+
// The language is the person's choice — kept on the host since #1665 and cached in this browser as agend_lang, the key
|
|
4
|
+
// every panel has used — else the device's.
|
|
4
5
|
|
|
5
6
|
const tables = { en: {}, "zh-TW": {} };
|
|
6
7
|
const listeners = new Set();
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/** The device's own language: what applies when none is chosen. */
|
|
10
|
+
export function deviceLanguage() { return typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "") ? "zh-TW" : "en"; }
|
|
11
|
+
function cacheLang(v) { try { if (v) localStorage.setItem("agend_lang", v); else localStorage.removeItem("agend_lang"); } catch { /* this page only */ } }
|
|
12
|
+
/**
|
|
13
|
+
* #1665: the signed-in page carries the host's values (<html data-prefs>) — the same rule as theme.js: the host's
|
|
14
|
+
* language is this page's (the browser's copy is refreshed, best effort); none chosen on the host and this device in
|
|
15
|
+
* step with it: the device's language; not in step yet, or no data-prefs: this browser's copy, else the device's.
|
|
16
|
+
*/
|
|
17
|
+
function initialLang() {
|
|
18
|
+
let d = null;
|
|
19
|
+
try { d = JSON.parse(document.documentElement.getAttribute("data-prefs") || "null"); } catch { d = null; }
|
|
20
|
+
if (d && typeof d === "object" && d.values && typeof d.values === "object") {
|
|
21
|
+
const host = d.values.lang;
|
|
22
|
+
if (host === "en" || host === "zh-TW") { cacheLang(host); return host; }
|
|
23
|
+
let inStep = false;
|
|
24
|
+
try { inStep = localStorage.getItem("agend_prefs_synced") === "1"; } catch { inStep = false; }
|
|
25
|
+
if (inStep) { cacheLang(null); return deviceLanguage(); }
|
|
26
|
+
}
|
|
27
|
+
try { const v = localStorage.getItem("agend_lang"); if (v === "en" || v === "zh-TW") return v; } catch { /* none */ }
|
|
28
|
+
return deviceLanguage();
|
|
10
29
|
}
|
|
11
|
-
let current =
|
|
30
|
+
let current = initialLang();
|
|
12
31
|
|
|
13
32
|
/** Add a namespace: `register("chat", { en: {...}, "zh-TW": {...} })`. */
|
|
14
33
|
export function register(ns, byLang) {
|
|
@@ -19,11 +38,14 @@ export function register(ns, byLang) {
|
|
|
19
38
|
|
|
20
39
|
export function lang() { return current; }
|
|
21
40
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Choose a language; every subscriber re-renders (a new navigation lease, §4). `{ store: false }`: this page only —
|
|
43
|
+
* the host's "none chosen" arriving from another device (app-prefs.js), which leaves no copy in this browser.
|
|
44
|
+
*/
|
|
45
|
+
export function setLang(next, opts = {}) {
|
|
24
46
|
if (next !== "en" && next !== "zh-TW") return;
|
|
25
47
|
current = next;
|
|
26
|
-
|
|
48
|
+
if (opts.store !== false) cacheLang(next);
|
|
27
49
|
if (typeof document !== "undefined" && document.documentElement) document.documentElement.lang = next === "zh-TW" ? "zh-Hant" : "en";
|
|
28
50
|
for (const fn of listeners) fn(next);
|
|
29
51
|
}
|
|
@@ -38,12 +60,12 @@ export function t(key, ...values) {
|
|
|
38
60
|
|
|
39
61
|
register("app", {
|
|
40
62
|
en: {
|
|
41
|
-
brand: "AgEnD", fleet: "Fleet", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
|
|
63
|
+
brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet overview", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
|
|
42
64
|
collapse: "Hide the sidebar", expand: "Show the sidebar", openMenu: "Open the sidebar", closeMenu: "Close the sidebar",
|
|
43
65
|
theme: "Theme", themeSystem: "System", themeLight: "Light", themeDark: "Dark", language: "Language",
|
|
44
|
-
previews: "Allow HTML previews
|
|
66
|
+
previews: "Allow HTML previews", tour: "Tour", tourTitle: "Show the web chat tour again",
|
|
45
67
|
needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
|
|
46
|
-
textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
|
|
68
|
+
textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage", usageHide: "Hide usage",
|
|
47
69
|
statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
|
|
48
70
|
connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
|
|
49
71
|
hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
|
|
@@ -67,12 +89,12 @@ register("app", {
|
|
|
67
89
|
needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed",
|
|
68
90
|
},
|
|
69
91
|
"zh-TW": {
|
|
70
|
-
brand: "AgEnD", fleet: "Fleet", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
|
|
92
|
+
brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
|
|
71
93
|
collapse: "隱藏側邊欄", expand: "顯示側邊欄", openMenu: "打開側邊欄", closeMenu: "關閉側邊欄",
|
|
72
94
|
theme: "主題", themeSystem: "跟隨系統", themeLight: "淺色", themeDark: "深色", language: "語言",
|
|
73
|
-
previews: "
|
|
95
|
+
previews: "允許預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
|
|
74
96
|
needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
|
|
75
|
-
textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
|
|
97
|
+
textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量", usageHide: "隱藏用量",
|
|
76
98
|
statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
|
|
77
99
|
connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
|
|
78
100
|
hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// #1665: the web preferences that follow the person between devices — kept on the host (/ui/prefs), with this browser's
|
|
2
|
+
// localStorage, under the keys every panel already reads, as the cache. Design: the comment on #1665.
|
|
3
|
+
//
|
|
4
|
+
// - Synced: theme, language, the sidebar (order, folds, filter, favorites — #1664) and the HTML-preview opt-in. The tour and the first-run
|
|
5
|
+
// card stay on each device, as every layout choice does.
|
|
6
|
+
// - The signed-in page is served with the host's values (<html data-prefs>). This module, imported before any panel
|
|
7
|
+
// reads its key, copies them into the cache: the host wins once it has a value — and, on a device already in step
|
|
8
|
+
// with it, a key the host does not have is the default (its stale copy here is cleared). theme.js and the
|
|
9
|
+
// dictionary did the same for the theme and language, before paint.
|
|
10
|
+
// - A change made here is cached at once and sent to the host (a short debounce gathers a burst, such as typing in the
|
|
11
|
+
// filter). Writes go one at a time; a key stays this page's own from the change until its write has ended, so no
|
|
12
|
+
// answer — a read or another write — that started before can put an older value back. Every answer carries the
|
|
13
|
+
// host's revision, and one older than what this page already has is ignored.
|
|
14
|
+
// - The public link, and the anonymous View reader, have no host write: theirs stays local.
|
|
15
|
+
// - The first load on a device (no agend_prefs_synced yet) uploads what it had locally for the keys the host does not
|
|
16
|
+
// have yet, each with onlyIfUnset — the first device to arrive wins — then reads the host's values back. Only once
|
|
17
|
+
// every upload got an answer and that read succeeded is the device marked: anything else tries again next load.
|
|
18
|
+
// - The status frame's prefs_rev: another device changed something; the host's values are read again and applied. A
|
|
19
|
+
// revision counts as seen only once its read succeeded (a failed one is tried again, at most every few seconds).
|
|
20
|
+
import { deviceLanguage, lang, setLang } from "./app-i18n.js";
|
|
21
|
+
import { appStore } from "./app-store.js";
|
|
22
|
+
|
|
23
|
+
const SYNCED_FLAG = "agend_prefs_synced";
|
|
24
|
+
const RETRY_MS = 5000;
|
|
25
|
+
const json = { toLocal: (v) => JSON.stringify(v), fromLocal: (s) => { try { return s == null ? undefined : JSON.parse(s); } catch { return undefined; } } };
|
|
26
|
+
/** Each synced preference: its cache key, and how a host value is cached (null: no entry) and read back. */
|
|
27
|
+
export const SYNCED = {
|
|
28
|
+
theme: { ls: "agend_theme", toLocal: (v) => (v === "light" || v === "dark" ? v : null), fromLocal: (s) => (s === "light" || s === "dark" ? s : undefined) },
|
|
29
|
+
lang: { ls: "agend_lang", toLocal: (v) => (v === "en" || v === "zh-TW" ? v : null), fromLocal: (s) => (s === "en" || s === "zh-TW" ? s : undefined) },
|
|
30
|
+
"nav.order": { ls: "agend_view_sidebar_order", ...json },
|
|
31
|
+
"nav.collapsed": { ls: "agend_instance_collapsed", ...json },
|
|
32
|
+
"nav.filter": { ls: "agend_instance_filter", ...json },
|
|
33
|
+
"nav.favorites": { ls: "agend_favorites", ...json }, // #1664
|
|
34
|
+
"preview.optIn": { ls: "agend_html_preview", toLocal: (v) => (v ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
|
|
35
|
+
};
|
|
36
|
+
const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
|
|
37
|
+
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
38
|
+
|
|
39
|
+
function readLocal(key) { try { return localStorage.getItem(key); } catch { return null; } }
|
|
40
|
+
function writeLocal(key, s) { try { if (s == null) localStorage.removeItem(key); else localStorage.setItem(key, s); } catch { /* this page only */ } }
|
|
41
|
+
function cache(key, value) { const k = SYNCED[key]; writeLocal(k.ls, value == null ? null : k.toLocal(value)); }
|
|
42
|
+
const inStep = () => readLocal(SYNCED_FLAG) === "1";
|
|
43
|
+
|
|
44
|
+
function pageHost() {
|
|
45
|
+
try {
|
|
46
|
+
const d = JSON.parse(document.documentElement.getAttribute("data-prefs") || "null");
|
|
47
|
+
return d && typeof d === "object" && typeof d.rev === "number" && d.values && typeof d.values === "object" ? d : null;
|
|
48
|
+
} catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const initial = typeof document !== "undefined" ? pageHost() : null;
|
|
52
|
+
let rev = initial ? initial.rev : null;
|
|
53
|
+
let host = initial ? initial.values : null;
|
|
54
|
+
const pending = new Map(); // key → value chosen here, not sent yet
|
|
55
|
+
const mine = new Map(); // key → value chosen here whose write is not over (pending or out)
|
|
56
|
+
const inflight = new Map(); // key → writes sent and not answered yet
|
|
57
|
+
let flushTimer = null;
|
|
58
|
+
let writes = Promise.resolve(); // one write at a time, in the order they were made
|
|
59
|
+
const listeners = new Set();
|
|
60
|
+
|
|
61
|
+
// The host's values into the cache, now — before the sidebar, the tour or the first-run card read their keys. A key
|
|
62
|
+
// the host does not have: the default, on a device in step with the host; this device's own (to upload) otherwise.
|
|
63
|
+
if (host) {
|
|
64
|
+
const step = inStep();
|
|
65
|
+
for (const key of Object.keys(SYNCED)) {
|
|
66
|
+
if (own(host, key)) cache(key, host[key]);
|
|
67
|
+
else if (step) cache(key, null);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** This page may change the host's preferences: the signed-in app, not over the public link. */
|
|
72
|
+
export function prefsWritable() {
|
|
73
|
+
return !!initial && !(typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The value in force for a key, in memory — never only in this browser's storage (a full storage must not bring back
|
|
77
|
+
* an old value): this page's own choice while its write is not over; else the host's; else, on a device in step with
|
|
78
|
+
* the host, the default ({ value: undefined }). null: not known here (no host values on this page, or a device not yet
|
|
79
|
+
* in step) — the caller reads this browser's copy.
|
|
80
|
+
*/
|
|
81
|
+
export function prefInForce(key) {
|
|
82
|
+
if (!own(SYNCED, key)) return null;
|
|
83
|
+
if (mine.has(key)) return { value: mine.get(key) };
|
|
84
|
+
if (host && own(host, key)) return { value: host[key] };
|
|
85
|
+
if (initial && inStep()) return { value: undefined };
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** The host's value for a key as this page last saw it (undefined: not set there, or not known). */
|
|
89
|
+
export function hostPref(key) { return host && own(host, key) ? host[key] : undefined; }
|
|
90
|
+
/** Called with the keys another device changed, after they are cached and applied here. Returns the unsubscribe. */
|
|
91
|
+
export function onPrefsChanged(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
92
|
+
|
|
93
|
+
/** A host answer ({ values, rev }): a real view of the host (not an unreadable store's defaults)? */
|
|
94
|
+
const isView = (body) => !!body && typeof body.rev === "number" && !!body.values && typeof body.values === "object" && !body.unreadable;
|
|
95
|
+
/** Take a view as this page's — unless it is older than the one it has. True: taken. */
|
|
96
|
+
function take(body) {
|
|
97
|
+
if (!isView(body) || (rev !== null && body.rev < rev)) return false;
|
|
98
|
+
rev = body.rev;
|
|
99
|
+
host = body.values;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A preference chosen on this page: cached now, and kept on the host (a burst within 400 ms is one write). Not writable
|
|
105
|
+
* (the public link, the anonymous reader): this browser only, as before.
|
|
106
|
+
*/
|
|
107
|
+
export function savePref(key, value) {
|
|
108
|
+
if (!own(SYNCED, key)) return;
|
|
109
|
+
cache(key, value);
|
|
110
|
+
if (!prefsWritable()) return;
|
|
111
|
+
pending.set(key, value);
|
|
112
|
+
mine.set(key, value);
|
|
113
|
+
clearTimeout(flushTimer);
|
|
114
|
+
flushTimer = setTimeout(flush, 400);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One write. ok: the host took it (its answer is a view; an older one than this page has is not taken). */
|
|
118
|
+
async function post(set, onlyIfUnset) {
|
|
119
|
+
const res = await fetch("/ui/prefs", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify(onlyIfUnset ? { set, onlyIfUnset: true } : { set }) });
|
|
121
|
+
const body = await res.json().catch(() => null);
|
|
122
|
+
const ok = res.ok && isView(body);
|
|
123
|
+
if (ok) take(body);
|
|
124
|
+
return { ok, status: res.status };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flush() {
|
|
128
|
+
flushTimer = null;
|
|
129
|
+
if (!pending.size) return writes;
|
|
130
|
+
const set = Object.fromEntries(pending);
|
|
131
|
+
const keys = [...pending.keys()];
|
|
132
|
+
pending.clear();
|
|
133
|
+
for (const k of keys) inflight.set(k, (inflight.get(k) || 0) + 1);
|
|
134
|
+
writes = writes.then(async () => {
|
|
135
|
+
let ok = false;
|
|
136
|
+
try { ok = (await post(set, false)).ok; } catch { ok = false; }
|
|
137
|
+
const released = new Set();
|
|
138
|
+
for (const k of keys) {
|
|
139
|
+
const n = (inflight.get(k) || 1) - 1;
|
|
140
|
+
if (n) inflight.set(k, n); else inflight.delete(k);
|
|
141
|
+
if (!n && !pending.has(k)) { mine.delete(k); released.add(k); }
|
|
142
|
+
}
|
|
143
|
+
// Each key whose last write this was, and not changed here again since, now shows the newest host view this page
|
|
144
|
+
// has — usually its own value; another device's newer one if that arrived while the write was out (held back then,
|
|
145
|
+
// and an older answer to this write cannot overrule it); refused or not sent, what the host has (read back first).
|
|
146
|
+
// Always applied: what the page shows came from this page's own choice, not from the cache, so the cache matching
|
|
147
|
+
// the host (a storage that refused the choice) says nothing about the screen.
|
|
148
|
+
if (!ok) await refresh();
|
|
149
|
+
align(released, { force: true });
|
|
150
|
+
});
|
|
151
|
+
return writes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read the host's values again and apply the ones that differ from this page's — not a key still being changed here
|
|
156
|
+
* (unsent, or its write not answered yet), and nothing from an answer older than the view this page has. True: the read
|
|
157
|
+
* succeeded (taken, or older than what this page already has — nothing to do).
|
|
158
|
+
*/
|
|
159
|
+
export async function refresh() {
|
|
160
|
+
if (!initial) return false;
|
|
161
|
+
let body = null;
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch("/ui/prefs", { credentials: "same-origin" });
|
|
164
|
+
body = res.ok ? await res.json() : null;
|
|
165
|
+
} catch { body = null; }
|
|
166
|
+
if (!isView(body)) return false;
|
|
167
|
+
const before = new Map(Object.keys(SYNCED).map((key) => [key, own(host, key) ? SYNCED[key].toLocal(host[key]) : null]));
|
|
168
|
+
if (take(body)) align(Object.keys(SYNCED), { before });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Show the host view this page has for these keys — except a key still being changed here (unsent, or out). A key is
|
|
174
|
+
* applied when its host value moved in memory (`before`: the host view before this read), when this browser's copy
|
|
175
|
+
* differs, or always (`force`: this page's own write just ended). The copy alone never decides: a storage that refused
|
|
176
|
+
* a write keeps an old copy that may equal the host while the screen shows something else.
|
|
177
|
+
*/
|
|
178
|
+
function align(keys, opts = {}) {
|
|
179
|
+
const changed = new Set();
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
if (pending.has(key) || inflight.has(key)) continue;
|
|
182
|
+
const k = SYNCED[key];
|
|
183
|
+
const want = own(host, key) ? k.toLocal(host[key]) : null;
|
|
184
|
+
const copyDiffers = readLocal(k.ls) !== want;
|
|
185
|
+
const moved = !!opts.before && opts.before.get(key) !== want;
|
|
186
|
+
if (!copyDiffers && !moved && !opts.force) continue;
|
|
187
|
+
if (copyDiffers) writeLocal(k.ls, want);
|
|
188
|
+
changed.add(key);
|
|
189
|
+
}
|
|
190
|
+
if (changed.size) apply(changed);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function apply(changed) {
|
|
194
|
+
// Only what differs from what the page shows (a key re-applied after its own write may already be in force).
|
|
195
|
+
const T = globalThis.AgendTheme;
|
|
196
|
+
if (changed.has("theme") && typeof T !== "undefined") { const v = hostPref("theme") || "system"; if (typeof T.get !== "function" || T.get() !== v) T.set(v); }
|
|
197
|
+
if (changed.has("lang")) {
|
|
198
|
+
const l = hostPref("lang");
|
|
199
|
+
// None chosen any more: the device's own language, on this page only (no copy is kept for it).
|
|
200
|
+
if (l) { if (l !== lang()) setLang(l); } else if (deviceLanguage() !== lang()) setLang(deviceLanguage(), { store: false });
|
|
201
|
+
}
|
|
202
|
+
const P = globalThis.AgendPreview;
|
|
203
|
+
if (changed.has("preview.optIn") && P && typeof P.setOptIn === "function") {
|
|
204
|
+
const on = hostPref("preview.optIn") === true;
|
|
205
|
+
if (typeof P.optedIn !== "function" || P.optedIn() !== on) P.setOptIn(on);
|
|
206
|
+
}
|
|
207
|
+
for (const fn of [...listeners]) { try { fn(changed); } catch { /* one listener's error stops no other */ } }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Once per device: upload what this browser had for the keys the host has not got yet (each on its own, onlyIfUnset — a
|
|
212
|
+
* value the host refuses as invalid is simply not taken), then adopt the host's values. Marked only when every upload
|
|
213
|
+
* was answered (taken, or refused as invalid) and the read back succeeded; a request with no answer, a server error, a
|
|
214
|
+
* refused session or an unreadable store leaves the device unmarked, so the next load tries again.
|
|
215
|
+
*/
|
|
216
|
+
export async function migratePrefs() {
|
|
217
|
+
if (!prefsWritable() || inStep()) return false;
|
|
218
|
+
for (const key of Object.keys(SYNCED)) {
|
|
219
|
+
if (own(host, key)) continue;
|
|
220
|
+
const local = SYNCED[key].fromLocal(readLocal(SYNCED[key].ls));
|
|
221
|
+
if (local === undefined) continue;
|
|
222
|
+
let r;
|
|
223
|
+
try { r = await post({ [key]: local }, true); } catch { return false; }
|
|
224
|
+
if (!r.ok && r.status !== 400) return false;
|
|
225
|
+
}
|
|
226
|
+
if (!(await refresh())) return false;
|
|
227
|
+
writeLocal(SYNCED_FLAG, "1");
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The app's boot ("full" mode): the first-load migration, then another device's changes at each status frame. */
|
|
232
|
+
export function bootPrefs() {
|
|
233
|
+
if (!initial) return;
|
|
234
|
+
// Each new revision the frames announce, read once (the store changes many times a second); one announced while a read
|
|
235
|
+
// is out is read as soon as it ends. Seen only once its read succeeded: after a failed read the next try waits for a
|
|
236
|
+
// later frame, at least RETRY_MS on.
|
|
237
|
+
let seen = rev, busy = false, failedAt = -Infinity;
|
|
238
|
+
const check = () => {
|
|
239
|
+
const r = appStore.get().prefsRev;
|
|
240
|
+
if (typeof r !== "number" || r === seen || busy) return;
|
|
241
|
+
if (r === rev) { seen = r; return; }
|
|
242
|
+
if (now() - failedAt < RETRY_MS) return;
|
|
243
|
+
busy = true;
|
|
244
|
+
refresh().then((ok) => { if (ok) seen = r; else failedAt = now(); }, () => { failedAt = now(); })
|
|
245
|
+
.finally(() => { busy = false; check(); });
|
|
246
|
+
};
|
|
247
|
+
appStore.subscribe(check);
|
|
248
|
+
migratePrefs();
|
|
249
|
+
}
|