@liguoshuai/pi-web-chat 2.18.13 → 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.18.13",
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-server": "2.18.13",
46
- "@liguoshuai/pi-chat-protocol": "2.18.13"
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() {
@@ -168,8 +168,17 @@ async function loadServerConfig() {
168
168
  }
169
169
  if (data.version) {
170
170
  state.version = data.version;
171
- const verEl = $("#appVersion");
172
- if (verEl) verEl.textContent = `v${data.version}`;
171
+ }
172
+ if (data.piVersion) {
173
+ state.piVersion = data.piVersion;
174
+ }
175
+ const piVerEl = $("#piVersion");
176
+ if (piVerEl) {
177
+ piVerEl.textContent = state.piVersion ? ` v${state.piVersion}` : "";
178
+ }
179
+ const verEl = $("#appVersion");
180
+ if (verEl) {
181
+ verEl.textContent = state.version ? ` v${state.version}` : "";
173
182
  }
174
183
  if (data.defaultModel) {
175
184
  state.defaultModel = data.defaultModel;
@@ -177,7 +186,7 @@ async function loadServerConfig() {
177
186
  state.thinkingLevel = data.defaultModel.thinkingLevel;
178
187
  }
179
188
  }
180
- } catch {}
189
+ } catch (e) { console.warn("[loadServerConfig] failed:", e); }
181
190
  if (!state.cwd) {
182
191
  state.cwd = state.serverCwd || state.homeDir || "";
183
192
  }
@@ -206,7 +215,7 @@ async function openCwdModal() {
206
215
  quicks.push({ label: "服务启动目录", path: state.serverCwd });
207
216
  }
208
217
  let recents = [];
