@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 +3 -3
- package/public/app.js +96 -31
- package/public/index.html +2 -1
- package/public/style.css +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
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.
|
|
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
|
-
//
|
|
376
|
-
|
|
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
|
-
|
|
1581
|
-
if (!
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
-
|
|
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
|
|
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(--
|
|
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>
|