@songsid/agend 2.3.3-beta.9 → 2.3.4-beta.1
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 +3 -3
- package/README.zh-TW.md +3 -3
- package/dist/agent-cli-instructions.md +1 -1
- package/dist/auth-api.d.ts +16 -3
- package/dist/auth-api.js +22 -6
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/web-authority.d.ts +15 -1
- package/dist/authority/web-authority.js +27 -10
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/claude-code.js +11 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +12 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/types.d.ts +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/config-validator.js +18 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.js +8 -1
- package/dist/daemon.js.map +1 -1
- package/dist/env-file.d.ts +2 -0
- package/dist/env-file.js +13 -8
- package/dist/env-file.js.map +1 -1
- package/dist/fleet-manager.d.ts +41 -1
- package/dist/fleet-manager.js +179 -22
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/instance-config-impact.js +1 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +2 -0
- package/dist/instance-lifecycle.js +23 -7
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +1 -1
- package/dist/instructions.js.map +1 -1
- package/dist/interaction-observation.d.ts +1 -0
- package/dist/interaction-observation.js +1 -1
- package/dist/interaction-observation.js.map +1 -1
- package/dist/locale.js +56 -2
- package/dist/locale.js.map +1 -1
- package/dist/next-steps.d.ts +12 -2
- package/dist/next-steps.js +19 -3
- package/dist/next-steps.js.map +1 -1
- package/dist/office-share-service.d.ts +57 -0
- package/dist/office-share-service.js +76 -11
- package/dist/office-share-service.js.map +1 -1
- package/dist/office-share.d.ts +8 -0
- package/dist/office-share.js +34 -0
- package/dist/office-share.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/page-share.d.ts +122 -0
- package/dist/page-share.js +199 -0
- package/dist/page-share.js.map +1 -0
- package/dist/public-link-progress.d.ts +5 -2
- package/dist/public-link-progress.js +4 -3
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.d.ts +4 -0
- package/dist/public-preview.js +5 -4
- package/dist/public-preview.js.map +1 -1
- package/dist/public-web-gateway.js +5 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +10 -0
- package/dist/public-web-link.js +24 -5
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +5 -0
- package/dist/quickstart-api.js +1 -1
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +40 -0
- package/dist/quickstart.js +174 -28
- package/dist/quickstart.js.map +1 -1
- package/dist/remote-card-answer.js +2 -0
- package/dist/remote-card-answer.js.map +1 -1
- package/dist/remote-card-prompt.js +4 -2
- package/dist/remote-card-prompt.js.map +1 -1
- package/dist/selection-card.d.ts +21 -0
- package/dist/selection-card.js +129 -0
- package/dist/selection-card.js.map +1 -0
- package/dist/settings-api.d.ts +10 -0
- package/dist/settings-api.js +45 -0
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +5 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/setup-host.js +2 -2
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +5 -0
- package/dist/tunnel/cloudflared.js +32 -1
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/named-diagnostics.d.ts +2 -1
- package/dist/tunnel/named-diagnostics.js +4 -1
- package/dist/tunnel/named-diagnostics.js.map +1 -1
- package/dist/tunnel/named-link-test.d.ts +90 -0
- package/dist/tunnel/named-link-test.js +270 -0
- package/dist/tunnel/named-link-test.js.map +1 -0
- package/dist/tunnel/purpose-lane.d.ts +3 -1
- package/dist/tunnel/purpose-lane.js +2 -0
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/ui/chat-store.js +8 -1
- package/dist/ui/chat-strings.js +36 -0
- package/dist/ui/page-share-dialog.js +191 -0
- package/dist/ui/panel-chat.js +14 -0
- package/dist/ui/panel-notices.js +98 -0
- package/dist/ui/panel-settings.js +14 -5
- package/dist/ui/preview-pages.js +41 -0
- package/dist/ui/preview-panel-actions.js +161 -0
- package/dist/ui/preview-panel.js +209 -28
- package/dist/ui/preview-versions.js +155 -0
- package/dist/ui/preview.js +88 -3
- package/dist/ui/settings-named-test.js +144 -0
- package/dist/ui/settings-registry.js +1 -0
- package/dist/ui/settings-strings.js +10 -6
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-route.js +4 -0
- package/dist/ui/shared/app-shell.js +5 -3
- package/dist/ui/shared/app-store.js +71 -1
- package/dist/ui/shared/app-stream.js +1 -1
- package/dist/ui/shared/app.css +50 -1
- package/dist/ui/shared/app.js +22 -1
- package/dist/ui/shared/signin.js +12 -1
- package/dist/ui/shared/ui-icons.js +3 -0
- package/dist/ui/shared/web-authorize.js +5 -3
- package/dist/web-api.d.ts +26 -0
- package/dist/web-api.js +180 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +7 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-login.d.ts +2 -0
- package/dist/web-login.js +3 -1
- package/dist/web-login.js.map +1 -1
- package/dist/web-notices.d.ts +51 -0
- package/dist/web-notices.js +118 -0
- package/dist/web-notices.js.map +1 -0
- package/dist/web-only-fleet.d.ts +22 -0
- package/dist/web-only-fleet.js +13 -0
- package/dist/web-only-fleet.js.map +1 -0
- package/dist/web-preview.d.ts +32 -3
- package/dist/web-preview.js +52 -19
- package/dist/web-preview.js.map +1 -1
- package/dist/web-session.d.ts +7 -0
- package/dist/web-session.js +2 -0
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +4 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +2 -0
- package/dist/web-shell-routes.js +3 -0
- package/dist/web-shell-routes.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// #2911 item 8: page share links on the dashboard. The preview panel's Share page action — a confirm dialog every time
|
|
2
|
+
// (what is shared, that it runs the page's own scripts, for how long), then the link shown once to copy — and, while that
|
|
3
|
+
// page is shared, "Shared until …" with Stop sharing; Settings → Access & security lists the open ones with Stop and
|
|
4
|
+
// Stop all. Design: #2911 issuecomment-6561078095 (approved).
|
|
5
|
+
// GET / POST /ui/page-share, DELETE /ui/page-share/<id|all> (web-api.ts): an admin session (directly or over the public
|
|
6
|
+
// link). The server reads the page itself from the chat history: the page only names the block.
|
|
7
|
+
import { html, useEffect, useState } from "/assets/app-html.js";
|
|
8
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
9
|
+
import { Dialog } from "/assets/ui-dialog.js";
|
|
10
|
+
import { toast } from "/assets/ui-toast.js";
|
|
11
|
+
|
|
12
|
+
register("pshare", {
|
|
13
|
+
en: {
|
|
14
|
+
share: "Share page", shareTitle: "Make a link anyone can open to see this page, for a limited time",
|
|
15
|
+
shared: "Shared until {0}", sharedTitle: "This page is shared: see or stop it",
|
|
16
|
+
title: "Share this page", what: "Anyone with the link can open “{0}” from {1}'s reply ({2}).",
|
|
17
|
+
runs: "It runs the page's own scripts in their browser, apart from this dashboard. They see the page only — not the chat, the agent or the fleet.",
|
|
18
|
+
snapshot: "The link shows the page as it is now; a later version needs a new link. The chat is told when it starts and ends.",
|
|
19
|
+
cdn: "This page may load libraries from cdnjs / jsDelivr; viewers' browsers will request them.",
|
|
20
|
+
how: "For", create: "Share", creating: "Starting the link…", cancel: "Cancel", close: "Close",
|
|
21
|
+
made: "Anyone with this link can open the page until {0}. It is shown only now: copy it.", copy: "Copy link", copied: "Link copied",
|
|
22
|
+
stop: "Stop sharing", stopAll: "Stop all", stopping: "Stopping…", stopped: "The page is no longer shared.", untitled: "this page",
|
|
23
|
+
nowShared: "Shared until {0}. Stop sharing ends the link at once.",
|
|
24
|
+
m15: "15 minutes", h1: "1 hour", h8: "8 hours", h24: "24 hours",
|
|
25
|
+
e_disabled: "Page share links are off on this fleet (web.page_share.enabled: false).",
|
|
26
|
+
e_duration: "That is longer than this fleet allows (web.page_share.max_hours).", e_too_many: "At most 10 pages can be shared at once: stop one first.",
|
|
27
|
+
e_too_large: "This page is too large to share (over 8 MB): download it instead.", e_page_gone: "This page is no longer in the chat's history.",
|
|
28
|
+
e_message_gone: "This page is no longer in the chat's history.", e_not_an_agent_page: "Only a page an agent sent can be shared.",
|
|
29
|
+
e_rate: "Cloudflare is limiting new quick tunnels right now. Try again in a few minutes.", e_tunnel: "The share link's tunnel did not start ({0}). Try again later.",
|
|
30
|
+
failed: "The link could not be made.",
|
|
31
|
+
cardTitle: "Shared pages", cardHint: "Links to single agent pages, made from the HTML preview panel's Share page. Each ends at its time; stop one to end it now.",
|
|
32
|
+
none: "No pages are shared.", from: "from {0}", ends: "until {0}",
|
|
33
|
+
},
|
|
34
|
+
"zh-TW": {
|
|
35
|
+
share: "分享頁面", shareTitle: "建立一個連結,讓任何人在限定時間內開啟這個頁面",
|
|
36
|
+
shared: "分享到 {0}", sharedTitle: "這個頁面正在分享:查看或停止",
|
|
37
|
+
title: "分享這個頁面", what: "拿到連結的人都能開啟 {1} 回覆裡的「{0}」({2})。",
|
|
38
|
+
runs: "頁面本身的腳本會在對方的瀏覽器執行,與這個儀表板分開。對方只看得到這個頁面——看不到對話、agent 或 fleet。",
|
|
39
|
+
snapshot: "連結顯示的是頁面現在的樣子;之後的新版本需要新的連結。開始與結束分享時會告知這個對話。",
|
|
40
|
+
cdn: "這個頁面可能從 cdnjs / jsDelivr 載入函式庫;觀看者的瀏覽器會向它們發出請求。",
|
|
41
|
+
how: "時間", create: "分享", creating: "正在啟動連結…", cancel: "取消", close: "關閉",
|
|
42
|
+
made: "拿到這個連結的人可以開啟頁面到 {0}。連結只會顯示這一次:請複製。", copy: "複製連結", copied: "已複製連結",
|
|
43
|
+
stop: "停止分享", stopAll: "全部停止", stopping: "正在停止…", stopped: "這個頁面已停止分享。", untitled: "這個頁面",
|
|
44
|
+
nowShared: "分享到 {0}。按「停止分享」會立即結束連結。",
|
|
45
|
+
m15: "15 分鐘", h1: "1 小時", h8: "8 小時", h24: "24 小時",
|
|
46
|
+
e_disabled: "這個 fleet 已關閉頁面分享連結(web.page_share.enabled: false)。",
|
|
47
|
+
e_duration: "超過這個 fleet 允許的時間(web.page_share.max_hours)。", e_too_many: "同時最多分享 10 個頁面:先停止一個。",
|
|
48
|
+
e_too_large: "這個頁面太大(超過 8 MB),無法分享:請改用下載。", e_page_gone: "對話紀錄裡已經沒有這個頁面。",
|
|
49
|
+
e_message_gone: "對話紀錄裡已經沒有這個頁面。", e_not_an_agent_page: "只能分享 agent 傳來的頁面。",
|
|
50
|
+
e_rate: "Cloudflare 目前限制新的臨時通道,請過幾分鐘再試。", e_tunnel: "分享連結的通道沒有啟動({0}),請稍後再試。",
|
|
51
|
+
failed: "無法建立連結。",
|
|
52
|
+
cardTitle: "分享中的頁面", cardHint: "單一 agent 頁面的連結,可在 HTML 預覽面板的「分享頁面」建立。每個連結到時間就結束;停止可立即結束。",
|
|
53
|
+
none: "目前沒有分享中的頁面。", from: "來自 {0}", ends: "到 {0} 為止",
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const tp = (k, ...v) => t(`pshare.${k}`, ...v);
|
|
57
|
+
export const DURATIONS = [["m15", 15], ["h1", 60], ["h8", 480], ["h24", 1440]];
|
|
58
|
+
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
59
|
+
const when = (ms) => { try { return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(ms)); } catch { return ""; } };
|
|
60
|
+
/** The end of a share for the bar: the time today, with the day when it is not today (a share may last 24 h). */
|
|
61
|
+
const until = (ms) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? when(ms) : whenLong(ms); };
|
|
62
|
+
const whenLong = (ms) => { try { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)); } catch { return ""; } };
|
|
63
|
+
|
|
64
|
+
/** What a refusal says (the host's `error` and `reason`). */
|
|
65
|
+
export function refusalText(d) {
|
|
66
|
+
const e = d && d.error;
|
|
67
|
+
if (e === "tunnel") return d.reason === "rate-limited" ? tp("e_rate") : tp("e_tunnel", d.reason || "unknown");
|
|
68
|
+
return ["disabled", "duration", "too_many", "too_large", "page_gone", "message_gone", "not_an_agent_page"].includes(e) ? tp(`e_${e}`) : tp("failed");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── The open page shares, one read for the page (an admin's; anyone else gets null) ──
|
|
72
|
+
let state = { loaded: false, canShare: false, enabled: false, cdn: false, shares: [] };
|
|
73
|
+
const subs = new Set();
|
|
74
|
+
let reading = null, expiry = null;
|
|
75
|
+
function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
|
|
76
|
+
/** Read the open shares again (after a change; at the soonest end). `get`: fetch. */
|
|
77
|
+
export function refreshPageShares(get = globalThis.fetch) {
|
|
78
|
+
if (reading) return reading;
|
|
79
|
+
reading = (async () => {
|
|
80
|
+
const auth = globalThis.AgendAuth;
|
|
81
|
+
const admin = !!(auth && typeof auth.canWrite === "function" && (await Promise.resolve(auth.canWrite()).catch(() => false)) === true);
|
|
82
|
+
let d = null;
|
|
83
|
+
if (admin) { try { const r = await get("/ui/page-share", { method: "GET", headers: JSON_HEADERS }); d = r.ok ? await r.json() : null; } catch { d = null; } }
|
|
84
|
+
const shares = d && Array.isArray(d.shares) ? d.shares : [];
|
|
85
|
+
set({ loaded: true, canShare: admin && !!d && d.enabled !== false, enabled: !!d && d.enabled !== false, cdn: !!d && d.cdn === true, shares });
|
|
86
|
+
// Read again when the soonest one ends, so its "Shared until" goes without a reload.
|
|
87
|
+
if (expiry) clearTimeout(expiry);
|
|
88
|
+
const soonest = Math.min(...shares.map((s) => s.expiresAt));
|
|
89
|
+
if (Number.isFinite(soonest)) expiry = setTimeout(() => { expiry = null; refreshPageShares(get); }, Math.max(1000, soonest - Date.now() + 1000));
|
|
90
|
+
})().finally(() => { reading = null; });
|
|
91
|
+
return reading;
|
|
92
|
+
}
|
|
93
|
+
export const pageShares = () => state;
|
|
94
|
+
/** The open shares, read once per page and on every change. */
|
|
95
|
+
export function usePageShares(get) {
|
|
96
|
+
const [st, setSt] = useState(state);
|
|
97
|
+
useEffect(() => { subs.add(setSt); if (!state.loaded) refreshPageShares(get); return () => { subs.delete(setSt); }; }, []);
|
|
98
|
+
return st;
|
|
99
|
+
}
|
|
100
|
+
/** The open share of a block `from` ({ boot, id, fence | att }) in `instance`, or null. */
|
|
101
|
+
export function shareOf(shares, instance, from) {
|
|
102
|
+
if (!from) return null;
|
|
103
|
+
return shares.find((s) => s.instance === instance && s.from && s.from.boot === from.boot && s.from.id === from.id
|
|
104
|
+
&& (from.att ? s.from.att === from.att : s.from.fence === from.fence)) || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function stopShare(id, get = globalThis.fetch) {
|
|
108
|
+
try { await get(`/ui/page-share/${id}`, { method: "DELETE", headers: JSON_HEADERS }); } catch { /* the list is read again */ }
|
|
109
|
+
await refreshPageShares(get);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The confirm dialog: what is shared (its title, whose reply, when), that it runs the page's own scripts, for how long;
|
|
114
|
+
* Share makes the link and shows it once. With `share` (this page is already shared): when it ends, and Stop sharing.
|
|
115
|
+
* `block`: { instance, from: {boot, id, fence | att}, title, sender, ts }.
|
|
116
|
+
*/
|
|
117
|
+
export function PageShareDialog({ block, share = null, get = globalThis.fetch, onClose }) {
|
|
118
|
+
const [minutes, setMinutes] = useState(60);
|
|
119
|
+
const [busy, setBusy] = useState(false);
|
|
120
|
+
const [made, setMade] = useState(null);
|
|
121
|
+
const [error, setError] = useState("");
|
|
122
|
+
const create = async () => {
|
|
123
|
+
setBusy(true); setError("");
|
|
124
|
+
try {
|
|
125
|
+
const r = await get("/ui/page-share", { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ instance: block.instance, boot: block.from.boot, id: block.from.id,
|
|
126
|
+
...(block.from.att ? { att: block.from.att } : { fence: block.from.fence }), minutes }) });
|
|
127
|
+
const d = await r.json().catch(() => ({}));
|
|
128
|
+
if (r.ok && d && typeof d.url === "string") setMade(d); else setError(refusalText(d));
|
|
129
|
+
} catch { setError(tp("failed")); }
|
|
130
|
+
setBusy(false);
|
|
131
|
+
refreshPageShares(get);
|
|
132
|
+
};
|
|
133
|
+
const stop = async (id) => { setBusy(true); await stopShare(id, get); setBusy(false); toast(tp("stopped")); onClose(); };
|
|
134
|
+
const copy = async () => { try { await navigator.clipboard.writeText(made.url); toast(tp("copied")); } catch { /* the link is on screen to copy by hand */ } };
|
|
135
|
+
const open = made ? made.share : share;
|
|
136
|
+
const name = block.title || tp("untitled");
|
|
137
|
+
const actions = made || share
|
|
138
|
+
? html`<button type="button" class="btn danger ps-stop" disabled=${busy} onClick=${() => stop(open.id)}>${busy ? tp("stopping") : tp("stop")}</button>
|
|
139
|
+
<button type="button" class="btn" onClick=${onClose} disabled=${busy}>${tp("close")}</button>`
|
|
140
|
+
: html`<button type="button" class="btn" onClick=${onClose} disabled=${busy}>${tp("cancel")}</button>
|
|
141
|
+
<button type="button" class="btn btn-primary ps-create" onClick=${create} disabled=${busy}>${busy ? tp("creating") : tp("create")}</button>`;
|
|
142
|
+
return html`<${Dialog} title=${tp("title")} onClose=${onClose} busy=${busy} actions=${actions}>
|
|
143
|
+
<div class="os ps">
|
|
144
|
+
<p class="ps-what">${tp("what", name, block.sender || block.instance, whenLong(block.ts ? Date.parse(block.ts) : Date.now()))}</p>
|
|
145
|
+
<p class="ps-runs"><strong>${tp("runs")}</strong></p>
|
|
146
|
+
${made ? html`<div class="os-made">
|
|
147
|
+
<input class="input mono os-url ps-url" readonly value=${made.url} aria-label=${tp("copy")} onFocus=${(e) => e.target.select && e.target.select()} />
|
|
148
|
+
<button type="button" class="btn btn-sm ps-copy" onClick=${copy}>${tp("copy")}</button>
|
|
149
|
+
<p class="note">${tp("made", whenLong(made.share.expiresAt))}</p>
|
|
150
|
+
</div>`
|
|
151
|
+
: share ? html`<p class="note ps-now">${tp("nowShared", whenLong(share.expiresAt))}</p>${share.cdn ? html`<p class="note ps-cdn">${tp("cdn")}</p>` : null}`
|
|
152
|
+
: html`<p class="muted">${tp("snapshot")}</p>
|
|
153
|
+
${state.cdn ? html`<p class="note ps-cdn">${tp("cdn")}</p>` : null}
|
|
154
|
+
<label class="field"><span>${tp("how")}</span>
|
|
155
|
+
<select class="tb-select ps-how" value=${String(minutes)} onChange=${(e) => setMinutes(Number(e.target.value))} disabled=${busy}>
|
|
156
|
+
${DURATIONS.map(([k, m]) => html`<option key=${k} value=${String(m)}>${tp(k)}</option>`)}
|
|
157
|
+
</select></label>`}
|
|
158
|
+
${error ? html`<p class="err os-err ps-err" role="alert">${error}</p>` : null}
|
|
159
|
+
</div>
|
|
160
|
+
<//>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Settings → Access & security: the shared pages, with Stop and Stop all. */
|
|
164
|
+
export function PageSharesCard({ get = globalThis.fetch }) {
|
|
165
|
+
const st = usePageShares(get);
|
|
166
|
+
const [busy, setBusy] = useState(false);
|
|
167
|
+
if (!st.loaded || !st.canShare && !st.shares.length) return null; // not kept on this fleet, or not this session's
|
|
168
|
+
const stop = async (id) => { setBusy(true); await stopShare(id, get); setBusy(false); };
|
|
169
|
+
return html`<section class="card os-card ps-card"><h3>${tp("cardTitle")}</h3>
|
|
170
|
+
<p class="note">${st.enabled ? tp("cardHint") : tp("e_disabled")}</p>
|
|
171
|
+
${!st.shares.length ? html`<p class="muted os-none">${tp("none")}</p>` : html`<div class="os-list">
|
|
172
|
+
<ul class="os-rows">${st.shares.map((s) => html`<li key=${s.id} class="os-row">
|
|
173
|
+
<span class="os-what"><strong>${s.title || tp("untitled")}</strong>
|
|
174
|
+
<span class="muted"> · ${tp("from", s.instance)} · ${tp("ends", whenLong(s.expiresAt))}</span></span>
|
|
175
|
+
<button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => stop(s.id)}>${tp("stop")}</button>
|
|
176
|
+
</li>`)}</ul>
|
|
177
|
+
${st.shares.length > 1 ? html`<button type="button" class="btn btn-sm danger os-all" disabled=${busy} onClick=${() => stop("all")}>${tp("stopAll")}</button>` : null}
|
|
178
|
+
</div>`}
|
|
179
|
+
</section>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The panel's action: Share page, or "Shared until …" when this block is shared. `block` as PageShareDialog's. */
|
|
183
|
+
export function PageShareButton({ block, Icon }) {
|
|
184
|
+
const st = usePageShares();
|
|
185
|
+
const [open, setOpen] = useState(false);
|
|
186
|
+
if (!block || !st.canShare) return null;
|
|
187
|
+
const share = shareOf(st.shares, block.instance, block.from);
|
|
188
|
+
return html`<button type="button" class=${`btn btn-sm ${share ? "pv-fb-shared on" : "pv-fb-share"}`} title=${tp(share ? "sharedTitle" : "shareTitle")} onClick=${() => setOpen(true)}>
|
|
189
|
+
<${Icon} name="globe" size=${14} /><span class="lbl">${share ? tp("shared", until(share.expiresAt)) : tp("share")}</span></button>
|
|
190
|
+
${open ? html`<${PageShareDialog} block=${block} share=${share} onClose=${() => setOpen(false)} />` : null}`;
|
|
191
|
+
}
|
package/dist/ui/panel-chat.js
CHANGED
|
@@ -76,6 +76,7 @@ import { installMessageActions } from "./chat-msg-actions.js";
|
|
|
76
76
|
import { createEmojiIndex } from "./chat-emojis.js";
|
|
77
77
|
import { pollPublicPreview } from "./preview-status.js";
|
|
78
78
|
import { createMentionIndex } from "./chat-mentions.js";
|
|
79
|
+
import { setPanelFeedback } from "./preview-panel-actions.js";
|
|
79
80
|
import { PreviewPanel, claimPanel, openPanel, panelClaims, panelStore, panelVersion, shownKey } from "./preview-panel.js";
|
|
80
81
|
import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
|
|
81
82
|
import { installTour, refreshTourSpot } from "./chat-tour.js";
|
|
@@ -142,6 +143,19 @@ export function boot({ stream, boot: bootData, deps = {} }) {
|
|
|
142
143
|
if (auth && typeof auth.sessionHandle === "function") auth.sessionHandle().then((h) => P().setBannerScope(h), () => P().setBannerScope(null));
|
|
143
144
|
else P().setBannerScope(null);
|
|
144
145
|
}
|
|
146
|
+
// #2911: the preview panel's "Reply about this page" reaches the composer through these (an undoable edit, as typing).
|
|
147
|
+
setPanelFeedback({
|
|
148
|
+
findMsg: (instance, key) => (store.state.msgs[instance] || []).find((x) => msgKey(x) === key) || null,
|
|
149
|
+
startReply: (instance, x) => store.startReply(instance, x),
|
|
150
|
+
clearReply: (instance) => store.clearReply(instance),
|
|
151
|
+
addToDraft: (instance, line) => { recordEdit(composerField()); store.addToDraft(instance, line); },
|
|
152
|
+
focus: () => {
|
|
153
|
+
const el = composerField(); if (!el) return;
|
|
154
|
+
el.focus();
|
|
155
|
+
// After the render that shows the line: the caret at its end.
|
|
156
|
+
setTimeout(() => { try { const n = el.value.length; el.setSelectionRange(n, n); } catch { /* not a text field */ } }, 0);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
145
159
|
// #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
|
|
146
160
|
installTour();
|
|
147
161
|
return store;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// #2910 D2: a web-only fleet's Notices (/ui/notices) — what General would have said: fleet errors, an agent's crash,
|
|
2
|
+
// quota or sign-in notice, a schedule that could not run, an available update. Behind the session gate (/ui/js/),
|
|
3
|
+
// loaded the first time it is opened. The list is the app store's `notices` (read from GET /ui/notices/list, then each
|
|
4
|
+
// new one from SSE `notice`); opening the page marks what it shows as read (POST /ui/notices/seen).
|
|
5
|
+
import { html, useEffect, useState } from "/assets/app-html.js";
|
|
6
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
7
|
+
import { appStore, applyNoticesSeen, useStore } from "/assets/app-store.js";
|
|
8
|
+
import { useLease } from "/assets/app-ctx.js";
|
|
9
|
+
import { PanelHeader, setTitle } from "/assets/app-shell.js";
|
|
10
|
+
import { chatPath } from "/assets/app-route.js";
|
|
11
|
+
import { Empty } from "/assets/ui-states.js";
|
|
12
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
13
|
+
|
|
14
|
+
register("notices", {
|
|
15
|
+
en: {
|
|
16
|
+
title: "Notices", sub: "What General would have said", empty: "No notices yet",
|
|
17
|
+
emptyHint: "This fleet has no Discord or Telegram connection, so what its General would say — errors, an agent that crashed or ran out of quota, a sign-in, an available update — is kept here.",
|
|
18
|
+
unread: "New", open: "Open chat",
|
|
19
|
+
kind_fleet: "Fleet", kind_instance: "Agent", kind_update: "Update", kind_schedule: "Schedule", kind_summary: "Daily summary",
|
|
20
|
+
ageNow: "just now", ageMin: "{0} min ago", ageHour: "{0} h ago", ageDay: "{0} d ago",
|
|
21
|
+
},
|
|
22
|
+
"zh-TW": {
|
|
23
|
+
title: "通知", sub: "原本會發到 General 的訊息", empty: "還沒有通知",
|
|
24
|
+
emptyHint: "這個 fleet 沒有 Discord 或 Telegram 連線,所以原本會由 General 說的事——錯誤、agent 當掉或額度用完、登入、新版本——都會留在這裡。",
|
|
25
|
+
unread: "新", open: "開啟聊天",
|
|
26
|
+
kind_fleet: "Fleet", kind_instance: "Agent", kind_update: "更新", kind_schedule: "排程", kind_summary: "每日摘要",
|
|
27
|
+
ageNow: "剛剛", ageMin: "{0} 分鐘前", ageHour: "{0} 小時前", ageDay: "{0} 天前",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const tn = (k, ...v) => t(`notices.${k}`, ...v);
|
|
31
|
+
const ICON = { fleet: "alert", instance: "bot", update: "download", schedule: "clock", summary: "chart" };
|
|
32
|
+
|
|
33
|
+
/** "3 min ago": a notice's age, refreshed by a display timer. */
|
|
34
|
+
export function noticeAge(at, nowMs) {
|
|
35
|
+
const minutes = Math.max(0, Math.floor((nowMs - at) / 60_000));
|
|
36
|
+
if (minutes < 1) return tn("ageNow");
|
|
37
|
+
if (minutes < 60) return tn("ageMin", minutes);
|
|
38
|
+
const hours = Math.floor(minutes / 60);
|
|
39
|
+
return hours < 24 ? tn("ageHour", hours) : tn("ageDay", Math.floor(hours / 24));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The highest id a mark was sent for (a second ask for it, while the first is out or after it landed, sends nothing). */
|
|
43
|
+
let markAsked = 0;
|
|
44
|
+
/** Everything up to `id` is read: the server's mark, then the store's (a failure leaves both as they were). */
|
|
45
|
+
export async function markNoticesSeen(id) {
|
|
46
|
+
if (id <= markAsked) return;
|
|
47
|
+
markAsked = id;
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetch("/ui/notices/seen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }) });
|
|
50
|
+
const d = r.ok ? await r.json() : null;
|
|
51
|
+
// The mark only moves forward, and unread is counted from what the page has (a notice that arrived meanwhile stays unread).
|
|
52
|
+
if (d && typeof d.seen === "number") { const cur = appStore.get().notices, next = applyNoticesSeen(cur, d.seen); if (next !== cur) appStore.set({ notices: next }); }
|
|
53
|
+
else markAsked = Math.min(markAsked, id - 1);
|
|
54
|
+
} catch { markAsked = Math.min(markAsked, id - 1); /* stays unread; the next view marks it */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The newest notice the page has, marked read — while the page is visible and it is not read yet. */
|
|
58
|
+
function markShown() {
|
|
59
|
+
const cur = appStore.get().notices, top = cur && cur.items.length ? cur.items[0].id : 0;
|
|
60
|
+
if (!top || top <= (cur.seen | 0)) return;
|
|
61
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
62
|
+
markNoticesSeen(top);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function NoticesPanel({ navKey }) {
|
|
66
|
+
const lease = useLease(navKey);
|
|
67
|
+
const { notices, instances } = useStore(appStore);
|
|
68
|
+
const items = notices && Array.isArray(notices.items) ? notices.items : [];
|
|
69
|
+
// What was unread when the page opened stays marked "New" while it is open (it is marked read on the server at once).
|
|
70
|
+
const [seenAtOpen] = useState(() => (notices ? notices.seen | 0 : 0));
|
|
71
|
+
const [, tick] = useState(0);
|
|
72
|
+
useEffect(() => { setTitle(tn("title")); const read = appStore.get().readNotices; if (read) read(); }, [navKey]);
|
|
73
|
+
useEffect(() => { lease.interval(() => tick((n) => n + 1), 30_000); }, [lease]);
|
|
74
|
+
// Shown → read: the newest one on the page, whenever a newer one arrives while it is open and visible.
|
|
75
|
+
const newest = items.length ? items[0].id : 0;
|
|
76
|
+
// …and, if one arrived while the tab was hidden, when it is shown again (#2925 review): one listener per visit.
|
|
77
|
+
useEffect(() => { markShown(); }, [newest]);
|
|
78
|
+
useEffect(() => { if (typeof document !== "undefined") lease.on(document, "visibilitychange", markShown); }, [lease]);
|
|
79
|
+
const known = new Set((instances || []).map((i) => i && i.name));
|
|
80
|
+
const nowMs = Date.now();
|
|
81
|
+
return html`<div data-effect-panel="notices" class="panel p-notices">
|
|
82
|
+
<${PanelHeader} title=${tn("title")} />
|
|
83
|
+
<div data-trust="panel-content" class="panel-body"><div class="col">
|
|
84
|
+
${items.length ? html`<p class="note notice-sub">${tn("sub")}</p>` : null}
|
|
85
|
+
${!items.length ? html`<${Empty} icon="bell" title=${tn("empty")} hint=${tn("emptyHint")} />`
|
|
86
|
+
: items.map((n) => html`<article key=${n.id} class=${`n-item notice${n.id > seenAtOpen ? " unread" : ""}${n.kind === "fleet" ? " bad" : ""}`}>
|
|
87
|
+
<span class="n-icon"><${Icon} name=${ICON[n.kind] || "info"} size=${18} /></span>
|
|
88
|
+
<div class="n-body">
|
|
89
|
+
<div class="n-title"><strong>${tn(`kind_${n.kind}`)}${n.instance ? ` · ${n.instance}` : ""}</strong>
|
|
90
|
+
${n.id > seenAtOpen ? html`<span class="notice-new">${tn("unread")}</span>` : null}
|
|
91
|
+
<span class="n-age" title=${new Date(n.at).toLocaleString()}>${noticeAge(n.at, nowMs)}</span></div>
|
|
92
|
+
<p class="n-detail notice-text">${n.text}</p>
|
|
93
|
+
${n.instance && known.has(n.instance) ? html`<div class="n-actions"><a class="btn btn-sm btn-ghost" href=${chatPath(n.instance)}>${tn("open")}</a></div>` : null}
|
|
94
|
+
</div>
|
|
95
|
+
</article>`)}
|
|
96
|
+
</div></div>
|
|
97
|
+
</div>`;
|
|
98
|
+
}
|
|
@@ -48,8 +48,10 @@ import "./chat-strings.js";
|
|
|
48
48
|
import { AppearanceSection } from "./settings-appearance.js";
|
|
49
49
|
import { GuestsCard } from "./settings-guests.js";
|
|
50
50
|
import { OfficeSharesCard } from "./office-share-dialog.js";
|
|
51
|
+
import { PageSharesCard } from "./page-share-dialog.js";
|
|
51
52
|
import { syncStage, useDraft } from "./settings-draft.js";
|
|
52
53
|
import { WebAuthorityCard } from "./settings-web-authority.js";
|
|
54
|
+
import { NamedLinkTest, setTypedTunnelToken } from "./settings-named-test.js";
|
|
53
55
|
import { SignInList } from "./backend-signin.js";
|
|
54
56
|
|
|
55
57
|
const tn = (k, ...v) => t(`settings.${k}`, ...v);
|
|
@@ -1199,13 +1201,14 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1199
1201
|
log: web.log_tab === true, // #1218: off by default
|
|
1200
1202
|
usagePub: web.usage_report_on_public_link === true, // #2122: off by default
|
|
1201
1203
|
levels: web.office_levels !== false, // #2661: on by default
|
|
1204
|
+
cdn: web.preview_cdn === true, // #2911 (7): off by default
|
|
1202
1205
|
pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2", hostname: (web.public_link && web.public_link.hostname) ?? "", tunnel_token_env: (web.public_link && web.public_link.tunnel_token_env) ?? "", origin_port: (web.public_link && web.public_link.origin_port) ?? 19282, auto_open: (web.public_link && web.public_link.auto_open) ?? false },
|
|
1203
1206
|
session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
|
|
1204
1207
|
terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
|
|
1205
1208
|
}), []);
|
|
1206
1209
|
const text = (v) => (v === null || v === undefined ? "" : String(v));
|
|
1207
1210
|
/** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
|
|
1208
|
-
const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, levels: original.levels, pub: original.pub.allow_public,
|
|
1211
|
+
const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, levels: original.levels, cdn: original.cdn, pub: original.pub.allow_public,
|
|
1209
1212
|
ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
|
|
1210
1213
|
abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
|
|
1211
1214
|
termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
|
|
@@ -1219,7 +1222,7 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1219
1222
|
return {
|
|
1220
1223
|
notify: String(pick(pending, "notify_login", original.notify)),
|
|
1221
1224
|
echo: pick(pending, "echo_to_channel", original.echo), log: pick(pending, "log_tab", original.log), usagePub: pick(pending, "usage_report_on_public_link", original.usagePub),
|
|
1222
|
-
levels: pick(pending, "office_levels", original.levels),
|
|
1225
|
+
levels: pick(pending, "office_levels", original.levels), cdn: pick(pending, "preview_cdn", original.cdn),
|
|
1223
1226
|
pub: pick(pub, "allow_public", original.pub.allow_public), ttl: String(pick(pub, "ttl_minutes", original.pub.ttl_minutes)), protocol: pick(pub, "protocol", original.pub.protocol),
|
|
1224
1227
|
hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)), autoOpen: pick(pub, "auto_open", original.pub.auto_open),
|
|
1225
1228
|
abs: text(pick(session, "absolute_hours", original.session.absolute_hours)), idle: text(pick(session, "idle_hours", original.session.idle_hours)),
|
|
@@ -1272,6 +1275,7 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1272
1275
|
...(f.log !== original.log ? { log_tab: f.log } : {}),
|
|
1273
1276
|
...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
|
|
1274
1277
|
...(f.levels !== original.levels ? { office_levels: f.levels } : {}),
|
|
1278
|
+
...(f.cdn !== original.cdn ? { preview_cdn: f.cdn } : {}),
|
|
1275
1279
|
...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
|
|
1276
1280
|
...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
|
|
1277
1281
|
...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
|
|
@@ -1282,6 +1286,7 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1282
1286
|
...(body.log_tab !== undefined ? ["web.log_tab"] : []),
|
|
1283
1287
|
...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
|
|
1284
1288
|
...(body.office_levels !== undefined ? ["web.office_levels"] : []),
|
|
1289
|
+
...(body.preview_cdn !== undefined ? ["web.preview_cdn"] : []),
|
|
1285
1290
|
...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
|
|
1286
1291
|
...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
|
|
1287
1292
|
...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
|
|
@@ -1353,6 +1358,7 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1353
1358
|
<div class=${fc("field", "originPort")}><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
|
|
1354
1359
|
</div>
|
|
1355
1360
|
<p class="note">${tn("namedTunnelHint")} <a href="https://github.com/songsid/AgEnD/blob/main/docs/named-tunnel.md" target="_blank" rel="noopener noreferrer">${tn("namedTunnelSetup")}</a></p>
|
|
1361
|
+
${String(f.hostname || "").trim() ? html`<${NamedLinkTest} hostname=${f.hostname} port=${f.originPort} protocol=${f.protocol} />` : null}
|
|
1356
1362
|
<div class="grid2">
|
|
1357
1363
|
<div class=${fc("field", "ttl")}><label for="publicWebTtl">${tn(named ? "publicTtlNamed" : "publicTtl")}</label><input id="publicWebTtl" type="number" min=${named ? "0" : "1"} max=${named ? "10080" : "480"} step="1" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
|
|
1358
1364
|
<div class=${fc("field", "protocol")}><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
|
|
@@ -1363,6 +1369,7 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1363
1369
|
</section>`) : null}
|
|
1364
1370
|
${has("access") && !overPublicLink ? html`<${PublicTunnelToken} />` : null}
|
|
1365
1371
|
${has("access") ? html`<${OfficeSharesCard} />` : null}
|
|
1372
|
+
${has("access") ? html`<${PageSharesCard} />` : null}
|
|
1366
1373
|
${has("access") && !overPublicLink ? html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
|
|
1367
1374
|
${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
|
|
1368
1375
|
<label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
|
|
@@ -1375,6 +1382,8 @@ function WebSection({ ctx, staged, part = "all" }) {
|
|
|
1375
1382
|
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
1376
1383
|
<label class=${fc("check", "levels")}><input id="webOfficeLevels" type="checkbox" checked=${f.levels} onChange=${(e) => set("levels")(e.target.checked)} /><span>${tn("webOfficeLevels")}</span><${Impact} schema=${schema} field="web.office_levels" /></label>
|
|
1377
1384
|
<p class="note">${tn("webOfficeLevelsHint")}</p>
|
|
1385
|
+
<label class=${fc("check", "cdn")}><input id="webPreviewCdn" type="checkbox" checked=${f.cdn} onChange=${(e) => set("cdn")(e.target.checked)} /><span>${tn("webPreviewCdn")}</span><${Impact} schema=${schema} field="web.preview_cdn" /></label>
|
|
1386
|
+
<p class="note">${tn("webPreviewCdnHint")}</p>
|
|
1378
1387
|
</section>`) : null}
|
|
1379
1388
|
${has("readonly") ? html`<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
|
|
1380
1389
|
<p class="note">${tn("webReadOnlyLead")}</p>
|
|
@@ -1548,11 +1557,11 @@ function PublicTunnelToken() {
|
|
|
1548
1557
|
const result = await api(base);
|
|
1549
1558
|
if (lease.current() && result.ok) setPresent(result.body?.[0]?.token_present === true);
|
|
1550
1559
|
};
|
|
1551
|
-
useEffect(() => { refresh().catch(() => {}); }, []);
|
|
1560
|
+
useEffect(() => { refresh().catch(() => {}); return () => setTypedTunnelToken(""); }, []);
|
|
1552
1561
|
const go = async (clear) => {
|
|
1553
1562
|
if (inFlight.current || !clear && !secret) return;
|
|
1554
1563
|
const value = secret;
|
|
1555
|
-
setSecret(""); inFlight.current = true; setBusy(true); setMessage("");
|
|
1564
|
+
setSecret(""); setTypedTunnelToken(""); inFlight.current = true; setBusy(true); setMessage("");
|
|
1556
1565
|
const key = newKey("tunnel-token");
|
|
1557
1566
|
try {
|
|
1558
1567
|
const prepared = await api(`${base}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(clear ? { clear: true } : { secret: value }) });
|
|
@@ -1576,7 +1585,7 @@ function PublicTunnelToken() {
|
|
|
1576
1585
|
return html`<section class="card"><h3>${tn("namedToken")}</h3>
|
|
1577
1586
|
<p class="note">${tn("namedTokenHint")}</p>
|
|
1578
1587
|
<span class="tag">${present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
|
|
1579
|
-
<input id="publicTunnelToken" type="password" autocomplete="new-password" aria-label=${tn("namedToken")} value=${secret} onInput=${e => setSecret(e.target.value)} />
|
|
1588
|
+
<input id="publicTunnelToken" type="password" autocomplete="new-password" aria-label=${tn("namedToken")} value=${secret} onInput=${e => { setSecret(e.target.value); setTypedTunnelToken(e.target.value); }} />
|
|
1580
1589
|
<button type="button" class="btn btn-sm" disabled=${busy || !secret} onClick=${() => go(false)}>${tn("namedTokenSet")}</button>
|
|
1581
1590
|
<button type="button" class="btn btn-sm" disabled=${busy || !present} onClick=${() => go(true)}>${tn("namedTokenClear")}</button>
|
|
1582
1591
|
${message ? html`<p class="note" role="status">${message}</p>` : null}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// #2911 (6): every page in the thread — the list the preview panel shows. A page is what preview-versions.js counts as
|
|
2
|
+
// versions of one thing: the agent's complete ```html blocks with one <title> (the untitled ones together), or its
|
|
3
|
+
// attached .html files of one name. Pure: the panel draws it; nothing here runs a preview.
|
|
4
|
+
import "./chat-render.js";
|
|
5
|
+
import { htmlTitle } from "./preview-versions.js";
|
|
6
|
+
|
|
7
|
+
const R = () => globalThis.AgendChatRender;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The pages in `msgs` (in thread order), newest first: [{ id, label, kind: "html" | "file", versions, latest }], where
|
|
11
|
+
* `latest` is the newest version as the panel opens it ({ key, instance, msgKey, n | att, code, sender, ts }; an
|
|
12
|
+
* attached file's code is read when it is opened). `label` is the title, the file name, or "" for untitled blocks.
|
|
13
|
+
*/
|
|
14
|
+
export function pagesOf(msgs, keyOf, instance) {
|
|
15
|
+
const pages = new Map();
|
|
16
|
+
const add = (id, label, kind, spec) => {
|
|
17
|
+
const p = pages.get(id);
|
|
18
|
+
if (p) { p.versions++; p.latest = spec; pages.delete(id); pages.set(id, p); } // re-inserted: newest last
|
|
19
|
+
else pages.set(id, { id, label, kind, versions: 1, latest: spec });
|
|
20
|
+
};
|
|
21
|
+
for (const x of Array.isArray(msgs) ? msgs : []) {
|
|
22
|
+
if (x.role !== "agent") continue;
|
|
23
|
+
const msgKey = keyOf(x);
|
|
24
|
+
R().htmlFences(x.text).forEach((f, n) => {
|
|
25
|
+
if (!f.terminated) return;
|
|
26
|
+
const title = htmlTitle(f.code);
|
|
27
|
+
add(`t:${title}`, title, "html", { key: `${msgKey}:f${n}`, instance, msgKey, n, code: f.code, sender: x.sender, ts: x.ts });
|
|
28
|
+
});
|
|
29
|
+
for (const a of Array.isArray(x.attachments) ? x.attachments : []) {
|
|
30
|
+
if (!R().isHtmlAttachment(a)) continue;
|
|
31
|
+
add(`a:${a.name.trim().toLowerCase()}`, a.name, "file", { key: `${msgKey}:a${a.id}`, instance, msgKey, att: { id: a.id, name: a.name, size: a.size }, code: null, sender: x.sender, ts: x.ts });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return [...pages.values()].reverse();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The page `open` belongs to: its id in pagesOf's terms. */
|
|
38
|
+
export function pageIdOf(open) {
|
|
39
|
+
if (!open) return null;
|
|
40
|
+
return open.att ? `a:${String(open.att.name || "").trim().toLowerCase()}` : `t:${htmlTitle(open.code)}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// #2911: the preview panel's feedback actions — what a person tells the agent about the page it made (M-2217). The
|
|
2
|
+
// panel (preview-panel.js, M-2216's chrome) renders <PanelActions> in its bar, before Download; everything the actions
|
|
3
|
+
// do lives here, so neither side edits the other's file.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here reads or relays the page. A preview's content is the agent's untrusted HTML, so only what the chat
|
|
6
|
+
// already holds goes into the message: a #2272 reply to the message the page came in, and the page's <title> or file
|
|
7
|
+
// name as one line of plain text in the composer — where the person edits it and sends it themselves. What goes is an
|
|
8
|
+
// ordinary message (Discord first): no new channel, nothing sent by itself.
|
|
9
|
+
|
|
10
|
+
import { html, useEffect, useState } from "/assets/app-html.js";
|
|
11
|
+
import { t } from "/assets/app-i18n.js";
|
|
12
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
13
|
+
import { closePanel, frameKey, htmlTitle } from "./preview-panel.js";
|
|
14
|
+
import { PageShareButton } from "./page-share-dialog.js";
|
|
15
|
+
|
|
16
|
+
const P = () => globalThis.AgendPreview;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The chat registers how to reach its composer (panel-chat.js): { findMsg(instance, msgKey), startReply(instance, msg),
|
|
20
|
+
* clearReply(instance), addToDraft(instance, line), focus() }. null: no chat is mounted, and the actions are not shown.
|
|
21
|
+
*/
|
|
22
|
+
let chat = null;
|
|
23
|
+
export function setPanelFeedback(deps) { chat = deps || null; }
|
|
24
|
+
|
|
25
|
+
/** The most of a page's title or file name the line carries. */
|
|
26
|
+
export const NAME_MAX = 80;
|
|
27
|
+
|
|
28
|
+
/** One line of plain text: format characters removed, whitespace and control characters collapsed, cut at NAME_MAX characters. */
|
|
29
|
+
export function plainName(value) {
|
|
30
|
+
// \p{Cf} first (bidi controls, zero-widths): what the person reads is what is sent (#2914 review).
|
|
31
|
+
const s = String(value == null ? "" : value).replace(/\p{Cf}+/gu, "").replace(/[\s\u0000-\u001f\u007f-\u009f\u2028\u2029]+/gu, " ").trim();
|
|
32
|
+
const chars = [...s];
|
|
33
|
+
return chars.length > NAME_MAX ? `${chars.slice(0, NAME_MAX - 1).join("")}…` : s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The composer's line naming the page: its <title>, else the attached file's name, else "this page". */
|
|
37
|
+
export function aboutLine(open) {
|
|
38
|
+
if (!open) return "";
|
|
39
|
+
const name = plainName(htmlTitle(open.code || "")) || plainName(open.att && open.att.name);
|
|
40
|
+
return name ? t("chat.pvFbAboutNamed", name) : t("chat.pvFbAboutThis");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reply about the open page: a reply to the message it came in (while the thread still has it), and the line naming
|
|
45
|
+
* the page added to the composer (never twice). Returns false when there is nothing to do it with.
|
|
46
|
+
*/
|
|
47
|
+
export function replyAboutPage(open, deps = chat) {
|
|
48
|
+
if (!open || !deps) return false;
|
|
49
|
+
const msg = open.msgKey ? deps.findMsg(open.instance, open.msgKey) : null;
|
|
50
|
+
// The page's message gone from the thread: no reply — and not one to another message started earlier, which the
|
|
51
|
+
// line naming this page would be sent as (#2913 review).
|
|
52
|
+
if (msg) deps.startReply(open.instance, msg);
|
|
53
|
+
else deps.clearReply(open.instance);
|
|
54
|
+
deps.addToDraft(open.instance, aboutLine(open));
|
|
55
|
+
deps.focus();
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* #2911: the lines the errors chip adds — a header saying the text below is the page's, then each kept error quoted
|
|
61
|
+
* ("> "), already one line of plain text (preview.js), and how many more there were. `log`: P().errors(key).
|
|
62
|
+
*/
|
|
63
|
+
export function errorLines(log) {
|
|
64
|
+
if (!log || !log.count) return [];
|
|
65
|
+
const kind = { error: "", rejection: t("chat.pvFbErrRejection"), console: "console.error: ", blocked: t("chat.pvCdnBlocked") };
|
|
66
|
+
const lines = [t(log.count === 1 ? "chat.pvFbErrHeadOne" : "chat.pvFbErrHead", String(log.count))];
|
|
67
|
+
for (const x of log.items) lines.push(`> ${kind[x.kind] ?? ""}${plainName(x.message)}${x.line ? ` (${t("chat.pvFbErrAt", String(x.line), String(x.col))})` : ""}`);
|
|
68
|
+
const more = log.count - log.items.length;
|
|
69
|
+
if (more > 0) lines.push(t("chat.pvFbErrMore", String(more)));
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The errors chip's click: reply about the page, then the error lines (each once) — the person edits and sends. */
|
|
74
|
+
export function sendErrors(open, log, deps = chat) {
|
|
75
|
+
if (!replyAboutPage(open, deps)) return false;
|
|
76
|
+
for (const line of errorLines(log)) deps.addToDraft(open.instance, line);
|
|
77
|
+
deps.focus();
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* #2911: the line a picked element adds — where in the page (the selector the page's frame built, as code) and its
|
|
83
|
+
* words, marked as the page's text. Backticks are taken out of the selector so it stays one code span.
|
|
84
|
+
*/
|
|
85
|
+
export function pickLine(result) {
|
|
86
|
+
if (!result) return "";
|
|
87
|
+
const where = plainName(String(result.selector || "").replace(/`/g, "")) || "?";
|
|
88
|
+
const words = plainName(result.text);
|
|
89
|
+
return words ? t("chat.pvFbPickWords", where, words) : t("chat.pvFbPickAt", where);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A picked element: reply about the page, then its line — the person writes what to change after it. */
|
|
93
|
+
export function sendPick(open, result, deps = chat) {
|
|
94
|
+
if (!result || !replyAboutPage(open, deps)) return false;
|
|
95
|
+
deps.addToDraft(open.instance, pickLine(result));
|
|
96
|
+
deps.focus();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether the open page's frame is in pick mode, and its answer handed to sendPick. */
|
|
101
|
+
function usePick(open) {
|
|
102
|
+
const key = open ? frameKey(open.key) : null;
|
|
103
|
+
const [on, setOn] = useState(() => !!(key && P() && P().picking && P().picking(key)));
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
setOn(!!(key && P() && P().picking && P().picking(key)));
|
|
106
|
+
if (!key || !P() || typeof P().onPick !== "function") return undefined;
|
|
107
|
+
return P().onPick((k, result, now) => {
|
|
108
|
+
if (k !== key) return;
|
|
109
|
+
setOn(!!now);
|
|
110
|
+
if (result) { if (narrow()) closePanel(); sendPick(open, result); }
|
|
111
|
+
});
|
|
112
|
+
}, [key]);
|
|
113
|
+
return on;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The errors the open page's frame reported, re-read when they change. */
|
|
117
|
+
function useErrors(open) {
|
|
118
|
+
const key = open ? frameKey(open.key) : null;
|
|
119
|
+
const read = () => (key && P() && typeof P().errors === "function" ? P().errors(key) : null);
|
|
120
|
+
const [log, setLog] = useState(read);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
setLog(read());
|
|
123
|
+
if (!key || !P() || typeof P().onErrors !== "function") return undefined;
|
|
124
|
+
return P().onErrors((k) => { if (k === key) setLog(read()); });
|
|
125
|
+
}, [key]);
|
|
126
|
+
return log;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* #2911 item 8: what Share page names — the block of the message the open page came in ({ boot, id } and its fence
|
|
131
|
+
* number or attachment id; the server reads the page itself), its title, and whose reply. null while the thread no
|
|
132
|
+
* longer has that message.
|
|
133
|
+
*/
|
|
134
|
+
export function shareBlock(open, deps = chat) {
|
|
135
|
+
if (!open || !deps) return null;
|
|
136
|
+
const msg = open.msgKey ? deps.findMsg(open.instance, open.msgKey) : null;
|
|
137
|
+
if (!msg || typeof msg.boot !== "string" || typeof msg.id !== "number") return null;
|
|
138
|
+
const from = open.att && typeof open.att.id === "string" ? { boot: msg.boot, id: msg.id, att: open.att.id }
|
|
139
|
+
: Number.isInteger(open.n) ? { boot: msg.boot, id: msg.id, fence: open.n } : null;
|
|
140
|
+
if (!from) return null;
|
|
141
|
+
const title = plainName(htmlTitle(open.code || "")) || plainName(open.att && open.att.name);
|
|
142
|
+
return { instance: open.instance, from, title, sender: msg.sender || open.sender || "", ts: msg.ts || open.ts || "" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const narrow = () => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(max-width: 899px)").matches;
|
|
146
|
+
|
|
147
|
+
/** The bar's actions for the open page. props: open, name, run, view (as the panel has them). */
|
|
148
|
+
export function PanelActions({ open, run }) {
|
|
149
|
+
const log = useErrors(open);
|
|
150
|
+
const picking = usePick(open);
|
|
151
|
+
if (!open || !chat) return null;
|
|
152
|
+
// On a phone the panel is a sheet over the composer: it closes, so the composer is there to type in.
|
|
153
|
+
const reply = () => { if (narrow()) closePanel(); replyAboutPage(open); };
|
|
154
|
+
const send = () => { const now = P().errors(frameKey(open.key)); if (narrow()) closePanel(); sendErrors(open, now); };
|
|
155
|
+
const n = log ? log.count : 0;
|
|
156
|
+
const key = open && frameKey(open.key);
|
|
157
|
+
const canPick = run && run.state === "running" && typeof P().pick === "function";
|
|
158
|
+
const point = () => { P().pick(key, !picking); };
|
|
159
|
+
return html`<${PageShareButton} block=${shareBlock(open)} Icon=${Icon} />${canPick ? html`<button type="button" class=${`btn btn-sm pv-fb-pick${picking ? " on" : ""}`} aria-pressed=${picking ? "true" : "false"}
|
|
160
|
+
title=${t(picking ? "chat.pvFbPickCancelTitle" : "chat.pvFbPickTitle")} onClick=${point}><${Icon} name="edit" size=${14} /><span class="lbl">${t(picking ? "chat.pvFbPickCancel" : "chat.pvFbPick")}</span></button>` : null}${n ? html`<button type="button" class="btn btn-sm pv-fb-errors" title=${t("chat.pvFbErrTitle")} onClick=${send}><${Icon} name="alert" size=${14} /><span class="pv-fb-n">${n >= P().ERRORS.maxCount ? `${n}+` : String(n)}</span><span class="lbl">${t(n === 1 ? "chat.pvFbErrOne" : "chat.pvFbErrMany")}</span></button>` : null}<button type="button" class="btn btn-sm pv-fb-reply" title=${t("chat.pvFbReplyTitle")} onClick=${reply}><${Icon} name="chat" size=${14} /><span class="lbl">${t("chat.pvFbReply")}</span></button>`;
|
|
161
|
+
}
|