@mlx-bun/module-chat 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +85 -2
- package/package.json +39 -4
- package/src/app-tools.ts +101 -0
- package/src/backend.ts +108 -0
- package/src/describe.ts +47 -0
- package/src/events.ts +75 -0
- package/src/extensions.ts +44 -0
- package/src/history.ts +188 -0
- package/src/index.ts +74 -0
- package/src/manifest.ts +26 -0
- package/src/model-bridge.ts +75 -0
- package/src/panel/adapters-panel.ts +287 -0
- package/src/panel/assistant.ts +469 -0
- package/src/panel/chat.ts +1076 -0
- package/src/panel/composer.ts +947 -0
- package/src/panel/dom.ts +94 -0
- package/src/panel/host.ts +28 -0
- package/src/panel/index.ts +100 -0
- package/src/panel/markdown.ts +547 -0
- package/src/panel/popovers.ts +17 -0
- package/src/panel/rag.ts +274 -0
- package/src/panel/sessions.ts +470 -0
- package/src/panel/style.ts +532 -0
- package/src/panel/template.ts +189 -0
- package/src/panel/ui-catalog.ts +77 -0
- package/src/panel/voice.ts +141 -0
- package/src/pi-backend.ts +989 -0
- package/src/policy.ts +417 -0
- package/src/protocol.ts +265 -0
- package/src/provider.ts +72 -0
- package/src/routes.ts +49 -0
- package/src/session-files.ts +36 -0
- package/src/session-search.ts +175 -0
- package/src/surface.ts +26 -0
- package/src/tool-approvals.ts +108 -0
- package/src/web-tools.ts +478 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
// Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
|
|
2
|
+
//
|
|
3
|
+
// The chat controller: WS wiring, thread render, streaming, tool cards,
|
|
4
|
+
// approval card, queue bar. This is the orchestrator for composer.ts and
|
|
5
|
+
// sessions.ts — it owns the WebSocket and the per-turn mutable state
|
|
6
|
+
// (curAssistant, lastUserMsgEl, etc.) that both of those modules act on via
|
|
7
|
+
// explicit function calls rather than shared closures (a deliberate change
|
|
8
|
+
// from the original single-IIFE app.html: the split's whole point is
|
|
9
|
+
// typed, checkable seams between these concerns).
|
|
10
|
+
//
|
|
11
|
+
// Typed WS contract: ClientMessage/ServerMessage
|
|
12
|
+
// and the history/session shapes are imported from ../protocol (data only,
|
|
13
|
+
// shared with the backend) so this module (and the whole bundle) contains
|
|
14
|
+
// zero server code.
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
ApiEnvelope, ClientMessage, HistoryItem, ReadyGenDefaults, ServerMessage, SessionListItem,
|
|
18
|
+
} from "../protocol";
|
|
19
|
+
import { $, api, currentRoute, el, toast } from "./dom";
|
|
20
|
+
import type { ChatHost, ToolCardHandle } from "./host";
|
|
21
|
+
import { esc, mdToHtml, highlightIn, linkifyCitations, makeFrameScheduler, renderBlocksIncremental, wireCanvasToggle, captureCanvasViewStates, restoreCanvasViewStates, type BlockState } from "./markdown";
|
|
22
|
+
import {
|
|
23
|
+
ComposerState, addFiles, buildMessageText, clearAttachments, clearOneShotArmed, initMentionPicker,
|
|
24
|
+
initSampling, initSystemPrompt, onSelectAdapter, refreshAdapters as composerRefreshAdapters, refreshSamplingRecs,
|
|
25
|
+
renderAttachments, renderContext, renderQueue, updateAttachHint,
|
|
26
|
+
updateThinkingToggle, type ContextFrame,
|
|
27
|
+
} from "./composer";
|
|
28
|
+
import { initVoiceInput } from "./voice";
|
|
29
|
+
import {
|
|
30
|
+
addMsgActions, newSiblingInfo, newSidebarState, renderSessions, renderSiblingToggle,
|
|
31
|
+
switchSiblingTarget, applySessSearch, type SidebarState, type SiblingInfo,
|
|
32
|
+
} from "./sessions";
|
|
33
|
+
import { AdaptersPanelState, initAdaptersPanel, refreshAdaptersPanel } from "./adapters-panel";
|
|
34
|
+
import type { Citation } from "./rag";
|
|
35
|
+
import { buildAppContext, queryUiElements, resolveSpotlightTarget, showSpotlight, type UiSnapshot } from "./assistant";
|
|
36
|
+
import { isRouteId } from "./ui-catalog";
|
|
37
|
+
|
|
38
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
39
|
+
Chat-with-files RAG v1 — Sources panel (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
40
|
+
beat-matrix Axis 5). Pure HTML builder so packages/module-chat/tests/panel.test.ts can exercise the
|
|
41
|
+
esc() discipline directly, same reasoning as renderAdapterOptionsHtml
|
|
42
|
+
etc. in composer.ts.
|
|
43
|
+
──────────────────────────────────────────────────────────────────── */
|
|
44
|
+
|
|
45
|
+
/** Pure: builds the collapsed "Sources · K" summary line + expandable list
|
|
46
|
+
* of [n] filename + snippet for a turn's citations. Snippets are
|
|
47
|
+
* truncated (a citation's chunk can be ~1200 chars — the panel is a
|
|
48
|
+
* pointer back to the source, not a document viewer). */
|
|
49
|
+
export function renderSourcesHtml(citations: Citation[]): string {
|
|
50
|
+
if (!citations.length) return "";
|
|
51
|
+
const rows = citations.map((c) => {
|
|
52
|
+
const snippet = c.text.length > 240 ? c.text.slice(0, 240).trim() + "…" : c.text.trim();
|
|
53
|
+
return '<div class="src-row" data-cite-row="' + c.n + '">' +
|
|
54
|
+
'<span class="src-n">[' + c.n + "]</span>" +
|
|
55
|
+
'<span class="src-meta"><span class="src-file">' + esc(c.fileName) + '</span>' +
|
|
56
|
+
'<span class="src-range">chars ' + c.start + "–" + c.end + "</span></span>" +
|
|
57
|
+
'<div class="src-snippet">' + esc(snippet) + "</div></div>";
|
|
58
|
+
}).join("");
|
|
59
|
+
return '<details class="sources"><summary>Sources · ' + citations.length + "</summary>" +
|
|
60
|
+
'<div class="src-list">' + rows + "</div></details>";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
64
|
+
Tool arg formatting helpers (shared by streaming + replayed history)
|
|
65
|
+
──────────────────────────────────────────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
function argSummary(args: unknown): string {
|
|
68
|
+
if (!args) return "";
|
|
69
|
+
if (typeof args === "string") return args.slice(0, 120);
|
|
70
|
+
const a = args as Record<string, unknown>;
|
|
71
|
+
if (a.command || a.cmd) return String(a.command || a.cmd).slice(0, 120);
|
|
72
|
+
if (a.file_path || a.path) return String(a.file_path || a.path).slice(0, 120);
|
|
73
|
+
if (a.query || a.url || a.location) return String(a.query || a.url || a.location).slice(0, 120);
|
|
74
|
+
const s = JSON.stringify(args); return s.length > 120 ? s.slice(0, 117) + "…" : s;
|
|
75
|
+
}
|
|
76
|
+
function prettyArgs(args: unknown): string {
|
|
77
|
+
if (args == null) return "";
|
|
78
|
+
if (typeof args === "string") return args;
|
|
79
|
+
try { return JSON.stringify(args, null, 2); } catch { return String(args); }
|
|
80
|
+
}
|
|
81
|
+
function diffView(args: unknown): string {
|
|
82
|
+
// Best-effort diff-ish view for edit/write tools.
|
|
83
|
+
if (!args) return "";
|
|
84
|
+
const a = args as Record<string, unknown>;
|
|
85
|
+
if (a.old_string != null || a.new_string != null) {
|
|
86
|
+
const o = String(a.old_string ?? "").split("\n").map((l) => '<span class="diffdel">- ' + esc(l) + "</span>").join("\n");
|
|
87
|
+
const n = String(a.new_string ?? "").split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
|
|
88
|
+
return (a.file_path ? esc(a.file_path) + "\n\n" : "") + o + (o && n ? "\n" : "") + n;
|
|
89
|
+
}
|
|
90
|
+
if (a.content != null) {
|
|
91
|
+
return (a.file_path ? esc(a.file_path) + "\n\n" : "") +
|
|
92
|
+
String(a.content).split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
|
|
93
|
+
}
|
|
94
|
+
return esc(prettyArgs(args));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
98
|
+
Per-assistant-message streaming state
|
|
99
|
+
──────────────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
// Two distinct card kinds share one map (curAssistant.tools): the generic
|
|
102
|
+
// "wrench" tool card and the memory provenance chip (the
|
|
103
|
+
// single highest-leverage rendering change: a citation, not a shell
|
|
104
|
+
// command). Dispatch on `.kind` at each lifecycle point (toolCard/
|
|
105
|
+
// toolUpdate/toolEnd) rather than keeping two parallel maps, since a card's
|
|
106
|
+
// kind is fixed at tool_start and every later frame (tool_update/tool_end)
|
|
107
|
+
// only carries the callId to look it up by.
|
|
108
|
+
interface WrenchCardState {
|
|
109
|
+
kind: "wrench";
|
|
110
|
+
wrap: HTMLElement;
|
|
111
|
+
res: HTMLElement;
|
|
112
|
+
resBlk: HTMLElement;
|
|
113
|
+
label: HTMLElement;
|
|
114
|
+
chunks: string;
|
|
115
|
+
}
|
|
116
|
+
interface MemChipCardState {
|
|
117
|
+
kind: "memchip";
|
|
118
|
+
wrap: HTMLElement;
|
|
119
|
+
handle: ToolCardHandle;
|
|
120
|
+
chunks: string;
|
|
121
|
+
}
|
|
122
|
+
type ToolCardState = WrenchCardState | MemChipCardState;
|
|
123
|
+
|
|
124
|
+
interface AssistantMsgState {
|
|
125
|
+
m: HTMLElement;
|
|
126
|
+
bubble: HTMLElement;
|
|
127
|
+
thinkBox: HTMLDetailsElement;
|
|
128
|
+
thinkBody: HTMLElement;
|
|
129
|
+
textNode: HTMLElement;
|
|
130
|
+
cursor: HTMLElement;
|
|
131
|
+
meta: HTMLElement;
|
|
132
|
+
tools: Map<string, ToolCardState>;
|
|
133
|
+
text: string;
|
|
134
|
+
thinking: string;
|
|
135
|
+
t0: number;
|
|
136
|
+
tFirst: number;
|
|
137
|
+
tokens: number;
|
|
138
|
+
settled: boolean;
|
|
139
|
+
blockState: BlockState;
|
|
140
|
+
scheduleText: () => void;
|
|
141
|
+
scheduleThinking: () => void;
|
|
142
|
+
// Chat-with-files RAG v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
143
|
+
// beat-matrix Axis 5): the
|
|
144
|
+
// citation list retrieved for the prompt that started THIS turn, if any
|
|
145
|
+
// (composer.ts's buildMessageText only returns a non-empty list when
|
|
146
|
+
// retrieval mode fired). Empty for the common case — no Sources panel,
|
|
147
|
+
// no [n] linkification, zero visual change from before this feature.
|
|
148
|
+
citations: Citation[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What the controller needs from its element and host. */
|
|
152
|
+
export interface ChatPort {
|
|
153
|
+
readonly host: ChatHost | undefined;
|
|
154
|
+
/** The panel element the markup lives in. */
|
|
155
|
+
readonly element: HTMLElement;
|
|
156
|
+
/** The chat page is the one showing (a reconnect waits for it). */
|
|
157
|
+
isActive(): boolean;
|
|
158
|
+
/** Closes the mobile recent-chats drawer. */
|
|
159
|
+
closeDrawer(): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createChatController(port: ChatPort) {
|
|
163
|
+
let ws: WebSocket | null = null, connected = false, reconnectTimer: ReturnType<typeof setTimeout> | undefined, manualClose = false;
|
|
164
|
+
let curAssistant: AssistantMsgState | null = null;
|
|
165
|
+
let turnActive = false;
|
|
166
|
+
// App-aware assistant: the last uiSnapshot pushed to the
|
|
167
|
+
// server, kept client-side too so spotlight_ui's ref resolution
|
|
168
|
+
// (resolveSpotlightTarget) doesn't need a round-trip — the browser
|
|
169
|
+
// already has the freshest snapshot it just sent.
|
|
170
|
+
let lastSnapshot: UiSnapshot | null = null;
|
|
171
|
+
// Citations retrieved for the prompt currently in flight (set in submit(),
|
|
172
|
+
// just before send()) — startAssistant() picks this up when turn_start
|
|
173
|
+
// arrives and attaches it to the new AssistantMsgState, then clears it so
|
|
174
|
+
// a later turn with no attachments doesn't inherit a stale citation set.
|
|
175
|
+
let pendingCitations: Citation[] = [];
|
|
176
|
+
// This connection's active session path (from `sessions` frames). Each
|
|
177
|
+
// connect gets a fresh server session; on a transient reconnect we re-open
|
|
178
|
+
// our own session so a blip doesn't strand us on a blank one.
|
|
179
|
+
let currentSessionPath: string | null = null, pendingResumePath: string | null = null;
|
|
180
|
+
const thread = () => $("chat-thread");
|
|
181
|
+
const composer = new ComposerState();
|
|
182
|
+
const sidebar: SidebarState = newSidebarState();
|
|
183
|
+
const adaptersPanel = new AdaptersPanelState();
|
|
184
|
+
|
|
185
|
+
function wsUrl(): string {
|
|
186
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
187
|
+
return proto + "//" + location.host + "/ws/chat";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function connect(): void {
|
|
191
|
+
manualClose = false;
|
|
192
|
+
// Remember our session across this (re)connect: if we were already in a
|
|
193
|
+
// session, re-open it once the new connection is ready (the server starts
|
|
194
|
+
// each connection on a fresh session by default).
|
|
195
|
+
pendingResumePath = currentSessionPath;
|
|
196
|
+
try { ws = new WebSocket(wsUrl()); } catch { scheduleReconnect(); return; }
|
|
197
|
+
ws.onopen = () => { connected = true; setChatStatus("connected"); };
|
|
198
|
+
ws.onclose = () => { connected = false; setChatStatus("disconnected"); if (!manualClose) scheduleReconnect(); };
|
|
199
|
+
ws.onerror = () => { setChatStatus("error"); };
|
|
200
|
+
ws.onmessage = (ev) => { let m: ServerMessage; try { m = JSON.parse(ev.data); } catch { return; } handle(m); };
|
|
201
|
+
}
|
|
202
|
+
// The served model changed (the hub or picker switched it): a session that is not mid-turn reconnects,
|
|
203
|
+
// so its capabilities (vision, thinking) and model name describe the model that will answer.
|
|
204
|
+
window.addEventListener("mlx-model-changed", () => { if (!turnActive && ws && ws.readyState === 1) ws.close(); });
|
|
205
|
+
function scheduleReconnect(): void {
|
|
206
|
+
clearTimeout(reconnectTimer);
|
|
207
|
+
reconnectTimer = setTimeout(() => { if (port.isActive()) connect(); }, 2200);
|
|
208
|
+
}
|
|
209
|
+
function send(obj: ClientMessage): boolean {
|
|
210
|
+
if (ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return true; }
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ── App-aware assistant (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
215
|
+
beat-matrix Axis 12) ──
|
|
216
|
+
"See": push a structured context frame on every route change and every
|
|
217
|
+
wizard-step change. Never on a timer — only these two triggers, so the
|
|
218
|
+
server's ambient one-liner always reflects a real, user-caused state
|
|
219
|
+
change. */
|
|
220
|
+
|
|
221
|
+
/** Which non-route overlay (if any) is currently open, layered on top of
|
|
222
|
+
* whatever route is active underneath — same "where is the user, really"
|
|
223
|
+
* question navigate_app/spotlight_ui need answered. Checked in a fixed
|
|
224
|
+
* order; the first open one wins (only one of these opens at a time in
|
|
225
|
+
* practice — each is closed by shell.ts's closeTopOverlay sweep before
|
|
226
|
+
* another opens). */
|
|
227
|
+
function currentView(): string | null {
|
|
228
|
+
if ($("mem-overlay")?.classList.contains("open")) return "memory-panel";
|
|
229
|
+
if ($("hub-overlay")?.classList.contains("open")) return "hub-panel";
|
|
230
|
+
if ($("adapters-overlay")?.classList.contains("open")) return "adapters-panel";
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pushAppContext(): void {
|
|
235
|
+
const route = currentRoute();
|
|
236
|
+
if (!isRouteId(route)) return; // "routes" (DAG diagram) isn't a useful assistant destination
|
|
237
|
+
const ctx = buildAppContext(route, currentView());
|
|
238
|
+
lastSnapshot = ctx.snapshot;
|
|
239
|
+
send({ type: "context", context: ctx });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Rebind after navigation: module panels are created lazily and own their step indicators in shadow DOM. */
|
|
243
|
+
let wizardObserver: MutationObserver | undefined;
|
|
244
|
+
function watchWizardSteps(): void {
|
|
245
|
+
wizardObserver ??= new MutationObserver(() => pushAppContext());
|
|
246
|
+
wizardObserver.disconnect();
|
|
247
|
+
for (const id of ["q-steps", "f-steps", "d-steps"]) {
|
|
248
|
+
const element = queryUiElements(`[id="${id}"]`)[0];
|
|
249
|
+
if (element) wizardObserver.observe(element, { childList: true, subtree: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function setChatStatus(s: "connected" | "disconnected" | "error"): void {
|
|
254
|
+
const line = $("chat-status-line");
|
|
255
|
+
// The "#" hint lives HERE, not in the composer placeholder — a long
|
|
256
|
+
// placeholder wraps + clips at phone widths (the 2026-07-06 jank class).
|
|
257
|
+
// This status line ellipsizes gracefully instead (.chat-hint CSS).
|
|
258
|
+
if (s === "connected") line.textContent = "connected · type to send · Shift+Enter for newline · # attaches files or recalls memory";
|
|
259
|
+
else if (s === "disconnected") line.textContent = "reconnecting to agent…";
|
|
260
|
+
else if (s === "error") line.textContent = "connection error — retrying";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* — message actions: copy / regenerate / edit-and-resend —
|
|
264
|
+
* Only the LAST assistant message gets "regenerate" and only the LAST
|
|
265
|
+
* user message gets "edit" + the sibling `< i/n >` toggle. Both "last"
|
|
266
|
+
* pointers are tracked module-side and re-derived whenever a `history`
|
|
267
|
+
* frame rebuilds #chat-thread wholesale (see renderHistory/endTurn below).
|
|
268
|
+
* Regenerate/edit-resend re-prompt on a new sibling branch WITHOUT a
|
|
269
|
+
* `history` frame (resendFrom in packages/module-chat/src/pi-backend.ts), so their click handlers below
|
|
270
|
+
* drop the stale lastAssistantMsgEl (and, for edit, update the surviving
|
|
271
|
+
* user bubble's text) themselves before the new reply streams in. */
|
|
272
|
+
let lastUserMsgEl: HTMLElement | null = null; // the <div class="msg user"> for the last user message
|
|
273
|
+
let lastUserEntryId: string | null = null; // its session entryId (from history/siblings frames)
|
|
274
|
+
let siblingInfo: SiblingInfo = newSiblingInfo();
|
|
275
|
+
let lastAssistantMsgEl: HTMLElement | null = null; // the <div class="msg assistant"> eligible for regenerate
|
|
276
|
+
// Citations for the reply currently sitting in lastAssistantMsgEl (kept in
|
|
277
|
+
// lockstep with it — set alongside it in endTurn, cleared alongside it
|
|
278
|
+
// everywhere it's nulled). Regenerate/edit-resend re-prompt the SAME
|
|
279
|
+
// underlying (possibly RAG'd) user turn without going through submit()/
|
|
280
|
+
// buildMessageText, so pendingCitations would otherwise be the stale `[]`
|
|
281
|
+
// left over from consumption at the prior startAssistant() call — see the
|
|
282
|
+
// pendingCitations declaration below. Re-seeding from here means a
|
|
283
|
+
// citation-bearing turn keeps its Sources panel across regenerate/edit.
|
|
284
|
+
let lastAssistantCitations: Citation[] = [];
|
|
285
|
+
|
|
286
|
+
function doRegenerate(): void {
|
|
287
|
+
if (turnActive) return;
|
|
288
|
+
if (!send({ type: "regenerate" })) { toast("Not connected to the agent yet.", "err"); return; }
|
|
289
|
+
// The server re-prompts on a new sibling branch — it does not send a
|
|
290
|
+
// `history` frame for this (see resendFrom in packages/module-chat/src/pi-backend.ts), so the old
|
|
291
|
+
// reply would otherwise linger above the new one as it streams in.
|
|
292
|
+
// Drop it now; startAssistant() appends the fresh reply right after.
|
|
293
|
+
if (lastAssistantMsgEl) { lastAssistantMsgEl.remove(); lastAssistantMsgEl = null; }
|
|
294
|
+
pendingCitations = lastAssistantCitations;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function switchSiblingDir(delta: number): void {
|
|
298
|
+
const target = switchSiblingTarget(siblingInfo, delta);
|
|
299
|
+
if (!target) return;
|
|
300
|
+
send({ type: "switch_sibling", entryId: target });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Turn the last user message bubble into an inline editor. Cancel restores
|
|
304
|
+
* the original text view; Send fires edit_resend, updates the surviving
|
|
305
|
+
* user bubble to the edited text in place, and drops the stale assistant
|
|
306
|
+
* reply — the server re-prompts on a new sibling branch without sending a
|
|
307
|
+
* `history` frame (see resendFrom in packages/module-chat/src/pi-backend.ts), so the old reply/text
|
|
308
|
+
* would otherwise linger next to the new one as it streams in. */
|
|
309
|
+
function startEditLast(m: HTMLElement): void {
|
|
310
|
+
if (turnActive) return;
|
|
311
|
+
const bubble = m.querySelector(".bubble") as HTMLElement;
|
|
312
|
+
const textEl = bubble.querySelector(".msg-text") as HTMLElement | null;
|
|
313
|
+
if (!textEl) return;
|
|
314
|
+
const original = textEl.textContent || "";
|
|
315
|
+
const box = document.createElement("textarea");
|
|
316
|
+
box.className = "msg-edit-box";
|
|
317
|
+
box.value = original;
|
|
318
|
+
const actions = el("div", "msg-edit-actions", null);
|
|
319
|
+
const cancelBtn = el("button", "btn ghost sm", actions);
|
|
320
|
+
cancelBtn.type = "button"; cancelBtn.textContent = "Cancel";
|
|
321
|
+
const sendBtn = el("button", "btn primary sm", actions);
|
|
322
|
+
sendBtn.type = "button"; sendBtn.textContent = "Send";
|
|
323
|
+
textEl.replaceWith(box);
|
|
324
|
+
box.after(actions);
|
|
325
|
+
box.focus(); box.setSelectionRange(box.value.length, box.value.length);
|
|
326
|
+
const restore = () => { box.replaceWith(textEl); actions.remove(); };
|
|
327
|
+
const doSend = () => {
|
|
328
|
+
const text = box.value.trim();
|
|
329
|
+
if (!text) return;
|
|
330
|
+
if (!send({ type: "edit_resend", text })) { toast("Not connected to the agent yet.", "err"); return; }
|
|
331
|
+
textEl.textContent = text;
|
|
332
|
+
box.replaceWith(textEl); actions.remove();
|
|
333
|
+
if (lastAssistantMsgEl) { lastAssistantMsgEl.remove(); lastAssistantMsgEl = null; }
|
|
334
|
+
pendingCitations = lastAssistantCitations;
|
|
335
|
+
};
|
|
336
|
+
cancelBtn.onclick = restore;
|
|
337
|
+
sendBtn.onclick = doSend;
|
|
338
|
+
box.addEventListener("keydown", (e) => {
|
|
339
|
+
if (e.key === "Escape") { e.preventDefault(); restore(); }
|
|
340
|
+
else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); doSend(); }
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* — message + tool DOM construction — */
|
|
345
|
+
function addUserMsg(text: string, atts: { kind: string; data?: string; mimeType?: string; name: string }[] | null, opts: { isLast?: boolean; entryId?: string }): void {
|
|
346
|
+
const m = el("div", "msg user", thread());
|
|
347
|
+
el("div", "who", m).textContent = "you";
|
|
348
|
+
const bubble = el("div", "bubble", m);
|
|
349
|
+
if (atts && atts.length) {
|
|
350
|
+
const wrap = el("div", "msg-atts", bubble);
|
|
351
|
+
for (const a of atts) {
|
|
352
|
+
if (a.kind === "image") {
|
|
353
|
+
const im = el("img", "msg-att-img", wrap);
|
|
354
|
+
im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name;
|
|
355
|
+
} else {
|
|
356
|
+
el("span", "msg-att-file", wrap).textContent = "📎 " + a.name;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (text) el("div", "msg-text", bubble).textContent = text;
|
|
361
|
+
if (opts.isLast) {
|
|
362
|
+
lastUserMsgEl = m;
|
|
363
|
+
lastUserEntryId = opts.entryId || null;
|
|
364
|
+
addMsgActions(m, text, { edit: true }, { onEdit: startEditLast });
|
|
365
|
+
renderSiblingToggle(lastUserMsgEl, lastUserEntryId, siblingInfo, switchSiblingDir);
|
|
366
|
+
}
|
|
367
|
+
stick(true);
|
|
368
|
+
}
|
|
369
|
+
function startAssistant(): void {
|
|
370
|
+
$("chat-hello").style.display = "none";
|
|
371
|
+
const m = el("div", "msg assistant", thread());
|
|
372
|
+
el("div", "who", m).textContent = "local agent";
|
|
373
|
+
const bubble = el("div", "bubble", m);
|
|
374
|
+
const thinkBox = el("details", "thinkbox", bubble);
|
|
375
|
+
thinkBox.open = false; // collapsed by default; the reasoning stays tucked behind "Thinking" — click to expand
|
|
376
|
+
el("summary", "", thinkBox).textContent = "Thinking";
|
|
377
|
+
const thinkBody = el("div", "thinkbody", thinkBox);
|
|
378
|
+
thinkBox.style.display = "none";
|
|
379
|
+
const textNode = el("span", "atext", bubble);
|
|
380
|
+
const cursor = el("span", "cursor", bubble);
|
|
381
|
+
const meta = el("div", "meta", m);
|
|
382
|
+
curAssistant = {
|
|
383
|
+
m, bubble, thinkBox, thinkBody, textNode, cursor, meta, tools: new Map(),
|
|
384
|
+
text: "", thinking: "", t0: performance.now(), tFirst: 0, tokens: 0, settled: false,
|
|
385
|
+
// Block-memoization state (see renderBlocksIncremental) for the main
|
|
386
|
+
// markdown text stream, plus a shared rAF scheduler so a burst of
|
|
387
|
+
// deltas within one frame (e.g. spec-decode emitting several tokens
|
|
388
|
+
// back-to-back) collapses into a single re-render instead of one per
|
|
389
|
+
// token. The thinking channel is plain text (no markdown — see
|
|
390
|
+
// appendThinkingDelta), so it only needs the textContent set
|
|
391
|
+
// throttled, not block splitting; it gets its own scheduler since
|
|
392
|
+
// both channels can stream concurrently (interleaved thinking/text).
|
|
393
|
+
blockState: { blocks: [] },
|
|
394
|
+
scheduleText: () => {},
|
|
395
|
+
scheduleThinking: () => {},
|
|
396
|
+
citations: pendingCitations,
|
|
397
|
+
};
|
|
398
|
+
// Consumed — a later turn with no RAG'd attachments must not inherit
|
|
399
|
+
// this turn's citation set (see the pendingCitations declaration above).
|
|
400
|
+
pendingCitations = [];
|
|
401
|
+
const assistant = curAssistant;
|
|
402
|
+
assistant.scheduleText = makeFrameScheduler(
|
|
403
|
+
() => {
|
|
404
|
+
if (!assistant.settled) renderBlocksIncremental(assistant.textNode, assistant.text, assistant.blockState);
|
|
405
|
+
},
|
|
406
|
+
atBottom, stick);
|
|
407
|
+
assistant.scheduleThinking = makeFrameScheduler(
|
|
408
|
+
() => {
|
|
409
|
+
if (!assistant.settled) assistant.thinkBody.textContent = assistant.thinking;
|
|
410
|
+
},
|
|
411
|
+
atBottom, stick);
|
|
412
|
+
stick(true);
|
|
413
|
+
}
|
|
414
|
+
function ensureAssistant(): void { if (!curAssistant) startAssistant(); }
|
|
415
|
+
|
|
416
|
+
function appendDelta(delta: string): void {
|
|
417
|
+
ensureAssistant();
|
|
418
|
+
const a = curAssistant!;
|
|
419
|
+
if (!a.tFirst) a.tFirst = performance.now();
|
|
420
|
+
a.tokens++;
|
|
421
|
+
a.text += delta;
|
|
422
|
+
// Block-memoized + rAF-throttled render (see renderBlocksIncremental):
|
|
423
|
+
// only the live tail block re-parses per delta; completed blocks are
|
|
424
|
+
// never touched again. A final full mdToHtml pass runs at turn-end
|
|
425
|
+
// (finishStreaming) to guarantee the streamed result is byte-identical
|
|
426
|
+
// to a non-streaming render. Autoscroll is handled INSIDE the scheduler
|
|
427
|
+
// (its shouldStick/doStick args) — not here — because the render itself
|
|
428
|
+
// is rAF-deferred: checking atBottom() synchronously at this point would
|
|
429
|
+
// measure scrollHeight from before the queued render grows it, which
|
|
430
|
+
// desyncs autoscroll once several deltas land within one frame.
|
|
431
|
+
a.scheduleText();
|
|
432
|
+
updateTps();
|
|
433
|
+
}
|
|
434
|
+
function appendThinkingDelta(delta: string): void {
|
|
435
|
+
ensureAssistant();
|
|
436
|
+
const a = curAssistant!;
|
|
437
|
+
if (!a.tFirst) a.tFirst = performance.now();
|
|
438
|
+
a.tokens++;
|
|
439
|
+
a.thinking += delta;
|
|
440
|
+
a.thinkBox.style.display = a.thinking.trim() ? "" : "none";
|
|
441
|
+
// Plain text (no markdown here), but still rAF-throttled: a long
|
|
442
|
+
// thinking trace can otherwise do one textContent-set per token.
|
|
443
|
+
// Autoscroll: see the comment in appendDelta above — handled inside the
|
|
444
|
+
// scheduler, not with a synchronous atBottom() check here.
|
|
445
|
+
a.scheduleThinking();
|
|
446
|
+
updateTps();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* tool card lifecycle — dispatches to the memory provenance chip
|
|
450
|
+
* for memory_ and reference_ tool calls, the generic wrench card
|
|
451
|
+
* for everything else. Both kinds live in the same a.tools map (see
|
|
452
|
+
* ToolCardState above). */
|
|
453
|
+
function toolCard(callId: string, tool: string, args: unknown): ToolCardState {
|
|
454
|
+
ensureAssistant();
|
|
455
|
+
const a = curAssistant!;
|
|
456
|
+
let t = a.tools.get(callId);
|
|
457
|
+
if (t) return t;
|
|
458
|
+
const custom = port.host?.toolCard?.(a.bubble, tool, args);
|
|
459
|
+
if (custom) {
|
|
460
|
+
t = { kind: "memchip", wrap: custom.wrap, handle: custom, chunks: "" };
|
|
461
|
+
a.tools.set(callId, t);
|
|
462
|
+
stick();
|
|
463
|
+
return t;
|
|
464
|
+
}
|
|
465
|
+
const wrap = el("div", "tool running open", a.bubble);
|
|
466
|
+
wrap.innerHTML =
|
|
467
|
+
'<div class="thead"><div class="ticon">⚙</div>' +
|
|
468
|
+
'<span class="tname">' + esc(tool || "tool") + '</span>' +
|
|
469
|
+
'<span class="targs">' + esc(argSummary(args)) + '</span>' +
|
|
470
|
+
'<span class="tstat"><span class="sdot"></span><span class="slabel">running</span></span>' +
|
|
471
|
+
'<span class="caret">›</span></div>' +
|
|
472
|
+
'<div class="tbody">' +
|
|
473
|
+
'<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(args)) + '</pre></div>' +
|
|
474
|
+
'<div class="blk tresult" style="display:none"><div class="blbl">result</div><pre class="ares"></pre></div>' +
|
|
475
|
+
'</div>';
|
|
476
|
+
wrap.querySelector(".thead")!.addEventListener("click", () => wrap.classList.toggle("open"));
|
|
477
|
+
t = {
|
|
478
|
+
kind: "wrench", wrap,
|
|
479
|
+
res: wrap.querySelector(".ares") as HTMLElement,
|
|
480
|
+
resBlk: wrap.querySelector(".tresult") as HTMLElement,
|
|
481
|
+
label: wrap.querySelector(".slabel") as HTMLElement,
|
|
482
|
+
chunks: "",
|
|
483
|
+
};
|
|
484
|
+
a.tools.set(callId, t);
|
|
485
|
+
stick();
|
|
486
|
+
return t;
|
|
487
|
+
}
|
|
488
|
+
function toolUpdate(callId: string, chunk: string): void {
|
|
489
|
+
const t = curAssistant && curAssistant.tools.get(callId);
|
|
490
|
+
if (!t) return;
|
|
491
|
+
t.chunks += chunk;
|
|
492
|
+
if (t.kind === "memchip") { t.handle.setResult(t.chunks); if (atBottom()) stick(); return; }
|
|
493
|
+
t.resBlk.style.display = "";
|
|
494
|
+
t.res.textContent = t.chunks.slice(-8000);
|
|
495
|
+
if (atBottom()) stick();
|
|
496
|
+
}
|
|
497
|
+
function toolEnd(callId: string, ok: boolean, result: unknown): void {
|
|
498
|
+
const t = curAssistant && curAssistant.tools.get(callId);
|
|
499
|
+
if (!t) return;
|
|
500
|
+
if (t.kind === "memchip") {
|
|
501
|
+
t.wrap.classList.toggle("fail", !ok);
|
|
502
|
+
t.handle.setResult(result != null ? result : t.chunks);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
t.wrap.classList.remove("running");
|
|
506
|
+
t.wrap.classList.add(ok ? "ok" : "fail");
|
|
507
|
+
t.label.textContent = ok ? "done" : "failed";
|
|
508
|
+
const body = result != null ? (typeof result === "string" ? result : JSON.stringify(result, null, 2)) : t.chunks;
|
|
509
|
+
if (body) { t.resBlk.style.display = ""; t.res.textContent = String(body).slice(-12000); }
|
|
510
|
+
if (!ok) t.wrap.classList.add("open");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* approval dialog (editable arguments + durable "always
|
|
514
|
+
* allow this tool", on top of the existing running/ok/fail visual
|
|
515
|
+
* language). The diff view (edit/write) stays as a read-only visual aid
|
|
516
|
+
* ABOVE the editable textarea — losing the diff would make large
|
|
517
|
+
* file-content proposals unreadable as raw JSON — but the textarea,
|
|
518
|
+
* pre-filled with the proposed args as JSON, is what actually gets sent:
|
|
519
|
+
* editing it and clicking Allow re-parses it and ships the edited object
|
|
520
|
+
* as `editedArgs`, which packages/module-chat/src/pi-backend.ts mutates onto the SDK's ToolCallEvent.input
|
|
521
|
+
* in place (the SDK's documented tool_call mutation contract). */
|
|
522
|
+
function approvalCard(callId: string, tool: string, args: unknown): void {
|
|
523
|
+
ensureAssistant();
|
|
524
|
+
const a = curAssistant!;
|
|
525
|
+
const wrap = el("div", "approval", a.bubble);
|
|
526
|
+
const isBash = /bash|shell|exec|run/i.test(tool || "");
|
|
527
|
+
const isEdit = /edit|write|patch|create/i.test(tool || "");
|
|
528
|
+
const diffHtml = isEdit ? '<pre class="a-diff">' + diffView(args) + '</pre>' : "";
|
|
529
|
+
const argsJson = prettyArgs(args);
|
|
530
|
+
wrap.innerHTML =
|
|
531
|
+
'<div class="ahead"><div class="ai">⚠</div><div class="at">Approval required</div>' +
|
|
532
|
+
'<div class="as">' + esc(tool || "tool") + '</div></div>' +
|
|
533
|
+
'<div class="abody">' + diffHtml +
|
|
534
|
+
'<div class="blbl" style="margin-bottom:7px">' +
|
|
535
|
+
(isBash ? "command (edit before approving if you like)" : isEdit ? "proposed change — arguments below, editable" : "arguments — editable before approving") +
|
|
536
|
+
'</div>' +
|
|
537
|
+
'<textarea class="a-args" spellcheck="false">' + esc(argsJson) + '</textarea>' +
|
|
538
|
+
'<div class="a-argerr" style="display:none"></div>' +
|
|
539
|
+
'</div>' +
|
|
540
|
+
'<label class="chk a-always"><input type="checkbox" class="a-always-cb">Always allow ' + esc(tool || "this tool") + ' (skip this card next time)</label>' +
|
|
541
|
+
'<div class="actions">' +
|
|
542
|
+
'<button class="btn ghost sm a-deny">Deny</button>' +
|
|
543
|
+
'<button class="btn primary sm a-allow">Allow</button></div>';
|
|
544
|
+
const textarea = wrap.querySelector(".a-args") as HTMLTextAreaElement;
|
|
545
|
+
const errEl = wrap.querySelector(".a-argerr") as HTMLElement;
|
|
546
|
+
const alwaysCb = wrap.querySelector(".a-always-cb") as HTMLInputElement;
|
|
547
|
+
const resolve = (decision: "allow" | "deny") => {
|
|
548
|
+
let editedArgs: Record<string, unknown> | undefined;
|
|
549
|
+
if (decision === "allow") {
|
|
550
|
+
const raw = textarea.value.trim();
|
|
551
|
+
if (raw && raw !== argsJson.trim()) {
|
|
552
|
+
try {
|
|
553
|
+
const parsed = JSON.parse(raw);
|
|
554
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
555
|
+
editedArgs = parsed as Record<string, unknown>;
|
|
556
|
+
} else {
|
|
557
|
+
errEl.textContent = "Arguments must be a JSON object — sending the original instead.";
|
|
558
|
+
errEl.style.display = "";
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
errEl.textContent = "Could not parse the edited arguments as JSON — sending the original instead.";
|
|
562
|
+
errEl.style.display = "";
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
send({ type: "approval", callId, decision, editedArgs, alwaysAllow: decision === "allow" && alwaysCb.checked });
|
|
567
|
+
wrap.querySelector(".actions")!.remove();
|
|
568
|
+
wrap.querySelector(".a-always")?.remove();
|
|
569
|
+
const r = el("div", "resolved " + (decision === "allow" ? "allow" : "deny"), wrap);
|
|
570
|
+
r.textContent = decision === "allow" ? (editedArgs ? "✓ allowed (edited)" : "✓ allowed") : "✕ denied";
|
|
571
|
+
};
|
|
572
|
+
(wrap.querySelector(".a-allow") as HTMLButtonElement).onclick = () => resolve("allow");
|
|
573
|
+
(wrap.querySelector(".a-deny") as HTMLButtonElement).onclick = () => resolve("deny");
|
|
574
|
+
stick(true);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function showAgentError(message: string): void {
|
|
578
|
+
const m = el("div", "msg assistant", thread());
|
|
579
|
+
el("div", "who", m).textContent = "local agent";
|
|
580
|
+
const bubble = el("div", "bubble", m);
|
|
581
|
+
bubble.innerHTML = '<strong style="color:var(--red)">Agent error</strong><br>' + esc(message || "unknown error");
|
|
582
|
+
stick(true);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Chat-with-files RAG v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
586
|
+
* beat-matrix Axis 5): append a
|
|
587
|
+
* collapsed "Sources · K" panel under the bubble and linkify any [n]
|
|
588
|
+
* markers the model actually used in its reply. Only ever called with a
|
|
589
|
+
* non-empty citation list (finishStreaming guards this) — an ordinary
|
|
590
|
+
* reply with no attachments gets neither the panel nor any [n]
|
|
591
|
+
* rewriting, so a message that happens to contain literal "[1]" text
|
|
592
|
+
* never gets a false citation link. */
|
|
593
|
+
function attachSourcesPanel(a: AssistantMsgState): void {
|
|
594
|
+
const validNs = new Set(a.citations.map((c) => c.n));
|
|
595
|
+
a.textNode.innerHTML = linkifyCitations(a.textNode.innerHTML, validNs);
|
|
596
|
+
const panel = el("div", "", null);
|
|
597
|
+
panel.innerHTML = renderSourcesHtml(a.citations);
|
|
598
|
+
const detailsEl = panel.firstElementChild as HTMLElement | null;
|
|
599
|
+
if (!detailsEl) return;
|
|
600
|
+
a.bubble.appendChild(detailsEl);
|
|
601
|
+
// Clicking a [n] marker in the rendered text expands the panel (if
|
|
602
|
+
// collapsed) and scrolls/highlights the matching source row.
|
|
603
|
+
a.textNode.querySelectorAll<HTMLElement>(".cite-mark").forEach((btn) => {
|
|
604
|
+
btn.addEventListener("click", () => {
|
|
605
|
+
const n = btn.dataset.cite;
|
|
606
|
+
(detailsEl as HTMLDetailsElement).open = true;
|
|
607
|
+
const row = detailsEl.querySelector<HTMLElement>('.src-row[data-cite-row="' + n + '"]');
|
|
608
|
+
if (row) {
|
|
609
|
+
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
610
|
+
row.classList.add("pulse");
|
|
611
|
+
setTimeout(() => row.classList.remove("pulse"), 1000);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Turn-end finalization for the streamed text channel: replace the
|
|
618
|
+
* block-memoized incremental render with ONE full non-memoized
|
|
619
|
+
* mdToHtml(fullText) pass. This is the acceptance test for block
|
|
620
|
+
* memoization — whatever the incremental path did while streaming,
|
|
621
|
+
* the final DOM must match exactly what a plain, non-streaming
|
|
622
|
+
* render of the same text would have produced. Cheap (happens once
|
|
623
|
+
* per turn, not per token) and removes any residual "done" block
|
|
624
|
+
* wrappers so the settled message's markup matches replayed history
|
|
625
|
+
* (renderAssistantStatic / renderHistory), which also call mdToHtml
|
|
626
|
+
* directly on the full string. */
|
|
627
|
+
function finishStreaming(a: AssistantMsgState | null): void {
|
|
628
|
+
if (!a) return;
|
|
629
|
+
a.settled = true;
|
|
630
|
+
// Preserve any Canvas Preview toggle the user made mid-stream (Principle
|
|
631
|
+
// 9: this full non-memoized re-render must not throw away user
|
|
632
|
+
// interaction state) — mdCodeBlock always emits qualifying fences back
|
|
633
|
+
// in their default Source view, so without this a live Preview click
|
|
634
|
+
// silently reverts the instant the turn finalizes.
|
|
635
|
+
const previewIdx = captureCanvasViewStates(a.textNode);
|
|
636
|
+
a.textNode.innerHTML = mdToHtml(a.text);
|
|
637
|
+
restoreCanvasViewStates(a.textNode, previewIdx);
|
|
638
|
+
highlightIn(a.textNode);
|
|
639
|
+
a.thinkBody.textContent = a.thinking;
|
|
640
|
+
if (a.citations.length) attachSourcesPanel(a);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Demote the previously-"last" assistant message: only ONE message ever
|
|
644
|
+
* shows Regenerate (the last assistant turn). */
|
|
645
|
+
function clearRegenerateAffordance(): void {
|
|
646
|
+
if (!lastAssistantMsgEl) return;
|
|
647
|
+
const row = lastAssistantMsgEl.querySelector(".msg-actions");
|
|
648
|
+
const btn = row && [...row.querySelectorAll(".maction")].find((b) => b.textContent === "Regenerate");
|
|
649
|
+
if (btn) btn.remove();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function endTurn(): void {
|
|
653
|
+
turnActive = false;
|
|
654
|
+
if (curAssistant) {
|
|
655
|
+
finishStreaming(curAssistant);
|
|
656
|
+
curAssistant.cursor.remove();
|
|
657
|
+
if (!curAssistant.text && !curAssistant.thinking && curAssistant.tools.size === 0) {
|
|
658
|
+
curAssistant.m.remove();
|
|
659
|
+
} else {
|
|
660
|
+
// A settled assistant reply becomes the new regenerate target.
|
|
661
|
+
// Works after abort/stop too — a partial reply still gets the
|
|
662
|
+
// action row, and the server resolves regenerate from the session's
|
|
663
|
+
// last user message regardless of whether this reply finished.
|
|
664
|
+
clearRegenerateAffordance();
|
|
665
|
+
addMsgActions(curAssistant.m, curAssistant.text, { regenerate: true }, { onRegenerate: doRegenerate });
|
|
666
|
+
lastAssistantMsgEl = curAssistant.m;
|
|
667
|
+
lastAssistantCitations = curAssistant.citations;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
curAssistant = null;
|
|
671
|
+
($("chat-send") as HTMLButtonElement).disabled = false;
|
|
672
|
+
$("chat-stop").style.display = "none";
|
|
673
|
+
renderQueue({});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Perf strip: tok/s + TTFT update live during streaming;
|
|
677
|
+
// context-fill is driven separately by the `context` frame (renderContext).
|
|
678
|
+
// Computed from data the composer already has.
|
|
679
|
+
function updateTps(): void {
|
|
680
|
+
const a = curAssistant; if (!a) return;
|
|
681
|
+
const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
|
|
682
|
+
if (a.tokens > 1 && secs > 0) {
|
|
683
|
+
const tps = $("pf-tps"); if (tps) tps.innerHTML = "<b style='color:var(--green)'>" + (a.tokens / secs).toFixed(1) + " tok/s</b>";
|
|
684
|
+
}
|
|
685
|
+
if (a.tFirst) {
|
|
686
|
+
const ttft = $("pf-ttft"); if (ttft) ttft.textContent = "TTFT " + Math.round(a.tFirst - a.t0) + "ms";
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function finalizeMeta(): void {
|
|
690
|
+
const a = curAssistant; if (!a) return;
|
|
691
|
+
const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
|
|
692
|
+
if (a.tokens > 1 && secs > 0)
|
|
693
|
+
a.meta.innerHTML = "<b>" + (a.tokens / secs).toFixed(1) + " tok/s</b> · " + a.tokens +
|
|
694
|
+
" tokens · first token " + Math.round((a.tFirst - a.t0)) + "ms";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function newChat(): void {
|
|
698
|
+
if (!send({ type: "new_session" })) { toast("Not connected to the agent yet.", "err"); return; }
|
|
699
|
+
$("chat-box").focus();
|
|
700
|
+
port.closeDrawer(); // a fresh chat is the natural "I'm done browsing" signal on mobile
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Render a finished assistant turn from replayed history (no streaming).
|
|
704
|
+
* `opts.isLast` attaches the Regenerate action (last turn only). */
|
|
705
|
+
function renderAssistantStatic(item: HistoryItem, opts: { isLast?: boolean }): void {
|
|
706
|
+
const m = el("div", "msg assistant", thread());
|
|
707
|
+
el("div", "who", m).textContent = "local agent";
|
|
708
|
+
const bubble = el("div", "bubble", m);
|
|
709
|
+
if (item.thinking && item.thinking.trim()) {
|
|
710
|
+
const tb = el("details", "thinkbox", bubble); tb.open = false;
|
|
711
|
+
el("summary", "", tb).textContent = "Thinking";
|
|
712
|
+
el("div", "thinkbody", tb).textContent = item.thinking;
|
|
713
|
+
}
|
|
714
|
+
if (item.text) { const tn = el("span", "atext", bubble); tn.innerHTML = mdToHtml(item.text); highlightIn(tn); }
|
|
715
|
+
for (const t of (item.tools || [])) {
|
|
716
|
+
const custom = port.host?.toolCard?.(bubble, t.name, t.args);
|
|
717
|
+
if (custom) {
|
|
718
|
+
if (t.result) custom.setResult(t.result);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const wrap = el("div", "tool ok", bubble); // collapsed; click to expand
|
|
722
|
+
wrap.innerHTML =
|
|
723
|
+
'<div class="thead"><div class="ticon">⚙</div>' +
|
|
724
|
+
'<span class="tname">' + esc(t.name || "tool") + '</span>' +
|
|
725
|
+
'<span class="targs">' + esc(argSummary(t.args)) + '</span>' +
|
|
726
|
+
'<span class="tstat"><span class="sdot"></span><span class="slabel">done</span></span>' +
|
|
727
|
+
'<span class="caret">›</span></div>' +
|
|
728
|
+
'<div class="tbody">' +
|
|
729
|
+
'<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(t.args)) + '</pre></div>' +
|
|
730
|
+
(t.result ? '<div class="blk tresult"><div class="blbl">result</div><pre class="ares">' + esc(String(t.result).slice(-12000)) + '</pre></div>' : '') +
|
|
731
|
+
'</div>';
|
|
732
|
+
wrap.querySelector(".thead")!.addEventListener("click", () => wrap.classList.toggle("open"));
|
|
733
|
+
}
|
|
734
|
+
if (item.text) {
|
|
735
|
+
addMsgActions(m, item.text, { regenerate: !!opts.isLast }, { onRegenerate: doRegenerate });
|
|
736
|
+
if (opts.isLast) {
|
|
737
|
+
lastAssistantMsgEl = m;
|
|
738
|
+
// History replay doesn't carry citations (HistoryItem has no
|
|
739
|
+
// citations field — the server doesn't persist them across
|
|
740
|
+
// reload), so there's nothing to restore here; this keeps
|
|
741
|
+
// lastAssistantCitations honest (no stale carry-over from a
|
|
742
|
+
// previous session) rather than claiming a citation set for a
|
|
743
|
+
// reply that regenerate can't actually attribute one to.
|
|
744
|
+
lastAssistantCitations = [];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Authoritative thread rebuild from a server `history` frame. */
|
|
750
|
+
function renderHistory(items: HistoryItem[]): void {
|
|
751
|
+
turnActive = false;
|
|
752
|
+
curAssistant = null;
|
|
753
|
+
($("chat-send") as HTMLButtonElement).disabled = false;
|
|
754
|
+
$("chat-stop").style.display = "none";
|
|
755
|
+
renderQueue({});
|
|
756
|
+
thread().innerHTML = "";
|
|
757
|
+
lastUserMsgEl = null; lastAssistantMsgEl = null;
|
|
758
|
+
if (!items || !items.length) { $("chat-hello").style.display = ""; return; }
|
|
759
|
+
$("chat-hello").style.display = "none";
|
|
760
|
+
const lastUserIdx = items.map((it) => it.role).lastIndexOf("user");
|
|
761
|
+
const lastAssistantIdx = items.map((it) => it.role).lastIndexOf("assistant");
|
|
762
|
+
items.forEach((it, i) => {
|
|
763
|
+
if (it.role === "user") addUserMsg(it.text, null, { isLast: i === lastUserIdx, entryId: it.entryId });
|
|
764
|
+
else renderAssistantStatic(it, { isLast: i === lastAssistantIdx });
|
|
765
|
+
});
|
|
766
|
+
stick(true);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/* — server -> client dispatch — */
|
|
770
|
+
function handle(m: ServerMessage): void {
|
|
771
|
+
switch (m.type) {
|
|
772
|
+
case "ready":
|
|
773
|
+
$("nav-model").textContent = m.model || ($("nav-model").textContent as string) || "no model";
|
|
774
|
+
composer.visionCapable = !!m.vision; composer.transcriptionCapable = !!m.transcription; updateAttachHint(composer);
|
|
775
|
+
composer.thinkingCapable = !!m.thinking; updateThinkingToggle(composer);
|
|
776
|
+
// Per-model recommended sampling (generation_config.json, server-CLI
|
|
777
|
+
// overrides applied) — replaces the old hardcoded SAMP_REC shape.
|
|
778
|
+
composer.genDefaults = m.genDefaults || ({ temperature: null, topP: null, topK: null } as ReadyGenDefaults);
|
|
779
|
+
refreshSamplingRecs(composer);
|
|
780
|
+
if (composer.thinkingCapable) send({ type: "set_thinking", enabled: composer.thinkingOn });
|
|
781
|
+
composerRefreshAdapters();
|
|
782
|
+
// Restore this browser's codingTools preference.
|
|
783
|
+
// Sent every `ready` (including reconnects): a fresh WS session
|
|
784
|
+
// always starts codingToolsRequested=false server-side, so a
|
|
785
|
+
// previously-on browser must re-assert it, and the coding_tools
|
|
786
|
+
// frame that follows will report whether it's active yet or only
|
|
787
|
+
// pending for the next new/opened chat.
|
|
788
|
+
send({ type: "set_coding_tools", enabled: port.host?.settings?.codingToolsPreference() ?? false });
|
|
789
|
+
// App-aware assistant: a fresh WS session starts with no
|
|
790
|
+
// stored context server-side (currentAppContext is null until the
|
|
791
|
+
// first push) — every `ready` (initial connect AND reconnect) must
|
|
792
|
+
// re-push so get_current_app_context / the ambient line are never
|
|
793
|
+
// blind while a browser tab sits idle on a non-chat route.
|
|
794
|
+
pushAppContext();
|
|
795
|
+
// Reconnect: re-open the session we were in (fresh load has none).
|
|
796
|
+
if (pendingResumePath) { send({ type: "open_session", path: pendingResumePath }); pendingResumePath = null; }
|
|
797
|
+
break;
|
|
798
|
+
case "coding_tools": port.host?.settings?.codingTools(m.active, m.pending); break;
|
|
799
|
+
case "tool_approvals":
|
|
800
|
+
port.host?.settings?.approvals(m.alwaysAllow, (tool) => forgetToolApproval(tool));
|
|
801
|
+
break;
|
|
802
|
+
case "history": renderHistory(m.items); break;
|
|
803
|
+
case "sessions":
|
|
804
|
+
currentSessionPath = m.activePath || null;
|
|
805
|
+
renderSessions(sidebar, m.items, m.activePath, {
|
|
806
|
+
onOpen: (path) => { send({ type: "open_session", path }); port.closeDrawer(); },
|
|
807
|
+
onFork: (path) => send({ type: "fork_session", path }),
|
|
808
|
+
onDelete: (path) => send({ type: "delete_session", path }),
|
|
809
|
+
});
|
|
810
|
+
break;
|
|
811
|
+
case "context": renderContext(m as ContextFrame); break;
|
|
812
|
+
// Sibling group for the last user message (edit-and-resend's `< i/n >`
|
|
813
|
+
// toggle) — see renderSiblingToggle/switchSiblingDir.
|
|
814
|
+
case "siblings":
|
|
815
|
+
siblingInfo = { entryId: m.entryId || null, index: m.index, count: m.count, siblingIds: m.siblingIds || [] };
|
|
816
|
+
lastUserEntryId = siblingInfo.entryId;
|
|
817
|
+
renderSiblingToggle(lastUserMsgEl, lastUserEntryId, siblingInfo, switchSiblingDir);
|
|
818
|
+
break;
|
|
819
|
+
case "turn_start": turnActive = true; startAssistant(); ($("chat-send") as HTMLButtonElement).disabled = true; $("chat-stop").style.display = "block"; break;
|
|
820
|
+
case "text_delta": appendDelta(m.delta || ""); break;
|
|
821
|
+
case "thinking_delta": appendThinkingDelta(m.delta || ""); break;
|
|
822
|
+
case "tool_start": toolCard(m.callId, m.tool, m.args); break;
|
|
823
|
+
case "tool_approval_request": approvalCard(m.callId, m.tool, m.args); break;
|
|
824
|
+
case "tool_update": toolUpdate(m.callId, (m.chunk as string) || ""); break;
|
|
825
|
+
case "tool_end": toolEnd(m.callId, m.ok, m.result); break;
|
|
826
|
+
// clearOneShotArmed: a one-shot next_turn sampling override (if any
|
|
827
|
+
// was armed) has now been consumed by the server for exactly this
|
|
828
|
+
// turn — clear the local indicator in lockstep.
|
|
829
|
+
case "turn_end": finalizeMeta(); clearOneShotArmed(composer); endTurn(); break;
|
|
830
|
+
case "queue_update": renderQueue(m); break;
|
|
831
|
+
// A prompt/regenerate/edit_resend that fails before the server ever
|
|
832
|
+
// reaches turn_start (no model selected, auth failure, etc.) never
|
|
833
|
+
// sends turn_end, so this is also where a one-shot next_turn sampling
|
|
834
|
+
// override must be cleared client-side — the server clears its own
|
|
835
|
+
// copy on this same failure path (clearArmedOneShotOnFailedPrompt in
|
|
836
|
+
// packages/module-chat/src/pi-backend.ts), so the two stay in lockstep instead of the UI's "armed"
|
|
837
|
+
// pill lingering after the override it referred to is already gone.
|
|
838
|
+
case "error": showAgentError(m.message || "agent error"); toast(m.message || "agent error", "err"); clearOneShotArmed(composer); if (turnActive) endTurn(); break;
|
|
839
|
+
// App-aware assistant: navigate_app / spotlight_ui tool
|
|
840
|
+
// calls arrive as these two frames. Navigation always routes through
|
|
841
|
+
// location.hash (the SAME mechanism a nav-tab click uses — shell.ts's
|
|
842
|
+
// hashchange listener does the actual section toggle), so it's
|
|
843
|
+
// indistinguishable from the user clicking the tab themselves.
|
|
844
|
+
case "ui_navigate":
|
|
845
|
+
location.hash = `#/${m.route}`;
|
|
846
|
+
break;
|
|
847
|
+
// The shell mounts non-workspace panels on hashchange. Resolve after
|
|
848
|
+
// that navigation has attached the requested page's controls.
|
|
849
|
+
case "ui_spotlight": {
|
|
850
|
+
const point = () => {
|
|
851
|
+
const resolved = resolveSpotlightTarget(
|
|
852
|
+
{ ref: m.ref, label: m.label, selector: m.selector, target: m.target, message: m.message },
|
|
853
|
+
lastSnapshot,
|
|
854
|
+
);
|
|
855
|
+
if (!resolved || !showSpotlight(resolved)) toast("Couldn't find that on screen to point at.", "err");
|
|
856
|
+
};
|
|
857
|
+
if (m.route) {
|
|
858
|
+
const hash = `#/${m.route}`;
|
|
859
|
+
const afterNavigation = () => requestAnimationFrame(point);
|
|
860
|
+
// A preceding ui_navigate may already have set this hash while
|
|
861
|
+
// its navigation event is still queued. The next task follows it;
|
|
862
|
+
// a changed hash instead waits for that navigation event directly.
|
|
863
|
+
if (location.hash === hash) setTimeout(afterNavigation, 0);
|
|
864
|
+
else {
|
|
865
|
+
window.addEventListener("hashchange", afterNavigation, {once:true});
|
|
866
|
+
location.hash = hash;
|
|
867
|
+
}
|
|
868
|
+
} else point();
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* — composer submit — */
|
|
875
|
+
function submit(): void {
|
|
876
|
+
const box = $("chat-box") as HTMLTextAreaElement;
|
|
877
|
+
const text = box.value.trim();
|
|
878
|
+
// Client-side slash commands for session control (not sent to the model).
|
|
879
|
+
const cmd = text.toLowerCase();
|
|
880
|
+
if (cmd === "/reload" || cmd === "/new" || cmd === "/clear") {
|
|
881
|
+
box.value = ""; box.style.height = "auto"; clearAttachments(composer); newChat();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!text && composer.attachments.length === 0) return;
|
|
885
|
+
if (!connected) { toast("Not connected to the agent yet — reconnecting.", "err"); return; }
|
|
886
|
+
const imgs = composer.attachments.filter((a) => a.kind === "image").map((a) => ({ data: a.data as string, mimeType: a.mimeType as string }));
|
|
887
|
+
const combined = buildMessageText(composer, text);
|
|
888
|
+
// entryId isn't known yet (assigned server-side); the next `siblings`
|
|
889
|
+
// frame (sent on turn_end) backfills it via renderSiblingToggle's
|
|
890
|
+
// entryId-mismatch check, same as a fresh page load's history replay.
|
|
891
|
+
addUserMsg(text, composer.attachments as unknown as { kind: string; data?: string; mimeType?: string; name: string }[], { isLast: true });
|
|
892
|
+
box.value = ""; box.style.height = "auto";
|
|
893
|
+
// buildMessageText returns { text, citations } (RAG v1,
|
|
894
|
+
// `02d723a:docs/design/web-chat-redesign.md` Appendix A, beat-matrix Axis 5):
|
|
895
|
+
// .text is the WS-outgoing prompt string (with the numbered
|
|
896
|
+
// context block injected when retrieval mode fired); .citations stash
|
|
897
|
+
// into pendingCitations so startAssistant() can attach them to the
|
|
898
|
+
// AssistantMsgState this prompt's reply streams into once turn_start
|
|
899
|
+
// arrives (empty array — the common case — is a no-op all the way
|
|
900
|
+
// through: no Sources panel, no [n] linkification).
|
|
901
|
+
pendingCitations = combined.citations;
|
|
902
|
+
const frame: ClientMessage = imgs.length ? { type: "prompt", text: combined.text, images: imgs } : { type: "prompt", text: combined.text };
|
|
903
|
+
// A composer submission is always a new user turn. If the model is still
|
|
904
|
+
// streaming, the server passes it to pi with streamingBehavior:"followUp"
|
|
905
|
+
// so it becomes the next turn instead of steering/mutating the current one.
|
|
906
|
+
send(frame);
|
|
907
|
+
clearAttachments(composer);
|
|
908
|
+
}
|
|
909
|
+
const scroll = () => $("chat-scroll");
|
|
910
|
+
const atBottom = () => scroll().scrollHeight - scroll().scrollTop - scroll().clientHeight < 90;
|
|
911
|
+
function stick(force?: boolean): void { if (force || atBottom()) scroll().scrollTop = scroll().scrollHeight; }
|
|
912
|
+
|
|
913
|
+
/** Cmd/Ctrl+Shift+C: reuses the exact per-message Copy button
|
|
914
|
+
* (addMsgActions) on the last assistant message rather than duplicating
|
|
915
|
+
* its clipboard logic — a synthetic click keeps the "Copied" flash
|
|
916
|
+
* feedback consistent with the mouse path. */
|
|
917
|
+
function copyLastResponse(): void {
|
|
918
|
+
if (!lastAssistantMsgEl) { toast("No response to copy yet.", "err"); return; }
|
|
919
|
+
const btn = lastAssistantMsgEl.querySelector(".msg-actions .maction") as HTMLButtonElement | null;
|
|
920
|
+
if (btn) btn.click();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Cross-controller call from shell.ts's settings toggle
|
|
924
|
+
* — shell.ts owns the checkbox + localStorage mirror but no WebSocket, so
|
|
925
|
+
* it calls this via controllers.chat.setCodingTools, same pattern as
|
|
926
|
+
* newChat/copyLastResponse below. */
|
|
927
|
+
function setCodingTools(enabled: boolean): void {
|
|
928
|
+
send({ type: "set_coding_tools", enabled });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** "Forget" a durable always-allow entry from the settings panel. REST,
|
|
932
|
+
* not a WS frame — packages/module-chat/src/tool-approvals.ts is a plain server-side file, no
|
|
933
|
+
* live session state to touch, matching the hf-token settings routes'
|
|
934
|
+
* own GET/POST-over-fetch pattern. Re-renders the list from the response
|
|
935
|
+
* rather than waiting on a tool_approvals WS frame (this connection's
|
|
936
|
+
* session may not be the one that granted it, and REST is instant). */
|
|
937
|
+
async function forgetToolApproval(tool: string): Promise<void> {
|
|
938
|
+
const d = await api("/api/settings/tool-approvals", { method: "DELETE", body: { tool } })
|
|
939
|
+
.catch((): ApiEnvelope => ({ ok: false, error: "request failed" }));
|
|
940
|
+
if (!d.ok) { toast(d.error || "could not forget that tool", "err"); return; }
|
|
941
|
+
port.host?.settings?.approvals((d as { alwaysAllow?: string[] }).alwaysAllow || [], (t) => forgetToolApproval(t));
|
|
942
|
+
toast("Forgot “" + tool + "” — it will ask again next time", "ok");
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
init() {
|
|
947
|
+
($("chat-send") as HTMLButtonElement).onclick = submit;
|
|
948
|
+
($("chat-new") as HTMLButtonElement).onclick = newChat;
|
|
949
|
+
($("chat-stop") as HTMLButtonElement).onclick = () => { send({ type: "abort" }); $("chat-stop").style.display = "none"; };
|
|
950
|
+
const thinkToggle = () => { composer.thinkingOn = !composer.thinkingOn; updateThinkingToggle(composer); send({ type: "set_thinking", enabled: composer.thinkingOn }); };
|
|
951
|
+
($("chat-think") as HTMLButtonElement).onclick = thinkToggle;
|
|
952
|
+
$("chat-think").addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { e.preventDefault(); thinkToggle(); } });
|
|
953
|
+
const adapterSel = $("chat-adapter") as HTMLSelectElement | null;
|
|
954
|
+
if (adapterSel) adapterSel.onchange = () => {
|
|
955
|
+
// Keep the routing table's notion of "selected for this chat" in
|
|
956
|
+
// sync with the quick <select> — one source of truth for what's
|
|
957
|
+
// selected, read by both surfaces.
|
|
958
|
+
adaptersPanel.selectedSpec = adapterSel.value || null;
|
|
959
|
+
adaptersPanel.stackPicks.clear();
|
|
960
|
+
onSelectAdapter(adapterSel, send as (obj: unknown) => boolean);
|
|
961
|
+
};
|
|
962
|
+
initAdaptersPanel(adaptersPanel, send as (obj: unknown) => boolean);
|
|
963
|
+
initSampling(composer, send as (obj: unknown) => boolean);
|
|
964
|
+
initSystemPrompt(composer, send as (obj: unknown) => boolean);
|
|
965
|
+
composerRefreshAdapters();
|
|
966
|
+
const sessSearch = $("chat-sess-search");
|
|
967
|
+
if (sessSearch) sessSearch.addEventListener("input", () => applySessSearch(sidebar));
|
|
968
|
+
const box = $("chat-box") as HTMLTextAreaElement;
|
|
969
|
+
// Mention-picker keydown (arrows/Enter/Escape) must see Enter BEFORE
|
|
970
|
+
// the submit-on-Enter handler below — addEventListener preserves
|
|
971
|
+
// registration order, and initMentionPicker's handler only
|
|
972
|
+
// preventDefault()s when its popover is actually open, so a plain
|
|
973
|
+
// Enter with no picker open falls through to submit() untouched.
|
|
974
|
+
initMentionPicker(composer, box, {
|
|
975
|
+
onFileSelected: (att) => {
|
|
976
|
+
const chip = document.querySelector<HTMLElement>('.attach-chip[data-att-id="' + att.id + '"]');
|
|
977
|
+
if (chip) { chip.scrollIntoView({ block: "nearest", behavior: "smooth" }); chip.classList.add("pulse"); setTimeout(() => chip.classList.remove("pulse"), 1000); }
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
box.addEventListener("keydown", (e) => {
|
|
981
|
+
if ($("chat-mention-pop").classList.contains("open")) return; // picker owns Enter/arrows/Escape while open
|
|
982
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
|
983
|
+
});
|
|
984
|
+
box.addEventListener("input", () => { box.style.height = "auto"; box.style.height = Math.min(box.scrollHeight, 200) + "px"; });
|
|
985
|
+
// Delegated (not per-chip) so the personalized chips memory-panel.ts
|
|
986
|
+
// inserts on top of these four static ones work with zero
|
|
987
|
+
// extra wiring — #chat-hello-chips is stable even though its contents
|
|
988
|
+
// change (static chips today, +2 personalized ones once the first
|
|
989
|
+
// /api/memory/status + /v1/adapters/available round trip resolves).
|
|
990
|
+
$("chat-hello-chips").addEventListener("click", (e) => {
|
|
991
|
+
const chip = (e.target as HTMLElement).closest<HTMLElement>(".chip");
|
|
992
|
+
if (!chip) return;
|
|
993
|
+
($("chat-box") as HTMLTextAreaElement).value = chip.dataset.q || "";
|
|
994
|
+
submit();
|
|
995
|
+
});
|
|
996
|
+
port.host?.mounted?.(port.element);
|
|
997
|
+
// Attachments: + button, file picker, clipboard paste, drag-and-drop.
|
|
998
|
+
($("chat-attach-btn") as HTMLButtonElement).onclick = () => ($("chat-file-input") as HTMLInputElement).click();
|
|
999
|
+
// Hold-to-talk mic → /v1/audio/sessions → text into the composer (voice.ts).
|
|
1000
|
+
initVoiceInput($("chat-mic-btn") as HTMLButtonElement, box as HTMLTextAreaElement);
|
|
1001
|
+
$("chat-file-input").addEventListener("change", (e) => { addFiles(composer, [...(e.target as HTMLInputElement).files || []]); (e.target as HTMLInputElement).value = ""; });
|
|
1002
|
+
box.addEventListener("paste", (e) => {
|
|
1003
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
1004
|
+
if (!items) return;
|
|
1005
|
+
const files: File[] = [];
|
|
1006
|
+
for (const it of items) if (it.kind === "file") { const f = it.getAsFile(); if (f) files.push(f); }
|
|
1007
|
+
if (files.length) { e.preventDefault(); addFiles(composer, files); }
|
|
1008
|
+
});
|
|
1009
|
+
const composerEl = document.querySelector<HTMLElement>("#s-chat .composer");
|
|
1010
|
+
if (composerEl) {
|
|
1011
|
+
["dragenter", "dragover"].forEach((ev) => composerEl.addEventListener(ev, (e) => { e.preventDefault(); composerEl.classList.add("dragover"); }));
|
|
1012
|
+
["dragleave", "drop"].forEach((ev) => composerEl.addEventListener(ev, (e) => {
|
|
1013
|
+
e.preventDefault();
|
|
1014
|
+
const de = e as DragEvent;
|
|
1015
|
+
if (ev === "drop" && de.dataTransfer && de.dataTransfer.files.length) addFiles(composer, [...de.dataTransfer.files]);
|
|
1016
|
+
if (ev === "drop" || !composerEl.contains(de.relatedTarget as Node)) composerEl.classList.remove("dragover");
|
|
1017
|
+
}));
|
|
1018
|
+
}
|
|
1019
|
+
// Delegated copy buttons for rendered code blocks (the thread element
|
|
1020
|
+
// is stable even though its contents re-render on every token).
|
|
1021
|
+
thread().addEventListener("click", (e) => {
|
|
1022
|
+
const target = e.target as HTMLElement;
|
|
1023
|
+
const btn = target.closest && target.closest(".cbcopy");
|
|
1024
|
+
if (!btn) return;
|
|
1025
|
+
const code = btn.closest(".codeblock")?.querySelector("code");
|
|
1026
|
+
if (!code || !navigator.clipboard) return;
|
|
1027
|
+
navigator.clipboard.writeText(code.textContent || "").then(() => {
|
|
1028
|
+
btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1200);
|
|
1029
|
+
}).catch(() => {});
|
|
1030
|
+
});
|
|
1031
|
+
// Canvas v1: Preview|Source toggle for html/svg
|
|
1032
|
+
// fences, delegated the same way as the copy button above.
|
|
1033
|
+
wireCanvasToggle(thread());
|
|
1034
|
+
|
|
1035
|
+
// App-aware assistant: registered at document/window level
|
|
1036
|
+
// (not scoped to the chat route being active) since chat.ts owns the
|
|
1037
|
+
// one WebSocket for the whole app and must push context on every
|
|
1038
|
+
// route, regardless of which route the user navigates TO or FROM.
|
|
1039
|
+
// hashchange covers route changes; the MutationObserver covers wizard
|
|
1040
|
+
// step changes (quantize/finetune/dataset); a MutationObserver on each
|
|
1041
|
+
// overlay's `class` attribute covers view changes (memory/hub/adapters
|
|
1042
|
+
// panel open/close) without touching those three modules.
|
|
1043
|
+
window.addEventListener("hashchange", () => { watchWizardSteps(); pushAppContext(); });
|
|
1044
|
+
watchWizardSteps();
|
|
1045
|
+
const overlayMo = new MutationObserver(() => pushAppContext());
|
|
1046
|
+
for (const id of ["mem-overlay", "hub-overlay", "adapters-overlay"]) {
|
|
1047
|
+
const el = $(id);
|
|
1048
|
+
if (el) overlayMo.observe(el, { attributes: true, attributeFilter: ["class"] });
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
enter() {
|
|
1052
|
+
if (!ws || ws.readyState > 1) connect();
|
|
1053
|
+
$("chat-box").focus();
|
|
1054
|
+
// Refresh the adapter list on every chat enter (`02d723a:docs/archive/planning/web-ui-pass-plan.md`
|
|
1055
|
+
// #15's staleness half): a training job that completed while the user
|
|
1056
|
+
// was elsewhere, or a manual drop into the adapters directory, should
|
|
1057
|
+
// show up without needing a full page reload. Cheap (one GET) and the
|
|
1058
|
+
// list is short.
|
|
1059
|
+
composerRefreshAdapters();
|
|
1060
|
+
},
|
|
1061
|
+
leave() { /* keep WS warm; closing on every nav would drop in-flight turns */ },
|
|
1062
|
+
// Exposed so the finetune controller can refresh the chat adapter chip
|
|
1063
|
+
// immediately when a training job finishes, instead of waiting for the
|
|
1064
|
+
// user to navigate back to Chat (which would also refresh it via enter()).
|
|
1065
|
+
refreshAdapters: composerRefreshAdapters,
|
|
1066
|
+
// Exposed for the global keyboard-shortcut sweep: Cmd/Ctrl+Shift+O
|
|
1067
|
+
// and Cmd/Ctrl+Shift+C, bound at document level so they work regardless
|
|
1068
|
+
// of which control has focus while on the chat route.
|
|
1069
|
+
newChat,
|
|
1070
|
+
copyLastResponse,
|
|
1071
|
+
// Exposed for shell.ts's settings modal — it owns the
|
|
1072
|
+
// checkbox/list DOM and localStorage, but has no WebSocket of its own.
|
|
1073
|
+
setCodingTools,
|
|
1074
|
+
forgetToolApproval,
|
|
1075
|
+
};
|
|
1076
|
+
}
|