@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 CHANGED
@@ -212,9 +212,7 @@ pi-web-chat/
212
212
  │ └── uninstall-service.sh 一键卸载脚本
213
213
  └── docs/ 项目文档库
214
214
  ├── ARCHITECTURE.md 架构设计文档
215
- ├── DESIGN.md 详细设计与决策文档
216
- ├── ISSUES.md 历次问题排查与修补记录
217
- └── CHANGELOG.md 版本变更日志
215
+ └── DESIGN.md 详细设计与决策文档
218
216
  ```
219
217
 
220
218
  ---
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.14.5 版本)。
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
- ├── DESIGN.md # 详细设计与决策文档
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.14.12",
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.14.12",
46
- "@liguoshuai/pi-chat-server": "2.14.12"
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 render (small, safe renderer) ----
265
- function escapeHtml(s) {
266
- return s.replace(/&/g, "&")
267
- .replace(/</g, "&lt;")
268
- .replace(/>/g, "&gt;")
269
- .replace(/"/g, "&quot;")
270
- .replace(/'/g, "&#39;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
271
269
  }
272
-
273
- async function copyToClipboard(text) {
274
- if (!text) return false;
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
- return true;
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
- function renderMarkdown(md) {
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, "&quot;").replace(/'/g, "&#39;");
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
- renderSidebar(data.sessions || []);
402
+ state.lastSessions = data.sessions || [];
403
+ renderSidebar(state.lastSessions);
618
404
  } catch (err) {
619
405
  console.warn("refreshSessions error:", err);
620
- renderSidebar([]);
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
- // Instantly render optimistic new session at the top of left sidebar
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 === file) {
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
- scrollBottom();
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 assistantMsgCount = 0;
905
- for (const e of entries) {
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
- const turnTiming = timing ? timing[assistantMsgCount] : null;
928
- if (turnTiming?.turnDuration != null) {
929
- turnDurationMs = turnTiming.turnDuration;
930
- } else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
931
- const diff = msgTs - lastUserTs;
932
- if (diff > 0 && diff < 15 * 60 * 1000) {
933
- turnDurationMs = diff;
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
- el("svg", { html: '<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>' }),
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
- el("svg", { html: '<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>' }),
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
- el("svg", { html: '<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>' }),
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
- function scrollBottom() {
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
- el("svg", { html: '<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>' }),
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");