@liguoshuai/pi-web-chat 2.19.1 → 2.20.7

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.19.1",
3
+ "version": "2.20.7",
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.19.1",
46
- "@liguoshuai/pi-chat-server": "2.19.1"
45
+ "@liguoshuai/pi-chat-protocol": "2.20.7",
46
+ "@liguoshuai/pi-chat-server": "2.20.7"
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
@@ -150,7 +150,7 @@ function saveRecentModel(model) {
150
150
  state.recentModels = filtered.slice(0, 4);
151
151
  try {
152
152
  localStorage.setItem("pi_recent_models", JSON.stringify(state.recentModels));
153
- } catch {}
153
+ } catch (e) { console.warn("[models] localStorage save failed:", e); }
154
154
  }
155
155
 
156
156
  async function loadServerConfig() {
@@ -186,7 +186,7 @@ async function loadServerConfig() {
186
186
  state.thinkingLevel = data.defaultModel.thinkingLevel;
187
187
  }
188
188
  }
189
- } catch {}
189
+ } catch (e) { console.warn("[loadServerConfig] failed:", e); }
190
190
  if (!state.cwd) {
191
191
  state.cwd = state.serverCwd || state.homeDir || "";
192
192
  }
@@ -215,7 +215,7 @@ async function openCwdModal() {
215
215
  quicks.push({ label: "服务启动目录", path: state.serverCwd });
216
216
  }
217
217
  let recents = [];
