@liguoshuai/pi-web-chat 2.15.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.15.4",
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,8 +42,8 @@
42
42
  "dependencies": {
43
43
  "express": "^4.21.2",
44
44
  "ws": "^8.18.0",
45
- "@liguoshuai/pi-chat-protocol": "2.15.4",
46
- "@liguoshuai/pi-chat-server": "2.15.4"
45
+ "@liguoshuai/pi-chat-protocol": "2.16.5",
46
+ "@liguoshuai/pi-chat-server": "2.16.5"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/markdown.js && node --check public/app.js",
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;
@@ -308,9 +309,11 @@ const makeCopyIconSvg = () => el("svg", {
308
309
  stroke: "currentColor",
309
310
  "stroke-width": "2",
310
311
  "stroke-linecap": "round",
311
- "stroke-linejoin": "round",
312
- 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>'
313
- });
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
+ ]);
314
317
 
315
318
  // ---- Message Time Formatter ----
316
319
  function formatMessageTime(ts) {
@@ -396,10 +399,14 @@ async function refreshSessions() {
396
399
  try {
397
400
  const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
398
401
  const data = await res.json();
399
- renderSidebar(data.sessions || []);
402
+ state.lastSessions = data.sessions || [];
403
+ renderSidebar(state.lastSessions);
400
404
  } catch (err) {
401
405
  console.warn("refreshSessions error:", err);
402
- 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
+ }
403
410
  }
404
411
  }
405
412
 
@@ -527,6 +534,7 @@ function startNewSession() {
527
534
  window.history.replaceState({}, "", window.location.pathname);
528
535
  } catch {}
529
536
  $("#topSessionName").textContent = "新对话";
537
+ updatePageTitle(null);
530
538
  if (wasStreaming) {
531
539
  showToast("前一个会话已转入后台继续运行");
532
540
  }
@@ -534,8 +542,8 @@ function startNewSession() {
534
542
  if (window.innerWidth <= 768) closeSidebar();
535
543
 
536
544
  connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
537
- // Instantly render optimistic new session at the top of left sidebar
538
- renderSidebar([]);
545
+ // Keep existing sessions in sidebar and immediately display new draft item
546
+ renderSidebar(state.lastSessions || []);
539
547
  refreshSessions();
540
548
  }
541
549
 
@@ -553,7 +561,7 @@ async function deleteSession(file, title) {
553
561
  return;
554
562
  }
555
563
  showToast("会话已删除");
