@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/README.zh-TW.md +6 -4
- package/dist/agent-cli.js +6 -1
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +82 -14
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-token-header.d.ts +13 -0
- package/dist/agent-token-header.js +16 -0
- package/dist/agent-token-header.js.map +1 -0
- 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/antigravity.js +4 -3
- package/dist/backend/antigravity.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/grok.js +1 -1
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +7 -0
- package/dist/backend/kiro.js +82 -33
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +7 -0
- package/dist/backend/muse.js +8 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +12 -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-analysis.d.ts +92 -0
- package/dist/cache-analysis.js +147 -0
- package/dist/cache-analysis.js.map +1 -0
- package/dist/cache-ledger.d.ts +108 -0
- package/dist/cache-ledger.js +230 -0
- package/dist/cache-ledger.js.map +1 -0
- package/dist/cache-prices.d.ts +32 -0
- package/dist/cache-prices.js +73 -0
- package/dist/cache-prices.js.map +1 -0
- package/dist/cache-scan.d.ts +39 -0
- package/dist/cache-scan.js +115 -0
- package/dist/cache-scan.js.map +1 -0
- package/dist/cache-service.d.ts +131 -0
- package/dist/cache-service.js +452 -0
- package/dist/cache-service.js.map +1 -0
- 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-entry.d.ts +19 -0
- package/dist/cli-entry.js +36 -0
- package/dist/cli-entry.js.map +1 -0
- package/dist/cli.js +376 -54
- 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 +18 -0
- package/dist/daemon.js +256 -63
- 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-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/event-log.d.ts +4 -1
- package/dist/event-log.js +6 -3
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-control-audit.d.ts +5 -0
- package/dist/fleet-control-audit.js +7 -0
- package/dist/fleet-control-audit.js.map +1 -1
- package/dist/fleet-level-config.d.ts +8 -1
- package/dist/fleet-level-config.js +53 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +122 -7
- package/dist/fleet-manager.js +871 -152
- 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/full-restart.js +2 -3
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
- package/dist/install-lock.d.ts +49 -0
- package/dist/install-lock.js +200 -0
- package/dist/install-lock.js.map +1 -0
- package/dist/instance-config-impact.js +3 -2
- package/dist/instance-config-impact.js.map +1 -1
- 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 +96 -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 +7 -2
- 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/package-preimage.d.ts +26 -0
- package/dist/package-preimage.js +96 -0
- package/dist/package-preimage.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 +73 -0
- package/dist/public-link-progress.js +190 -0
- package/dist/public-link-progress.js.map +1 -0
- package/dist/public-web-gateway.js +2 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +6 -1
- package/dist/public-web-link.js +87 -15
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +93 -20
- package/dist/quickstart-api.js +194 -62
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +15 -0
- package/dist/quickstart.js +110 -33
- 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 +53 -0
- package/dist/restart-guard.js +114 -0
- package/dist/restart-guard.js.map +1 -0
- package/dist/scheduler/db.js +6 -2
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-activation.d.ts +62 -7
- package/dist/service-activation.js +237 -55
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +56 -1
- package/dist/service-installer.js +152 -12
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +65 -0
- package/dist/service-plan.js +108 -0
- package/dist/service-plan.js.map +1 -0
- package/dist/settings-api.js +92 -10
- 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 +37 -8
- 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 +15 -6
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +27 -19
- package/dist/setup-wizard.js.map +1 -1
- package/dist/sqlite-open-errors.d.ts +12 -0
- package/dist/sqlite-open-errors.js +13 -0
- package/dist/sqlite-open-errors.js.map +1 -0
- 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 +44 -0
- package/dist/tmux-control.js +170 -21
- 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 +56 -85
- 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 +18 -0
- package/dist/tunnel/cloudflared-install.js +15 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +3 -0
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/ui/chat-commands.js +43 -0
- package/dist/ui/chat-render.js +34 -8
- package/dist/ui/chat-store.js +51 -2
- package/dist/ui/chat-strings.js +30 -2
- package/dist/ui/chat-thread.js +141 -47
- package/dist/ui/first-run.js +64 -0
- package/dist/ui/fleet-cache.js +168 -0
- package/dist/ui/html-attachment.js +26 -0
- package/dist/ui/panel-chat.js +117 -23
- package/dist/ui/panel-fleet.js +31 -17
- package/dist/ui/panel-settings.js +29 -15
- package/dist/ui/preview-panel.js +237 -0
- package/dist/ui/preview.js +7 -3
- 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 +50 -8
- package/dist/ui/settings-token.js +54 -0
- package/dist/ui/settings-wizard.js +63 -29
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-shell.js +25 -37
- package/dist/ui/shared/app-store.js +5 -3
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +181 -5
- 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/signin.js +13 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/view-roster-store.js +5 -0
- package/dist/ui/shared/view-strings.js +12 -0
- package/dist/ui/signin.html +2 -1
- package/dist/update-dispatch.d.ts +28 -0
- package/dist/update-dispatch.js +73 -0
- package/dist/update-dispatch.js.map +1 -0
- package/dist/update-install.d.ts +78 -8
- package/dist/update-install.js +158 -30
- 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 +22 -0
- package/dist/web-api.js +115 -16
- 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-commands.d.ts +77 -0
- package/dist/web-commands.js +81 -0
- package/dist/web-commands.js.map +1 -0
- 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-session.d.ts +7 -1
- package/dist/web-session.js +23 -2
- package/dist/web-session.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/launcher/agend +110 -0
- package/launcher/agend-agent +110 -0
- package/launcher/agend-agent.cjs +4 -0
- package/launcher/agend.cjs +4 -0
- package/launcher/install-admission.cjs +105 -0
- package/launcher/launch.cjs +64 -0
- package/launcher/postinstall.cjs +118 -0
- package/launcher/run-in-process.cjs +13 -0
- package/launcher/runtime-platform.cjs +86 -0
- package/launcher/runtime-select.cjs +217 -0
- package/package.json +12 -4
- package/scripts/preinstall-guard.cjs +40 -21
- package/templates/launchd.plist.ejs +2 -2
- package/templates/systemd.service.ejs +4 -4
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// #1481: an agent's HTML preview (#1306) in a panel beside the conversation — a split with a draggable divider on a
|
|
2
|
+
// wide screen, a full-screen sheet on a narrow one. Nothing about the preview itself changes here: the frame is the one
|
|
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
|
+
// - "Open in panel" on an idle card opens the panel with its Preview button — the panel runs nothing by itself;
|
|
6
|
+
// - on a card whose preview is running, the same button moves that run to the panel (stopped in the card, started in
|
|
7
|
+
// the panel: a moved frame reloads anyway);
|
|
8
|
+
// - a newer version of the block in a later reply is offered, never swapped in, and offered with Preview to click.
|
|
9
|
+
// Closing the panel or leaving the chat stops its frame; one preview per page still holds (start() stops any other).
|
|
10
|
+
import { html, useEffect, useRef, useState } from "/assets/app-html.js";
|
|
11
|
+
import { t } from "/assets/app-i18n.js";
|
|
12
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
13
|
+
import "./chat-render.js";
|
|
14
|
+
import "./preview.js";
|
|
15
|
+
import { loadHtmlAttachment } from "./html-attachment.js";
|
|
16
|
+
|
|
17
|
+
const R = () => globalThis.AgendChatRender;
|
|
18
|
+
const P = () => globalThis.AgendPreview;
|
|
19
|
+
|
|
20
|
+
// ── The panel's state, one per page (the chat shows one instance at a time) ──
|
|
21
|
+
|
|
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). */
|
|
23
|
+
let state = { open: null, run: 0 };
|
|
24
|
+
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 */ } } }
|
|
26
|
+
export const panelStore = { get: () => state, subscribe(fn) { subs.add(fn); return () => { subs.delete(fn); }; } };
|
|
27
|
+
|
|
28
|
+
/** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
|
|
29
|
+
export const frameKey = (key) => `panel:${key}`;
|
|
30
|
+
/** Show `spec` in the panel; `run`: start it there (a click that moves a running preview). The block shown before stops. */
|
|
31
|
+
export function openPanel(spec, { run = false } = {}) {
|
|
32
|
+
if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
|
|
33
|
+
set({ open: { ...spec }, run: run ? state.run + 1 : 0 });
|
|
34
|
+
}
|
|
35
|
+
/** Close the panel: its frame stops first. */
|
|
36
|
+
export function closePanel() {
|
|
37
|
+
if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
|
|
38
|
+
if (state.open) set({ open: null, run: 0 });
|
|
39
|
+
}
|
|
40
|
+
/** The card key the panel shows, or null. */
|
|
41
|
+
export const shownKey = () => (state.open ? state.open.key : null);
|
|
42
|
+
|
|
43
|
+
/** The text of a block's <title>, or "" — what says two blocks are versions of one thing. */
|
|
44
|
+
export function htmlTitle(code) {
|
|
45
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(String(code || ""));
|
|
46
|
+
return m ? m[1].replace(/\s+/g, " ").trim() : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A newer version of the shown block: the last complete ```html block in an agent reply after the one it came from.
|
|
51
|
+
* When the shown block has a <title>, only a block with the same title counts (another page is not a new version);
|
|
52
|
+
* without one, the newest block does. `msgs` in order; `keyOf` makes a message's key.
|
|
53
|
+
*/
|
|
54
|
+
export function newerVersion(open, msgs, keyOf) {
|
|
55
|
+
if (!open || !Array.isArray(msgs)) return null;
|
|
56
|
+
const at = msgs.findIndex(x => keyOf(x) === open.msgKey);
|
|
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
|
+
}
|
|
74
|
+
const title = htmlTitle(open.code);
|
|
75
|
+
for (let i = msgs.length - 1; i > at; i--) {
|
|
76
|
+
const x = msgs[i];
|
|
77
|
+
if (x.role !== "agent") continue;
|
|
78
|
+
const fences = R().htmlFences(x.text);
|
|
79
|
+
for (let n = fences.length - 1; n >= 0; n--) {
|
|
80
|
+
const f = fences[n];
|
|
81
|
+
if (!f.terminated || (title && htmlTitle(f.code) !== title)) continue;
|
|
82
|
+
const msgKey = keyOf(x);
|
|
83
|
+
return { key: `${msgKey}:f${n}`, instance: open.instance, msgKey, n, code: f.code, sender: x.sender, ts: x.ts };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── The panel's width: this device's choice (a convenience — the panel works without storage) ──
|
|
90
|
+
|
|
91
|
+
const WIDTH_KEY = "agend_preview_panel_w";
|
|
92
|
+
export const MIN_PANEL = 320, MIN_THREAD = 360, STEP = 32;
|
|
93
|
+
function storedWidth() { try { const v = Number(localStorage.getItem(WIDTH_KEY)); return Number.isFinite(v) && v > 0 ? v : null; } catch { return null; } }
|
|
94
|
+
function storeWidth(w) { try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); } catch { /* this page only */ } }
|
|
95
|
+
/** The width a panel may have in a split `total` px wide: at least MIN_PANEL, leaving the thread MIN_THREAD. */
|
|
96
|
+
export function clampWidth(w, total) {
|
|
97
|
+
const max = Math.max(MIN_PANEL, total - MIN_THREAD - 8);
|
|
98
|
+
return Math.round(Math.min(max, Math.max(MIN_PANEL, w)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "");
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The panel, inside the chat's split (`split`: that element — the divider sets its width there, through CSSOM).
|
|
105
|
+
* props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
|
|
106
|
+
* reveal(msgKey) (the thread shows that message), download(code).
|
|
107
|
+
*/
|
|
108
|
+
export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download }) {
|
|
109
|
+
const [st, setSt] = useState(state);
|
|
110
|
+
const [view, setView] = useState("preview");
|
|
111
|
+
const [run, setRun] = useState({ state: "idle", reason: "" });
|
|
112
|
+
const [newer, setNewer] = useState(null);
|
|
113
|
+
const [newerNote, setNewerNote] = useState("");
|
|
114
|
+
const reading = useRef(null); // the newer version whose file is being read (one at a time)
|
|
115
|
+
const holder = useRef(null), divider = useRef(null), panel = useRef(null);
|
|
116
|
+
const open = st.open && st.open.instance === name ? st.open : null;
|
|
117
|
+
|
|
118
|
+
useEffect(() => panelStore.subscribe(setSt), []);
|
|
119
|
+
// This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
|
|
120
|
+
const [, choice] = useState(0);
|
|
121
|
+
useEffect(() => (P() ? P().onChange(() => choice(n => n + 1)) : undefined), []);
|
|
122
|
+
// Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
|
|
123
|
+
useEffect(() => () => closePanel(), []);
|
|
124
|
+
|
|
125
|
+
const ui = { state: (s, reason) => setRun({ state: s === "starting" || s === "running" ? s : "idle", reason: reason || "" }) };
|
|
126
|
+
const start = () => {
|
|
127
|
+
if (!open || !holder.current) return;
|
|
128
|
+
setView("preview");
|
|
129
|
+
P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
|
|
130
|
+
};
|
|
131
|
+
const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
|
|
132
|
+
// A new block in the panel: what ran before is gone (openPanel stopped it); a moved run starts here.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
setRun({ state: "idle", reason: "" });
|
|
135
|
+
setView("preview");
|
|
136
|
+
if (open && st.run) start();
|
|
137
|
+
// Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
|
|
138
|
+
if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
|
|
139
|
+
}, [open && open.key, st.run]);
|
|
140
|
+
// A later reply with a newer version of this block: offered, not swapped in.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setNewerNote(""); reading.current = null;
|
|
143
|
+
if (!open) { setNewer(null); return undefined; }
|
|
144
|
+
const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
|
|
145
|
+
check();
|
|
146
|
+
return subscribe((instance, kind) => { if (instance === name && kind === "msgs") check(); });
|
|
147
|
+
}, [open && open.key, name]);
|
|
148
|
+
|
|
149
|
+
// The divider: drag, or the arrow keys, Home and End; the width is this device's.
|
|
150
|
+
const widthNow = useRef(MIN_PANEL);
|
|
151
|
+
const applyWidth = (w, save) => {
|
|
152
|
+
const sp = split.current; if (!sp) return;
|
|
153
|
+
const total = sp.clientWidth || 0;
|
|
154
|
+
const v = clampWidth(w, total);
|
|
155
|
+
widthNow.current = v;
|
|
156
|
+
sp.style.setProperty("--pv-w", `${v}px`); // CSSOM: the page's CSP allows no style attribute
|
|
157
|
+
if (divider.current) { divider.current.setAttribute("aria-valuenow", String(v)); divider.current.setAttribute("aria-valuemax", String(clampWidth(Infinity, total))); }
|
|
158
|
+
if (save) storeWidth(v);
|
|
159
|
+
};
|
|
160
|
+
useEffect(() => { if (open) applyWidth(storedWidth() ?? (split.current && split.current.clientWidth || 0) / 2, false); }, [!!open]);
|
|
161
|
+
const width = () => widthNow.current;
|
|
162
|
+
const setWidth = (w) => applyWidth(w, true);
|
|
163
|
+
const onKey = (e) => {
|
|
164
|
+
const k = e.key;
|
|
165
|
+
if (k === "ArrowLeft") setWidth(width() + STEP);
|
|
166
|
+
else if (k === "ArrowRight") setWidth(width() - STEP);
|
|
167
|
+
else if (k === "Home") setWidth(Infinity);
|
|
168
|
+
else if (k === "End") setWidth(0);
|
|
169
|
+
else return;
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
};
|
|
172
|
+
const onDown = (e) => {
|
|
173
|
+
const sp = split.current; if (!sp || e.button !== 0) return;
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
const el = e.currentTarget;
|
|
176
|
+
try { el.setPointerCapture(e.pointerId); } catch { /* the move still works while over the divider */ }
|
|
177
|
+
sp.classList.add("resizing"); // frames take no pointer events meanwhile, or the drag would stop over one
|
|
178
|
+
const right = sp.getBoundingClientRect().right;
|
|
179
|
+
const move = (ev) => applyWidth(right - ev.clientX - 4, false);
|
|
180
|
+
const up = () => { sp.classList.remove("resizing"); storeWidth(widthNow.current); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up); };
|
|
181
|
+
el.addEventListener("pointermove", move);
|
|
182
|
+
el.addEventListener("pointerup", up);
|
|
183
|
+
el.addEventListener("pointercancel", up);
|
|
184
|
+
};
|
|
185
|
+
// Show the newer version: an attached file's HTML is read first. A read the panel moved on from is dropped.
|
|
186
|
+
const showNewer = async () => {
|
|
187
|
+
if (!newer || reading.current) return;
|
|
188
|
+
if (newer.code != null) { openPanel(newer); return; }
|
|
189
|
+
const was = open && open.key, mine = reading.current = newer;
|
|
190
|
+
setNewerNote(t("chat.pvLoading"));
|
|
191
|
+
const r = await loadHtmlAttachment(newer.att);
|
|
192
|
+
if (reading.current !== mine) return;
|
|
193
|
+
reading.current = null;
|
|
194
|
+
const now = panelStore.get().open;
|
|
195
|
+
if (!now || now.key !== was) return;
|
|
196
|
+
if (!r.ok) { setNewerNote(t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed")); return; }
|
|
197
|
+
openPanel({ ...newer, code: r.code });
|
|
198
|
+
};
|
|
199
|
+
const fileName = open && open.att ? open.att.name : undefined;
|
|
200
|
+
// Esc in the panel closes it (and is not the chat's "stop the reply").
|
|
201
|
+
const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
|
|
202
|
+
|
|
203
|
+
if (!open) return null;
|
|
204
|
+
const going = run.state === "starting" || run.state === "running";
|
|
205
|
+
const a = P().availability();
|
|
206
|
+
const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : a.reason);
|
|
207
|
+
const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
|
|
208
|
+
return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
|
|
209
|
+
aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
|
|
210
|
+
<aside class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
|
|
211
|
+
<div class="pv-panel-head">
|
|
212
|
+
<button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
|
|
213
|
+
<div class="pv-panel-title"><strong>${t("chat.pvPanel")}</strong>
|
|
214
|
+
<button type="button" class="pv-source" title=${source} onClick=${() => { if (window.matchMedia && window.matchMedia("(max-width: 899px)").matches) closePanel(); reveal(open.msgKey); }}>${source}</button></div>
|
|
215
|
+
<button type="button" class="icon-btn only-wide pv-panel-close" title=${t("chat.pvClose")} aria-label=${t("chat.pvClose")} onClick=${closePanel}><${Icon} name="close" size=${18} /></button>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="pv-panel-bar">
|
|
218
|
+
<div class="seg-inline" role="group" aria-label=${t("chat.pvPanel")}>
|
|
219
|
+
<button type="button" class=${`btn btn-sm${view === "preview" ? " on" : ""}`} aria-pressed=${view === "preview" ? "true" : "false"} onClick=${() => setView("preview")}><${Icon} name="eye" size=${14} />${t("chat.pvPage")}</button>
|
|
220
|
+
<button type="button" class=${`btn btn-sm${view === "code" ? " on" : ""}`} aria-pressed=${view === "code" ? "true" : "false"} onClick=${() => setView("code")}><${Icon} name="code" size=${14} />${t("chat.pvCode")}</button>
|
|
221
|
+
</div>
|
|
222
|
+
<span class="grow"></span>
|
|
223
|
+
${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>
|
|
224
|
+
<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>`
|
|
225
|
+
: 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>`}
|
|
226
|
+
<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>
|
|
227
|
+
</div>
|
|
228
|
+
${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
|
|
229
|
+
<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}
|
|
230
|
+
${going ? html`<div class="pv-banner">${P().BANNER}</div>` : null}
|
|
231
|
+
${note && view === "preview" ? html`<div class="pv-note">${note}</div>` : null}
|
|
232
|
+
<div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
|
|
233
|
+
<div class="pv-holder" ref=${holder}></div>
|
|
234
|
+
${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
|
|
235
|
+
</div>
|
|
236
|
+
</aside>`;
|
|
237
|
+
}
|
package/dist/ui/preview.js
CHANGED
|
@@ -128,8 +128,10 @@
|
|
|
128
128
|
/**
|
|
129
129
|
* Run `html` in `holder` (a node of the card) for `key`. `ui` is told what happened: ui.state(name, reason) with
|
|
130
130
|
* "starting" | "running" | "stopped" | "unavailable". One preview at a time: any other running one stops first.
|
|
131
|
+
* opts.fill (#1481, the side panel): the frame fills its holder — the same frame from mountPreview, only sized by the
|
|
132
|
+
* panel instead of by the frame's height messages, which it then ignores.
|
|
131
133
|
*/
|
|
132
|
-
function start(key, holder, html, ui) {
|
|
134
|
+
function start(key, holder, html, ui, opts) {
|
|
133
135
|
var a = availability();
|
|
134
136
|
if (!a.ok) { ui.state("unavailable", a.reason); return false; }
|
|
135
137
|
if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", "This HTML is over 1 MiB; it is not previewed."); return false; }
|
|
@@ -139,14 +141,15 @@
|
|
|
139
141
|
var card = {
|
|
140
142
|
key: key, iframe: iframe, ch: randomCh(), holder: holder, html: html, ui: ui, rendered: false,
|
|
141
143
|
height: LIMITS.minHeight, lastApplied: -Infinity, appliedInSecond: [], frozen: false, growth: [], pendingFrame: false,
|
|
142
|
-
readyTimer: null, watchdogTimer: null,
|
|
144
|
+
readyTimer: null, watchdogTimer: null, fill: !!(opts && opts.fill),
|
|
143
145
|
};
|
|
146
|
+
if (card.fill) iframe.classList.add("fill");
|
|
144
147
|
live.set(key, card);
|
|
145
148
|
card.readyTimer = root.setTimeout(function () {
|
|
146
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.");
|
|
147
150
|
}, LIMITS.readyMs);
|
|
148
151
|
holder.appendChild(iframe);
|
|
149
|
-
setHeight(card, LIMITS.minHeight);
|
|
152
|
+
if (!card.fill) setHeight(card, LIMITS.minHeight);
|
|
150
153
|
ui.state("starting", "");
|
|
151
154
|
return true;
|
|
152
155
|
}
|
|
@@ -235,6 +238,7 @@
|
|
|
235
238
|
/** Height rules (design §4.2): clamp to [40, 4000]; ≤ 1 per animation frame and ≤ 10 per second; Δ < 2 px ignored;
|
|
236
239
|
* after 5 consecutive increases within 2 s the height freezes and the frame scrolls. */
|
|
237
240
|
function requestHeight(card, h) {
|
|
241
|
+
if (card.fill) return; // the panel sizes it (#1481)
|
|
238
242
|
var want = Math.max(LIMITS.minHeight, Math.min(LIMITS.maxHeight, h));
|
|
239
243
|
if (Math.abs(want - card.height) < LIMITS.minDelta) return;
|
|
240
244
|
var t = now();
|
|
@@ -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
|
+
}
|