@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.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/README.md +4 -4
- package/README.zh-TW.md +4 -4
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +14 -2
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/atomic-write.d.ts +21 -0
- package/dist/atomic-write.js +65 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/auth-api.js +2 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/codex.d.ts +7 -0
- package/dist/backend/codex.js +7 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/muse.d.ts +7 -0
- package/dist/backend/muse.js +7 -0
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +6 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/binary-probe.d.ts +80 -0
- package/dist/binary-probe.js +182 -0
- package/dist/binary-probe.js.map +1 -0
- package/dist/cache-service.d.ts +17 -1
- package/dist/cache-service.js +39 -4
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +5 -1
- package/dist/channel/adapters/discord.js +56 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +38 -1
- package/dist/channel/adapters/telegram.js +82 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.d.ts +8 -1
- package/dist/channel/tool-router.js +4 -2
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +21 -0
- package/dist/cli.js +133 -38
- package/dist/cli.js.map +1 -1
- package/dist/connection-secrets.d.ts +5 -0
- package/dist/connection-secrets.js.map +1 -1
- package/dist/cpu-profile.js +50 -0
- package/dist/cpu-profile.js.map +1 -1
- package/dist/daemon.d.ts +12 -0
- package/dist/daemon.js +166 -26
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +1 -0
- package/dist/delivery-outbox.js +12 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/discord-general.d.ts +15 -0
- package/dist/discord-general.js +24 -0
- package/dist/discord-general.js.map +1 -0
- package/dist/discord-permissions.d.ts +33 -0
- package/dist/discord-permissions.js +42 -0
- package/dist/discord-permissions.js.map +1 -0
- package/dist/env-file.d.ts +11 -0
- package/dist/env-file.js +27 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-level-config.d.ts +8 -1
- package/dist/fleet-level-config.js +31 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +76 -7
- package/dist/fleet-manager.js +610 -142
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-stop-budget.d.ts +22 -0
- package/dist/fleet-stop-budget.js +95 -0
- package/dist/fleet-stop-budget.js.map +1 -0
- package/dist/kiro-transcript-lane.d.ts +8 -4
- package/dist/kiro-transcript-lane.js +66 -44
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/locale.js +88 -0
- package/dist/locale.js.map +1 -1
- package/dist/next-steps.d.ts +5 -0
- package/dist/next-steps.js +26 -0
- package/dist/next-steps.js.map +1 -0
- package/dist/outbound-handlers.js +8 -0
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +4 -0
- package/dist/outbound-schemas.js +6 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/outbox-open-error.d.ts +7 -0
- package/dist/outbox-open-error.js +15 -0
- package/dist/outbox-open-error.js.map +1 -0
- package/dist/pane-context-cache.d.ts +25 -0
- package/dist/pane-context-cache.js +73 -0
- package/dist/pane-context-cache.js.map +1 -0
- package/dist/paths.d.ts +0 -7
- package/dist/paths.js +8 -2
- package/dist/paths.js.map +1 -1
- package/dist/provider-probe.d.ts +15 -0
- package/dist/provider-probe.js +20 -0
- package/dist/provider-probe.js.map +1 -1
- package/dist/public-link-progress.d.ts +75 -0
- package/dist/public-link-progress.js +204 -0
- package/dist/public-link-progress.js.map +1 -0
- package/dist/public-web-gateway.js +4 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -2
- package/dist/public-web-link.js +89 -15
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +93 -20
- package/dist/quickstart-api.js +198 -63
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +15 -0
- package/dist/quickstart.js +100 -29
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +98 -0
- package/dist/reply-buttons-controller.js +174 -0
- package/dist/reply-buttons-controller.js.map +1 -0
- package/dist/reply-buttons.d.ts +113 -0
- package/dist/reply-buttons.js +208 -0
- package/dist/reply-buttons.js.map +1 -0
- package/dist/reply-dedup.js +3 -1
- package/dist/reply-dedup.js.map +1 -1
- package/dist/restart-guard.d.ts +1 -0
- package/dist/restart-guard.js +8 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/service-activation.d.ts +15 -2
- package/dist/service-activation.js +81 -4
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.js +19 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +53 -12
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-authority.d.ts +11 -0
- package/dist/settings-authority.js +128 -0
- package/dist/settings-authority.js.map +1 -0
- package/dist/settings-change.d.ts +5 -0
- package/dist/settings-change.js +4 -3
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -0
- package/dist/settings-confirmation.js +7 -1
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +19 -7
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +1 -1
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-redaction.d.ts +18 -0
- package/dist/settings-redaction.js +58 -0
- package/dist/settings-redaction.js.map +1 -0
- package/dist/setup-form.d.ts +9 -1
- package/dist/setup-form.js +25 -1
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.js +13 -3
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +26 -19
- package/dist/setup-wizard.js.map +1 -1
- package/dist/systemd-runtime.d.ts +15 -0
- package/dist/systemd-runtime.js +41 -0
- package/dist/systemd-runtime.js.map +1 -0
- package/dist/tmux-control.d.ts +22 -0
- package/dist/tmux-control.js +82 -17
- package/dist/tmux-control.js.map +1 -1
- package/dist/token-env-name.d.ts +37 -0
- package/dist/token-env-name.js +93 -0
- package/dist/token-env-name.js.map +1 -0
- package/dist/tool-permissions.js +5 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +7 -16
- package/dist/topic-commands.js +48 -84
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +68 -0
- package/dist/transcript-sources.js +336 -29
- package/dist/transcript-sources.js.map +1 -1
- package/dist/transcript-turns.d.ts +39 -0
- package/dist/transcript-turns.js +58 -0
- package/dist/transcript-turns.js.map +1 -0
- package/dist/tunnel/cloudflared-install.d.ts +42 -0
- package/dist/tunnel/cloudflared-install.js +194 -9
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/deb-extract.d.ts +9 -0
- package/dist/tunnel/deb-extract.js +94 -0
- package/dist/tunnel/deb-extract.js.map +1 -0
- package/dist/turn-reply-guard.d.ts +3 -0
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/ui/app.html +3 -0
- package/dist/ui/chat-render.js +32 -6
- package/dist/ui/chat-store.js +21 -1
- package/dist/ui/chat-strings.js +45 -0
- package/dist/ui/chat-thread.js +109 -52
- package/dist/ui/first-run.js +64 -0
- package/dist/ui/html-attachment.js +26 -0
- package/dist/ui/icons/apple-touch-icon.png +0 -0
- package/dist/ui/icons/favicon.ico +0 -0
- package/dist/ui/icons/favicon.svg +8 -0
- package/dist/ui/panel-chat.js +16 -7
- package/dist/ui/panel-fleet.js +5 -5
- package/dist/ui/panel-settings.js +18 -8
- package/dist/ui/preview-panel.js +47 -7
- package/dist/ui/preview.js +36 -13
- package/dist/ui/settings-apply.js +3 -1
- package/dist/ui/settings-dialogs.js +95 -25
- package/dist/ui/settings-discord.js +130 -0
- package/dist/ui/settings-model.js +21 -0
- package/dist/ui/settings-strings.js +46 -6
- package/dist/ui/settings-token.js +54 -0
- package/dist/ui/settings-wizard.js +63 -29
- package/dist/ui/shared/app-shell.js +19 -33
- package/dist/ui/shared/app-store.js +5 -3
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +34 -2
- package/dist/ui/shared/app.js +2 -1
- package/dist/ui/shared/instance-nav.js +310 -0
- package/dist/ui/shared/panel-view.js +12 -190
- package/dist/ui/shared/view-roster-store.js +5 -0
- package/dist/ui/shared/view-strings.js +12 -0
- package/dist/ui/signin.html +3 -0
- package/dist/update-dispatch.d.ts +1 -1
- package/dist/update-dispatch.js +1 -1
- package/dist/update-install.d.ts +15 -3
- package/dist/update-install.js +41 -13
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +22 -0
- package/dist/update-launch.js +106 -0
- package/dist/update-launch.js.map +1 -0
- package/dist/update-marker.js +3 -8
- package/dist/update-marker.js.map +1 -1
- package/dist/usage/format-rich.js +7 -1
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +4 -0
- package/dist/usage/providers.js +2 -1
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.js +48 -2
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/view-api.d.ts +40 -0
- package/dist/view-api.js +28 -10
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +8 -0
- package/dist/web-api.js +53 -17
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +8 -0
- package/dist/web-auth.js +34 -8
- package/dist/web-auth.js.map +1 -1
- package/dist/web-chat-history.d.ts +21 -0
- package/dist/web-chat-history.js +44 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-host-guard.d.ts +18 -2
- package/dist/web-host-guard.js +30 -5
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-icons.d.ts +21 -0
- package/dist/web-icons.js +38 -0
- package/dist/web-icons.js.map +1 -0
- package/dist/web-preview.d.ts +9 -0
- package/dist/web-preview.js +15 -9
- package/dist/web-preview.js.map +1 -1
- package/dist/web-session.d.ts +7 -1
- package/dist/web-session.js +23 -2
- package/dist/web-session.js.map +1 -1
- package/launcher/postinstall.cjs +8 -4
- package/package.json +1 -1
- package/templates/systemd.service.ejs +3 -3
package/dist/ui/preview-panel.js
CHANGED
|
@@ -12,13 +12,14 @@ import { t } from "/assets/app-i18n.js";
|
|
|
12
12
|
import { Icon } from "/assets/ui-icons.js";
|
|
13
13
|
import "./chat-render.js";
|
|
14
14
|
import "./preview.js";
|
|
15
|
+
import { loadHtmlAttachment } from "./html-attachment.js";
|
|
15
16
|
|
|
16
17
|
const R = () => globalThis.AgendChatRender;
|
|
17
18
|
const P = () => globalThis.AgendPreview;
|
|
18
19
|
|
|
19
20
|
// ── The panel's state, one per page (the chat shows one instance at a time) ──
|
|
20
21
|
|
|
21
|
-
/** open: { key, instance, msgKey, n, code, sender, ts } | null; run: start it on mount (a moved run). */
|
|
22
|
+
/** open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4); run: start it on mount (a moved run). */
|
|
22
23
|
let state = { open: null, run: 0 };
|
|
23
24
|
const subs = new Set();
|
|
24
25
|
function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
|
|
@@ -54,6 +55,22 @@ export function newerVersion(open, msgs, keyOf) {
|
|
|
54
55
|
if (!open || !Array.isArray(msgs)) return null;
|
|
55
56
|
const at = msgs.findIndex(x => keyOf(x) === open.msgKey);
|
|
56
57
|
if (at < 0) return null;
|
|
58
|
+
// An attached file: a later agent reply attaching a file of the same name is its newer version. Its HTML is read
|
|
59
|
+
// when the person picks it (code: null here), as on a card.
|
|
60
|
+
if (open.att) {
|
|
61
|
+
const name = String(open.att.name || "").trim().toLowerCase();
|
|
62
|
+
for (let i = msgs.length - 1; i > at; i--) {
|
|
63
|
+
const x = msgs[i];
|
|
64
|
+
if (x.role !== "agent" || !Array.isArray(x.attachments)) continue;
|
|
65
|
+
for (let k = x.attachments.length - 1; k >= 0; k--) {
|
|
66
|
+
const a = x.attachments[k];
|
|
67
|
+
if (!R().isHtmlAttachment(a) || a.name.trim().toLowerCase() !== name) continue;
|
|
68
|
+
const msgKey = keyOf(x);
|
|
69
|
+
return { key: `${msgKey}:a${a.id}`, instance: open.instance, msgKey, att: { id: a.id, name: a.name, size: a.size }, code: null, sender: x.sender, ts: x.ts };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
57
74
|
const title = htmlTitle(open.code);
|
|
58
75
|
for (let i = msgs.length - 1; i > at; i--) {
|
|
59
76
|
const x = msgs[i];
|
|
@@ -88,11 +105,14 @@ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digi
|
|
|
88
105
|
* props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
|
|
89
106
|
* reveal(msgKey) (the thread shows that message), download(code).
|
|
90
107
|
*/
|
|
91
|
-
|
|
108
|
+
/** allow(): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then setOptIn) → the choice. */
|
|
109
|
+
export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download, allow }) {
|
|
92
110
|
const [st, setSt] = useState(state);
|
|
93
111
|
const [view, setView] = useState("preview");
|
|
94
112
|
const [run, setRun] = useState({ state: "idle", reason: "" });
|
|
95
113
|
const [newer, setNewer] = useState(null);
|
|
114
|
+
const [newerNote, setNewerNote] = useState("");
|
|
115
|
+
const reading = useRef(null); // the newer version whose file is being read (one at a time)
|
|
96
116
|
const holder = useRef(null), divider = useRef(null), panel = useRef(null);
|
|
97
117
|
const open = st.open && st.open.instance === name ? st.open : null;
|
|
98
118
|
|
|
@@ -120,6 +140,7 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
|
|
|
120
140
|
}, [open && open.key, st.run]);
|
|
121
141
|
// A later reply with a newer version of this block: offered, not swapped in.
|
|
122
142
|
useEffect(() => {
|
|
143
|
+
setNewerNote(""); reading.current = null;
|
|
123
144
|
if (!open) { setNewer(null); return undefined; }
|
|
124
145
|
const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
|
|
125
146
|
check();
|
|
@@ -162,13 +183,32 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
|
|
|
162
183
|
el.addEventListener("pointerup", up);
|
|
163
184
|
el.addEventListener("pointercancel", up);
|
|
164
185
|
};
|
|
186
|
+
// Show the newer version: an attached file's HTML is read first. A read the panel moved on from is dropped.
|
|
187
|
+
const showNewer = async () => {
|
|
188
|
+
if (!newer || reading.current) return;
|
|
189
|
+
if (newer.code != null) { openPanel(newer); return; }
|
|
190
|
+
const was = open && open.key, mine = reading.current = newer;
|
|
191
|
+
setNewerNote(t("chat.pvLoading"));
|
|
192
|
+
const r = await loadHtmlAttachment(newer.att);
|
|
193
|
+
if (reading.current !== mine) return;
|
|
194
|
+
reading.current = null;
|
|
195
|
+
const now = panelStore.get().open;
|
|
196
|
+
if (!now || now.key !== was) return;
|
|
197
|
+
if (!r.ok) { setNewerNote(t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed")); return; }
|
|
198
|
+
openPanel({ ...newer, code: r.code });
|
|
199
|
+
};
|
|
200
|
+
const fileName = open && open.att ? open.att.name : undefined;
|
|
165
201
|
// Esc in the panel closes it (and is not the chat's "stop the reply").
|
|
166
202
|
const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
|
|
167
203
|
|
|
168
204
|
if (!open) return null;
|
|
169
205
|
const going = run.state === "starting" || run.state === "running";
|
|
170
206
|
const a = P().availability();
|
|
171
|
-
|
|
207
|
+
// #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm); Preview
|
|
208
|
+
// is then enabled — still a click. Why a run stopped (e.g. switched off in another tab) is still said first.
|
|
209
|
+
const askOptIn = !going && !a.ok && a.why === "optin" && typeof allow === "function";
|
|
210
|
+
const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : askOptIn ? t("chat.pvWhyOptinPanel") : a.reason);
|
|
211
|
+
const allowHere = async () => { if (await allow()) setRun({ state: "idle", reason: "" }); };
|
|
172
212
|
const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
|
|
173
213
|
return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
|
|
174
214
|
aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
|
|
@@ -188,12 +228,12 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
|
|
|
188
228
|
${going ? html`<button type="button" class="btn btn-sm pv-panel-reload" title=${t("chat.pvReload")} onClick=${() => { stop(); start(); }}><${Icon} name="restart" size=${14} /><span class="lbl">${t("chat.pvReload")}</span></button>
|
|
189
229
|
<button type="button" class="btn btn-sm pv-panel-stop" title=${t("chat.pvStop")} onClick=${stop}><${Icon} name="stop" size=${14} /><span class="lbl">${t("chat.pvStop")}</span></button>`
|
|
190
230
|
: html`<button type="button" class="btn btn-sm btn-primary pv-panel-run" title=${t("chat.pvPreview")} disabled=${!a.ok} onClick=${start}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvPreview")}</span></button>`}
|
|
191
|
-
<button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
|
|
231
|
+
<button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code, fileName)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
|
|
192
232
|
</div>
|
|
193
233
|
${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
|
|
194
|
-
<button type="button" class="btn btn-sm pv-newer-show" onClick=${
|
|
195
|
-
${going ? html`<div class="pv-banner">${P().
|
|
196
|
-
${note && view === "preview" ? html`<div class="pv-note">${note}</div>` : null}
|
|
234
|
+
<button type="button" class="btn btn-sm pv-newer-show" onClick=${showNewer}>${t("chat.pvNewerShow")}</button>${newerNote ? html`<span class="pv-newer-note">${newerNote}</span>` : null}</div>` : null}
|
|
235
|
+
${going ? html`<div class="pv-banner">${P().banner()}</div>` : null}
|
|
236
|
+
${note && view === "preview" ? html`<div class="pv-note">${note}${askOptIn ? html` <button type="button" class="btn btn-sm pv-panel-allow" onClick=${allowHere}>${t("chat.pvAllowButton")}</button>` : null}</div>` : null}
|
|
197
237
|
<div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
|
|
198
238
|
<div class="pv-holder" ref=${holder}></div>
|
|
199
239
|
${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
|
package/dist/ui/preview.js
CHANGED
|
@@ -25,7 +25,18 @@
|
|
|
25
25
|
var NEVER_KEY = "agend_html_preview_never"; // sessionStorage: "1" — never preview on this device (this session)
|
|
26
26
|
var BANNER = "Previews run the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out. Only preview content you trust. A preview can slow or freeze this tab.";
|
|
27
27
|
|
|
28
|
-
var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "" };
|
|
28
|
+
var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "", reasonCode: "" };
|
|
29
|
+
// #1554: the page's words. `say(key, english, …values)` asks the app's dictionary (init's opts.text) and keeps the
|
|
30
|
+
// English here when it has none — so this file still works, and reads the same, without the app.
|
|
31
|
+
var text = null;
|
|
32
|
+
function say(key, english) {
|
|
33
|
+
var values = Array.prototype.slice.call(arguments, 2);
|
|
34
|
+
var s = null;
|
|
35
|
+
if (text) { try { s = text.apply(null, [key].concat(values)); } catch (e) { s = null; } }
|
|
36
|
+
if (typeof s === "string" && s && s !== key) return s;
|
|
37
|
+
for (var i = 0; i < values.length; i++) english = english.split("{" + i + "}").join(String(values[i]));
|
|
38
|
+
return english;
|
|
39
|
+
}
|
|
29
40
|
var changed = []; // the page's "this device's choice changed" callbacks (checkbox, cards)
|
|
30
41
|
var live = new Map(); // cardKey → { key, iframe, ch, holder, state, … } (at most one: one running preview per page)
|
|
31
42
|
var listening = false;
|
|
@@ -41,14 +52,19 @@
|
|
|
41
52
|
return s;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
55
|
+
/**
|
|
56
|
+
* What the server said about this page load (the <body data-*> attributes /ui was served with). opts.text(key,
|
|
57
|
+
* …values): the app's dictionary for this file's words (#1554); without it they are the English below.
|
|
58
|
+
*/
|
|
59
|
+
function init(data, opts) {
|
|
46
60
|
cfg = {
|
|
47
61
|
dashboardOrigin: String((data && data.dashboardOrigin) || ""),
|
|
48
62
|
previewOrigin: String((data && data.previewOrigin) || ""),
|
|
49
63
|
boot: String((data && data.previewBoot) || ""),
|
|
50
64
|
reason: String((data && data.previewReason) || ""),
|
|
65
|
+
reasonCode: String((data && data.previewReasonCode) || ""),
|
|
51
66
|
};
|
|
67
|
+
if (opts && typeof opts.text === "function") text = opts.text;
|
|
52
68
|
if (!listening && typeof root.addEventListener === "function") {
|
|
53
69
|
root.addEventListener("message", onMessage);
|
|
54
70
|
// The opt-in is per device: another tab of this dashboard turning it off (or clearing storage) stops the
|
|
@@ -96,13 +112,18 @@
|
|
|
96
112
|
* a frame from the wrong place), and the device must have opted in and not switched previews off.
|
|
97
113
|
*/
|
|
98
114
|
function availability() {
|
|
99
|
-
if (!cfg.previewOrigin || !cfg.boot)
|
|
115
|
+
if (!cfg.previewOrigin || !cfg.boot) {
|
|
116
|
+
// The server's reason, in the page's language when the server named it (a code), else as the server wrote it.
|
|
117
|
+
var server = cfg.reasonCode ? say("pvServer_" + cfg.reasonCode, cfg.reason || "Previews are not available here.", cfg.dashboardOrigin) : "";
|
|
118
|
+
return { ok: false, why: "server", reason: server || cfg.reason || say("pvWhyServer", "Previews are not available here.") };
|
|
119
|
+
}
|
|
100
120
|
var at = root.location && root.location.origin;
|
|
101
121
|
if (!cfg.dashboardOrigin || at !== cfg.dashboardOrigin) {
|
|
102
|
-
return { ok: false, why: "origin", reason: "Previews are off: this page is at
|
|
122
|
+
return { ok: false, why: "origin", reason: say("pvWhyOrigin", "Previews are off: this page is at {0}, but the fleet believes it is at {1} (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin.",
|
|
123
|
+
at, cfg.dashboardOrigin || say("pvUnknownAddress", "an unknown address")) };
|
|
103
124
|
}
|
|
104
|
-
if (never()) return { ok: false, why: "never", reason: "Previews are switched off on this device for this session." };
|
|
105
|
-
if (!optedIn()) return { ok: false, why: "optin", reason: "Previews are off on this device. Allow them from the card's menu or the sidebar." };
|
|
125
|
+
if (never()) return { ok: false, why: "never", reason: say("pvWhyNever", "Previews are switched off on this device for this session.") };
|
|
126
|
+
if (!optedIn()) return { ok: false, why: "optin", reason: say("pvWhyOptin", "Previews are off on this device. Allow them from the card's menu or the sidebar.") };
|
|
106
127
|
return { ok: true, why: "", reason: "" };
|
|
107
128
|
}
|
|
108
129
|
|
|
@@ -134,7 +155,7 @@
|
|
|
134
155
|
function start(key, holder, html, ui, opts) {
|
|
135
156
|
var a = availability();
|
|
136
157
|
if (!a.ok) { ui.state("unavailable", a.reason); return false; }
|
|
137
|
-
if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", "This HTML is over 1 MiB; it is not previewed."); return false; }
|
|
158
|
+
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; }
|
|
138
159
|
stopAll("another");
|
|
139
160
|
var doc = holder.ownerDocument;
|
|
140
161
|
var iframe = mountPreview(doc);
|
|
@@ -146,7 +167,7 @@
|
|
|
146
167
|
if (card.fill) iframe.classList.add("fill");
|
|
147
168
|
live.set(key, card);
|
|
148
169
|
card.readyTimer = root.setTimeout(function () {
|
|
149
|
-
if (live.get(key) === card && !card.rendered) stop(key, "unavailable", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too.");
|
|
170
|
+
if (live.get(key) === card && !card.rendered) stop(key, "unavailable", say("pvNoAnswer", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too."));
|
|
150
171
|
}, LIMITS.readyMs);
|
|
151
172
|
holder.appendChild(iframe);
|
|
152
173
|
if (!card.fill) setHeight(card, LIMITS.minHeight);
|
|
@@ -163,7 +184,7 @@
|
|
|
163
184
|
// Best effort: a frame that froze this tab freezes this timer too. Only while the page is visible.
|
|
164
185
|
var hidden = root.document && root.document.visibilityState === "hidden";
|
|
165
186
|
if (hidden) { armWatchdog(card); return; }
|
|
166
|
-
stop(card.key, "stopped", "The preview stopped answering, so it was closed.");
|
|
187
|
+
stop(card.key, "stopped", say("pvStoppedAnswering", "The preview stopped answering, so it was closed."));
|
|
167
188
|
}, LIMITS.watchdogMs);
|
|
168
189
|
}
|
|
169
190
|
|
|
@@ -181,7 +202,7 @@
|
|
|
181
202
|
function stopAll(why) {
|
|
182
203
|
var keys = [];
|
|
183
204
|
live.forEach(function (_c, k) { keys.push(k); });
|
|
184
|
-
for (var i = 0; i < keys.length; i++) stop(keys[i], "stopped", why === "off" ? "Previews were switched off on this device." : why === "another" ? "Another preview started." : "");
|
|
205
|
+
for (var i = 0; i < keys.length; i++) stop(keys[i], "stopped", why === "off" ? say("pvSwitchedOff", "Previews were switched off on this device.") : why === "another" ? say("pvAnother", "Another preview started.") : "");
|
|
185
206
|
return keys;
|
|
186
207
|
}
|
|
187
208
|
/** Stop every preview inside `node` (before the chat replaces, moves or removes it); returns the stopped keys. */
|
|
@@ -214,7 +235,7 @@
|
|
|
214
235
|
if (Object.keys(d).sort().join(",") !== KEYS[d.type]) return;
|
|
215
236
|
if (d.type === "ready") {
|
|
216
237
|
if (d.ch !== null || typeof d.boot !== "string" || card.rendered) return;
|
|
217
|
-
if (d.boot !== cfg.boot) { stop(card.key, "unavailable", "Preview unavailable: something else answered on the preview port."); return; }
|
|
238
|
+
if (d.boot !== cfg.boot) { stop(card.key, "unavailable", say("pvWrongBoot", "Preview unavailable: something else answered on the preview port.")); return; }
|
|
218
239
|
if (!availability().ok) { stop(card.key, "unavailable", availability().reason); return; }
|
|
219
240
|
card.rendered = true;
|
|
220
241
|
root.clearTimeout(card.readyTimer); card.readyTimer = null;
|
|
@@ -227,7 +248,7 @@
|
|
|
227
248
|
if (d.type === "heartbeat") {
|
|
228
249
|
// A heartbeat never outlives the permission: if this device stopped allowing previews (and the storage event
|
|
229
250
|
// was missed), the next heartbeat ends it.
|
|
230
|
-
if (!availability().ok) { stop(card.key, "stopped", "Previews were switched off on this device."); return; }
|
|
251
|
+
if (!availability().ok) { stop(card.key, "stopped", say("pvSwitchedOff", "Previews were switched off on this device.")); return; }
|
|
231
252
|
armWatchdog(card);
|
|
232
253
|
return;
|
|
233
254
|
}
|
|
@@ -266,5 +287,7 @@
|
|
|
266
287
|
init: init, availability: availability, optedIn: optedIn, setOptIn: setOptIn, never: never, setNever: setNever, onChange: onChange, listeners: listeners, onStorage: onStorage,
|
|
267
288
|
start: start, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
|
|
268
289
|
mountPreview: mountPreview, onMessage: onMessage, BANNER: BANNER, LIMITS: LIMITS,
|
|
290
|
+
/** The banner every running preview carries, in the page's language (#1554); BANNER is its English. */
|
|
291
|
+
banner: function () { return say("pvBanner", BANNER); },
|
|
269
292
|
};
|
|
270
293
|
});
|
|
@@ -26,7 +26,7 @@ function view() {
|
|
|
26
26
|
if (!op) return null;
|
|
27
27
|
return {
|
|
28
28
|
id: op.id, phase: op.phase, error: op.error, job: op.job, lostJob: op.lostJob, restart: op.restart,
|
|
29
|
-
steps: op.steps.map(({ label, impact, status, error, pendingId }) => ({ label, impact, status, error: error || null, pendingId: pendingId || null })),
|
|
29
|
+
steps: op.steps.map(({ label, impact, status, error, pendingId, note }) => ({ label, impact, status, error: error || null, pendingId: pendingId || null, ...(note ? { note } : {}) })),
|
|
30
30
|
leftover: op.leftover,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
@@ -138,6 +138,8 @@ async function rotateConnectionSecret(step, o) {
|
|
|
138
138
|
if (status && status.ok) result = status.body || result;
|
|
139
139
|
}
|
|
140
140
|
const ok = result.result === "applied" || result.result === "restart_required";
|
|
141
|
+
// Stored, but no adapter was running to take it (#1519 P1): said as such — not "done", and never "failed".
|
|
142
|
+
if (result.result === "restart_required") { step.note = t("settings.tokenSavedRestart"); publish(); }
|
|
141
143
|
return { ok, status: ok ? 200 : 409, body: ok ? result : { error: result.error || result.result || "apply failed" } };
|
|
142
144
|
}
|
|
143
145
|
|
|
@@ -10,6 +10,9 @@ import { Icon } from "/assets/ui-icons.js";
|
|
|
10
10
|
import { toast } from "/assets/ui-toast.js";
|
|
11
11
|
import { confirmDialog } from "/assets/ui-confirm.js";
|
|
12
12
|
import { api, confirmedWrite, newKey } from "./settings-confirm.js";
|
|
13
|
+
import { botHandle, TokenEnvNote, TokenField, verifyBotToken } from "./settings-token.js";
|
|
14
|
+
import { DiscordServerPicker } from "./settings-discord.js";
|
|
15
|
+
import { startOperation } from "./settings-apply.js";
|
|
13
16
|
import {
|
|
14
17
|
ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
|
|
15
18
|
VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
|
|
@@ -341,15 +344,31 @@ export function BotDialog({ id, ctx, onClose }) {
|
|
|
341
344
|
const [bindBusy, setBindBusy] = useState(false);
|
|
342
345
|
const [token, setToken] = useState("");
|
|
343
346
|
const [tokenNote, setTokenNote] = useState(null);
|
|
347
|
+
// Replace token (#1519 P1): the field opens on Replace, and a token is staged only once the platform has named its bot.
|
|
348
|
+
const [replacing, setReplacing] = useState(false);
|
|
349
|
+
const [tokenId, setTokenId] = useState(null);
|
|
350
|
+
const [tokenBusy, setTokenBusy] = useState(false);
|
|
351
|
+
const status = (ctx.connections || []).find((c) => c && c.id === id) || {};
|
|
344
352
|
const [emojis, setEmojis] = useState({ value: null, baseline: null });
|
|
345
353
|
const lease = useLease("bot-dialog");
|
|
346
354
|
const accessWarn = mode === "locked" && users.length === 0 ? "accessLockedEmpty" : "";
|
|
355
|
+
// The token's revision (#1529 review): typing moves it, and a Verify for an older value is dropped when it lands.
|
|
356
|
+
const tokenRev = useRef(0);
|
|
357
|
+
const verifyToken = async () => {
|
|
358
|
+
setTokenBusy(true);
|
|
359
|
+
const mine = epoch.current, at = tokenRev.current;
|
|
360
|
+
const identity = await verifyBotToken(type, token);
|
|
361
|
+
if (!lease.current() || epoch.current !== mine) return;
|
|
362
|
+
setTokenBusy(false);
|
|
363
|
+
if (tokenRev.current === at) setTokenId(identity);
|
|
364
|
+
};
|
|
347
365
|
const stageToken = () => {
|
|
348
366
|
if (!ch) return;
|
|
349
367
|
if (!token) { setTokenNote({ error: tn("tokenRequired") }); return; }
|
|
368
|
+
if (!tokenId || !tokenId.valid) { setTokenNote({ error: tn("wizardNeedVerify") }); return; }
|
|
350
369
|
// The token goes into the staged change and nowhere else: not a label, not the URL, not storage.
|
|
351
370
|
ctx.stage(`secret:${id}`, { label: tn("rotateToken", type), impact: schema.order[0], connectionSecret: { id, secret: token } });
|
|
352
|
-
setToken("");
|
|
371
|
+
setToken(""); setTokenId(null); setReplacing(false);
|
|
353
372
|
setTokenNote({ text: tn("tokenQueued") });
|
|
354
373
|
};
|
|
355
374
|
const stage = () => {
|
|
@@ -419,45 +438,96 @@ export function BotDialog({ id, ctx, onClose }) {
|
|
|
419
438
|
previewBody=${(map) => ({ channel_id: id, platform: type, channel_config: map })} onChange=${setEmojis} />
|
|
420
439
|
</${Drawer}>
|
|
421
440
|
<${Drawer} title=${tn("advancedSection")}>
|
|
422
|
-
<div class="field"><span>${tn("
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
441
|
+
<div class="field"><span>${tn("tokenStatus")}</span>
|
|
442
|
+
<div class="dlg-inline-actions"><span class="token-status">${status.token_present === false ? tn("tokenMissing")
|
|
443
|
+
: status.identity && status.identity.username ? tn("tokenSetAs", botHandle(status.identity.username)) : tn("tokenSet")}</span>
|
|
444
|
+
${replacing ? null : html`<button type="button" class="btn btn-sm" onClick=${() => { setReplacing(true); setTokenNote(null); }}>${tn("tokenReplace")}</button>`}</div></div>
|
|
445
|
+
${replacing ? html`<${TokenField} id="bot-token" platform=${type} value=${token} identity=${tokenId} busy=${tokenBusy}
|
|
446
|
+
onInput=${(v) => { tokenRev.current++; setToken(v); setTokenId(null); }} onVerify=${verifyToken} hint=${tn("tokenHint")} />
|
|
447
|
+
<div class="dlg-inline-actions"><button type="button" class="btn btn-sm" disabled=${!tokenId || !tokenId.valid} onClick=${stageToken}>${tn("tokenButton")}</button>
|
|
448
|
+
<button type="button" class="btn btn-sm btn-ghost" onClick=${() => { tokenRev.current++; setReplacing(false); setToken(""); setTokenId(null); }}>${tn("cancel")}</button></div>` : null}
|
|
449
|
+
${tokenNote ? html`<p class=${`feedback${tokenNote.error ? " error" : ""}`} role="status">${tokenNote.error || tokenNote.text}</p>` : null}
|
|
450
|
+
<${TokenEnvNote} name=${ch.bot_token_env} />
|
|
428
451
|
<div class="dlg-inline-actions"><button type="button" class="btn btn-sm danger" onClick=${remove}><${Icon} name="trash" size=${14} />${tn("deleteBot")}</button></div>
|
|
429
452
|
</${Drawer}>
|
|
430
453
|
</${EditDialog}>`;
|
|
431
454
|
}
|
|
432
455
|
|
|
433
|
-
/**
|
|
456
|
+
/**
|
|
457
|
+
* A new connection (#1519 P1): the bot token, checked with the platform, then the server-side plan — a free id and a
|
|
458
|
+
* generated token env (shown under Advanced, read-only) — then the same write the setup wizard makes, without an agent:
|
|
459
|
+
* one operation (the confirmation an admin may have to give, then Apply, which offers the restart a new connection
|
|
460
|
+
* needs). Never a replacement: the server refuses an id or token env another connection holds.
|
|
461
|
+
*/
|
|
434
462
|
export function NewBotDialog({ ctx, onClose }) {
|
|
435
463
|
const lease = useLease("new-bot");
|
|
436
|
-
const [f, setF] = useState({ type: "discord", id: "",
|
|
464
|
+
const [f, setF] = useState({ type: "discord", id: "", token: "", identity: null, guilds: [], group: "", general: "" });
|
|
465
|
+
const [plan, setPlan] = useState(null);
|
|
437
466
|
const [busy, setBusy] = useState(false);
|
|
438
467
|
const [err, setErr] = useState("");
|
|
468
|
+
// The form's revision (#1529 review): any change to the token, platform, id or group moves it; a Verify or a plan
|
|
469
|
+
// that began before is dropped when it lands — never restoring an old token, naming it, or planning for it.
|
|
470
|
+
const rev = useRef(0);
|
|
471
|
+
const body = (x) => ({ platform: x.type, connection_only: true, ...(x.id.trim() ? { channel_id: x.id.trim() } : {}),
|
|
472
|
+
...(x.group.trim() ? (x.type === "discord" ? { guild_id: x.group.trim() } : { group_id: x.group.trim() }) : {}),
|
|
473
|
+
...(x.type === "discord" && x.general.trim() ? { general_channel_id: x.general.trim() } : {}) });
|
|
474
|
+
const planFor = async (x) => {
|
|
475
|
+
const at = rev.current;
|
|
476
|
+
const res = await api("/api/settings/quickstart/plan", { method: "POST", body: JSON.stringify(body(x)) }).catch(() => ({ ok: false, body: {} }));
|
|
477
|
+
if (!lease.current() || rev.current !== at) return null;
|
|
478
|
+
if (!res.ok) { setErr((res.body && res.body.error) || tn("failed")); setPlan(null); return null; }
|
|
479
|
+
setErr(""); setPlan(res.body); return res.body;
|
|
480
|
+
};
|
|
481
|
+
const verify = async () => {
|
|
482
|
+
const at = rev.current, x = f;
|
|
483
|
+
const live = () => lease.current() && rev.current === at;
|
|
484
|
+
setBusy(true); setErr("");
|
|
485
|
+
const identity = await verifyBotToken(x.type, x.token);
|
|
486
|
+
if (!live()) { if (lease.current()) setBusy(false); return; }
|
|
487
|
+
let guilds = [];
|
|
488
|
+
if (identity.valid && x.type === "discord") {
|
|
489
|
+
const g = await api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify({ action: "guilds", token: x.token }) }).catch(() => null);
|
|
490
|
+
if (!live()) { if (lease.current()) setBusy(false); return; }
|
|
491
|
+
guilds = (g && g.body && g.body.guilds) || [];
|
|
492
|
+
}
|
|
493
|
+
// Onto the form as it is now (same revision, so the same token): never a copy of the form from before the await.
|
|
494
|
+
setF((cur) => ({ ...cur, identity, guilds })); setBusy(false);
|
|
495
|
+
if (identity.valid) await planFor({ ...x, identity, guilds });
|
|
496
|
+
};
|
|
439
497
|
const save = async () => {
|
|
440
|
-
if (!f.
|
|
441
|
-
const
|
|
498
|
+
if (!f.identity || !f.identity.valid) { setErr(tn("wizardNeedVerify")); return; }
|
|
499
|
+
const x = f;
|
|
442
500
|
setBusy(true);
|
|
443
|
-
const
|
|
444
|
-
if (!
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
501
|
+
const p = await planFor(x);
|
|
502
|
+
if (!p) { if (lease.current()) setBusy(false); return; }
|
|
503
|
+
const handed = startOperation([{ label: tn("addBotLabel", p.channel_id), impact: impactOf(ctx.schema, "fleet.channels"),
|
|
504
|
+
request: { method: "POST", url: "/api/settings/quickstart/commit",
|
|
505
|
+
body: { ...body(x), channel_id: p.channel_id, token_env: p.token_env, token_env_generated: true, token: x.token }, sensitive: true } }]);
|
|
506
|
+
if (!handed) { setErr(tn("applyBusyLocal")); setBusy(false); return; }
|
|
507
|
+
setF((x) => ({ ...x, token: "" }));
|
|
508
|
+
onClose();
|
|
448
509
|
};
|
|
449
|
-
|
|
510
|
+
// A change of platform or id is another connection: what was verified and planned no longer applies. The server or
|
|
511
|
+
// group does not change the id or the token's name, so the plan (and its name under Advanced) stays.
|
|
512
|
+
const set = (k) => (v) => { rev.current++; setF((x) => ({ ...x, [k]: v, ...(k === "type" ? { identity: null, guilds: [], group: "", general: "" } : {}) })); if (k !== "group" && k !== "general") setPlan(null); };
|
|
450
513
|
return html`<${Dialog} title=${tn("newBot")} onClose=${onClose} busy=${busy}
|
|
451
514
|
actions=${html`<button type="button" class="btn" disabled=${busy} onClick=${onClose}>${tn("cancel")}</button>
|
|
452
|
-
<button type="button" class="btn btn-primary" disabled=${busy} onClick=${save}>${tn("save")}</button>`}>
|
|
515
|
+
<button type="button" class="btn btn-primary" disabled=${busy || !f.identity || !f.identity.valid} onClick=${save}>${tn("save")}</button>`}>
|
|
453
516
|
<div class="form">
|
|
454
|
-
<div class="
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
517
|
+
<div class="field"><label for="nb-type">${tn("type")}</label><${Select} id="nb-type" value=${f.type} onChange=${set("type")} options=${CH_TYPES} /></div>
|
|
518
|
+
<${TokenField} id="nb-token" platform=${f.type} value=${f.token} identity=${f.identity} busy=${busy}
|
|
519
|
+
onInput=${(v) => { rev.current++; setF((x) => ({ ...x, token: v, identity: null, guilds: [] })); setPlan(null); }} onVerify=${verify} hint=${tn("newBotTokenHint")} />
|
|
520
|
+
${f.type === "discord"
|
|
521
|
+
? html`<${DiscordServerPicker} idPrefix="nb" token=${f.identity && f.identity.valid ? f.token : ""} bot=${f.identity && botHandle(f.identity.username)}
|
|
522
|
+
invite=${f.identity && f.identity.invite} portal=${f.identity && f.identity.portal} guilds=${f.guilds} onGuilds=${(g) => setF((x) => ({ ...x, guilds: g }))}
|
|
523
|
+
guild=${f.group} onGuild=${set("group")} channel=${f.general} onChannel=${set("general")} />`
|
|
524
|
+
: html`<div class="field"><label for="nb-group">${tn("groupIdField")}</label>
|
|
525
|
+
<input id="nb-group" type="text" value=${f.group} onInput=${(e) => set("group")(e.target.value.trim())} /></div>`}
|
|
526
|
+
<${Drawer} title=${tn("advancedSection")}>
|
|
527
|
+
<div class="field"><label for="nb-id">${tn("connectionIdOptional")}</label><input id="nb-id" type="text" placeholder=${plan ? plan.channel_id : f.type}
|
|
528
|
+
value=${f.id} onInput=${(e) => set("id")(e.target.value)} onBlur=${() => { if (f.identity && f.identity.valid) planFor(f); }} /></div>
|
|
529
|
+
<${TokenEnvNote} name=${plan && plan.token_env} />
|
|
530
|
+
</${Drawer}>
|
|
461
531
|
${err ? html`<p class="feedback error" role="alert">${err}</p>` : null}
|
|
462
532
|
</div></${Dialog}>`;
|
|
463
533
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// #1519 P3 (docs/design/ux-onboarding-walkthrough.md §5.3): after a Discord token verifies — the invite (one permission
|
|
2
|
+
// set, never Administrator), the Message Content note with the portal's Bot page, the server the bot joins found by
|
|
3
|
+
// asking Discord every 2 s for up to 2 minutes, and the server's text channels as a list ("general" first choice). The
|
|
4
|
+
// setup wizard and New connection use it.
|
|
5
|
+
//
|
|
6
|
+
// Every read is an operation of its own (#1533 review): numbered when it starts, and its answer is taken only while it is
|
|
7
|
+
// still the current one of its kind and this picker is still on the page. A new token, another server, a manual choice,
|
|
8
|
+
// Stop, the 2-minute limit or leaving the page each retire what was asked before — an answer about A never lands after
|
|
9
|
+
// B, nor after A again (an older A), nor once the page is gone.
|
|
10
|
+
import { html, useEffect, useRef, useState } from "/assets/app-html.js";
|
|
11
|
+
import { t } from "/assets/app-i18n.js";
|
|
12
|
+
import { api } from "./settings-confirm.js";
|
|
13
|
+
import { Select } from "./settings-dialogs.js";
|
|
14
|
+
|
|
15
|
+
const tn = (k, ...v) => t(`settings.${k}`, ...v);
|
|
16
|
+
export const POLL_MS = 2000, POLL_FOR_MS = 120_000;
|
|
17
|
+
const clock = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); // elapsed time: monotonic
|
|
18
|
+
const probe = (body) => api("/api/settings/quickstart/probe", { method: "POST", body: JSON.stringify(body) }).catch(() => ({ ok: false, body: {} }));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* props: idPrefix, token, bot (@name), invite, portal, guilds, onGuilds(list), guild, onGuild(id), channel, onChannel(id).
|
|
22
|
+
* The parent owns the values; this owns the asking.
|
|
23
|
+
*/
|
|
24
|
+
export function DiscordServerPicker({ idPrefix, token, bot, invite, portal, guilds, onGuilds, guild, onGuild, channel, onChannel }) {
|
|
25
|
+
const [polling, setPolling] = useState(null); // { timedOut } while waiting for the bot to join
|
|
26
|
+
const [channels, setChannels] = useState(null); // null: not asked; { list } | { error }
|
|
27
|
+
const live = useRef(true);
|
|
28
|
+
const now = useRef({ token, guild, channel });
|
|
29
|
+
now.current = { token, guild, channel };
|
|
30
|
+
// The current operation of each kind; an answer is taken only while its number is still the one here.
|
|
31
|
+
// `choice`: the one generation of the server choice that polls and Check again both write (#1533 review r2) — a read
|
|
32
|
+
// started before any later pick, Check again or Stop cannot pick.
|
|
33
|
+
const op = useRef({ poll: 0, check: 0, channels: 0, choice: 0 });
|
|
34
|
+
const poll = useRef({ timer: null, until: 0, known: new Set(), asking: false });
|
|
35
|
+
const chosen = useRef(false); // the person picked a General channel since this server's list was asked
|
|
36
|
+
|
|
37
|
+
const stopPolling = (timedOut = false) => {
|
|
38
|
+
op.current.poll++; op.current.choice++; // retires the poll's reads still on their way
|
|
39
|
+
if (poll.current.timer) { clearInterval(poll.current.timer); poll.current.timer = null; }
|
|
40
|
+
setPolling(timedOut ? { timedOut: true } : null);
|
|
41
|
+
};
|
|
42
|
+
useEffect(() => () => { live.current = false; if (poll.current.timer) clearInterval(poll.current.timer); }, []);
|
|
43
|
+
// A new token is another bot: what was being waited for, or listed, was about the old one — and so was its General.
|
|
44
|
+
const lastToken = useRef(token);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
op.current.check++; op.current.channels++; stopPolling(); setChannels(null);
|
|
47
|
+
if (lastToken.current !== token && now.current.channel) onChannel("");
|
|
48
|
+
lastToken.current = token;
|
|
49
|
+
}, [token]);
|
|
50
|
+
|
|
51
|
+
/** One read of the bot's servers, for operation `kind` number `seq`; joined: pick it (unless the person chose meanwhile). */
|
|
52
|
+
const readGuilds = async (kind, seq, known, deadline) => {
|
|
53
|
+
const asked = token, choice = op.current.choice;
|
|
54
|
+
const res = await probe({ action: "guilds", token: asked });
|
|
55
|
+
const current = () => live.current && op.current[kind] === seq && op.current.choice === choice && now.current.token === asked
|
|
56
|
+
&& (deadline == null || clock() <= deadline);
|
|
57
|
+
if (!current()) return false;
|
|
58
|
+
const list = (res.ok && res.body && Array.isArray(res.body.guilds)) ? res.body.guilds : null;
|
|
59
|
+
if (!list) return false;
|
|
60
|
+
onGuilds(list);
|
|
61
|
+
const joined = list.filter((g) => !known.has(g.id));
|
|
62
|
+
if (!joined.length) return false;
|
|
63
|
+
if (joined.length === 1 || !now.current.guild) pickGuild(joined[0].id, false);
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
const startPolling = () => {
|
|
67
|
+
stopPolling();
|
|
68
|
+
const seq = op.current.poll, known = new Set((guilds || []).map((g) => g.id)), until = clock() + POLL_FOR_MS;
|
|
69
|
+
poll.current = { timer: null, until, known, asking: false };
|
|
70
|
+
setPolling({ timedOut: false });
|
|
71
|
+
const record = poll.current;
|
|
72
|
+
record.timer = setInterval(async () => {
|
|
73
|
+
if (op.current.poll !== seq) return;
|
|
74
|
+
if (clock() > until) { stopPolling(true); return; }
|
|
75
|
+
if (record.asking) return; // one read at a time
|
|
76
|
+
record.asking = true;
|
|
77
|
+
// Its own record only: a later poll's in-flight flag is not this read's to clear.
|
|
78
|
+
await readGuilds("poll", seq, known, until).finally(() => { record.asking = false; });
|
|
79
|
+
}, POLL_MS);
|
|
80
|
+
};
|
|
81
|
+
// Check again: a new, explicit read of its own (it does not revive a stopped poll).
|
|
82
|
+
// A newer, explicit read: it supersedes any read still on its way (theirs no longer picks).
|
|
83
|
+
const checkAgain = () => { op.current.choice++; const seq = ++op.current.check; void readGuilds("check", seq, poll.current.known, null); };
|
|
84
|
+
|
|
85
|
+
/** A server is picked — by the person (`manual`) or because the bot joined it. Either way the old General goes. */
|
|
86
|
+
const pickGuild = (id, manual) => {
|
|
87
|
+
// Any pick — the person's, or a read's — ends the waiting and retires every other read still on its way.
|
|
88
|
+
stopPolling(); op.current.check++;
|
|
89
|
+
if (id !== now.current.guild && now.current.channel) onChannel("");
|
|
90
|
+
onGuild(id);
|
|
91
|
+
};
|
|
92
|
+
const pickChannel = (id) => { chosen.current = true; onChannel(id); };
|
|
93
|
+
|
|
94
|
+
// The chosen server's text channels; "general" when there is one and none is chosen yet.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const seq = ++op.current.channels;
|
|
97
|
+
chosen.current = false;
|
|
98
|
+
setChannels(null);
|
|
99
|
+
if (!guild || !token) return;
|
|
100
|
+
const askedToken = token, askedGuild = guild;
|
|
101
|
+
(async () => {
|
|
102
|
+
const res = await probe({ action: "channels", token: askedToken, guild_id: askedGuild });
|
|
103
|
+
if (!live.current || op.current.channels !== seq || now.current.token !== askedToken || now.current.guild !== askedGuild) return;
|
|
104
|
+
if (!res.ok || !res.body || !Array.isArray(res.body.channels)) { setChannels({ error: (res.body && res.body.error) || tn("failed") }); return; }
|
|
105
|
+
setChannels({ list: res.body.channels });
|
|
106
|
+
// The first choice only fills an empty choice the person has not made.
|
|
107
|
+
if (!chosen.current && !now.current.channel) {
|
|
108
|
+
const general = res.body.channels.find((c) => c.name.toLowerCase() === "general");
|
|
109
|
+
if (general) onChannel(general.id);
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
}, [guild, token]);
|
|
113
|
+
|
|
114
|
+
return html`<div class="discord-setup">
|
|
115
|
+
${invite ? html`<div class="dlg-inline-actions">
|
|
116
|
+
<a class="btn btn-sm btn-primary" href=${invite} target="_blank" rel="noopener noreferrer" onClick=${startPolling}>${tn("discordInvite", bot || tn("discordTheBot"))}</a>
|
|
117
|
+
${polling && !polling.timedOut ? html`<span class="note" role="status">${tn("discordWaitingJoin")}</span>` : null}
|
|
118
|
+
${polling ? html`<button type="button" class="btn btn-sm btn-ghost" onClick=${checkAgain}>${tn("discordCheckAgain")}</button>` : null}</div>
|
|
119
|
+
<p class="note">${tn("discordIntentNote")} ${portal ? html`<a href=${portal} target="_blank" rel="noopener noreferrer">${tn("discordPortalBot")}</a>` : null}</p>` : null}
|
|
120
|
+
<div class="field"><label for=${`${idPrefix}-guild`}>${tn("guildIdField")}</label>
|
|
121
|
+
${guilds && guilds.length
|
|
122
|
+
? html`<${Select} id=${`${idPrefix}-guild`} value=${guild} onChange=${(v) => pickGuild(v, true)} options=${["", ...guilds.map((g) => ({ value: g.id, label: `${g.name} (${g.id})` }))]} />`
|
|
123
|
+
: html`<input id=${`${idPrefix}-guild`} type="text" value=${guild} placeholder=${tn("discordNoServerYet")} onInput=${(e) => pickGuild(e.target.value.trim(), true)} />`}</div>
|
|
124
|
+
<div class="field"><label for=${`${idPrefix}-gen`}>${tn("wizardGeneralChannel")}</label>
|
|
125
|
+
${channels && channels.list && channels.list.length
|
|
126
|
+
? html`<${Select} id=${`${idPrefix}-gen`} value=${channel} onChange=${pickChannel} options=${["", ...channels.list.map((c) => ({ value: c.id, label: `#${c.name}` }))]} />`
|
|
127
|
+
: html`<input id=${`${idPrefix}-gen`} type="text" value=${channel} onInput=${(e) => pickChannel(e.target.value.trim())} />`}
|
|
128
|
+
${channels && channels.error ? html`<p class="feedback warning">${channels.error}</p>` : null}</div>
|
|
129
|
+
</div>`;
|
|
130
|
+
}
|
|
@@ -275,3 +275,24 @@ export function fullModelRequests(model, current) {
|
|
|
275
275
|
}
|
|
276
276
|
return out;
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* #1519 P6 (design §5.6): one connection's own state, from its own metadata (GET /api/settings/connections) — not the
|
|
281
|
+
* fleet's. { key, cls, bot }: key names the settings string, cls the dot ("ok" | "warn" | "bad" | "off"), bot its @name.
|
|
282
|
+
* In order: no fleet answering, no token, a named problem, then the adapter's own status.
|
|
283
|
+
*/
|
|
284
|
+
export function connectionState(meta, fleetUp) {
|
|
285
|
+
const m = meta || {};
|
|
286
|
+
if (!fleetUp) return { key: "problem", cls: "bad" };
|
|
287
|
+
if (m.token_present === false) return { key: "connTokenMissing", cls: "bad" };
|
|
288
|
+
if (m.problem === "missing_intent") return { key: "discordMissingIntent", cls: "bad" };
|
|
289
|
+
if (m.problem === "rejected") return { key: "connRejected", cls: "bad" };
|
|
290
|
+
const bot = m.identity && m.identity.username ? `@${String(m.identity.username).replace(/^@/, "")}` : "";
|
|
291
|
+
switch (m.status) {
|
|
292
|
+
case "connected": return { key: bot ? "connConnectedAs" : "connected", cls: "ok", bot };
|
|
293
|
+
case "retrying": return { key: "connReconnecting", cls: "warn" };
|
|
294
|
+
case "starting": return { key: "connStarting", cls: "warn" };
|
|
295
|
+
case "failed": return { key: "connFailed", cls: "bad" };
|
|
296
|
+
default: return { key: "connNotRunning", cls: "off" };
|
|
297
|
+
}
|
|
298
|
+
}
|