@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/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/pi-web-chat.js +67 -0
- package/package.json +47 -0
- package/public/app.js +860 -0
- package/public/index.html +68 -0
- package/public/style.css +349 -0
- package/server.js +322 -0
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);
|