556
- if (state.currentSessionFile === file) {
564
+ if (sameSession(state.currentSessionFile, file)) {
557
565
  startNewSession();
558
566
  } else {
559
567
  await refreshSessions();
@@ -586,6 +594,12 @@ function initMobileToolbarFab() {
586
594
  } else {
587
595
  fab.classList.remove("visible");
588
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
+ }
589
603
  };
590
604
 
591
605
  chat.addEventListener("scroll", onScroll);
@@ -616,6 +630,7 @@ async function syncSessionHistory(file, force = false) {
616
630
 
617
631
  const topName = data.sessionName || data.firstUser || "新对话";
618
632
  $("#topSessionName").textContent = topName;
633
+ updatePageTitle(topName);
619
634
 
620
635
  // Only overwrite chat if not actively backfilling
621
636
  if (!state.isBackfilling && (force || !state.streaming)) {
@@ -625,7 +640,8 @@ async function syncSessionHistory(file, force = false) {
625
640
  for (const m of msgs) {
626
641
  appendMessageNode(m.role, m);
627
642
  }
628
- scrollBottom();
643
+ userScrolledUp = false;
644
+ scrollBottom(true);
629
645
  refreshSessions();
630
646
  }
631
647
  } catch (e) {
@@ -681,16 +697,39 @@ function reconstructFromEntries(entries, timing = null) {
681
697
  }
682
698
  }
683
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
+
684
719
  const out = [];
685
720
  let lastUserTs = null;
686
- let assistantMsgCount = 0;
687
- 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];
688
725
  if (e.type !== "message") continue;
689
726
  const m = e.message;
690
727
  if (!m || m.role === "bashExecution") continue;
691
728
  const msgTs = parseEntryTimestamp(m.timestamp || e.timestamp);
692
729
  if (m.role === "user") {
693
730
  lastUserTs = msgTs;
731
+ turnIndex++;
732
+ thinkingIdx = 0; // reset thinking index for the new turn
694
733
  // Extract optional images from user message content array
695
734
  let images = [];
696
735
  if (Array.isArray(m.content)) {
@@ -705,17 +744,19 @@ function reconstructFromEntries(entries, timing = null) {
705
744
  out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
706
745
  } else if (m.role === "assistant") {
707
746
  let turnDurationMs = null;
708
- // Try timing data first, then fall back to timestamp heuristic
709
- const turnTiming = timing ? timing[assistantMsgCount] : null;
710
- if (turnTiming?.turnDuration != null) {
711
- turnDurationMs = turnTiming.turnDuration;
712
- } else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
713
- const diff = msgTs - lastUserTs;
714
- if (diff > 0 && diff < 15 * 60 * 1000) {
715
- 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
+ }
716
758
  }
717
759
  }
718
- let thinkingIdx = 0;
719
760
  const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
720
761
  const content = rawContent.map(part => {
721
762
  if (part && part.type === "thinking") {
@@ -753,13 +794,11 @@ function reconstructFromEntries(entries, timing = null) {
753
794
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
754
795
  }
755
796
  out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
756
- assistantMsgCount++;
757
797
  }
758
798
  // toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
759
799
  }
760
800
  return out;
761
801
  }
762
-
763
802
  function extractContentText(content) {
764
803
  if (typeof content === "string") return content;
765
804
  if (!Array.isArray(content)) return "";
@@ -1025,6 +1064,22 @@ async function handleIncomingFiles(files) {
1025
1064
 
1026
1065
  const handleImageFiles = handleIncomingFiles;
1027
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
+
1028
1083
  function exportCurrentSession() {
1029
1084
  const chatInner = $("#chat-inner");
1030
1085
  if (!chatInner || chatInner.children.length === 0) {
@@ -1388,10 +1443,25 @@ function summaryArgs(name, args) {
1388
1443
  } catch { return ""; }
1389
1444
  }
1390
1445
 
1391
- 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) {
1392
1457
  // Don't fight the user during a background-event replay (backfill).
1393
1458
  if (state.isBackfilling) return;
1394
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
+ }
1395
1465
  chat.scrollTop = chat.scrollHeight;
1396
1466
  }
1397
1467
 
@@ -1873,6 +1943,19 @@ function handlePiMessage(obj) {
1873
1943
  state.aborting = false;
1874
1944
  setComposerStreaming(false);
1875
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
+ }
1876
1959
  else if (obj.command === "switch_session" && obj.success) {
1877
1960
  // ask pi for current state so we can get session id, name
1878
1961
  sendWs({ type: "get_state" });
@@ -2838,6 +2921,8 @@ function submitSteer() {
2838
2921
  appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
2839
2922
  ta.value = "";
2840
2923
  autoResize();
2924
+ userScrolledUp = false;
2925
+ scrollBottom(true);
2841
2926
  updateComposerUI();
2842
2927
 
2843
2928
  if (hint) hint.textContent = "已插入指导指令!pi 将在当前轮次中实时接收并调整方向。";
@@ -2882,6 +2967,8 @@ function submitPrompt() {
2882
2967
  const now = Date.now();
2883
2968
  // Render the user's message locally for instant feedback.
2884
2969
  appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
2970
+ userScrolledUp = false;
2971
+ scrollBottom(true);
2885
2972
 
2886
2973
  ta.value = "";
2887
2974
  state.attachedImages = [];
@@ -2898,6 +2985,7 @@ function submitPrompt() {
2898
2985
  if (state.currentSessionFile == null && text) {
2899
2986
  const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
2900
2987
  $("#topSessionName").textContent = promptTitle;
2988
+ updatePageTitle(promptTitle);
2901
2989
  sendWs({ type: "set_session_name", name: promptTitle });
2902
2990
 
2903
2991
  // Update draft session item in sidebar immediately
@@ -2929,8 +3017,18 @@ function submitPrompt() {
2929
3017
  state.streamingMsgDurationEl = null;
2930
3018
  if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
2931
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
+ }
2932
3030
  const hint = $(".composer-hint");
2933
- if (hint) hint.textContent = "发送失败:WebSocket 连接已断开。正在尝试重连…";
3031
+ if (hint) hint.textContent = "发送失败:WebSocket 连接已断开,已为你恢复输入内容。正在尝试重连…";
2934
3032
  scheduleReconnect(0);
2935
3033
  }
2936
3034
  }
@@ -3142,6 +3240,12 @@ async function init() {
3142
3240
  exportBtn.addEventListener("click", exportCurrentSession);
3143
3241
  }
3144
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
+
3145
3249
  // Image and file attach / picker / paste / drag-and-drop
3146
3250
  const btnAttach = $("#btnAttachImage");
3147
3251
  const fileInput = $("#imageFileInput");
package/public/index.html CHANGED
@@ -76,6 +76,14 @@
76
76
  </svg>
77
77
  <span class="topbar-action-label">导出</span>
78
78
  </button>
79
+ <button class="btn-topbar-action" id="btnCompactChat" title="压缩当前上下文(长会话变慢时使用)">
80
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
81
+ <polyline points="4 7 4 4 20 4 20 7"></polyline>
82
+ <line x1="9" y1="20" x2="15" y2="20"></line>
83
+ <line x1="12" y1="4" x2="12" y2="20"></line>
84
+ </svg>
85
+ <span class="topbar-action-label">压缩</span>
86
+ </button>
79
87
  <div class="model-menu" id="modelMenu"></div>
80
88
  <div class="thinking-menu" id="thinkingMenu"></div>
81
89
  </div>
@@ -42,7 +42,7 @@
42
42
  function sanitizeUrl(url) {
43
43
  if (!url) return "#";
44
44
  const trimmed = url.trim();
45
- if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
45
+ if (/^(https?:\/\/|mailto:|#)/i.test(trimmed)) {
46
46
  return trimmed.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
47
47
  }
48
48
  return "#";
@@ -111,6 +111,7 @@
111
111
  }
112
112
  let outHtml = "";
113
113
  let para = [];
114
+ let quoteLines = [];
114
115
  let linkListOpen = null;
115
116
  let linkListOrdered = null;
116
117
 
@@ -122,6 +123,13 @@
122
123
  }
123
124
  }
124
125
 
126
+ function flushQuote() {
127
+ if (quoteLines.length === 0) return;
128
+ const block = quoteLines.map(l => mdInlineBlock(l)).join("<br>");
129
+ quoteLines = [];
130
+ outHtml += `<blockquote>${block}</blockquote>`;
131
+ }
132
+
125
133
  function flushPara() {
126
134
  if (para.length === 0) return;
127
135
  const block = para.join("\n").trim();
@@ -132,30 +140,35 @@
132
140
  if (seg.kind === "table") {
133
141
  flushPara();
134
142
  flushList();
143
+ flushQuote();
135
144
  outHtml += mdTable(seg.lines);
136
145
  } else if (seg.kind === "line") {
137
146
  // horizontal rule
138
147
  if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
139
148
  flushPara();
140
149
  flushList();
150
+ flushQuote();
141
151
  outHtml += "<hr>";
142
152
  } else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
143
153
  // headings
144
154
  const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
145
155
  flushPara();
146
156
  flushList();
157
+ flushQuote();
147
158
  const level = m[1].length;
148
159
  outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
149
160
  } else if (/^\s*$/.test(seg.text)) {
150
161
  flushPara();
151
162
  flushList();
163
+ flushQuote();
152
164
  } else if (/^>\s?/.test(seg.text)) {
153
- // blockquote line
165
+ // blockquote line (accumulate adjacent lines into a single blockquote)
154
166
  flushPara();
155
167
  flushList();
156
- outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
168
+ quoteLines.push(seg.text.replace(/^>\s?/, ""));
157
169
  } else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
158
170
  // list item
171
+ flushQuote();
159
172
  const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
160
173
  if (!linkListOpen || linkListOrdered !== isOrdered) {
161
174
  flushPara();
@@ -176,11 +189,13 @@
176
189
  outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
177
190
  } else {
178
191
  flushList();
192
+ flushQuote();
179
193
  para.push(seg.text);
180
194
  }
181
195
  }
182
196
  }
183
197
  flushList();
198
+ flushQuote();
184
199
  flushPara();
185
200
  // restore inline code
186
201
  outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
package/public/style.css CHANGED
@@ -741,6 +741,9 @@ body {
741
741
  stroke-linejoin: round;
742
742
  flex-shrink: 0;
743
743
  }
744
+ .btn-copy-code svg *, .btn-copy-msg svg *, .btn-copy-tool svg * {
745
+ fill: none;
746
+ }
744
747
  .msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
745
748
  .msg.assistant .content li { margin: 4px 0; }
746
749
  .msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3,