@liguoshuai/pi-web-chat 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,860 @@
1
+ // app.js — front-end logic for pi-web-chat
2
+ // Connects to the WebSocket, renders streaming responses,
3
+ // manages the session sidebar, and the composer.
4
+
5
+ // Global error catcher to report front-end errors back to node console for debug
6
+ window.onerror = function (message, source, lineno, colno, error) {
7
+ fetch("/api/log-error", {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({
11
+ message,
12
+ source,
13
+ lineno,
14
+ colno,
15
+ error: error ? { message: error.message, stack: error.stack } : null,
16
+ userAgent: navigator.userAgent
17
+ })
18
+ }).catch(() => {});
19
+ return false; // let it still output to browser console too
20
+ };
21
+
22
+ const API = ""; // same origin
23
+ const state = {
24
+ ws: null,
25
+ wsConnected: false,
26
+ cwd: null,
27
+ currentSessionFile: null,
28
+ // entriesByCallId: for live assistant messages we accumulate tool calls + text
29
+ streamingMsg: null, // DOM node for the in-progress assistant message
30
+ streamingText: "", // accumulated text deltas
31
+ streamingThinking: "",
32
+ activeToolCalls: new Map(), // toolCallId -> { node, body, state }
33
+ thinkingOpen: false,
34
+ queuedAssistantTextId: null,
35
+ streaming: false,
36
+ models: [],
37
+ currentModel: null,
38
+ thinkingLevel: "medium",
39
+ sessionId: null,
40
+ };
41
+
42
+ // ---- Markdown render (small, safe renderer) ----
43
+ function escapeHtml(s) {
44
+ return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
45
+ }
46
+
47
+ function renderMarkdown(md) {
48
+ // Strip headings of # etc. and convert to proper elements with escaping.
49
+ // We do a fenced-code-first approach so we don't process markdown inside code.
50
+ const parts = [];
51
+ let rest = md;
52
+ while (rest.length) {
53
+ const fenceIdx = rest.search(/```/);
54
+ if (fenceIdx === -1) {
55
+ parts.push({ kind: "md", text: rest });
56
+ rest = "";
57
+ } else {
58
+ if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
59
+ rest = rest.slice(fenceIdx + 3);
60
+ // optional language on this line
61
+ const nl = rest.indexOf("\n");
62
+ let lang = "";
63
+ if (nl !== -1) {
64
+ const firstLine = rest.slice(0, nl).trim();
65
+ if (firstLine && !firstLine.includes("```")) lang = firstLine;
66
+ rest = rest.slice(nl + 1);
67
+ }
68
+ const closeIdx = rest.indexOf("```");
69
+ let code;
70
+ if (closeIdx === -1) { code = rest; rest = ""; }
71
+ else { code = rest.slice(0, closeIdx); rest = rest.slice(closeIdx + 3).replace(/^\n/, ""); }
72
+ parts.push({ kind: "code", lang, code });
73
+ }
74
+ }
75
+ let html = "";
76
+ for (const p of parts) {
77
+ if (p.kind === "code") {
78
+ html += `<pre><code data-lang="${escapeHtml(p.lang)}">${escapeHtml(p.code)}</code></pre>`;
79
+ } else {
80
+ html += renderInlineMd(p.text);
81
+ }
82
+ }
83
+ return html;
84
+ }
85
+
86
+ function renderInlineMd(text) {
87
+ // tables, then markdown-ish transforms. Escape first.
88
+ // Split out inline code first using placeholders to protect them.
89
+ const codeChunks = [];
90
+ let t = text.replace(/`([^`\n]+)`/g, (m) => {
91
+ const i = codeChunks.length;
92
+ codeChunks.push(m);
93
+ return `\u0000CODE${i}\u0000`;
94
+ });
95
+
96
+ // Tables: a block of consecutive lines delimited by blank lines,
97
+ // where the second line is like |---|---|.
98
+ const lines = t.split("\n");
99
+ const out = [];
100
+ let i = 0;
101
+ while (i < lines.length) {
102
+ if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i+1].includes("-")) {
103
+ // collect table block
104
+ const header = lines[i];
105
+ let rows = [];
106
+ let j = i;
107
+ out.push({ kind: "blockskip", range: [i, j] });
108
+ const tblLines = [header, lines[i + 1]];
109
+ j = i + 2;
110
+ while (j < lines.length && lines[j].includes("|")) { tblLines.push(lines[j]); j++; }
111
+ out.push({ kind: "table", lines: tblLines });
112
+ i = j;
113
+ continue;
114
+ }
115
+ out.push({ kind: "line", text: lines[i] });
116
+ i++;
117
+ }
118
+ let outHtml = "";
119
+ let para = [];
120
+ function flushPara() {
121
+ if (para.length === 0) return;
122
+ const block = para.join("\n").trim();
123
+ para = [];
124
+ outHtml += "<p>" + mdInlineBlock(block) + "</p>";
125
+ }
126
+ for (const seg of out) {
127
+ if (seg.kind === "table") {
128
+ flushPara();
129
+ outHtml += mdTable(seg.lines);
130
+ } else if (seg.kind === "line") {
131
+ // headings
132
+ const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
133
+ if (m) {
134
+ flushPara();
135
+ const level = m[1].length;
136
+ outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
137
+ } else if (/^\s*$/.test(seg.text)) {
138
+ flushPara();
139
+ } else if (/^>\s?/.test(seg.text)) {
140
+ // blockquote line — group simple consecutive ones
141
+ flushPara();
142
+ outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
143
+ } else if (/^\s*[-*]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
144
+ // list item — group consecutive into ul/ol
145
+ // simple inline handling: wrap each list item line.
146
+ const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
147
+ if (!out.linkListOpen || out.linkListOrdered !== isOrdered) {
148
+ flushPara();
149
+ if (out.linkListOpen) outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>";
150
+ out.linkListOpen = isOrdered ? "ol" : "ul";
151
+ out.linkListOrdered = isOrdered;
152
+ outHtml += "<" + out.linkListOpen + ">";
153
+ }
154
+ outHtml += `<li>${mdInlineBlock(seg.text.replace(/^\s*([-*]|\d+\.)\s+/, ""))}</li>`;
155
+ } else {
156
+ if (out.linkListOpen) { outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>"; out.linkListOpen = null; }
157
+ para.push(seg.text);
158
+ }
159
+ }
160
+ }
161
+ if (out.linkListOpen) { outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>"; out.linkListOpen = null; }
162
+ flushPara();
163
+ // restore inline code
164
+ outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => `<code>${escapeHtml(codeChunks[+n].slice(1, -1))}</code>`);
165
+ return outHtml;
166
+ }
167
+
168
+ // helper state bag attached to the function during line scan
169
+ function mdInlineBlock(text) {
170
+ function esc(s) { return escapeHtml(s); }
171
+ let s = escapeHtml(text);
172
+ // bold
173
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
174
+ s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
175
+ // italic
176
+ s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
177
+ s = s.replace(/(^|[^_])_([^_]+)_/g, "$1<em>$2</em>");
178
+ // links [txt](url)
179
+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
180
+ return s;
181
+ }
182
+
183
+ function mdTable(lines) {
184
+ const parseRow = (l) => l.split("|").map(c => c.trim()).filter((_, i, arr) => !(i === 0 && arr[0] === "") && !(i === arr.length - 1 && arr[arr.length - 1] === ""));
185
+ const header = parseRow(lines[0]);
186
+ const body = lines.slice(2).filter(l => l.trim()).map(parseRow);
187
+ let h = '<table><thead><tr>';
188
+ header.forEach((c) => h += `<th>${mdInlineBlock(c)}</th>`);
189
+ h += '</tr></thead><tbody>';
190
+ body.forEach((r) => {
191
+ h += '<tr>';
192
+ r.forEach((c) => h += `<td>${mdInlineBlock(c)}</td>`);
193
+ h += '</tr>';
194
+ });
195
+ h += '</tbody></table>';
196
+ return h;
197
+ }
198
+
199
+ // ---- DOM helpers ----
200
+ const $ = (sel, root = document) => root.querySelector(sel);
201
+ const el = (tag, props = {}, children = []) => {
202
+ const n = document.createElement(tag);
203
+ for (const [k, v] of Object.entries(props)) {
204
+ if (k === "class") n.className = v;
205
+ else if (k === "html") n.innerHTML = v;
206
+ else if (k === "text") n.textContent = v;
207
+ else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2).toLowerCase(), v);
208
+ else if (k === "dataset") Object.assign(n.dataset, v);
209
+ else n.setAttribute(k, v);
210
+ }
211
+ for (const c of [].concat(children)) {
212
+ if (c == null) continue;
213
+ if (typeof c === "string") n.appendChild(document.createTextNode(c));
214
+ else n.appendChild(c);
215
+ }
216
+ return n;
217
+ };
218
+
219
+ // ---- Sidebar / sessions ----
220
+ async function refreshSessions() {
221
+ const cwd = state.cwd || "";
222
+ const res = await fetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
223
+ const data = await res.json();
224
+ renderSidebar(data.sessions || []);
225
+ }
226
+
227
+ function renderSidebar(sessions) {
228
+ const list = $("#sessionList");
229
+ list.innerHTML = "";
230
+ if (sessions.length === 0) {
231
+ list.appendChild(el("div", { class: "sidebar-empty", text: "没有会话记录" }));
232
+ return;
233
+ }
234
+ sessions.forEach((s) => {
235
+ const title = s.firstUser || "新对话";
236
+ const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
237
+ const item = el("div", {
238
+ class: "session-item" + (s.file === state.currentSessionFile ? " active" : ""),
239
+ dataset: { file: s.file },
240
+ title: s.file, // hover tooltip = raw jsonl path
241
+ onclick: () => loadSession(s.file),
242
+ }, [
243
+ el("div", { class: "title" }, [
244
+ el("div", { text: title }),
245
+ el("div", { class: "meta", text: `${when} · ${s.messageCount || 0} 条` }),
246
+ ]),
247
+ ]);
248
+ list.appendChild(item);
249
+ });
250
+ }
251
+
252
+ async function loadSession(file) {
253
+ state.currentSessionFile = file;
254
+ // pull transcript from REST then connect a fresh WS pointed at this session
255
+ const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
256
+ const data = await res.json();
257
+ clearChat();
258
+ document.querySelector("#emptyState").style.display = "none";
259
+ const chat = $("#chat-inner");
260
+ // Walk through path entries to render messages in order.
261
+ // We reconstruct assistant/user/toolResult blocks.
262
+ const msgs = reconstructFromEntries(data.entries || []);
263
+ for (const m of msgs) {
264
+ appendMessageNode(m.role, m);
265
+ }
266
+ // Reconnect websocket pointed at this session so new prompts continue history.
267
+ connectWs({ session: file });
268
+ // Update sidebar active highlight
269
+ refreshSessions();
270
+ }
271
+
272
+ function reconstructFromEntries(entries) {
273
+ // entries contains message + message_summary + model_change etc., in path order.
274
+ const out = [];
275
+ for (const e of entries) {
276
+ if (e.type !== "message") continue;
277
+ const m = e.message;
278
+ if (!m || m.role === "bashExecution") continue;
279
+ if (m.role === "user") {
280
+ // skip "bash execution" pseudo-users (those have role user but content type special)
281
+ out.push({ role: "user", text: extractContentText(m.content), ts: m.timestamp });
282
+ } else if (m.role === "assistant") {
283
+ out.push({ role: "assistant", content: m.content, ts: m.timestamp, usage: m.usage });
284
+ } else if (m.role === "toolResult") {
285
+ out.push({ role: "toolResult", toolCallId: m.toolCallId, toolName: m.toolName, content: m.content, isError: m.isError, ts: m.timestamp });
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ function extractContentText(content) {
292
+ if (typeof content === "string") return content;
293
+ if (!Array.isArray(content)) return "";
294
+ return content
295
+ .filter(c => c.type === "text" || typeof c === "string")
296
+ .map(c => typeof c === "string" ? c : c.text)
297
+ .join("");
298
+ }
299
+
300
+ // ---- Chat rendering ----
301
+ function clearChat() {
302
+ const chatInner = $("#chat-inner");
303
+ chatInner.innerHTML = "";
304
+ state.streamingMsg = null;
305
+ state.streamingText = "";
306
+ state.streamingThinking = "";
307
+ state.activeToolCalls.clear();
308
+ }
309
+
310
+ function showEmptyState(show) {
311
+ document.querySelector("#emptyState").style.display = show ? "flex" : "none";
312
+ }
313
+
314
+ function appendMessageNode(role, m) {
315
+ if (role === "user") {
316
+ const node = el("div", { class: "msg user" }, [
317
+ el("div", { class: "bubble", text: m.text }),
318
+ ]);
319
+ $("#chat-inner").appendChild(node);
320
+ scrollBottom();
321
+ return node;
322
+ }
323
+ return renderAssistantBlock(m);
324
+ }
325
+
326
+ function renderAssistantBlock(m) {
327
+ // m.content is array of {type:text|thinking|toolCall}
328
+ const node = el("div", { class: "msg assistant" }, [
329
+ el("div", { class: "role-tag", text: "pi" }),
330
+ el("div", { class: "content" }),
331
+ ]);
332
+ const content = node.querySelector(".content");
333
+ const parts = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
334
+ for (let i = 0; i < parts.length; i++) {
335
+ const c = parts[i];
336
+ if (c.type === "text") {
337
+ const div = el("div", { html: renderMarkdown(c.text) });
338
+ content.appendChild(div);
339
+ } else if (c.type === "thinking") {
340
+ content.appendChild(makeThinkingBlock(c.thinking));
341
+ } else if (c.type === "toolCall") {
342
+ content.appendChild(makeToolBlockFromCall(c));
343
+ }
344
+ }
345
+ // If this message is followed (in same assistant message) by a toolResult,
346
+ // we don't have it here — toolResults come as separate messages in pi.
347
+ $("#chat-inner").appendChild(node);
348
+ scrollBottom();
349
+ return node;
350
+ }
351
+
352
+ function makeThinkingBlock(thinkingText) {
353
+ const block = el("div", { class: "thinking-block" });
354
+ const head = el("div", { class: "thinking-head", onclick: () => body.style.display = body.style.display === "none" ? "block" : "none" }, [
355
+ el("span", { text: "💭 思考过程" }),
356
+ el("span", { text: "(点击展开/收起)" }),
357
+ ]);
358
+ const body = el("div", { class: "thinking-body", html: escapeHtml(thinkingText) });
359
+ body.style.display = "none";
360
+ block.appendChild(head);
361
+ block.appendChild(body);
362
+ return block;
363
+ }
364
+
365
+ function makeToolBlockFromCall(call) {
366
+ const block = el("div", { class: "tool-block" });
367
+ const head = el("div", { class: "tool-head" }, [
368
+ el("span", { class: "ic", text: "⚙" }),
369
+ el("span", { class: "name", text: call.name }),
370
+ el("span", { class: "args", text: summaryArgs(call.name, call.arguments) }),
371
+ el("span", { class: "state", text: "…" }),
372
+ ]);
373
+ const body = el("div", { class: "tool-body", html: "执行中…" });
374
+ body.style.display = "none";
375
+ head.addEventListener("click", () => body.style.display = body.style.display === "none" ? "block" : "none");
376
+ block.appendChild(head);
377
+ block.appendChild(body);
378
+ block._head = head;
379
+ block._body = body;
380
+ block._callId = call.id;
381
+ state.activeToolCalls.set(call.id, { block, body, head });
382
+ return block;
383
+ }
384
+
385
+ function summaryArgs(name, args) {
386
+ if (!args) return "";
387
+ try {
388
+ if (name === "bash" && args.command) return args.command;
389
+ if (name === "read" && args.path) return args.path;
390
+ if (name === "write" && args.path) return args.path;
391
+ if (name === "edit" && args.path) return args.path;
392
+ if (name === "ls" && args.path) return args.path;
393
+ if (name === "grep") return args.pattern || "";
394
+ if (name === "find") return args.pattern || args.path || "";
395
+ return "";
396
+ } catch { return ""; }
397
+ }
398
+
399
+ function scrollBottom() {
400
+ const chat = $("#chat");
401
+ chat.scrollTop = chat.scrollHeight;
402
+ }
403
+
404
+ // ---- Streaming: handle live assistant message ----
405
+ function ensureStreamingMsg() {
406
+ if (state.streamingMsg) return state.streamingMsg;
407
+ showEmptyState(false);
408
+ const node = el("div", { class: "msg assistant" }, [
409
+ el("div", { class: "role-tag", text: "pi" }),
410
+ el("div", { class: "content" }),
411
+ ]);
412
+ state.streamingMsg = node;
413
+ state.streamingText = "";
414
+ state.streamingThinking = "";
415
+ state.activeToolCalls.clear();
416
+ $("#chat-inner").appendChild(node);
417
+ scrollBottom();
418
+ return node;
419
+ }
420
+
421
+ function refreshStreamingContent() {
422
+ const node = state.streamingMsg;
423
+ if (!node) return;
424
+ const content = node.querySelector(".content");
425
+ // Build the current content html again from scratch.
426
+ // Order: text then thinking then tool calls. We keep it simple — append in
427
+ // arrival order using permanent child slots keyed by index.
428
+ // Easiest: rebuild.
429
+ content.innerHTML = "";
430
+ if (state.streamingThinking) {
431
+ content.appendChild(makeThinkingBlock(state.streamingThinking));
432
+ }
433
+ if (state.streamingText) {
434
+ content.appendChild(el("div", { html: renderMarkdown(state.streamingText) + (state.streaming ? '<span class="typing-cursor"></span>' : "") }));
435
+ }
436
+ // Re-append tool call blocks. Active ones are kept in a Map by insertion order.
437
+ for (const v of state.activeToolCalls.values()) {
438
+ content.appendChild(v.block);
439
+ }
440
+ scrollBottom();
441
+ }
442
+
443
+ function finalizeStreamingMsg() {
444
+ state.streamingMsg = null;
445
+ state.streamingText = "";
446
+ state.streamingThinking = "";
447
+ state.activeToolCalls.clear();
448
+ }
449
+
450
+ // Each generation of WebSocket gets its own id; late stragglers from
451
+ // a previous-generation ws are silently dropped to keep state consistent.
452
+ let wsGen = 0;
453
+
454
+ function connectWs(opts = {}) {
455
+ if (state.ws) {
456
+ try {
457
+ // Suppress onclose so the connection indicator doesn't flicker to red
458
+ // while a new socket is opening.
459
+ state.ws._suppressOnclose = true;
460
+ state.ws.close();
461
+ } catch {}
462
+ }
463
+ const myGen = ++wsGen;
464
+ const proto = location.protocol === "https:" ? "wss" : "ws";
465
+ const cwd = encodeURIComponent(state.cwd || "");
466
+ const sess = opts.session ? `&session=${encodeURIComponent(opts.session)}` : "";
467
+ // When user clicks "new session", pre-flush any "stuck streaming" state
468
+ // from the previous connection so the new connection starts clean.
469
+ if (!opts.session) {
470
+ state.streaming = false;
471
+ state.streamingText = "";
472
+ state.streamingThinking = "";
473
+ state.streamingMsg = null;
474
+ state.activeToolCalls.clear();
475
+ }
476
+ const url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}`;
477
+ const ws = new WebSocket(url);
478
+ state.ws = ws;
479
+ ws._gen = myGen;
480
+ const setConn = (ok) => {
481
+ if (ws._gen !== wsGen) return; // ignore stragglers from a previous socket
482
+ state.wsConnected = ok;
483
+ const dot = $("#connDot");
484
+ const label = $("#connLabel");
485
+ if (dot) dot.style.color = ok ? "var(--accent)" : "var(--danger)";
486
+ if (label) label.textContent = ok ? "已连接" : "已断开";
487
+ const btn = $("#sendBtn");
488
+ if (btn && !state.streaming) btn.disabled = !ok;
489
+ };
490
+ ws.onopen = () => setConn(true);
491
+ ws.onclose = () => {
492
+ if (ws._suppressOnclose) return;
493
+ setConn(false);
494
+ };
495
+ ws.onerror = () => setConn(false);
496
+ ws.onmessage = (ev) => {
497
+ if (ws._gen !== wsGen) return; // drop stragglers
498
+ let obj;
499
+ try { obj = JSON.parse(ev.data); } catch { return; }
500
+ handlePiMessage(obj);
501
+ };
502
+ }
503
+
504
+ function sendWs(obj) {
505
+ if (!state.ws || state.ws.readyState !== 1) return;
506
+ state.ws.send(JSON.stringify(obj));
507
+ }
508
+
509
+ function handlePiMessage(obj) {
510
+ // Responses to commands we issued (get_state etc.) come back with success+data.
511
+ if (obj.type === "response") {
512
+ if (obj.command === "get_state" && obj.success) updateState(obj.data);
513
+ else if (obj.command === "get_available_models" && obj.success) updateModels(obj.data.models || []);
514
+ else if (obj.command === "switch_session" && obj.success) {
515
+ // ask pi for current state so we can get session id, name
516
+ sendWs({ type: "get_state" });
517
+ // List entries to render history. For "open" we already rendered from REST.
518
+ // But for in-session edits later, entries may have arrived — call again.
519
+ sendWs({ type: "get_entries" });
520
+ } else if (obj.command === "get_entries" && obj.success) {
521
+ handleEntries(obj.data.entries || [], obj.data.leafId);
522
+ } else if (obj.command === "new_session" && obj.success) {
523
+ state.currentSessionFile = null;
524
+ clearChat();
525
+ showEmptyState(true);
526
+ sendWs({ type: "get_state" });
527
+ refreshSessions();
528
+ }
529
+ return;
530
+ }
531
+ // Events from pi.
532
+ switch (obj.type) {
533
+ case "agent_start":
534
+ state.streaming = true;
535
+ setComposerAborting(true);
536
+ // Refresh opportunistically; pi's session file may not be fsync'd yet
537
+ // at agent_start (in which case this refresh is a no-op), but the
538
+ // later agent_settled branch refreshes too — so a new conversation
539
+ // shows up in the sidebar as soon as the reply finishes, without
540
+ // needing a manual page reload.
541
+ refreshSessions();
542
+ break;
543
+ case "agent_end":
544
+ finalizeStreamingMsg();
545
+ break;
546
+ case "agent_settled":
547
+ state.streaming = false;
548
+ setComposerAborting(false);
549
+ refreshSessions(); // titles may have changed
550
+ break;
551
+ case "message_start": {
552
+ // Only open an assistant streaming block when the message is actually
553
+ // an assistant message. pi also emits message_start for the echoed
554
+ // user message, and before this distinction we'd create an empty "pi"
555
+ // bubble for every user turn — which showed up as a blank assistant
556
+ // message. User bubbles are rendered locally in submitPrompt(), so
557
+ // ignore user echoes here entirely.
558
+ const m = obj.message;
559
+ if (m && m.role !== "assistant") break;
560
+ ensureStreamingMsg();
561
+ // Each turn within one agent reply gets its own message_start, so reset
562
+ // the text/thinking accumulators here so text_end's overwrite (and
563
+ // text_delta accumulation) only reflect THIS message, not a stale
564
+ // one from the previous turn. Tool-call blocks persist across the
565
+ // whole reply (keyed by toolCallId) and stay visible.
566
+ state.streamingText = "";
567
+ state.streamingThinking = "";
568
+ // NOTE: do NOT pre-fill streamingText from message.content here.
569
+ // pi sends the full content on message_start for assistant turns but
570
+ // then also streams the same text via text_delta → pre-filling would
571
+ // duplicate it ("WS_OKWS_OK"). We rely on text_delta for incremental
572
+ // display and on text_end.content for the final, authoritative text.
573
+ break;
574
+ }
575
+ case "message_end": {
576
+ // pi's message_end carries the final message object, which includes
577
+ // stopReason. If the model errored (bad model, rate limit, network),
578
+ // pi emits assistant messages with stopReason === "error" AND empty
579
+ // content — which otherwise renders as a blank pi bubble. Surface
580
+ // those failures explicitly so the user isn't left staring at
581
+ // an empty reply.
582
+ const m = obj.message;
583
+ if (m && m.role === "assistant" && m.stopReason === "error" && !state.streamingText) {
584
+ state.streamingText = "⚠️ 生成失败(模型返回错误)。可能是当前模型不可用,请从右上角切换一个模型后重试。";
585
+ refreshStreamingContent();
586
+ }
587
+ break;
588
+ }
589
+ case "message_update": {
590
+ const ev = obj.assistantMessageEvent;
591
+ if (!ev) break;
592
+ if (ev.type === "text_delta") {
593
+ state.streamingText += ev.delta;
594
+ refreshStreamingContent();
595
+ } else if (ev.type === "text_end") {
596
+ // Authoritative final text for this content slot. Overwrite any
597
+ // accumulated/delta text so we display exactly what the model
598
+ // produced (handles non-streamed replies where deltas never come,
599
+ // and avoids duplicates when both message_start.content and deltas
600
+ // carried the same string).
601
+ if (typeof ev.content === "string") state.streamingText = ev.content;
602
+ refreshStreamingContent();
603
+ } else if (ev.type === "thinking_delta" || ev.type === "thinking_start" || ev.type === "thinking_end") {
604
+ // For thinking we accumulate deltas; thinking_delta carries .delta
605
+ if (ev.type === "thinking_delta") {
606
+ state.streamingThinking += ev.delta || "";
607
+ }
608
+ refreshStreamingContent();
609
+ } else if (ev.type === "toolcall_start") {
610
+ ensureStreamingMsg();
611
+ const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
612
+ // args may be incomplete until toolcall_end; we fill what we have now
613
+ // and patch the head display on toolcall_end.
614
+ ensureToolBlock(call.id, call.name, call.arguments);
615
+ } else if (ev.type === "toolcall_delta") {
616
+ // Streaming function-call argument JSON. We don't render it live
617
+ // (JSON fragments are not useful UX), but make sure the tool block
618
+ // exists so toolcall_end has somewhere to write into.
619
+ const id = obj.toolCallId || ev.id;
620
+ ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args);
621
+ } else if (ev.type === "toolcall_end") {
622
+ // Authoritative final toolCall object (with full arguments). Patch
623
+ // the block head so the displayed args are the final ones, not the
624
+ // partial ones we got at toolcall_start.
625
+ const call = ev.toolCall || { id: obj.toolCallId, name: obj.toolName, arguments: obj.args };
626
+ const id = call.id || obj.toolCallId;
627
+ const tc = state.activeToolCalls.get(id);
628
+ if (tc) {
629
+ const argsEl = tc.head.querySelector(".args");
630
+ if (argsEl) argsEl.textContent = summaryArgs(call.name, call.arguments);
631
+ }
632
+ }
633
+ break;
634
+ }
635
+ case "tool_execution_start":
636
+ ensureStreamingMsg();
637
+ ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
638
+ break;
639
+ case "tool_execution_update": {
640
+ const tc = state.activeToolCalls.get(obj.toolCallId);
641
+ if (tc) {
642
+ const pr = obj.partialResult;
643
+ const text = pr && pr.content ? (Array.isArray(pr.content) ? pr.content.map(c => c.text || "").join("") : "") : "";
644
+ tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
645
+ }
646
+ break;
647
+ }
648
+ case "tool_execution_end": {
649
+ const tc = state.activeToolCalls.get(obj.toolCallId);
650
+ if (tc) {
651
+ const res = obj.result;
652
+ const text = res && res.content ? (Array.isArray(res.content) ? res.content.map(c => c.text || "").join("") : "") : "";
653
+ tc.body.innerHTML = escapeHtml(text) || "(无输出)";
654
+ tc.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
655
+ tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
656
+ }
657
+ break;
658
+ }
659
+ case "pi_exit":
660
+ state.streaming = false;
661
+ setComposerAborting(false);
662
+ $("#connDot").style.color = "var(--danger)";
663
+ break;
664
+ default:
665
+ // ignore unknown events
666
+ break;
667
+ }
668
+ }
669
+
670
+ function ensureToolBlock(toolCallId, name, args) {
671
+ if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
672
+ makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
673
+ refreshStreamingContent();
674
+ return state.activeToolCalls.get(toolCallId);
675
+ }
676
+
677
+ // We render incoming session entries (for live new messages we use streaming
678
+ // events instead). get_entries is used after switch_session to render the
679
+ // canonical view. But to keep this simple we render history via REST /api/session
680
+ // and treat live events as the source of truth during a session.
681
+ function handleEntries(entries, leafId) { /* no-op: history rendered via REST */ }
682
+
683
+ function updateState(d) {
684
+ if (d?.sessionFile) state.currentSessionFile = d.sessionFile;
685
+ if (d?.sessionId) state.sessionId = d.sessionId;
686
+ if (d?.model) { state.currentModel = d.model; renderModelPill(); }
687
+ if (d?.thinkingLevel) state.thinkingLevel = d.thinkingLevel;
688
+ $("#topSessionName").textContent = d?.sessionName || (d?.sessionFile ? baseName(d.sessionFile) : "新对话");
689
+ }
690
+
691
+ function updateModels(models) {
692
+ state.models = models;
693
+ renderModelMenu();
694
+ }
695
+
696
+ function renderModelPill() {
697
+ const m = state.currentModel;
698
+ const pill = $("#modelPill");
699
+ if (!m) { pill.textContent = "选择模型"; return; }
700
+ const provider = m.provider || "?";
701
+ pill.textContent = `${provider} / ${m.id || m.name}`;
702
+ }
703
+
704
+ function renderModelMenu() {
705
+ const menu = $("#modelMenu");
706
+ menu.innerHTML = "";
707
+ // group by provider
708
+ const groups = {};
709
+ for (const m of state.models) {
710
+ const p = m.provider || "other";
711
+ (groups[p] = groups[p] || []).push(m);
712
+ }
713
+ for (const [provider, items] of Object.entries(groups).sort()) {
714
+ menu.appendChild(el("div", { class: "group-label", text: provider }));
715
+ for (const m of items) {
716
+ const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
717
+ menu.appendChild(el("div", {
718
+ class: "opt" + (active ? " active" : ""),
719
+ onclick: () => { sendWs({ type: "set_model", provider: m.provider, modelId: m.id }); menu.classList.remove("open"); },
720
+ }, [
721
+ el("span", { class: "check", html: active ? "✓ " : "" }),
722
+ document.createTextNode(`${m.name || m.id}`),
723
+ ]));
724
+ }
725
+ }
726
+ }
727
+
728
+ function baseName(p) {
729
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
730
+ return i >= 0 ? p.slice(i + 1) : p;
731
+ }
732
+
733
+ // ---- Composer ----
734
+ function setComposerAborting(yes) {
735
+ const btn = $("#sendBtn");
736
+ if (yes) {
737
+ btn.classList.add("stop");
738
+ btn.disabled = false;
739
+ btn.textContent = "■";
740
+ } else {
741
+ btn.classList.remove("stop");
742
+ btn.disabled = false;
743
+ btn.textContent = "↑";
744
+ }
745
+ }
746
+
747
+ function submitPrompt() {
748
+ const ta = $("#composer");
749
+ const text = ta.value.trim();
750
+ const hint = $(".composer-hint");
751
+ if (!text) return;
752
+ if (!state.wsConnected) {
753
+ if (hint) hint.textContent = "发送失败:WebSocket 未连接。正在尝试重连…";
754
+ const box = $("#composerInner");
755
+ if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
756
+ return;
757
+ }
758
+ if (state.streaming) {
759
+ if (hint) hint.textContent = "中止当前生成中…";
760
+ sendWs({ type: "abort" });
761
+ return;
762
+ }
763
+
764
+ if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
765
+ // Render the user's message locally for instant feedback.
766
+ appendMessageNode("user", { text });
767
+ ta.value = "";
768
+ autoResize();
769
+ // Set session name from the first prompt of a brand-new session.
770
+ if (state.currentSessionFile == null) {
771
+ sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
772
+ }
773
+ sendWs({ type: "prompt", message: text });
774
+ }
775
+
776
+ function autoResize() {
777
+ const ta = $("#composer");
778
+ ta.style.height = "auto";
779
+ ta.style.height = Math.min(ta.scrollHeight, 220) + "px";
780
+ }
781
+
782
+ // ---- Init ----
783
+ function init() {
784
+ // Default cwd to home (server uses home default too).
785
+ state.cwd = document.body.dataset.cwd || "";
786
+
787
+ // event listeners
788
+ $("#btnNew").addEventListener("click", () => {
789
+ if (state.streaming) {
790
+ if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
791
+ sendWs({ type: "abort" });
792
+ }
793
+ clearChat();
794
+ showEmptyState(true);
795
+ connectWs({}); // no session -> pi creates a new one
796
+ $("#topSessionName").textContent = "新对话";
797
+ state.currentSessionFile = null;
798
+ // Pull the updated sidebar immediately so the just-opened session
799
+ // appears as soon as pi reports back (and on subsequent resolves).
800
+ refreshSessions();
801
+ });
802
+
803
+ $("#sendBtn").addEventListener("click", submitPrompt);
804
+ const ta = $("#composer");
805
+ ta.addEventListener("input", autoResize);
806
+ ta.addEventListener("keydown", (e) => {
807
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
808
+ e.preventDefault();
809
+ submitPrompt();
810
+ }
811
+ });
812
+
813
+ // model pill / menu
814
+ $("#modelPill").addEventListener("click", (e) => {
815
+ e.stopPropagation();
816
+ sendWs({ type: "get_available_models" });
817
+ $("#modelMenu").classList.toggle("open");
818
+ });
819
+ document.addEventListener("click", (e) => {
820
+ if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
821
+ $("#modelMenu").classList.remove("open");
822
+ }
823
+ });
824
+
825
+ // suggestions
826
+ document.querySelectorAll(".suggestions .chip").forEach((c) => {
827
+ c.addEventListener("click", () => {
828
+ $("#composer").value = c.dataset.prompt || c.textContent;
829
+ autoResize();
830
+ submitPrompt();
831
+ });
832
+ });
833
+
834
+ // sidebar search (client side filter)
835
+ $("#sidebarSearch").addEventListener("input", (e) => {
836
+ const q = e.target.value.toLowerCase();
837
+ document.querySelectorAll(".session-item").forEach((it) => {
838
+ it.style.display = it.textContent.toLowerCase().includes(q) ? "" : "none";
839
+ });
840
+ });
841
+
842
+ refreshSessions();
843
+ // start in the disconnected state; connectWs will flip to green on open.
844
+ const initDot = $("#connDot");
845
+ const initLabel = $("#connLabel");
846
+ if (initDot) initDot.style.color = "var(--danger)";
847
+ if (initLabel) initLabel.textContent = "连接中…";
848
+ $("#sendBtn").disabled = true;
849
+ connectWs({});
850
+ showEmptyState(true);
851
+ // Pull the current pi state (model, session id, thinking level) once the
852
+ // socket is open. connectWs() registers onopen asynchronously; defer long
853
+ // enough that the writable is ready. (An earlier version wrote the `\n` as
854
+ // literal backslash-n inside a single-line comment, so setTimeout never ran
855
+ // and the model pill never populated.)
856
+ setTimeout(() => sendWs({ type: "get_state" }), 400);
857
+ setTimeout(() => sendWs({ type: "get_available_models" }), 600);
858
+ }
859
+
860
+ document.addEventListener("DOMContentLoaded", init);