@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 +3 -3
- package/public/app.js +137 -36
- package/public/index.html +4 -4
- package/public/style.css +8 -2
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-
|
|
46
|
-
"@liguoshuai/pi-chat-
|
|
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
|
-
|
|
172
|
-
|
|
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.
|
|
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
|
-
//
|
|
367
|
-
|
|
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
|
-
|
|
1562
|
-
if (!
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
-
|
|
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
|
|
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(--
|
|
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> <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
|
|
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-
|
|
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: #
|
|
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);
|