@liguoshuai/pi-web-chat 2.14.12 → 2.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/docs/ARCHITECTURE.md +3 -6
- package/package.json +4 -4
- package/public/app.js +153 -267
- package/public/index.html +9 -0
- package/public/markdown.js +278 -0
- package/public/style.css +9 -0
- package/docs/CHANGELOG.md +0 -990
- package/docs/ISSUES.md +0 -268
package/README.md
CHANGED
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构设计文档
|
|
2
2
|
|
|
3
|
-
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.14.
|
|
3
|
+
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.14.13 版本)。
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -164,7 +164,6 @@ Browser server.js pi RPC 子进程
|
|
|
164
164
|
pi-web-chat/
|
|
165
165
|
├── README.md # 主说明文档
|
|
166
166
|
├── package.json
|
|
167
|
-
├── package-lock.json
|
|
168
167
|
├── server.js # Express + WebSocket 服务器与 PiAgent 管理
|
|
169
168
|
├── bin/
|
|
170
169
|
│ └── pi-web-chat.js # 可执行入口
|
|
@@ -172,11 +171,9 @@ pi-web-chat/
|
|
|
172
171
|
│ ├── index.html # 单页 UI
|
|
173
172
|
│ ├── app.js # 前端逻辑与状态机
|
|
174
173
|
│ └── style.css # CSS 样式
|
|
175
|
-
├── docs/ #
|
|
174
|
+
├── docs/ # 本模块文档(CHANGELOG / ISSUES 已移至项目根 docs/)
|
|
176
175
|
│ ├── ARCHITECTURE.md # 架构设计文档(本文件)
|
|
177
|
-
│
|
|
178
|
-
│ ├── ISSUES.md # 排查与修补记录
|
|
179
|
-
│ └── CHANGELOG.md # 版本变更日志
|
|
176
|
+
│ └── DESIGN.md # 详细设计与决策文档
|
|
180
177
|
└── scripts/ # 服务安装/卸载脚本
|
|
181
178
|
├── install-service.sh
|
|
182
179
|
├── uninstall-service.sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.5",
|
|
4
4
|
"description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"express": "^4.21.2",
|
|
44
44
|
"ws": "^8.18.0",
|
|
45
|
-
"@liguoshuai/pi-chat-protocol": "2.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
45
|
+
"@liguoshuai/pi-chat-protocol": "2.16.5",
|
|
46
|
+
"@liguoshuai/pi-chat-server": "2.16.5"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
-
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/app.js",
|
|
49
|
+
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/markdown.js && node --check public/app.js",
|
|
50
50
|
"start": "node server.js",
|
|
51
51
|
"dev": "node --watch server.js",
|
|
52
52
|
"test": "node --test"
|
package/public/app.js
CHANGED
|
@@ -81,6 +81,7 @@ const state = {
|
|
|
81
81
|
attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
|
|
82
82
|
turnStartedAt: null,
|
|
83
83
|
streamingMsgDurationEl: null,
|
|
84
|
+
lastSessions: [],
|
|
84
85
|
};
|
|
85
86
|
|
|
86
87
|
let toastTimer = null;
|
|
@@ -261,252 +262,22 @@ async function confirmCwdChange() {
|
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
// ---- Markdown
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.replace(/>/g, ">")
|
|
269
|
-
.replace(/"/g, """)
|
|
270
|
-
.replace(/'/g, "'");
|
|
265
|
+
// ---- Markdown & Clipboard helpers (modularized in /markdown.js) ----
|
|
266
|
+
// Fallbacks in case markdown.js failed to load
|
|
267
|
+
if (typeof escapeHtml === "undefined") {
|
|
268
|
+
window.escapeHtml = (s) => (s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
271
269
|
}
|
|
272
|
-
|
|
273
|
-
async
|
|
274
|
-
|
|
275
|
-
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
270
|
+
if (typeof copyToClipboard === "undefined") {
|
|
271
|
+
window.copyToClipboard = async (text) => {
|
|
272
|
+
if (!text) return false;
|
|
276
273
|
try {
|
|
277
|
-
await navigator.clipboard.writeText(text);
|
|
278
|
-
|
|
279
|
-
} catch (e) {
|
|
280
|
-
console.warn("navigator.clipboard.writeText failed:", e);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
try {
|
|
284
|
-
const ta = document.createElement("textarea");
|
|
285
|
-
ta.value = text;
|
|
286
|
-
ta.style.position = "fixed";
|
|
287
|
-
ta.style.left = "-9999px";
|
|
288
|
-
ta.style.top = "-9999px";
|
|
289
|
-
document.body.appendChild(ta);
|
|
290
|
-
ta.focus();
|
|
291
|
-
ta.select();
|
|
292
|
-
const ok = document.execCommand("copy");
|
|
293
|
-
document.body.removeChild(ta);
|
|
294
|
-
return ok;
|
|
295
|
-
} catch (e) {
|
|
296
|
-
console.error("Fallback copy error:", e);
|
|
274
|
+
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
|
|
275
|
+
} catch {}
|
|
297
276
|
return false;
|
|
298
|
-
}
|
|
277
|
+
};
|
|
299
278
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (!md) return "";
|
|
303
|
-
if (typeof md !== "string") md = String(md);
|
|
304
|
-
// Strip headings of # etc. and convert to proper elements with escaping.
|
|
305
|
-
// We do a fenced-code-first approach so we don't process markdown inside code.
|
|
306
|
-
const parts = [];
|
|
307
|
-
let rest = md;
|
|
308
|
-
while (rest.length) {
|
|
309
|
-
// Only match ``` at the beginning of a line (or start of string) to avoid
|
|
310
|
-
// misinterpreting inline triple-backticks as code fences.
|
|
311
|
-
const fenceMatch = rest.match(/(?:^|\n)```/);
|
|
312
|
-
if (!fenceMatch) {
|
|
313
|
-
parts.push({ kind: "md", text: rest });
|
|
314
|
-
rest = "";
|
|
315
|
-
} else {
|
|
316
|
-
const fenceIdx = fenceMatch.index + (fenceMatch[0].length - 3);
|
|
317
|
-
if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
|
|
318
|
-
rest = rest.slice(fenceIdx + 3);
|
|
319
|
-
// optional language on this line
|
|
320
|
-
const nl = rest.indexOf("\n");
|
|
321
|
-
let lang = "";
|
|
322
|
-
if (nl !== -1) {
|
|
323
|
-
const firstLine = rest.slice(0, nl).trim();
|
|
324
|
-
if (firstLine && !firstLine.includes("```")) lang = firstLine;
|
|
325
|
-
rest = rest.slice(nl + 1);
|
|
326
|
-
}
|
|
327
|
-
const closeIdx = rest.indexOf("```");
|
|
328
|
-
let code;
|
|
329
|
-
if (closeIdx === -1) { code = rest; rest = ""; }
|
|
330
|
-
else { code = rest.slice(0, closeIdx); rest = rest.slice(closeIdx + 3).replace(/^\n/, ""); }
|
|
331
|
-
parts.push({ kind: "code", lang, code });
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
let html = "";
|
|
335
|
-
for (const p of parts) {
|
|
336
|
-
if (p.kind === "code") {
|
|
337
|
-
const lang = p.lang || "";
|
|
338
|
-
const displayLang = lang || "code";
|
|
339
|
-
html += `<div class="code-block-wrapper">` +
|
|
340
|
-
`<div class="code-block-header">` +
|
|
341
|
-
`<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
|
|
342
|
-
`<button class="btn-copy-code" type="button" aria-label="复制代码" title="复制代码">` +
|
|
343
|
-
`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>` +
|
|
344
|
-
`<span>复制</span>` +
|
|
345
|
-
`</button>` +
|
|
346
|
-
`</div>` +
|
|
347
|
-
`<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(p.code)}</code></pre>` +
|
|
348
|
-
`</div>`;
|
|
349
|
-
} else {
|
|
350
|
-
html += renderInlineMd(p.text);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
return html;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function renderInlineMd(text) {
|
|
357
|
-
// tables, then markdown-ish transforms. Escape first.
|
|
358
|
-
// Split out inline code first using placeholders to protect them.
|
|
359
|
-
const codeChunks = [];
|
|
360
|
-
let t = text.replace(/`([^`\n]+)`/g, (m) => {
|
|
361
|
-
const i = codeChunks.length;
|
|
362
|
-
codeChunks.push(m);
|
|
363
|
-
return `\u0000CODE${i}\u0000`;
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
// Tables: a block of consecutive lines delimited by blank lines,
|
|
367
|
-
// where the second line is like |---|---|.
|
|
368
|
-
const lines = t.split("\n");
|
|
369
|
-
const out = [];
|
|
370
|
-
let i = 0;
|
|
371
|
-
while (i < lines.length) {
|
|
372
|
-
if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i+1].includes("-")) {
|
|
373
|
-
// collect table block
|
|
374
|
-
const header = lines[i];
|
|
375
|
-
const tblLines = [header, lines[i + 1]];
|
|
376
|
-
let j = i + 2;
|
|
377
|
-
while (j < lines.length && lines[j].includes("|")) { tblLines.push(lines[j]); j++; }
|
|
378
|
-
out.push({ kind: "table", lines: tblLines });
|
|
379
|
-
i = j;
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
out.push({ kind: "line", text: lines[i] });
|
|
383
|
-
i++;
|
|
384
|
-
}
|
|
385
|
-
let outHtml = "";
|
|
386
|
-
let para = [];
|
|
387
|
-
let linkListOpen = null;
|
|
388
|
-
let linkListOrdered = null;
|
|
389
|
-
|
|
390
|
-
function flushList() {
|
|
391
|
-
if (linkListOpen) {
|
|
392
|
-
outHtml += linkListOpen === "ol" ? "</ol>" : "</ul>";
|
|
393
|
-
linkListOpen = null;
|
|
394
|
-
linkListOrdered = null;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function flushPara() {
|
|
399
|
-
if (para.length === 0) return;
|
|
400
|
-
const block = para.join("\n").trim();
|
|
401
|
-
para = [];
|
|
402
|
-
outHtml += "<p>" + mdInlineBlock(block).replace(/\n/g, "<br>") + "</p>";
|
|
403
|
-
}
|
|
404
|
-
for (const seg of out) {
|
|
405
|
-
if (seg.kind === "table") {
|
|
406
|
-
flushPara();
|
|
407
|
-
flushList();
|
|
408
|
-
outHtml += mdTable(seg.lines);
|
|
409
|
-
} else if (seg.kind === "line") {
|
|
410
|
-
// horizontal rule
|
|
411
|
-
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
412
|
-
flushPara();
|
|
413
|
-
flushList();
|
|
414
|
-
outHtml += "<hr>";
|
|
415
|
-
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
416
|
-
// headings
|
|
417
|
-
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
418
|
-
flushPara();
|
|
419
|
-
flushList();
|
|
420
|
-
const level = m[1].length;
|
|
421
|
-
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
422
|
-
} else if (/^\s*$/.test(seg.text)) {
|
|
423
|
-
flushPara();
|
|
424
|
-
flushList();
|
|
425
|
-
} else if (/^>\s?/.test(seg.text)) {
|
|
426
|
-
// blockquote line — group simple consecutive ones
|
|
427
|
-
flushPara();
|
|
428
|
-
flushList();
|
|
429
|
-
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
430
|
-
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
431
|
-
// list item — group consecutive into ul/ol
|
|
432
|
-
// simple inline handling: wrap each list item line.
|
|
433
|
-
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
434
|
-
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
435
|
-
flushPara();
|
|
436
|
-
flushList();
|
|
437
|
-
linkListOpen = isOrdered ? "ol" : "ul";
|
|
438
|
-
linkListOrdered = isOrdered;
|
|
439
|
-
outHtml += "<" + linkListOpen + ">";
|
|
440
|
-
}
|
|
441
|
-
let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
442
|
-
let taskPrefix = "";
|
|
443
|
-
if (/^\[ \]\s+/.test(itemText)) {
|
|
444
|
-
taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
|
|
445
|
-
itemText = itemText.replace(/^\[ \]\s+/, "");
|
|
446
|
-
} else if (/^\[[xX]\]\s+/.test(itemText)) {
|
|
447
|
-
taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
|
|
448
|
-
itemText = itemText.replace(/^\[[xX]\]\s+/, "");
|
|
449
|
-
}
|
|
450
|
-
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
451
|
-
} else {
|
|
452
|
-
flushList();
|
|
453
|
-
para.push(seg.text);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
flushList();
|
|
458
|
-
flushPara();
|
|
459
|
-
// restore inline code
|
|
460
|
-
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
|
|
461
|
-
const chunk = codeChunks[+n];
|
|
462
|
-
return chunk ? `<code>${escapeHtml(chunk.slice(1, -1))}</code>` : "";
|
|
463
|
-
});
|
|
464
|
-
return outHtml;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function sanitizeUrl(url) {
|
|
468
|
-
if (!url) return "#";
|
|
469
|
-
const trimmed = url.trim();
|
|
470
|
-
if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
|
|
471
|
-
return trimmed.replace(/"/g, """).replace(/'/g, "'");
|
|
472
|
-
}
|
|
473
|
-
return "#";
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// helper state bag attached to the function during line scan
|
|
477
|
-
function mdInlineBlock(text) {
|
|
478
|
-
let s = escapeHtml(text);
|
|
479
|
-
// bold
|
|
480
|
-
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
481
|
-
s = s.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
482
|
-
// strikethrough
|
|
483
|
-
s = s.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
|
484
|
-
// italic (asterisk)
|
|
485
|
-
s = s.replace(/(^|[^*])\*([^*\s](?:[^*]*?[^*\s])?)\*(?!\*)/g, "$1<em>$2</em>");
|
|
486
|
-
// italic (underscore): only match boundary/space so identifier_names are preserved
|
|
487
|
-
s = s.replace(/(^|[\s(\[<,;:])_([^_\s](?:[^_]*?[^_\s])?)_(?=[\s)\]>,;:!?.]|$)/g, "$1<em>$2</em>");
|
|
488
|
-
// links [txt](url) - strictly sanitize URL
|
|
489
|
-
s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (match, txt, url) => {
|
|
490
|
-
const safeUrl = sanitizeUrl(url);
|
|
491
|
-
return `<a href="${safeUrl}" target="_blank" rel="noopener">${txt}</a>`;
|
|
492
|
-
});
|
|
493
|
-
return s;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function mdTable(lines) {
|
|
497
|
-
const parseRow = (l) => l.split("|").map(c => c.trim()).filter((_, i, arr) => !(i === 0 && arr[0] === "") && !(i === arr.length - 1 && arr[arr.length - 1] === ""));
|
|
498
|
-
const header = parseRow(lines[0]);
|
|
499
|
-
const body = lines.slice(2).filter(l => l.trim()).map(parseRow);
|
|
500
|
-
let h = '<table><thead><tr>';
|
|
501
|
-
header.forEach((c) => h += `<th>${mdInlineBlock(c)}</th>`);
|
|
502
|
-
h += '</tr></thead><tbody>';
|
|
503
|
-
body.forEach((r) => {
|
|
504
|
-
h += '<tr>';
|
|
505
|
-
r.forEach((c) => h += `<td>${mdInlineBlock(c)}</td>`);
|
|
506
|
-
h += '</tr>';
|
|
507
|
-
});
|
|
508
|
-
h += '</tbody></table>';
|
|
509
|
-
return h;
|
|
279
|
+
if (typeof renderMarkdown === "undefined") {
|
|
280
|
+
window.renderMarkdown = (md) => escapeHtml(md || "").replace(/\n/g, "<br>");
|
|
510
281
|
}
|
|
511
282
|
|
|
512
283
|
// ---- DOM helpers ----
|
|
@@ -530,6 +301,20 @@ const el = (tag, props = {}, children = []) => {
|
|
|
530
301
|
return n;
|
|
531
302
|
};
|
|
532
303
|
|
|
304
|
+
const makeCopyIconSvg = () => el("svg", {
|
|
305
|
+
viewBox: "0 0 24 24",
|
|
306
|
+
width: "12",
|
|
307
|
+
height: "12",
|
|
308
|
+
fill: "none",
|
|
309
|
+
stroke: "currentColor",
|
|
310
|
+
"stroke-width": "2",
|
|
311
|
+
"stroke-linecap": "round",
|
|
312
|
+
"stroke-linejoin": "round"
|
|
313
|
+
}, [
|
|
314
|
+
el("rect", { x: "9", y: "9", width: "13", height: "13", rx: "2", ry: "2" }),
|
|
315
|
+
el("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
|
|
316
|
+
]);
|
|
317
|
+
|
|
533
318
|
// ---- Message Time Formatter ----
|
|
534
319
|
function formatMessageTime(ts) {
|
|
535
320
|
if (!ts) return "";
|
|
@@ -614,10 +399,14 @@ async function refreshSessions() {
|
|
|
614
399
|
try {
|
|
615
400
|
const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
616
401
|
const data = await res.json();
|
|
617
|
-
|
|
402
|
+
state.lastSessions = data.sessions || [];
|
|
403
|
+
renderSidebar(state.lastSessions);
|
|
618
404
|
} catch (err) {
|
|
619
405
|
console.warn("refreshSessions error:", err);
|
|
620
|
-
|
|
406
|
+
// Preserve existing sessions list rather than blanking out the sidebar on error
|
|
407
|
+
if (state.lastSessions && state.lastSessions.length > 0) {
|
|
408
|
+
renderSidebar(state.lastSessions);
|
|
409
|
+
}
|
|
621
410
|
}
|
|
622
411
|
}
|
|
623
412
|
|
|
@@ -745,6 +534,7 @@ function startNewSession() {
|
|
|
745
534
|
window.history.replaceState({}, "", window.location.pathname);
|
|
746
535
|
} catch {}
|
|
747
536
|
$("#topSessionName").textContent = "新对话";
|
|
537
|
+
updatePageTitle(null);
|
|
748
538
|
if (wasStreaming) {
|
|
749
539
|
showToast("前一个会话已转入后台继续运行");
|
|
750
540
|
}
|
|
@@ -752,8 +542,8 @@ function startNewSession() {
|
|
|
752
542
|
if (window.innerWidth <= 768) closeSidebar();
|
|
753
543
|
|
|
754
544
|
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
755
|
-
//
|
|
756
|
-
renderSidebar([]);
|
|
545
|
+
// Keep existing sessions in sidebar and immediately display new draft item
|
|
546
|
+
renderSidebar(state.lastSessions || []);
|
|
757
547
|
refreshSessions();
|
|
758
548
|
}
|
|
759
549
|
|
|
@@ -771,7 +561,7 @@ async function deleteSession(file, title) {
|
|
|
771
561
|
return;
|
|
772
562
|
}
|
|
773
563
|
showToast("会话已删除");
|
|
774
|
-
if (state.currentSessionFile
|
|
564
|
+
if (sameSession(state.currentSessionFile, file)) {
|
|
775
565
|
startNewSession();
|
|
776
566
|
} else {
|
|
777
567
|
await refreshSessions();
|
|
@@ -804,6 +594,12 @@ function initMobileToolbarFab() {
|
|
|
804
594
|
} else {
|
|
805
595
|
fab.classList.remove("visible");
|
|
806
596
|
}
|
|
597
|
+
const distanceFromBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight;
|
|
598
|
+
if (distanceFromBottom <= 80) {
|
|
599
|
+
userScrolledUp = false;
|
|
600
|
+
} else if (distanceFromBottom > 150) {
|
|
601
|
+
userScrolledUp = true;
|
|
602
|
+
}
|
|
807
603
|
};
|
|
808
604
|
|
|
809
605
|
chat.addEventListener("scroll", onScroll);
|
|
@@ -834,6 +630,7 @@ async function syncSessionHistory(file, force = false) {
|
|
|
834
630
|
|
|
835
631
|
const topName = data.sessionName || data.firstUser || "新对话";
|
|
836
632
|
$("#topSessionName").textContent = topName;
|
|
633
|
+
updatePageTitle(topName);
|
|
837
634
|
|
|
838
635
|
// Only overwrite chat if not actively backfilling
|
|
839
636
|
if (!state.isBackfilling && (force || !state.streaming)) {
|
|
@@ -843,7 +640,8 @@ async function syncSessionHistory(file, force = false) {
|
|
|
843
640
|
for (const m of msgs) {
|
|
844
641
|
appendMessageNode(m.role, m);
|
|
845
642
|
}
|
|
846
|
-
|
|
643
|
+
userScrolledUp = false;
|
|
644
|
+
scrollBottom(true);
|
|
847
645
|
refreshSessions();
|
|
848
646
|
}
|
|
849
647
|
} catch (e) {
|
|
@@ -899,16 +697,39 @@ function reconstructFromEntries(entries, timing = null) {
|
|
|
899
697
|
}
|
|
900
698
|
}
|
|
901
699
|
|
|
700
|
+
// Precompute which entry indices are the last assistant message in their turn.
|
|
701
|
+
// A turn is delimited by user messages; the last assistant message before the
|
|
702
|
+
// next user message (or end of entries) is the last in its turn.
|
|
703
|
+
// We only show the total turn duration on this final message, not on every
|
|
704
|
+
// intermediate assistant message (e.g. tool-call messages within the same turn).
|
|
705
|
+
const lastAssistantInTurn = new Set();
|
|
706
|
+
let lastAssistantIdx = null;
|
|
707
|
+
for (let i = 0; i < entries.length; i++) {
|
|
708
|
+
const e = entries[i];
|
|
709
|
+
if (e.type !== "message" || !e.message) continue;
|
|
710
|
+
if (e.message.role === "assistant") {
|
|
711
|
+
lastAssistantIdx = i;
|
|
712
|
+
} else if (e.message.role === "user" && lastAssistantIdx != null) {
|
|
713
|
+
lastAssistantInTurn.add(lastAssistantIdx);
|
|
714
|
+
lastAssistantIdx = null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (lastAssistantIdx != null) lastAssistantInTurn.add(lastAssistantIdx);
|
|
718
|
+
|
|
902
719
|
const out = [];
|
|
903
720
|
let lastUserTs = null;
|
|
904
|
-
let
|
|
905
|
-
|
|
721
|
+
let turnIndex = -1; // incremented per user message — aligns with timing[] (one entry per turn)
|
|
722
|
+
let thinkingIdx = 0; // accumulated across assistant messages within the same turn
|
|
723
|
+
for (let i = 0; i < entries.length; i++) {
|
|
724
|
+
const e = entries[i];
|
|
906
725
|
if (e.type !== "message") continue;
|
|
907
726
|
const m = e.message;
|
|
908
727
|
if (!m || m.role === "bashExecution") continue;
|
|
909
728
|
const msgTs = parseEntryTimestamp(m.timestamp || e.timestamp);
|
|
910
729
|
if (m.role === "user") {
|
|
911
730
|
lastUserTs = msgTs;
|
|
731
|
+
turnIndex++;
|
|
732
|
+
thinkingIdx = 0; // reset thinking index for the new turn
|
|
912
733
|
// Extract optional images from user message content array
|
|
913
734
|
let images = [];
|
|
914
735
|
if (Array.isArray(m.content)) {
|
|
@@ -923,17 +744,19 @@ function reconstructFromEntries(entries, timing = null) {
|
|
|
923
744
|
out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
|
|
924
745
|
} else if (m.role === "assistant") {
|
|
925
746
|
let turnDurationMs = null;
|
|
926
|
-
// Try timing data first, then fall back to timestamp heuristic
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if (
|
|
933
|
-
|
|
747
|
+
// Try timing data first, then fall back to timestamp heuristic.
|
|
748
|
+
// Only show turn duration on the last assistant message of the turn.
|
|
749
|
+
const turnTiming = timing ? timing[turnIndex] : null;
|
|
750
|
+
if (lastAssistantInTurn.has(i)) {
|
|
751
|
+
if (turnTiming?.turnDuration != null) {
|
|
752
|
+
turnDurationMs = turnTiming.turnDuration;
|
|
753
|
+
} else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
|
|
754
|
+
const diff = msgTs - lastUserTs;
|
|
755
|
+
if (diff > 0 && diff < 15 * 60 * 1000) {
|
|
756
|
+
turnDurationMs = diff;
|
|
757
|
+
}
|
|
934
758
|
}
|
|
935
759
|
}
|
|
936
|
-
let thinkingIdx = 0;
|
|
937
760
|
const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
|
|
938
761
|
const content = rawContent.map(part => {
|
|
939
762
|
if (part && part.type === "thinking") {
|
|
@@ -971,13 +794,11 @@ function reconstructFromEntries(entries, timing = null) {
|
|
|
971
794
|
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
972
795
|
}
|
|
973
796
|
out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
|
|
974
|
-
assistantMsgCount++;
|
|
975
797
|
}
|
|
976
798
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
977
799
|
}
|
|
978
800
|
return out;
|
|
979
801
|
}
|
|
980
|
-
|
|
981
802
|
function extractContentText(content) {
|
|
982
803
|
if (typeof content === "string") return content;
|
|
983
804
|
if (!Array.isArray(content)) return "";
|
|
@@ -1243,6 +1064,22 @@ async function handleIncomingFiles(files) {
|
|
|
1243
1064
|
|
|
1244
1065
|
const handleImageFiles = handleIncomingFiles;
|
|
1245
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* 手动压缩当前会话上下文,释放长会话累积的 token,缓解越聊越慢。
|
|
1069
|
+
* 触发后由 pi 端执行 compaction,返回结果通过 response 事件回显。
|
|
1070
|
+
*/
|
|
1071
|
+
function compactContext() {
|
|
1072
|
+
if (state.streaming) {
|
|
1073
|
+
showToast("请等待当前任务结束后再压缩上下文");
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (!sendWs({ type: "compact" })) {
|
|
1077
|
+
showToast("连接不可用,无法压缩上下文");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
showToast("正在压缩上下文…");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1246
1083
|
function exportCurrentSession() {
|
|
1247
1084
|
const chatInner = $("#chat-inner");
|
|
1248
1085
|
if (!chatInner || chatInner.children.length === 0) {
|
|
@@ -1366,7 +1203,7 @@ function renderAssistantBlock(m) {
|
|
|
1366
1203
|
}
|
|
1367
1204
|
}
|
|
1368
1205
|
}, [
|
|
1369
|
-
|
|
1206
|
+
makeCopyIconSvg(),
|
|
1370
1207
|
el("span", { text: "复制全文" })
|
|
1371
1208
|
]);
|
|
1372
1209
|
|
|
@@ -1490,7 +1327,7 @@ function updateToolBlockCopyBtn(tc, call) {
|
|
|
1490
1327
|
}
|
|
1491
1328
|
}
|
|
1492
1329
|
}, [
|
|
1493
|
-
|
|
1330
|
+
makeCopyIconSvg(),
|
|
1494
1331
|
el("span", { text: "复制" })
|
|
1495
1332
|
]);
|
|
1496
1333
|
const stateEl = tc.head.querySelector(".state");
|
|
@@ -1547,7 +1384,7 @@ function makeToolBlockFromCall(call, ts = null) {
|
|
|
1547
1384
|
}
|
|
1548
1385
|
}
|
|
1549
1386
|
}, [
|
|
1550
|
-
|
|
1387
|
+
makeCopyIconSvg(),
|
|
1551
1388
|
el("span", { text: "复制" })
|
|
1552
1389
|
]) : null;
|
|
1553
1390
|
|
|
@@ -1606,10 +1443,25 @@ function summaryArgs(name, args) {
|
|
|
1606
1443
|
} catch { return ""; }
|
|
1607
1444
|
}
|
|
1608
1445
|
|
|
1609
|
-
|
|
1446
|
+
let userScrolledUp = false;
|
|
1447
|
+
|
|
1448
|
+
function updatePageTitle(title) {
|
|
1449
|
+
if (!title || title === "新对话") {
|
|
1450
|
+
document.title = "pi-chat";
|
|
1451
|
+
} else {
|
|
1452
|
+
document.title = `${title} · pi-chat`;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function scrollBottom(force = false) {
|
|
1610
1457
|
// Don't fight the user during a background-event replay (backfill).
|
|
1611
1458
|
if (state.isBackfilling) return;
|
|
1612
1459
|
const chat = $("#chat");
|
|
1460
|
+
if (!chat) return;
|
|
1461
|
+
// If streaming and user manually scrolled up to read history, don't hijack scroll unless forced!
|
|
1462
|
+
if (!force && userScrolledUp && state.streaming) {
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1613
1465
|
chat.scrollTop = chat.scrollHeight;
|
|
1614
1466
|
}
|
|
1615
1467
|
|
|
@@ -1641,7 +1493,7 @@ function ensureStreamingMsg(ts = Date.now()) {
|
|
|
1641
1493
|
}
|
|
1642
1494
|
}
|
|
1643
1495
|
}, [
|
|
1644
|
-
|
|
1496
|
+
makeCopyIconSvg(),
|
|
1645
1497
|
el("span", { text: "复制全文" })
|
|
1646
1498
|
]);
|
|
1647
1499
|
|
|
@@ -2091,6 +1943,19 @@ function handlePiMessage(obj) {
|
|
|
2091
1943
|
state.aborting = false;
|
|
2092
1944
|
setComposerStreaming(false);
|
|
2093
1945
|
}
|
|
1946
|
+
else if (obj.command === "compact") {
|
|
1947
|
+
if (obj.success) {
|
|
1948
|
+
const after = obj.data?.estimatedTokensAfter;
|
|
1949
|
+
const msg = typeof after === "number"
|
|
1950
|
+
? `上下文已压缩(预估剩余约 ${after} tokens)`
|
|
1951
|
+
: "上下文已压缩";
|
|
1952
|
+
showToast(msg);
|
|
1953
|
+
appendSystemNotice(msg);
|
|
1954
|
+
sendWs({ type: "get_state" });
|
|
1955
|
+
} else {
|
|
1956
|
+
showToast(`压缩上下文失败: ${obj.error || "未知错误"}`);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
2094
1959
|
else if (obj.command === "switch_session" && obj.success) {
|
|
2095
1960
|
// ask pi for current state so we can get session id, name
|
|
2096
1961
|
sendWs({ type: "get_state" });
|
|
@@ -3056,6 +2921,8 @@ function submitSteer() {
|
|
|
3056
2921
|
appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
|
|
3057
2922
|
ta.value = "";
|
|
3058
2923
|
autoResize();
|
|
2924
|
+
userScrolledUp = false;
|
|
2925
|
+
scrollBottom(true);
|
|
3059
2926
|
updateComposerUI();
|
|
3060
2927
|
|
|
3061
2928
|
if (hint) hint.textContent = "已插入指导指令!pi 将在当前轮次中实时接收并调整方向。";
|
|
@@ -3100,6 +2967,8 @@ function submitPrompt() {
|
|
|
3100
2967
|
const now = Date.now();
|
|
3101
2968
|
// Render the user's message locally for instant feedback.
|
|
3102
2969
|
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
|
|
2970
|
+
userScrolledUp = false;
|
|
2971
|
+
scrollBottom(true);
|
|
3103
2972
|
|
|
3104
2973
|
ta.value = "";
|
|
3105
2974
|
state.attachedImages = [];
|
|
@@ -3116,6 +2985,7 @@ function submitPrompt() {
|
|
|
3116
2985
|
if (state.currentSessionFile == null && text) {
|
|
3117
2986
|
const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
|
|
3118
2987
|
$("#topSessionName").textContent = promptTitle;
|
|
2988
|
+
updatePageTitle(promptTitle);
|
|
3119
2989
|
sendWs({ type: "set_session_name", name: promptTitle });
|
|
3120
2990
|
|
|
3121
2991
|
// Update draft session item in sidebar immediately
|
|
@@ -3147,8 +3017,18 @@ function submitPrompt() {
|
|
|
3147
3017
|
state.streamingMsgDurationEl = null;
|
|
3148
3018
|
if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
|
|
3149
3019
|
state.streamingItems = [];
|
|
3020
|
+
// Restore user input and attachments so text is not lost
|
|
3021
|
+
if (ta) {
|
|
3022
|
+
ta.value = text;
|
|
3023
|
+
autoResize();
|
|
3024
|
+
ta.focus();
|
|
3025
|
+
}
|
|
3026
|
+
if (imagesToSend && imagesToSend.length > 0) {
|
|
3027
|
+
state.attachedImages = imagesToSend;
|
|
3028
|
+
renderImagePreviews();
|
|
3029
|
+
}
|
|
3150
3030
|
const hint = $(".composer-hint");
|
|
3151
|
-
if (hint) hint.textContent = "发送失败:WebSocket
|
|
3031
|
+
if (hint) hint.textContent = "发送失败:WebSocket 连接已断开,已为你恢复输入内容。正在尝试重连…";
|
|
3152
3032
|
scheduleReconnect(0);
|
|
3153
3033
|
}
|
|
3154
3034
|
}
|
|
@@ -3360,6 +3240,12 @@ async function init() {
|
|
|
3360
3240
|
exportBtn.addEventListener("click", exportCurrentSession);
|
|
3361
3241
|
}
|
|
3362
3242
|
|
|
3243
|
+
// Compact context button (manual compaction for long/slow sessions)
|
|
3244
|
+
const compactBtn = $("#btnCompactChat");
|
|
3245
|
+
if (compactBtn) {
|
|
3246
|
+
compactBtn.addEventListener("click", compactContext);
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3363
3249
|
// Image and file attach / picker / paste / drag-and-drop
|
|
3364
3250
|
const btnAttach = $("#btnAttachImage");
|
|
3365
3251
|
const fileInput = $("#imageFileInput");
|