209
- 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); }
210
219
  recents.forEach(r => {
211
220
  if (r && r !== state.homeDir && r !== state.serverCwd && !quicks.some(q => q.path === r)) {
212
221
  quicks.push({ label: formatCwdDisplay(r), path: r });
@@ -242,7 +251,7 @@ async function confirmCwdChange() {
242
251
  const data = await res.json();
243
252
  if (data.ok && data.path) {
244
253
  let recents = [];
245
- 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); }
246
255
  recents = [data.path, ...recents.filter(r => r !== data.path)].slice(0, 8);
247
256
  localStorage.setItem("pi_recent_cwds", JSON.stringify(recents));
248
257
 
@@ -273,7 +282,7 @@ if (typeof copyToClipboard === "undefined") {
273
282
  if (!text) return false;
274
283
  try {
275
284
  if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
276
- } catch {}
285
+ } catch (e) { console.warn("[cwd] save failed:", e); }
277
286
  return false;
278
287
  };
279
288
  }
@@ -283,14 +292,15 @@ if (typeof renderMarkdown === "undefined") {
283
292
 
284
293
  // ---- DOM helpers ----
285
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"]);
286
296
  const el = (tag, props = {}, children = []) => {
287
- 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"]);
288
297
  const n = SVG_TAGS.has(tag) ? document.createElementNS("http://www.w3.org/2000/svg", tag) : document.createElement(tag);
289
298
  for (const [k, v] of Object.entries(props)) {
290
- if (k === "class") n.className = v;
299
+ if (k === "class") n.setAttribute("class", v);
291
300
  else if (k === "html") n.innerHTML = v;
292
301
  else if (k === "text") n.textContent = v;
293
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 */ }
294
304
  else if (k === "dataset") Object.assign(n.dataset, v);
295
305
  else n.setAttribute(k, v);
296
306
  }
@@ -358,16 +368,18 @@ function formatDuration(ms) {
358
368
  let liveTimerInterval = null;
359
369
  function startStreamingTimer() {
360
370
  if (liveTimerInterval) return;
371
+ let tickCount = 0;
361
372
  liveTimerInterval = setInterval(() => {
362
373
  const now = Date.now();
374
+ // Refresh cache once per second (every 5th tick) instead of every 200ms
375
+ if (++tickCount % 5 === 0) refreshLiveDurationCache();
363
376
  if (state.turnStartedAt && state.streamingMsgDurationEl) {
364
377
  state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
365
378
  }
366
- // Single combined query instead of two separate querySelectorAll calls.
367
- // Interval at 200ms (5 scans/sec) is still smooth for duration display.
368
- 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 || [];
369
381
  liveEls.forEach((el) => {
370
- if (el._startedAt) {
382
+ if (el._startedAt && el.isConnected) {
371
383
  el.textContent = formatDuration(now - el._startedAt);
372
384
  el.style.display = "";
373
385
  }
@@ -408,7 +420,7 @@ function updateUrlSession(sessionFile) {
408
420
  const query = params.toString();
409
421
  const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
410
422
  window.history.replaceState({ session: sessionFile }, "", newUrl);
411
- } catch {}
423
+ } catch (e) { console.warn("[config] load failed:", e); }
412
424
  }
413
425
 
414
426
  function sameSession(a, b) {
@@ -438,6 +450,11 @@ async function refreshSessions() {
438
450
  function renderSidebar(sessions) {
439
451
  const list = $("#sessionList");
440
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;
441
458
  list.innerHTML = "";
442
459
 
443
460
  const hasCurrentInSessions = Boolean(state.currentSessionFile && sessions.some(s => sameSession(s.file, state.currentSessionFile)));
@@ -458,11 +475,16 @@ function renderSidebar(sessions) {
458
475
 
459
476
  const draftItem = el("div", {
460
477
  class: "session-item active draft-session",
478
+ role: "button",
479
+ tabindex: "0",
461
480
  dataset: { file: state.currentSessionFile || "" },
462
481
  title: state.currentSessionFile || "新对话",
463
482
  onclick: () => {
464
483
  if (state.currentSessionFile) loadSession(state.currentSessionFile);
465
484
  },
485
+ onkeydown: (e) => {
486
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (state.currentSessionFile) loadSession(state.currentSessionFile); }
487
+ },
466
488
  }, [
467
489
  el("div", { class: "title" }, [
468
490
  titleEl,
@@ -505,9 +527,14 @@ function renderSidebar(sessions) {
505
527
 
506
528
  const item = el("div", {
507
529
  class: "session-item" + (sameSession(s.file, state.currentSessionFile) ? " active" : ""),
530
+ role: "button",
531
+ tabindex: "0",
508
532
  dataset: { file: s.file },
509
533
  title: s.file, // hover tooltip = raw jsonl path
510
534
  onclick: () => loadSession(s.file),
535
+ onkeydown: (e) => {
536
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); loadSession(s.file); }
537
+ },
511
538
  }, [
512
539
  el("div", { class: "title" }, [
513
540
  titleEl,
@@ -562,7 +589,7 @@ function startNewSession() {
562
589
  const query = params.toString();
563
590
  const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
564
591
  window.history.replaceState({}, "", newUrl);
565
- } catch {}
592
+ } catch (e) { console.warn("[sidebar] render failed:", e); }
566
593
  $("#topSessionName").textContent = "新对话";
567
594
  updatePageTitle(null);
568
595
  if (wasStreaming) {
@@ -757,6 +784,7 @@ function reconstructFromEntries(entries, timing = null) {
757
784
  lastUserTs = msgTs;
758
785
  turnIndex++;
759
786
  thinkingIdx = 0; // reset thinking index for the new turn
787
+ const isSteer = Boolean(m.isSteer || e.isSteer || e.customType === "steer" || m.customType === "steer");
760
788
  // Extract optional images from user message content array
761
789
  let images = [];
762
790
  if (Array.isArray(m.content)) {
@@ -768,7 +796,7 @@ function reconstructFromEntries(entries, timing = null) {
768
796
  url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
769
797
  }));
770
798
  }
771
- out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
799
+ out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs, isSteer });
772
800
  } else if (m.role === "assistant") {
773
801
  let turnDurationMs = null;
774
802
  // Try timing data first, then fall back to timestamp heuristic.
@@ -817,7 +845,7 @@ function reconstructFromEntries(entries, timing = null) {
817
845
  try {
818
846
  const parsed = JSON.parse(errMsg);
819
847
  if (parsed.error?.message) errMsg = parsed.error.message;
820
- } catch {}
848
+ } catch (e) { console.warn("[stream] parse failed:", e); }
821
849
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
822
850
  }
823
851
  out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
@@ -836,10 +864,19 @@ function extractContentText(content) {
836
864
  .join("\n\n");
837
865
  }
838
866
 
839
- function appendSystemNotice(text) {
867
+ function appendSystemNotice(text, replaceIfLast = true) {
840
868
  if (!text) return;
841
869
  const chatInner = $("#chat-inner");
842
870
  if (!chatInner) return;
871
+ const lastChild = chatInner.lastElementChild;
872
+ if (replaceIfLast && lastChild && lastChild.classList.contains("system-notice-divider")) {
873
+ const textEl = lastChild.querySelector(".system-notice-text");
874
+ if (textEl) {
875
+ textEl.textContent = text;
876
+ scrollBottom();
877
+ return;
878
+ }
879
+ }
843
880
  const node = el("div", { class: "system-notice-divider" }, [
844
881
  el("span", { class: "system-notice-text", text })
845
882
  ]);
@@ -934,6 +971,11 @@ function detectImageMimeType(file) {
934
971
 
935
972
  async function processImageFile(file) {
936
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
+ }
937
979
  const dataUrl = await new Promise((resolve, reject) => {
938
980
  const reader = new FileReader();
939
981
  reader.onload = () => resolve(reader.result);
@@ -1555,16 +1597,22 @@ function updatePageTitle(title) {
1555
1597
  }
1556
1598
  }
1557
1599
 
1600
+ let _scrollRafId = 0;
1558
1601
  function scrollBottom(force = false) {
1559
1602
  // Don't fight the user during a background-event replay (backfill).
1560
1603
  if (state.isBackfilling) return;
1561
- const chat = $("#chat");
1562
- if (!chat) return;
1563
- // If streaming and user manually scrolled up to read history, don't hijack scroll unless forced!
1564
- if (!force && userScrolledUp && state.streaming) {
1565
- return;
1566
- }
1567
- 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
+ });
1568
1616
  }
1569
1617
 
1570
1618
  function getStreamingFullText() {
@@ -1651,6 +1699,9 @@ function refreshStreamingContent() {
1651
1699
  el("span", { class: "thinking-spinner" }),
1652
1700
  el("span", { class: "thinking-label", text: "正在思考中…" })
1653
1701
  ]));
1702
+ content.appendChild(el("div", { class: "streaming-cursor-row" }, [
1703
+ el("span", { class: "typing-cursor" })
1704
+ ]));
1654
1705
  }
1655
1706
  }
1656
1707
  scrollBottom();
@@ -1663,7 +1714,7 @@ function refreshStreamingContent() {
1663
1714
  const lastItem = items[items.length - 1];
1664
1715
  if (lastItem.type === "text") {
1665
1716
  const lastEl = content.lastElementChild;
1666
- if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block")) {
1717
+ if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block") && !lastEl.classList.contains("streaming-cursor-row")) {
1667
1718
  const showCursor = state.streaming;
1668
1719
  lastEl.innerHTML = renderMarkdown(lastItem.text) + (showCursor ? '<span class="typing-cursor"></span>' : "");
1669
1720
  scrollBottom();
@@ -1675,6 +1726,7 @@ function refreshStreamingContent() {
1675
1726
  // Full rebuild (new items added or structure changed)
1676
1727
  content.innerHTML = "";
1677
1728
  const lastIdx = items.length - 1;
1729
+ let hasText = false;
1678
1730
  for (let i = 0; i < items.length; i++) {
1679
1731
  const item = items[i];
1680
1732
  const isLast = (i === lastIdx);
@@ -1684,10 +1736,16 @@ function refreshStreamingContent() {
1684
1736
  } else if (item.type === "tool") {
1685
1737
  if (item.tc?.block) content.appendChild(item.tc.block);
1686
1738
  } else if (item.type === "text") {
1739
+ hasText = true;
1687
1740
  const showCursor = state.streaming && isLast;
1688
1741
  content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
1689
1742
  }
1690
1743
  }
1744
+ if (state.streaming && !hasText) {
1745
+ content.appendChild(el("div", { class: "streaming-cursor-row" }, [
1746
+ el("span", { class: "typing-cursor" })
1747
+ ]));
1748
+ }
1691
1749
  lastRenderedItemCount = items.length;
1692
1750
  scrollBottom();
1693
1751
  }
@@ -1797,7 +1855,7 @@ function startPingInterval() {
1797
1855
  }
1798
1856
  try {
1799
1857
  state.ws.send(JSON.stringify({ type: "ping" }));
1800
- } catch {}
1858
+ } catch (e) { console.warn("[ws] close failed:", e); }
1801
1859
  }
1802
1860
  }, 15000);
1803
1861
  }
@@ -1851,7 +1909,7 @@ function connectWs(opts = {}) {
1851
1909
  // while a new socket is opening.
1852
1910
  state.ws._suppressOnclose = true;
1853
1911
  state.ws.close();
1854
- } catch {}
1912
+ } catch (e) { console.warn("[ws] reconnect failed:", e); }
1855
1913
  }
1856
1914
 
1857
1915
  isConnecting = true;
@@ -1879,9 +1937,21 @@ function connectWs(opts = {}) {
1879
1937
  }
1880
1938
 
1881
1939
  const token = getAuthToken();
1882
- const tokParam = token ? `&token=${encodeURIComponent(token)}` : "";
1883
- const url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}${tokParam}`;
1884
- 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
+ }
1885
1955
  state.ws = ws;
1886
1956
  ws._gen = myGen;
1887
1957
 
@@ -1891,6 +1961,11 @@ function connectWs(opts = {}) {
1891
1961
  setConnStatus("connected");
1892
1962
  startPingInterval();
1893
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
+
1894
1969
  const isReconnecting = wasDisconnected || reconnectAttempts > 0 || opts.isReconnect;
1895
1970
  if (isReconnecting) {
1896
1971
  showToast("网络连接已恢复");
@@ -1927,6 +2002,13 @@ function connectWs(opts = {}) {
1927
2002
  }
1928
2003
  }
1929
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
+
1930
2012
  wasDisconnected = true;
1931
2013
  setConnStatus("disconnected");
1932
2014
  scheduleReconnect();
@@ -1955,7 +2037,19 @@ function connectWs(opts = {}) {
1955
2037
  }
1956
2038
  return;
1957
2039
  }
1958
- 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
+ }
1959
2053
  };
1960
2054
  }
1961
2055
 
@@ -2156,7 +2250,7 @@ function handlePiMessage(obj) {
2156
2250
  try {
2157
2251
  const parsed = JSON.parse(errMsg);
2158
2252
  if (parsed.error?.message) errMsg = parsed.error.message;
2159
- } catch {}
2253
+ } catch (e) { console.warn("[msg] handle failed:", e); }
2160
2254
  state.streamingItems.push({ type: "text", text: `⚠️ **${errMsg}**` });
2161
2255
  refreshStreamingContent();
2162
2256
  }
@@ -3071,6 +3165,13 @@ function submitSteer() {
3071
3165
  return;
3072
3166
  }
3073
3167
 
3168
+ // If there was an active streaming assistant block before steering, finalize it
3169
+ // so that subsequent assistant chunks render in a new block below this steer message!
3170
+ if (state.streamingMsg) {
3171
+ finalizeStreamingMsg();
3172
+ state.streaming = true; // resume streaming flag for subsequent response
3173
+ }
3174
+
3074
3175
  appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
3075
3176
  ta.value = "";
3076
3177
  autoResize();
@@ -3253,7 +3354,7 @@ function initSidebarResize() {
3253
3354
  isDragging = false;
3254
3355
  try {
3255
3356
  resizer.releasePointerCapture(e.pointerId);
3256
- } catch {}
3357
+ } catch (e) { console.warn("[image] process failed:", e); }
3257
3358
  document.body.classList.remove("is-resizing");
3258
3359
 
3259
3360
  const finalWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sidebar-width"), 10);
@@ -3667,10 +3768,10 @@ async function init() {
3667
3768
  initSidebarResize();
3668
3769
 
3669
3770
  refreshSessions();
3670
- // 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.
3671
3772
  const initDot = $("#connDot");
3672
3773
  const initLabel = $("#connLabel");
3673
- if (initDot) initDot.style.color = "var(--danger)";
3774
+ if (initDot) initDot.style.color = "var(--warning)";
3674
3775
  if (initLabel) initLabel.textContent = "连接中…";
3675
3776
  $("#sendBtn").disabled = true;
3676
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">
@@ -30,11 +31,10 @@
30
31
  <div id="connStatus" class="conn-status" title="点击可重新连接"><span id="connDot" style="color: var(--danger);">●</span>&nbsp;<span id="connLabel">连接中…</span></div>
31
32
  <div class="sidebar-bottom-meta">
32
33
  <div class="sidebar-bottom-links">
33
- <a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a>
34
+ <a href="https://pi.dev" target="_blank" rel="noopener">pi<span id="piVersion"></span></a>
34
35
  <span class="sidebar-link-sep">·</span>
35
- <a href="https://github.com/liguoshuai-1990/pi-web-chat" target="_blank" rel="noopener">pi-web-chat</a>
36
+ <a href="https://github.com/liguoshuai-1990/pi-chat" target="_blank" rel="noopener">pi-chat<span id="appVersion"></span></a>
36
37
  </div>
37
- <span id="appVersion" class="app-version" title="pi-web-chat 版本"></span>
38
38
  </div>
39
39
  </div>
40
40
  <div class="sidebar-resizer" id="sidebarResizer" title="拖拽调整侧边栏宽度,双击恢复默认"></div>
@@ -174,7 +174,7 @@
174
174
  </div>
175
175
 
176
176
  <!-- Toast notification -->
177
- <div class="toast" id="toast"></div>
177
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
178
178
  <script src="/markdown.js"></script>
179
179
  <script src="/app.js"></script>
180
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;
@@ -342,7 +342,6 @@ body {
342
342
  margin-top: 6px;
343
343
  display: flex;
344
344
  align-items: center;
345
- justify-content: space-between;
346
345
  gap: 8px;
347
346
  }
348
347
  .sidebar-bottom-links {
@@ -928,6 +927,13 @@ body {
928
927
  to { opacity: 1; transform: scale(1.25); }
929
928
  }
930
929
 
930
+ .streaming-cursor-row {
931
+ margin-top: 6px;
932
+ line-height: 1;
933
+ }
934
+ .streaming-cursor-row .typing-cursor::after {
935
+ margin-left: 0;
936
+ }
931
937
  .typing-cursor::after {
932
938
  content: "▋"; display: inline-block; margin-left: 2px;
933
939
  animation: blink 1s steps(2) infinite; color: var(--accent);