218
- try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch {}
218
+ try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch (e) { console.warn("[cwd] recents parse failed:", e); }
219
219
  recents.forEach(r => {
220
220
  if (r && r !== state.homeDir && r !== state.serverCwd && !quicks.some(q => q.path === r)) {
221
221
  quicks.push({ label: formatCwdDisplay(r), path: r });
@@ -251,7 +251,7 @@ async function confirmCwdChange() {
251
251
  const data = await res.json();
252
252
  if (data.ok && data.path) {
253
253
  let recents = [];
254
- try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch {}
254
+ try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch (e) { console.warn("[cwd] recents parse failed:", e); }
255
255
  recents = [data.path, ...recents.filter(r => r !== data.path)].slice(0, 8);
256
256
  localStorage.setItem("pi_recent_cwds", JSON.stringify(recents));
257
257
 
@@ -282,7 +282,7 @@ if (typeof copyToClipboard === "undefined") {
282
282
  if (!text) return false;
283
283
  try {
284
284
  if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
285
- } catch {}
285
+ } catch (e) { console.warn("[cwd] save failed:", e); }
286
286
  return false;
287
287
  };
288
288
  }
@@ -292,14 +292,15 @@ if (typeof renderMarkdown === "undefined") {
292
292
 
293
293
  // ---- DOM helpers ----
294
294
  const $ = (sel, root = document) => root.querySelector(sel);
295
+ const SVG_TAGS = new Set(["svg","rect","path","circle","line","polyline","polygon","ellipse","g","defs","use","text","tspan","linearGradient","radialGradient","stop","clipPath","mask","pattern","filter","feGaussianBlur","feOffset","feMerge","feMergeNode","animate","animateTransform","animateMotion"]);
295
296
  const el = (tag, props = {}, children = []) => {
296
- const SVG_TAGS = new Set(["svg","rect","path","circle","line","polyline","polygon","ellipse","g","defs","use","text","tspan","linearGradient","radialGradient","stop","clipPath","mask","pattern","filter","feGaussianBlur","feOffset","feMerge","feMergeNode","animate","animateTransform","animateMotion"]);
297
297
  const n = SVG_TAGS.has(tag) ? document.createElementNS("http://www.w3.org/2000/svg", tag) : document.createElement(tag);
298
298
  for (const [k, v] of Object.entries(props)) {
299
- if (k === "class") n.className = v;
299
+ if (k === "class") n.setAttribute("class", v);
300
300
  else if (k === "html") n.innerHTML = v;
301
301
  else if (k === "text") n.textContent = v;
302
302
  else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2).toLowerCase(), v);
303
+ else if (k.startsWith("on")) { /* non-function on* — silently ignore to prevent inline handler injection */ }
303
304
  else if (k === "dataset") Object.assign(n.dataset, v);
304
305
  else n.setAttribute(k, v);
305
306
  }
@@ -367,16 +368,18 @@ function formatDuration(ms) {
367
368
  let liveTimerInterval = null;
368
369
  function startStreamingTimer() {
369
370
  if (liveTimerInterval) return;
371
+ let tickCount = 0;
370
372
  liveTimerInterval = setInterval(() => {
371
373
  const now = Date.now();
374
+ // Refresh cache once per second (every 5th tick) instead of every 200ms
375
+ if (++tickCount % 5 === 0) refreshLiveDurationCache();
372
376
  if (state.turnStartedAt && state.streamingMsgDurationEl) {
373
377
  state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
374
378
  }
375
- // Single combined query instead of two separate querySelectorAll calls.
376
- // Interval at 200ms (5 scans/sec) is still smooth for duration display.
377
- const liveEls = document.querySelectorAll(".thinking-duration.live, .tool-duration.live");
379
+ // PE-3: Use cached live elements instead of querySelectorAll every 200ms
380
+ const liveEls = state._cachedLiveDurations || [];
378
381
  liveEls.forEach((el) => {
379
- if (el._startedAt) {
382
+ if (el._startedAt && el.isConnected) {
380
383
  el.textContent = formatDuration(now - el._startedAt);
381
384
  el.style.display = "";
382
385
  }
@@ -417,7 +420,7 @@ function updateUrlSession(sessionFile) {
417
420
  const query = params.toString();
418
421
  const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
419
422
  window.history.replaceState({ session: sessionFile }, "", newUrl);
420
- } catch {}
423
+ } catch (e) { console.warn("[config] load failed:", e); }
421
424
  }
422
425
 
423
426
  function sameSession(a, b) {
@@ -447,6 +450,11 @@ async function refreshSessions() {
447
450
  function renderSidebar(sessions) {
448
451
  const list = $("#sessionList");
449
452
  if (!list) return;
453
+ // S14: Skip full DOM rebuild if session list hasn't changed (by file+name+count)
454
+ const sig = sessions.map(s => `${s.file}|${s.sessionName || s.firstUser || ""}|${s.messageCount || 0}|${s.isStreaming ? 1 : 0}`).join("\n");
455
+ if (list._lastSig === sig && list._lastCurrent === state.currentSessionFile) return;
456
+ list._lastSig = sig;
457
+ list._lastCurrent = state.currentSessionFile;
450
458
  list.innerHTML = "";
451
459
 
452
460
  const hasCurrentInSessions = Boolean(state.currentSessionFile && sessions.some(s => sameSession(s.file, state.currentSessionFile)));
@@ -467,11 +475,16 @@ function renderSidebar(sessions) {
467
475
 
468
476
  const draftItem = el("div", {
469
477
  class: "session-item active draft-session",
478
+ role: "button",
479
+ tabindex: "0",
470
480
  dataset: { file: state.currentSessionFile || "" },
471
481
  title: state.currentSessionFile || "新对话",
472
482
  onclick: () => {
473
483
  if (state.currentSessionFile) loadSession(state.currentSessionFile);
474
484
  },
485
+ onkeydown: (e) => {
486
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (state.currentSessionFile) loadSession(state.currentSessionFile); }
487
+ },
475
488
  }, [
476
489
  el("div", { class: "title" }, [
477
490
  titleEl,
@@ -514,9 +527,14 @@ function renderSidebar(sessions) {
514
527
 
515
528
  const item = el("div", {
516
529
  class: "session-item" + (sameSession(s.file, state.currentSessionFile) ? " active" : ""),
530
+ role: "button",
531
+ tabindex: "0",
517
532
  dataset: { file: s.file },
518
533
  title: s.file, // hover tooltip = raw jsonl path
519
534
  onclick: () => loadSession(s.file),
535
+ onkeydown: (e) => {
536
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); loadSession(s.file); }
537
+ },
520
538
  }, [
521
539
  el("div", { class: "title" }, [
522
540
  titleEl,
@@ -571,7 +589,7 @@ function startNewSession() {
571
589
  const query = params.toString();
572
590
  const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
573
591
  window.history.replaceState({}, "", newUrl);
574
- } catch {}
592
+ } catch (e) { console.warn("[sidebar] render failed:", e); }
575
593
  $("#topSessionName").textContent = "新对话";
576
594
  updatePageTitle(null);
577
595
  if (wasStreaming) {
@@ -827,7 +845,7 @@ function reconstructFromEntries(entries, timing = null) {
827
845
  try {
828
846
  const parsed = JSON.parse(errMsg);
829
847
  if (parsed.error?.message) errMsg = parsed.error.message;
830
- } catch {}
848
+ } catch (e) { console.warn("[stream] parse failed:", e); }
831
849
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
832
850
  }
833
851
  out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
@@ -953,6 +971,11 @@ function detectImageMimeType(file) {
953
971
 
954
972
  async function processImageFile(file) {
955
973
  const mimeType = detectImageMimeType(file);
974
+ // P1-23: Reject images larger than 20MB to prevent tab crash
975
+ if (file.size > 20 * 1024 * 1024) {
976
+ showToast("图片过大(超过 20MB),请压缩后上传");
977
+ return null;
978
+ }
956
979
  const dataUrl = await new Promise((resolve, reject) => {
957
980
  const reader = new FileReader();
958
981
  reader.onload = () => resolve(reader.result);
@@ -1574,16 +1597,22 @@ function updatePageTitle(title) {
1574
1597
  }
1575
1598
  }
1576
1599
 
1600
+ let _scrollRafId = 0;
1577
1601
  function scrollBottom(force = false) {
1578
1602
  // Don't fight the user during a background-event replay (backfill).
1579
1603
  if (state.isBackfilling) return;
1580
- const chat = $("#chat");
1581
- if (!chat) return;
1582
- // If streaming and user manually scrolled up to read history, don't hijack scroll unless forced!
1583
- if (!force && userScrolledUp && state.streaming) {
1584
- return;
1585
- }
1586
- chat.scrollTop = chat.scrollHeight;
1604
+ // PE-2: Throttle via requestAnimationFrame to avoid layout thrashing on every streaming token
1605
+ if (!force && _scrollRafId) return; // already scheduled
1606
+ if (_scrollRafId) cancelAnimationFrame(_scrollRafId);
1607
+ _scrollRafId = requestAnimationFrame(() => {
1608
+ _scrollRafId = 0;
1609
+ const chat = $("#chat");
1610
+ if (!chat) return;
1611
+ if (!force && userScrolledUp && state.streaming) {
1612
+ return;
1613
+ }
1614
+ chat.scrollTop = chat.scrollHeight;
1615
+ });
1587
1616
  }
1588
1617
 
1589
1618
  function getStreamingFullText() {
@@ -1826,7 +1855,7 @@ function startPingInterval() {
1826
1855
  }
1827
1856
  try {
1828
1857
  state.ws.send(JSON.stringify({ type: "ping" }));
1829
- } catch {}
1858
+ } catch (e) { console.warn("[ws] close failed:", e); }
1830
1859
  }
1831
1860
  }, 15000);
1832
1861
  }
@@ -1880,7 +1909,7 @@ function connectWs(opts = {}) {
1880
1909
  // while a new socket is opening.
1881
1910
  state.ws._suppressOnclose = true;
1882
1911
  state.ws.close();
1883
- } catch {}
1912
+ } catch (e) { console.warn("[ws] reconnect failed:", e); }
1884
1913
  }
1885
1914
 
1886
1915
  isConnecting = true;
@@ -1908,9 +1937,21 @@ function connectWs(opts = {}) {
1908
1937
  }
1909
1938
 
1910
1939
  const token = getAuthToken();
1911
- const tokParam = token ? `&token=${encodeURIComponent(token)}` : "";
1912
- const url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}${tokParam}`;
1913
- const ws = new WebSocket(url);
1940
+ const url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}`;
1941
+ if (!state.wsConnected && !reconnectAttempts) {
1942
+ setConnStatus("reconnecting", "连接中…");
1943
+ }
1944
+ let ws;
1945
+ try {
1946
+ ws = new WebSocket(url);
1947
+ } catch (err) {
1948
+ console.error("[WS] constructor failed:", err);
1949
+ isConnecting = false;
1950
+ wasDisconnected = true;
1951
+ setConnStatus("disconnected", "连接失败");
1952
+ scheduleReconnect();
1953
+ return;
1954
+ }
1914
1955
  state.ws = ws;
1915
1956
  ws._gen = myGen;
1916
1957
 
@@ -1920,6 +1961,11 @@ function connectWs(opts = {}) {
1920
1961
  setConnStatus("connected");
1921
1962
  startPingInterval();
1922
1963
 
1964
+ // Send auth token via in-band message (not URL query param) for security
1965
+ if (token) {
1966
+ sendWs({ type: "auth", token });
1967
+ }
1968
+
1923
1969
  const isReconnecting = wasDisconnected || reconnectAttempts > 0 || opts.isReconnect;
1924
1970
  if (isReconnecting) {
1925
1971
  showToast("网络连接已恢复");
@@ -1956,6 +2002,13 @@ function connectWs(opts = {}) {
1956
2002
  }
1957
2003
  }
1958
2004
 
2005
+ // If the agent was actively streaming, show a hint so the user knows
2006
+ // the task may still be running on the server — reconnection will either
2007
+ // restore the streaming state via backfill or finalize it via get_state.
2008
+ if (state.streaming) {
2009
+ showToast("连接中断,正在重连…", "warn");
2010
+ }
2011
+
1959
2012
  wasDisconnected = true;
1960
2013
  setConnStatus("disconnected");
1961
2014
  scheduleReconnect();
@@ -1984,7 +2037,19 @@ function connectWs(opts = {}) {
1984
2037
  }
1985
2038
  return;
1986
2039
  }
1987
- handlePiMessage(obj);
2040
+ try {
2041
+ handlePiMessage(obj);
2042
+ } catch (e) {
2043
+ console.error("[WS] handlePiMessage error:", e, obj);
2044
+ // Reset streaming state on unexpected errors to avoid permanent lockup
2045
+ if (state.streaming) {
2046
+ state.streaming = false;
2047
+ state.streamingItems = [];
2048
+ state.streamingMsg = null;
2049
+ state.activeToolCalls.clear();
2050
+ updateComposer();
2051
+ }
2052
+ }
1988
2053
  };
1989
2054
  }
1990
2055
 
@@ -2185,7 +2250,7 @@ function handlePiMessage(obj) {
2185
2250
  try {
2186
2251
  const parsed = JSON.parse(errMsg);
2187
2252
  if (parsed.error?.message) errMsg = parsed.error.message;
2188
- } catch {}
2253
+ } catch (e) { console.warn("[msg] handle failed:", e); }
2189
2254
  state.streamingItems.push({ type: "text", text: `⚠️ **${errMsg}**` });
2190
2255
  refreshStreamingContent();
2191
2256
  }
@@ -3289,7 +3354,7 @@ function initSidebarResize() {
3289
3354
  isDragging = false;
3290
3355
  try {
3291
3356
  resizer.releasePointerCapture(e.pointerId);
3292
- } catch {}
3357
+ } catch (e) { console.warn("[image] process failed:", e); }
3293
3358
  document.body.classList.remove("is-resizing");
3294
3359
 
3295
3360
  const finalWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sidebar-width"), 10);
@@ -3703,10 +3768,10 @@ async function init() {
3703
3768
  initSidebarResize();
3704
3769
 
3705
3770
  refreshSessions();
3706
- // start in the disconnected state; connectWs will flip to green on open.
3771
+ // start in the connecting state; connectWs will flip to green on open.
3707
3772
  const initDot = $("#connDot");
3708
3773
  const initLabel = $("#connLabel");
3709
- if (initDot) initDot.style.color = "var(--danger)";
3774
+ if (initDot) initDot.style.color = "var(--warning)";
3710
3775
  if (initLabel) initLabel.textContent = "连接中…";
3711
3776
  $("#sendBtn").disabled = true;
3712
3777
 
package/public/index.html CHANGED
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self';">
6
7
  <meta name="theme-color" content="#18181b">
7
8
  <meta name="apple-mobile-web-app-capable" content="yes">
8
9
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -173,7 +174,7 @@
173
174
  </div>
174
175
 
175
176
  <!-- Toast notification -->
176
- <div class="toast" id="toast"></div>
177
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
177
178
  <script src="/markdown.js"></script>
178
179
  <script src="/app.js"></script>
179
180
  </body>
package/public/style.css CHANGED
@@ -9,7 +9,7 @@
9
9
  --border: #3a3a3a;
10
10
  --text: #ececec;
11
11
  --text-muted: #9b9b9b;
12
- --text-dim: #6f6f6f;
12
+ --text-dim: #848484;
13
13
  --accent: #10a37f;
14
14
  --accent-hover: #0e8c6d;
15
15
  --user-text: #ececec;