@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.2
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 +2 -0
- package/README.zh-TW.md +2 -0
- package/dist/agent-cli.js +6 -1
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +68 -12
- 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/backend/antigravity.js +4 -3
- package/dist/backend/antigravity.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.js +1 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +6 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/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 +115 -0
- package/dist/cache-service.js +417 -0
- package/dist/cache-service.js.map +1 -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 +251 -24
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.js +90 -37
- package/dist/daemon.js.map +1 -1
- 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-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.js +22 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +46 -0
- package/dist/fleet-manager.js +287 -40
- package/dist/fleet-manager.js.map +1 -1
- 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/locale.js +14 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- 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/public-web-gateway.js +2 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart.js +10 -4
- package/dist/quickstart.js.map +1 -1
- package/dist/restart-guard.d.ts +52 -0
- package/dist/restart-guard.js +109 -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 +47 -5
- package/dist/service-activation.js +156 -51
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +56 -1
- package/dist/service-installer.js +134 -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 +46 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +34 -6
- package/dist/settings-change.js.map +1 -1
- package/dist/setup-host.js +2 -3
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +2 -1
- 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/tmux-control.d.ts +22 -0
- package/dist/tmux-control.js +89 -5
- package/dist/tmux-control.js.map +1 -1
- package/dist/topic-commands.js +14 -5
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/chat-commands.js +43 -0
- package/dist/ui/chat-render.js +2 -2
- package/dist/ui/chat-store.js +30 -1
- package/dist/ui/chat-strings.js +22 -2
- package/dist/ui/chat-thread.js +47 -10
- package/dist/ui/fleet-cache.js +168 -0
- package/dist/ui/panel-chat.js +104 -18
- package/dist/ui/panel-fleet.js +26 -12
- package/dist/ui/panel-settings.js +12 -8
- package/dist/ui/preview-panel.js +202 -0
- package/dist/ui/preview.js +7 -3
- package/dist/ui/settings-strings.js +4 -2
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-shell.js +6 -4
- package/dist/ui/shared/app.css +147 -3
- package/dist/ui/shared/signin.js +13 -1
- package/dist/ui/shared/ui-icons.js +2 -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 +65 -7
- package/dist/update-install.js +126 -26
- package/dist/update-install.js.map +1 -1
- package/dist/web-api.d.ts +14 -0
- package/dist/web-api.js +65 -0
- package/dist/web-api.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-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 +114 -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 +1 -1
package/dist/ui/chat-thread.js
CHANGED
|
@@ -19,12 +19,19 @@ export const msgKey = (x) => `${x.boot}-${x.id}`;
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
|
|
22
|
-
* setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code) }.
|
|
22
|
+
* setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code), panel? }. panel (#1481): { shown() → the card
|
|
23
|
+
* key the side panel shows, or null; open(spec, { run }) } — without it a card has no "Open in panel".
|
|
23
24
|
*/
|
|
24
25
|
export function createThread(list, scroller, opts) {
|
|
25
26
|
const nodes = new Map(); // key → { html, node }
|
|
26
27
|
let msgs = [];
|
|
27
28
|
let unseen = 0;
|
|
29
|
+
// The reader is at the newest message. While pinned, content that grows after it was drawn — an image loading, a
|
|
30
|
+
// preview card, the composer growing — keeps the view at the bottom (#1269); a reader who scrolled up is never moved.
|
|
31
|
+
// Only a move UP unpins: the browser's own scroll anchoring also fires scroll events when content grows (the view
|
|
32
|
+
// moves down or stays), and those must not count as the reader leaving the bottom. Reaching the bottom pins again.
|
|
33
|
+
let pinned = true;
|
|
34
|
+
let lastTop = 0;
|
|
28
35
|
const cardRefresh = new WeakMap(); // card node → re-read availability (weak: a card that left takes its source with it)
|
|
29
36
|
const tr = opts.t, trf = opts.tf;
|
|
30
37
|
const stopIn = (node) => (P() ? P().stopIn(node) : []);
|
|
@@ -89,12 +96,15 @@ export function createThread(list, scroller, opts) {
|
|
|
89
96
|
const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
|
|
90
97
|
const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
|
|
91
98
|
const dl = el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload")); dl.type = "button";
|
|
99
|
+
const panel = opts.panel || null;
|
|
100
|
+
const toPanel = panel ? el("button", "btn btn-sm btn-ghost pv-open", tr("chat.pvOpenPanel")) : null;
|
|
101
|
+
if (toPanel) toPanel.type = "button";
|
|
92
102
|
const menu = el("details", "pv-menu");
|
|
93
103
|
const sum = el("summary", null, "⋯"); sum.title = tr("chat.pvMenu"); sum.setAttribute("aria-label", tr("chat.pvMenu"));
|
|
94
104
|
const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
|
|
95
105
|
const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
|
|
96
106
|
menu.append(sum, optB, neverB);
|
|
97
|
-
head.append(label, run, stopB, dl, menu);
|
|
107
|
+
head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), dl, menu);
|
|
98
108
|
const note = el("div", "pv-note");
|
|
99
109
|
const banner = el("div", "pv-banner", P().BANNER); banner.hidden = true;
|
|
100
110
|
const holder = el("div", "pv-holder");
|
|
@@ -103,16 +113,25 @@ export function createThread(list, scroller, opts) {
|
|
|
103
113
|
const refresh = (reason) => {
|
|
104
114
|
const a = P().availability();
|
|
105
115
|
const going = state === "starting" || state === "running";
|
|
106
|
-
|
|
116
|
+
const inPanel = !!panel && panel.shown() === key; // #1481: the panel shows this block; the card points there
|
|
117
|
+
run.hidden = !a.ok || going || inPanel;
|
|
118
|
+
if (toPanel) toPanel.hidden = inPanel;
|
|
107
119
|
stopB.hidden = !going;
|
|
108
120
|
banner.hidden = !going;
|
|
109
|
-
note.textContent = reason != null ? reason : going ? (state === "starting" ? tr("chat.pvStarting") : "") : a.ok ? "" : a.reason;
|
|
121
|
+
note.textContent = reason != null ? reason : going ? (state === "starting" ? tr("chat.pvStarting") : "") : inPanel ? tr("chat.pvInPanel") : a.ok ? "" : a.reason;
|
|
110
122
|
optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
|
|
111
123
|
neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
|
|
112
124
|
};
|
|
113
125
|
const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
|
|
114
126
|
run.onclick = () => P().start(key, holder, fence.code, ui);
|
|
115
127
|
stopB.onclick = () => P().stop(key, "stopped", "");
|
|
128
|
+
// A running preview moves: it stops here and starts in the panel (the click that ran it). An idle one opens the
|
|
129
|
+
// panel with its own Preview to click.
|
|
130
|
+
if (toPanel) toPanel.onclick = () => {
|
|
131
|
+
const moving = P().running(key);
|
|
132
|
+
if (moving) P().stop(key, "stopped", "");
|
|
133
|
+
panel.open({ key, instance: x.instance, msgKey: msgKey(x), n, code: fence.code, sender: x.sender, ts: x.ts }, { run: moving });
|
|
134
|
+
};
|
|
116
135
|
dl.onclick = () => opts.download(fence.code);
|
|
117
136
|
optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
|
|
118
137
|
neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
|
|
@@ -127,10 +146,15 @@ export function createThread(list, scroller, opts) {
|
|
|
127
146
|
|
|
128
147
|
const nearBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
|
|
129
148
|
function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
|
|
149
|
+
function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
|
|
150
|
+
// Sizes change without a scroll event (layout after the render): follow them while pinned.
|
|
151
|
+
const resized = () => { if (pinned && !nearBottom()) toBottom(); reportJump(); };
|
|
152
|
+
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
|
|
153
|
+
if (ro) { ro.observe(list); ro.observe(scroller); }
|
|
130
154
|
|
|
131
155
|
/** Draw `next` (this instance's messages). `restore`: the scroll position to return to (null = the bottom). */
|
|
132
156
|
function render(next, renderOpts = {}) {
|
|
133
|
-
const stick = nearBottom();
|
|
157
|
+
const stick = pinned || nearBottom();
|
|
134
158
|
msgs = next || [];
|
|
135
159
|
if (!msgs.length) {
|
|
136
160
|
for (const v of nodes.values()) stopIn(v.node);
|
|
@@ -159,19 +183,31 @@ export function createThread(list, scroller, opts) {
|
|
|
159
183
|
}
|
|
160
184
|
if ("restore" in renderOpts) {
|
|
161
185
|
unseen = 0;
|
|
162
|
-
|
|
163
|
-
|
|
186
|
+
if (renderOpts.restore == null) toBottom();
|
|
187
|
+
else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
|
|
188
|
+
} else if (stick) toBottom();
|
|
164
189
|
else unseen += added;
|
|
165
190
|
reportJump();
|
|
166
191
|
}
|
|
167
192
|
|
|
168
193
|
function onScroll() {
|
|
169
|
-
const bottom = nearBottom();
|
|
194
|
+
const bottom = nearBottom(), top = scroller.scrollTop;
|
|
195
|
+
if (bottom) pinned = true;
|
|
196
|
+
else if (top < lastTop) pinned = false;
|
|
197
|
+
lastTop = top;
|
|
170
198
|
if (bottom) unseen = 0;
|
|
171
199
|
reportJump();
|
|
172
200
|
return bottom ? null : scroller.scrollTop;
|
|
173
201
|
}
|
|
174
|
-
function jumpLatest() {
|
|
202
|
+
function jumpLatest() { toBottom(); unseen = 0; reportJump(); }
|
|
203
|
+
/** Show one message (the panel's "from …" link, #1481): scrolled into view and briefly marked. */
|
|
204
|
+
function reveal(key) {
|
|
205
|
+
const have = nodes.get(key); if (!have) return false;
|
|
206
|
+
if (typeof have.node.scrollIntoView === "function") have.node.scrollIntoView({ block: "center" });
|
|
207
|
+
have.node.classList.add("flash");
|
|
208
|
+
setTimeout(() => { have.node.classList.remove("flash"); }, 1600);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
175
211
|
|
|
176
212
|
async function copyFrom(button, text) {
|
|
177
213
|
const ok = await opts.copyText(text);
|
|
@@ -201,9 +237,10 @@ export function createThread(list, scroller, opts) {
|
|
|
201
237
|
|
|
202
238
|
/** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
|
|
203
239
|
function dispose() {
|
|
240
|
+
if (ro) ro.disconnect();
|
|
204
241
|
if (P()) P().stopAll("leave");
|
|
205
242
|
nodes.clear();
|
|
206
243
|
}
|
|
207
244
|
|
|
208
|
-
return { render, onScroll, onClick, jumpLatest, refreshCards, dispose, nodes, get unseen() { return unseen; } };
|
|
245
|
+
return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
|
|
209
246
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// #1468: Fleet → Cache (/ui/fleet/cache) — how often each instance's prompt cache expires between requests, what
|
|
2
|
+
// that costs, and whether keeping it warm would pay off. Behind the session gate (/ui/js/), imported by the Fleet
|
|
3
|
+
// panel. Read-only (Discord-first): it recommends, it changes nothing.
|
|
4
|
+
//
|
|
5
|
+
// One GET /ui/cache?window=… per opening or per choice of window (a person's navigation); no timer re-reads it
|
|
6
|
+
// (#1374). While the server is still reading transcripts the page says so and offers Refresh.
|
|
7
|
+
import { html } from "/assets/app-html.js";
|
|
8
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
9
|
+
import { Empty } from "/assets/ui-states.js";
|
|
10
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
11
|
+
|
|
12
|
+
register("cache", {
|
|
13
|
+
en: {
|
|
14
|
+
title: "Cache", w24h: "24 h", w7d: "7 days", w30d: "30 days", window: "Window",
|
|
15
|
+
fleetExpiry: "Expiry cost", fleetSaving: "Keep-warm would save", fleetRecommended: "Recommended", ofN: "{0} of {1}",
|
|
16
|
+
pricesNote: "List prices checked {0}.", quotaNote: "Subscriptions: Anthropic and OpenAI do not publish how cache reads weigh against a plan's quota, so savings are exact at list price and an estimate in quota terms.",
|
|
17
|
+
estimateNote: "Estimate: Codex does not record cache writes, so a write is taken as the request's uncached input.",
|
|
18
|
+
notPricedNote: "Some models are not in the price table; those instances are compared in input-price units.",
|
|
19
|
+
scanning: "Still reading transcripts — {0} left. Refresh to see more.", scanningFirst: "Reading transcripts for the first time — Refresh to see more.", refresh: "Refresh",
|
|
20
|
+
keepWarm: "Keep warm", leaveOff: "Leave off", estimate: "estimate", notPriced: "not priced",
|
|
21
|
+
requests: "Requests", pastTtl: "Past the TTL ({0})", expired: "Expired (rewrote)", rewritten: "Tokens rewritten",
|
|
22
|
+
expiryCost: "Expiry cost", share: "{0} of its cost", pings: "Keep-warm pings", pingsAfter: "{0} after its last request",
|
|
23
|
+
pingCost: "Ping cost", net: "Net with keep-warm", gaps: "Gaps between requests", ttlLine: "TTL {0}",
|
|
24
|
+
quiet: "Too little activity in this window to tell.",
|
|
25
|
+
neverExpired: "No gap outlasted the {0} cache.", survived: "{0} gaps outlasted the {1} TTL, but the cache was still there.",
|
|
26
|
+
onlyIdle: "No request ever came back to an expired cache: each session went quiet for good. Keep-warm would only send {0} pings nobody reads.",
|
|
27
|
+
saves: "Often idle past the {0} TTL and then used again: keep-warm would save {1} ({2} of the expiry cost).",
|
|
28
|
+
costsMore: "Keep-warm would send about {0} pings per expired request — it would cost {1} more than it saves.",
|
|
29
|
+
costsMoreUnits: "Keep-warm would send about {0} pings per expired request — it would cost more than it saves.",
|
|
30
|
+
unavailable: "Not available", creditBilled: "credit-billed; no cache data", unsupported: "no cache data from this CLI", noData: "no requests read yet",
|
|
31
|
+
shared: "shares its working directory with {0}; their transcripts cannot be told apart",
|
|
32
|
+
empty: "No instances", emptyHint: "Instances show here once the fleet has them.",
|
|
33
|
+
b0: "≤5m", b1: "≤30m", b2: "≤1h", b3: "≤2h", b4: "≤6h", b5: "≤24h", b6: ">24h", tokensUnit: "input-token equivalents",
|
|
34
|
+
},
|
|
35
|
+
"zh-TW": {
|
|
36
|
+
title: "快取", w24h: "24 小時", w7d: "7 天", w30d: "30 天", window: "期間",
|
|
37
|
+
fleetExpiry: "過期成本", fleetSaving: "保溫可省", fleetRecommended: "建議保溫", ofN: "{1} 個中的 {0} 個",
|
|
38
|
+
pricesNote: "牌價查證日期:{0}。", quotaNote: "訂閱方案:Anthropic 與 OpenAI 未公布快取讀取如何計入方案額度,因此節省金額以牌價計算為準,換算成額度則是估算。",
|
|
39
|
+
estimateNote: "估算:Codex 不記錄快取寫入,寫入量以該次請求未命中快取的輸入估算。",
|
|
40
|
+
notPricedNote: "有些模型不在價目表中,這些 instance 以「輸入價格單位」比較。",
|
|
41
|
+
scanning: "仍在讀取 transcript——還剩 {0}。重新整理可看到更多。", scanningFirst: "第一次讀取 transcript 中——重新整理可看到更多。", refresh: "重新整理",
|
|
42
|
+
keepWarm: "建議保溫", leaveOff: "不需保溫", estimate: "估算", notPriced: "未定價",
|
|
43
|
+
requests: "請求數", pastTtl: "超過 TTL({0})", expired: "已過期(重寫)", rewritten: "重寫的 token",
|
|
44
|
+
expiryCost: "過期成本", share: "占其成本 {0}", pings: "保溫 ping 次數", pingsAfter: "其中 {0} 次在最後一次請求之後",
|
|
45
|
+
pingCost: "ping 成本", net: "保溫後淨額", gaps: "請求間隔", ttlLine: "TTL {0}",
|
|
46
|
+
quiet: "這段期間活動太少,無法判斷。",
|
|
47
|
+
neverExpired: "沒有任何間隔超過 {0} 的快取時效。", survived: "有 {0} 個間隔超過 {1} TTL,但快取仍在。",
|
|
48
|
+
onlyIdle: "沒有任何請求在快取過期後回來使用:每個 session 閒置後就不再使用。保溫只會送出 {0} 次沒人讀的 ping。",
|
|
49
|
+
saves: "常閒置超過 {0} TTL 後又再使用:保溫可省 {1}(過期成本的 {2})。",
|
|
50
|
+
costsMore: "每次過期約需送 {0} 次 ping——保溫的花費比省下的多 {1}。",
|
|
51
|
+
costsMoreUnits: "每次過期約需送 {0} 次 ping——保溫的花費比省下的多。",
|
|
52
|
+
unavailable: "不提供", creditBilled: "以 credit 計費,沒有快取資料", unsupported: "這個 CLI 沒有快取資料", noData: "尚未讀到任何請求",
|
|
53
|
+
shared: "與 {0} 共用工作目錄,無法分辨各自的 transcript",
|
|
54
|
+
empty: "沒有 instance", emptyHint: "fleet 有 instance 後會顯示在這裡。",
|
|
55
|
+
b0: "≤5分", b1: "≤30分", b2: "≤1時", b3: "≤2時", b4: "≤6時", b5: "≤24時", b6: ">24時", tokensUnit: "輸入 token 當量",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const tc = (k, ...v) => t(`cache.${k}`, ...v);
|
|
59
|
+
|
|
60
|
+
export const CACHE_WINDOWS = ["24h", "7d", "30d"];
|
|
61
|
+
const EDGES = [300, 1800, 3600, 7200, 21600, 86400];
|
|
62
|
+
|
|
63
|
+
export function money(usd) {
|
|
64
|
+
if (!(usd > 0)) return "$0";
|
|
65
|
+
if (usd < 0.01) return "<$0.01";
|
|
66
|
+
return usd >= 100 ? `$${Math.round(usd).toLocaleString("en-US")}` : `$${usd.toFixed(2)}`;
|
|
67
|
+
}
|
|
68
|
+
export function tokens(n) {
|
|
69
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
70
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
71
|
+
if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
|
|
72
|
+
return String(Math.round(n));
|
|
73
|
+
}
|
|
74
|
+
export function bytes(n) { return n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${Math.round(n / 1e6)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; }
|
|
75
|
+
export function ttlText(sec) { return sec >= 3600 ? `${sec / 3600} h` : `${Math.round(sec / 60)} min`; }
|
|
76
|
+
const pct = (x) => `${x >= 0.1 ? Math.round(x * 100) : (x * 100).toFixed(1)}%`;
|
|
77
|
+
|
|
78
|
+
/** The one line under an instance's recommendation. */
|
|
79
|
+
export function reasonText(a) {
|
|
80
|
+
const r = a.recommendation, ttl = ttlText(a.ttlSec);
|
|
81
|
+
if (r.reason === "quiet") return tc("quiet");
|
|
82
|
+
if (r.reason === "never_expired") {
|
|
83
|
+
if (a.sim.pastTtl) return tc("survived", a.sim.pastTtl, ttl);
|
|
84
|
+
return a.sim.tailPings ? tc("onlyIdle", a.sim.tailPings.toLocaleString("en-US")) : tc("neverExpired", ttl);
|
|
85
|
+
}
|
|
86
|
+
if (r.reason === "saves") return tc("saves", ttl, a.priced ? money(a.sim.net) : `${tokens(a.sim.net * 1e6)} ${tc("tokensUnit")}`, a.sim.expiryCost > 0 ? pct(a.sim.net / a.sim.expiryCost) : "—");
|
|
87
|
+
return a.priced ? tc("costsMore", r.pingsPerExpiry ?? 0, money(-a.sim.net)) : tc("costsMoreUnits", r.pingsPerExpiry ?? 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Gap histogram: one bar per bucket (square-root scale: a thousand short gaps must not flatten the few long ones). */
|
|
91
|
+
function Histogram({ buckets, ttlSec }) {
|
|
92
|
+
const W = 280, H = 84, top = 14, base = 66, slot = W / buckets.length, bw = slot - 8;
|
|
93
|
+
const max = Math.max(1, ...buckets);
|
|
94
|
+
const ttlAt = EDGES.indexOf(ttlSec);
|
|
95
|
+
const label = `${tc("gaps")}: ${buckets.map((n, i) => `${tc(`b${i}`)} ${n}`).join(", ")}`;
|
|
96
|
+
return html`<svg class="ch-hist" viewBox=${`0 0 ${W} ${H}`} role="img" aria-label=${label} preserveAspectRatio="none">
|
|
97
|
+
${buckets.map((n, i) => {
|
|
98
|
+
const h = n ? Math.max(2, Math.round((base - top) * Math.sqrt(n / max))) : 0;
|
|
99
|
+
const x = i * slot + 4;
|
|
100
|
+
return html`<g key=${i} class=${ttlAt >= 0 && i > ttlAt ? "ch-col past" : "ch-col"}>
|
|
101
|
+
<rect x=${x} y=${base - h} width=${bw} height=${h} rx="2" />
|
|
102
|
+
<text class="ch-n" x=${x + bw / 2} y=${base - h - 3} text-anchor="middle">${n || ""}</text>
|
|
103
|
+
<text class="ch-l" x=${x + bw / 2} y=${H - 4} text-anchor="middle">${tc(`b${i}`)}</text></g>`;
|
|
104
|
+
})}
|
|
105
|
+
${ttlAt >= 0 ? html`<g class="ch-ttl"><line x1=${(ttlAt + 1) * slot} x2=${(ttlAt + 1) * slot} y1="4" y2=${base} />
|
|
106
|
+
<text x=${(ttlAt + 1) * slot + 3} y="11">${tc("ttlLine", ttlText(ttlSec))}</text></g>` : null}
|
|
107
|
+
</svg>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Metric({ label, value, sub }) {
|
|
111
|
+
return html`<div class="ch-metric"><dt>${label}</dt><dd>${value}${sub ? html`<span class="ch-sub">${sub}</span>` : null}</dd></div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function InstanceCard({ r }) {
|
|
115
|
+
const a = r.analysis, s = a.sim, $ = (v) => (a.priced ? money(v) : "—");
|
|
116
|
+
const on = a.recommendation.on;
|
|
117
|
+
return html`<article class=${`ch-card${on ? " on" : ""}`} aria-labelledby=${`ch-${r.name}`}>
|
|
118
|
+
<header class="ch-head">
|
|
119
|
+
<div class="ch-id"><h3 id=${`ch-${r.name}`} class="mono">${r.name}</h3>
|
|
120
|
+
<span class="ch-models">${r.backend}${a.models.length ? ` · ${a.models.join(", ")}` : ""}</span></div>
|
|
121
|
+
<span class=${`ch-rec${on ? " on" : ""}`}>${on ? tc("keepWarm") : tc("leaveOff")}</span>
|
|
122
|
+
</header>
|
|
123
|
+
<p class="ch-reason">${reasonText(a)}${a.estimate ? html` <span class="ch-tag">${tc("estimate")}</span>` : null}${a.priced ? null : html` <span class="ch-tag">${tc("notPriced")}</span>`}</p>
|
|
124
|
+
<dl class="ch-metrics">
|
|
125
|
+
<${Metric} label=${tc("requests")} value=${a.requests.toLocaleString("en-US")} />
|
|
126
|
+
<${Metric} label=${tc("pastTtl", ttlText(a.ttlSec))} value=${s.pastTtl} />
|
|
127
|
+
<${Metric} label=${tc("expired")} value=${s.expired} />
|
|
128
|
+
<${Metric} label=${tc("rewritten")} value=${tokens(s.rewriteTokens)} />
|
|
129
|
+
<${Metric} label=${tc("expiryCost")} value=${$(s.expiryCost)} sub=${a.share != null && a.priced && s.expiryCost > 0 ? tc("share", pct(a.share)) : null} />
|
|
130
|
+
<${Metric} label=${tc("pings")} value=${(s.pings + s.tailPings).toLocaleString("en-US")} sub=${s.tailPings ? tc("pingsAfter", s.tailPings) : null} />
|
|
131
|
+
<${Metric} label=${tc("pingCost")} value=${$(s.pingCost + s.tailCost)} />
|
|
132
|
+
<${Metric} label=${tc("net")} value=${a.priced ? `${s.net >= 0 ? "+" : "−"}${money(Math.abs(s.net))}` : "—"} />
|
|
133
|
+
</dl>
|
|
134
|
+
<${Histogram} buckets=${a.gapBuckets} ttlSec=${a.ttlSec} />
|
|
135
|
+
</article>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The tab's body. `report`: GET /ui/cache's answer; `win`/`setWin`: the window; `refresh`: read again. */
|
|
139
|
+
export function CacheReport({ report, win, setWin, refresh }) {
|
|
140
|
+
const ok = report.instances.filter((r) => r.status === "ok")
|
|
141
|
+
.sort((x, y) => (y.analysis.priced - x.analysis.priced) || (y.analysis.sim.expiryCost - x.analysis.sim.expiryCost));
|
|
142
|
+
const rest = report.instances.filter((r) => r.status !== "ok");
|
|
143
|
+
const f = report.fleet;
|
|
144
|
+
const anyEstimate = ok.some((r) => r.analysis.estimate);
|
|
145
|
+
return html`<div class="ch">
|
|
146
|
+
<div class="ch-bar">
|
|
147
|
+
<div class="seg-inline" role="group" aria-label=${tc("window")}>${CACHE_WINDOWS.map((w) => html`<button key=${w} type="button"
|
|
148
|
+
class=${`btn btn-sm${w === win ? " btn-primary" : ""}`} aria-pressed=${w === win ? "true" : "false"} onClick=${() => setWin(w)}>${tc(`w${w}`)}</button>`)}</div>
|
|
149
|
+
${!report.scanning.caughtUp || report.scanning.pendingBytes > 0 ? html`<div class="ch-scan" role="status"><span>${report.scanning.pendingBytes > 0
|
|
150
|
+
? tc("scanning", bytes(report.scanning.pendingBytes)) : tc("scanningFirst")}</span>
|
|
151
|
+
<button type="button" class="btn btn-sm" onClick=${refresh}><${Icon} name="restart" size=${14} />${tc("refresh")}</button></div>` : null}
|
|
152
|
+
</div>
|
|
153
|
+
${report.instances.length ? html`<section class="ch-fleet card">
|
|
154
|
+
<${Metric} label=${tc("fleetExpiry")} value=${money(f.expiryCost)} />
|
|
155
|
+
<${Metric} label=${tc("fleetSaving")} value=${money(f.saving)} />
|
|
156
|
+
<${Metric} label=${tc("fleetRecommended")} value=${tc("ofN", f.recommended, f.analysed)} />
|
|
157
|
+
</section>` : html`<${Empty} icon="chart" title=${tc("empty")} hint=${tc("emptyHint")} />`}
|
|
158
|
+
<div class="ch-notes note">
|
|
159
|
+
<p>${tc("pricesNote", report.pricesChecked)} ${tc("quotaNote")}</p>
|
|
160
|
+
${anyEstimate ? html`<p>${tc("estimateNote")}</p>` : null}
|
|
161
|
+
${f.priced ? null : html`<p>${tc("notPricedNote")}</p>`}
|
|
162
|
+
</div>
|
|
163
|
+
<div class="ch-cards">${ok.map((r) => html`<${InstanceCard} key=${r.name} r=${r} />`)}</div>
|
|
164
|
+
${rest.length ? html`<ul class="rows ch-rest">${rest.map((r) => html`<li key=${r.name} class="row-item">
|
|
165
|
+
<span class="grow strong mono">${r.name}</span><span class="muted">${r.backend} · ${r.status === "no_data" ? tc("noData")
|
|
166
|
+
: `${tc("unavailable")} (${r.status === "credit_billed" ? tc("creditBilled") : r.status === "shared" ? tc("shared", (r.with || []).join(", ")) : tc("unsupported")})`}</span></li>`)}</ul>` : null}
|
|
167
|
+
</div>`;
|
|
168
|
+
}
|
package/dist/ui/panel-chat.js
CHANGED
|
@@ -21,7 +21,9 @@ import { confirmDialog } from "/assets/ui-confirm.js";
|
|
|
21
21
|
import "./chat-strings.js";
|
|
22
22
|
import { confirmedWrite } from "./settings-confirm.js";
|
|
23
23
|
import { createChatStore } from "./chat-store.js";
|
|
24
|
-
import { createThread } from "./chat-thread.js";
|
|
24
|
+
import { createThread, msgKey } from "./chat-thread.js";
|
|
25
|
+
import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.js";
|
|
26
|
+
import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
|
|
25
27
|
import { installTour, refreshTourSpot, startTour } from "./chat-tour.js";
|
|
26
28
|
|
|
27
29
|
const R = () => globalThis.AgendChatRender;
|
|
@@ -56,7 +58,7 @@ export async function setPreviewOptIn(on) {
|
|
|
56
58
|
export function boot({ stream, boot: bootData, deps = {} }) {
|
|
57
59
|
if (store) return store;
|
|
58
60
|
store = createChatStore({
|
|
59
|
-
fetch: (...a) => fetch(...a), toast, announce, t, setTimeout: (fn, ms) => setTimeout(fn, ms), ...deps,
|
|
61
|
+
fetch: (...a) => fetch(...a), toast, announce, t, setTimeout: (fn, ms) => setTimeout(fn, ms), confirm: confirmDialog, ...deps,
|
|
60
62
|
});
|
|
61
63
|
store.attach(stream);
|
|
62
64
|
// #1306: what the server said about previews for this load. The page-wide message and storage listeners this adds
|
|
@@ -146,7 +148,7 @@ function NotFound({ name }) {
|
|
|
146
148
|
function ChatView({ name, inst, lease, exec, awaiting }) {
|
|
147
149
|
const [dialog, setDialog] = useState(null); // "details" | "delete" | null
|
|
148
150
|
const [wrap, setWrap] = useState(codeWrap);
|
|
149
|
-
const view = useRef(null);
|
|
151
|
+
const view = useRef(null), split = useRef(null), thread = useRef(null);
|
|
150
152
|
useEffect(() => { wrapListeners.add(setWrap); return () => wrapListeners.delete(setWrap); }, []);
|
|
151
153
|
// First visit to this instance's chat on this page: its history (a navigation — it counts as use). Back again: nothing.
|
|
152
154
|
useEffect(() => { store.openHistory(name, lease); }, [name]);
|
|
@@ -189,11 +191,21 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
|
|
|
189
191
|
{ key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
|
|
190
192
|
];
|
|
191
193
|
const cls = statusClass(inst, exec, awaiting);
|
|
192
|
-
|
|
194
|
+
// #1269 quick actions: the model and effort it runs, each a way to /model or /effort (the same pick list).
|
|
195
|
+
const pick = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
|
|
196
|
+
const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>
|
|
197
|
+
${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", inst.model)} aria-label=${t("chat.chipModel", inst.model)} onClick=${() => pick("model")}>${inst.model}</button>` : null}
|
|
198
|
+
${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
|
|
193
199
|
return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
|
|
194
200
|
<${PanelHeader} title=${name} sub=${sub}><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
|
|
195
|
-
|
|
196
|
-
|
|
201
|
+
<div class="chat-split" ref=${split}>
|
|
202
|
+
<div class="chat-main">
|
|
203
|
+
<${Thread} name=${name} th=${thread} />
|
|
204
|
+
<${Dock} name=${name} inst=${inst} lease=${lease} exec=${exec} awaiting=${awaiting} />
|
|
205
|
+
</div>
|
|
206
|
+
<${PreviewPanel} name=${name} split=${split} msgs=${() => store.state.msgs[name] || []} subscribe=${(fn) => store.subscribe(fn)}
|
|
207
|
+
keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} />
|
|
208
|
+
</div>
|
|
197
209
|
<div class="drop-overlay" aria-hidden="true"><div class="drop-card"><${Icon} name="attach" size=${32} /><span>${t("chat.dropHere")}</span></div></div>
|
|
198
210
|
${dialog === "details" ? html`<${DetailsDialog} name=${name} onClose=${() => setDialog(null)} />` : null}
|
|
199
211
|
${dialog === "delete" ? html`<${DeleteDialog} name=${name} onClose=${() => setDialog(null)} />` : null}
|
|
@@ -201,14 +213,15 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
|
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
/** The thread: Preact owns the elements, the keyed renderer owns what is inside the list (chat-thread.js). */
|
|
204
|
-
function Thread({ name }) {
|
|
205
|
-
const scroller = useRef(null), list = useRef(null)
|
|
216
|
+
function Thread({ name, th }) {
|
|
217
|
+
const scroller = useRef(null), list = useRef(null);
|
|
206
218
|
const [jump, setJump] = useState({ show: false, unseen: 0 });
|
|
207
219
|
const [empty, setEmpty] = useState(false);
|
|
208
220
|
useLayoutEffect(() => {
|
|
209
221
|
const thread = createThread(list.current, scroller.current, {
|
|
210
222
|
t, tf: t, isUser: (x) => store.isUser(x, name), onJump: setJump, onEmpty: setEmpty,
|
|
211
223
|
setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
|
|
224
|
+
panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o) }, // #1481
|
|
212
225
|
});
|
|
213
226
|
th.current = thread;
|
|
214
227
|
thread.render(store.state.msgs[name] || [], { restore: store.state.scrollMemo[name] ?? null });
|
|
@@ -218,12 +231,13 @@ function Thread({ name }) {
|
|
|
218
231
|
else if (kind === "sent") thread.jumpLatest();
|
|
219
232
|
});
|
|
220
233
|
const offPv = P() ? P().onChange(() => thread.refreshCards()) : () => {};
|
|
234
|
+
const offPanel = panelStore.subscribe(() => thread.refreshCards()); // a card shows whether the panel has its block
|
|
221
235
|
const onScroll = () => { store.state.scrollMemo[name] = thread.onScroll(); };
|
|
222
236
|
const sc = scroller.current, ls = list.current;
|
|
223
237
|
sc.addEventListener("scroll", onScroll, { passive: true });
|
|
224
238
|
ls.addEventListener("click", thread.onClick);
|
|
225
239
|
return () => {
|
|
226
|
-
off(); offPv();
|
|
240
|
+
off(); offPv(); offPanel();
|
|
227
241
|
sc.removeEventListener("scroll", onScroll);
|
|
228
242
|
ls.removeEventListener("click", thread.onClick);
|
|
229
243
|
thread.dispose(); // the whole thread goes: every preview in it stops first
|
|
@@ -239,20 +253,51 @@ function Thread({ name }) {
|
|
|
239
253
|
}
|
|
240
254
|
|
|
241
255
|
/** Everything under the thread: prompts, the working line, a failed send, files waiting, and the composer. */
|
|
242
|
-
function Dock({ name, lease, exec, awaiting }) {
|
|
256
|
+
function Dock({ name, inst, lease, exec, awaiting }) {
|
|
243
257
|
useChatTick(name, ["state"]);
|
|
244
258
|
const s = store.state;
|
|
245
259
|
return html`<div class="dock"><div class="dock-col">
|
|
246
260
|
<${Prompts} name=${name} />
|
|
247
261
|
<${WorkBar} name=${name} lease=${lease} exec=${exec} awaiting=${awaiting} />
|
|
262
|
+
<${CommandCard} name=${name} lease=${lease} />
|
|
263
|
+
<${QuickActions} name=${name} inst=${inst} lease=${lease} />
|
|
248
264
|
${s.failedSends[name] ? html`<div class="failed-send" role="alert"><span>${t("chat.notSent")}</span><span class="txt">${s.failedSends[name]}</span>
|
|
249
265
|
<button type="button" class="btn btn-primary btn-sm" onClick=${() => store.putBack(name)}>${t("chat.putBack")}</button>
|
|
250
266
|
<button type="button" class="btn btn-sm" onClick=${() => store.discardFailed(name)}>${t("chat.discard")}</button></div>` : null}
|
|
251
|
-
<${Composer} name=${name} busy=${R().isBusy(exec)} />
|
|
267
|
+
<${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
|
|
252
268
|
<p class="hint">${t("chat.composerHint")}</p>
|
|
253
269
|
</div></div>`;
|
|
254
270
|
}
|
|
255
271
|
|
|
272
|
+
/** #1269: what the last chat command answered — its text, why it was refused, or a list to choose from. */
|
|
273
|
+
function CommandCard({ name, lease }) {
|
|
274
|
+
const c = store.state.commands[name];
|
|
275
|
+
if (!c) return null;
|
|
276
|
+
const run = (args) => store.runCommand(name, c.command, args, { alive: () => lease.current() });
|
|
277
|
+
return html`<div class=${`cmd-card${c.error ? " bad" : ""}`} role=${c.error ? "alert" : "status"}>
|
|
278
|
+
<div class="cmd-head"><span class="cmd-name mono">/${c.command}</span>
|
|
279
|
+
${c.busy ? html`<span class="wait">${t("chat.cmdRunning", c.command)}</span>` : html`<button type="button" class="icon-btn" title=${t("chat.cmdDismiss")}
|
|
280
|
+
aria-label=${t("chat.cmdDismiss")} onClick=${() => store.dismissCommand(name)}><${Icon} name="close" size=${14} /></button>`}</div>
|
|
281
|
+
${c.error ? html`<p class="cmd-text">${c.error}</p>` : null}
|
|
282
|
+
${c.text ? html`<pre class="cmd-text">${c.text}</pre>` : null}
|
|
283
|
+
${c.choices ? html`<div class="cmd-choices" role="group" aria-label=${t("chat.cmdChoose")}>${c.choices.options.map((o) => html`<button key=${o.id} type="button"
|
|
284
|
+
class=${`btn btn-sm${o.id === c.choices.current ? " current" : ""}`} aria-current=${o.id === c.choices.current ? "true" : undefined} onClick=${() => run(o.id)}>${o.label}</button>`)}</div>` : null}
|
|
285
|
+
</div>`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** #1269 quick actions: Compact and Clear… once the instance's context is 70% used (from the status it already has). */
|
|
289
|
+
function QuickActions({ name, inst, lease }) {
|
|
290
|
+
const pct = inst && typeof inst.context_pct === "number" ? Math.round(inst.context_pct) : null;
|
|
291
|
+
if (pct == null || pct < 70) return null;
|
|
292
|
+
const busy = !!(store.state.commands[name] && store.state.commands[name].busy);
|
|
293
|
+
const run = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
|
|
294
|
+
return html`<div class="quick-actions" role="group" aria-label=${t("chat.chipContext", pct)}>
|
|
295
|
+
<span class="qa-ctx">${t("chat.chipContext", pct)}</span>
|
|
296
|
+
<button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => run("compact")}>${t("chat.chipCompact")}</button>
|
|
297
|
+
<button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => run("clear")}>${t("chat.chipClear")}</button>
|
|
298
|
+
</div>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
256
301
|
function Prompts({ name }) {
|
|
257
302
|
const list = store.promptsFor(name);
|
|
258
303
|
if (!list.length) return null;
|
|
@@ -281,20 +326,53 @@ function WorkBar({ name, lease, exec, awaiting }) {
|
|
|
281
326
|
: html`<span class="elapsed" title=${t("chat.elapsedTitle")} aria-hidden="true">${since == null ? "" : R().formatElapsed(performance.now() - since)}</span>`}</div>`;
|
|
282
327
|
}
|
|
283
328
|
|
|
284
|
-
function Composer({ name, busy }) {
|
|
329
|
+
function Composer({ name, lease, busy }) {
|
|
285
330
|
const s = store.state;
|
|
286
331
|
const input = useRef(null), fileIn = useRef(null);
|
|
287
332
|
const [, tick] = useState(0);
|
|
333
|
+
const [sel, setSel] = useState(0);
|
|
334
|
+
const [closedFor, setClosedFor] = useState(null); // the draft the palette was closed on (Esc); it opens again on change
|
|
288
335
|
const value = s.drafts[name] || "";
|
|
289
336
|
const files = s.pendingFiles[name] || [];
|
|
290
337
|
const content = !!value.trim() || files.length > 0;
|
|
338
|
+
// A file waiting: what goes is a message with its file, never a command (no palette, no command line).
|
|
339
|
+
const palette = files.length || closedFor === value ? null : paletteFor(value);
|
|
340
|
+
const pick = palette && palette.length ? palette[Math.min(sel, palette.length - 1)] : null;
|
|
291
341
|
// Grow with the text, up to a limit: set through the CSSOM, never a style attribute (#1300).
|
|
292
342
|
useLayoutEffect(() => { const el = input.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 240)}px`; }, [value]);
|
|
293
343
|
useEffect(() => { if (input.current) input.current.focus(); }, [name]);
|
|
294
344
|
useEffect(() => { refreshTourSpot(); });
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
345
|
+
const setValue = (v) => { store.setDraft(name, v); setSel(0); tick(n => n + 1); };
|
|
346
|
+
// #1269: a command line runs the command (POST /ui/command); anything else — "/" included — is sent as before.
|
|
347
|
+
// The draft is consumed only by a command that starts: one still out keeps the new line in the composer (#1476 review).
|
|
348
|
+
const runLine = (cmd) => {
|
|
349
|
+
if (!store.commandFree(name)) return;
|
|
350
|
+
setValue("");
|
|
351
|
+
store.runCommand(name, cmd.command, cmd.args, { alive: () => lease.current() });
|
|
352
|
+
};
|
|
353
|
+
const complete = (c) => setValue(`/${c.name}${c.arg ? " " : ""}`);
|
|
354
|
+
const submit = () => {
|
|
355
|
+
if (pick) {
|
|
356
|
+
if (value === `/${pick.name}` && !needsArgument(pick.name)) runLine({ command: pick.name, args: "" });
|
|
357
|
+
else complete(pick);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const cmd = files.length ? null : parseCommandLine(value);
|
|
361
|
+
if (cmd && needsArgument(cmd.command) && !cmd.args) { setValue(`/${cmd.command} `); return; }
|
|
362
|
+
if (cmd) { runLine(cmd); return; }
|
|
363
|
+
store.send(name);
|
|
364
|
+
};
|
|
365
|
+
const onKeyDown = (e) => {
|
|
366
|
+
if (palette && palette.length && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
setSel((n) => (Math.min(n, palette.length - 1) + (e.key === "ArrowDown" ? 1 : palette.length - 1)) % palette.length);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (pick && e.key === "Tab" && !e.shiftKey) { e.preventDefault(); complete(pick); return; }
|
|
372
|
+
if (palette && e.key === "Escape" && !e.isComposing) { e.preventDefault(); setClosedFor(value); return; }
|
|
373
|
+
if (R().composerKey(e) === "send") { e.preventDefault(); submit(); }
|
|
374
|
+
};
|
|
375
|
+
const onInput = (e) => { setValue(e.target.value); };
|
|
298
376
|
// Files in a paste are attached; a very long text paste goes as a text file — only once it is really attached.
|
|
299
377
|
const onPaste = (e) => {
|
|
300
378
|
const list = e.clipboardData && e.clipboardData.files;
|
|
@@ -302,18 +380,26 @@ function Composer({ name, busy }) {
|
|
|
302
380
|
if (list && list.length) { store.addFiles(name, list); if (!text) e.preventDefault(); return; }
|
|
303
381
|
if (R().isLongPaste(text) && store.attachPastedText(name, text)) e.preventDefault();
|
|
304
382
|
};
|
|
383
|
+
const optId = (c) => `cmd-${c.name}`;
|
|
305
384
|
return html`<div class="composer-box">
|
|
385
|
+
${palette ? html`<ul class="cmd-palette" id="cmdPalette" role="listbox" aria-label=${t("chat.cmdCommands")}>
|
|
386
|
+
${palette.length ? palette.map((c) => html`<li key=${c.name} id=${optId(c)} role="option" aria-selected=${c === pick ? "true" : "false"}
|
|
387
|
+
class=${c === pick ? "on" : ""} onMouseDown=${(e) => { e.preventDefault(); complete(c); }}>
|
|
388
|
+
<span class="cmd-name mono">/${c.name}${c.arg ? html` <span class="cmd-arg">${c.arg}</span>` : null}</span><span class="cmd-desc">${t(`chat.cmd_${c.name}`)}</span></li>`)
|
|
389
|
+
: html`<li class="cmd-none" role="option" aria-selected="false">${t("chat.cmdNotCommand")}</li>`}
|
|
390
|
+
</ul>` : null}
|
|
306
391
|
${files.length ? html`<div class="pending-files">${files.map((f, i) => html`<${FileChip} key=${`${f.name}-${i}`} f=${f} i=${i} name=${name} />`)}</div>` : null}
|
|
307
392
|
<div class="composer">
|
|
308
393
|
<button id="attachBtn" type="button" class="icon-btn" title=${t("chat.attachTitle")} aria-label=${t("chat.attachTitle")} onClick=${() => fileIn.current && fileIn.current.click()}><${Icon} name="attach" /></button>
|
|
309
394
|
<input ref=${fileIn} type="file" multiple hidden accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*,.md,.csv,.json,.log,.yaml,.yml"
|
|
310
395
|
onChange=${(e) => { store.addFiles(name, e.target.files); e.target.value = ""; }} />
|
|
311
396
|
<textarea id="msgIn" ref=${input} rows="1" value=${value} placeholder=${t("chat.composerPlaceholder", name)} aria-label=${t("chat.composerPlaceholder", name)}
|
|
312
|
-
autocomplete="off"
|
|
397
|
+
autocomplete="off" aria-controls=${palette ? "cmdPalette" : undefined} aria-expanded=${palette ? "true" : "false"}
|
|
398
|
+
aria-activedescendant=${pick ? optId(pick) : undefined} onInput=${onInput} onKeyDown=${onKeyDown} onPaste=${onPaste}></textarea>
|
|
313
399
|
<button id="stopBtn" type="button" class="btn btn-stop" hidden=${!busy} title=${t("chat.stopReplyTitle")}
|
|
314
|
-
disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14}
|
|
400
|
+
disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14} /><span class="stop-label">${t("chat.stopReply")}</span></button>
|
|
315
401
|
<button id="sendBtn" type="button" class="btn btn-primary btn-send" hidden=${busy && !content} disabled=${!content || !!s.sending[name]}
|
|
316
|
-
aria-label=${t("chat.send")} title=${t("chat.send")} onClick=${
|
|
402
|
+
aria-label=${t("chat.send")} title=${t("chat.send")} onClick=${submit}><${Icon} name="send" size=${16} /><span class="send-label">${t("chat.send")}</span></button>
|
|
317
403
|
</div></div>`;
|
|
318
404
|
}
|
|
319
405
|
|