@liguoshuai/pi-web-chat 2.1.0 → 2.3.0
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/docs/CHANGELOG.md +38 -0
- package/package.json +4 -3
- package/public/app.js +52 -20
- package/public/style.css +35 -0
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Fixed & Improved
|
|
13
|
+
- **后台异步长任务稳定性与持久化机制全面强化 (Background Async Persistence & Lifetime Fix)**:
|
|
14
|
+
- **移除 30 分钟无条件强杀定时器**:将 `MAX_AGENT_LIFETIME_MS` 默认设为 `0`(禁用硬超时杀进程)。若用户配置了正数生命周期上限,也会严格判断 `isBusy`(正在生成/执行工具中)与 `hasListeners`(仍有客户端监听),任务执行期间绝不强杀子进程。
|
|
15
|
+
- **扩大离线事件环形缓冲区**:将 `EVENT_BUFFER_SIZE` 默认容量从 2000 提升至 5000,保障长时间后台离线输出不丢帧。
|
|
16
|
+
- **Web 端平滑多任务切换与静默转入后台 (Seamless Background Session Switch)**:
|
|
17
|
+
- 移除了在生成过程中新建会话(`startNewSession`)或侧边栏切换会话(`loadSession`)时的强制中断(`abortGeneration()`)逻辑。
|
|
18
|
+
- 切换或新建会话时,原会话平滑转入后台静默继续执行,并弹出轻提示告知用户。
|
|
19
|
+
- 修复了断线重连(`ws.onopen`)与离线事件增量回放(Backfill)之间的 DOM 渲染竞态条件,避免流式内容被意外清除。
|
|
20
|
+
- **侧边栏会话后台执行状态感知 (Sidebar Background Execution Indicator)**:
|
|
21
|
+
- 服务端 `/api/sessions` 接口扩展返回 `isStreaming` 状态字段,精准反馈每个会话对应的后台 Agent 是否正在忙碌。
|
|
22
|
+
- Web 端侧边栏为正在后台生成中的会话实时展示 `⚡ 运行中` 呼吸动画状态徽标。
|
|
23
|
+
- 页面可见性恢复(`visibilitychange`)及定时后台状态轮询自动刷新各会话状态。
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## [2.2.0] - 2026-08-30
|
|
28
|
+
|
|
29
|
+
### Fixed & Improved
|
|
30
|
+
- **服务端会话路径解析与遍历安全加固 (Session Path Resolution & Security)**:
|
|
31
|
+
- 修复了 `/api/session`(GET 与 DELETE)在处理会话相对路径时由于错误相对路径基准导致的误拦截(403 Forbidden),增强为相对于 `config.sessionsDir` 的安全解析并保留严格的路径越界校验与符号链接真实路径防穿透防护。
|
|
32
|
+
- 会话扫描增强为多层级安全目录递归遍历(`listAllSessionFiles`),支持更深的会话目录结构与符号链接解析。
|
|
33
|
+
- 为服务端 `sessionMetadataCache` 增加 LRU/最大条目上限限制(5000条),防止长期运行内存泄漏。
|
|
34
|
+
- **服务端网关稳健性与跨平台优化 (Gateway Robustness & Cross-Platform)**:
|
|
35
|
+
- 完善了 WebSocket 握手鉴权响应对请求 `id` 的透传关联,优化了 `PING`/`heartbeat` 的 ID 支持。
|
|
36
|
+
- 规范了 `PROMPT` 消息中的 `message` 字符串与 `images` 数组格式,防止向 Pi 进程传递 `undefined`。
|
|
37
|
+
- 改进了 SSE 客户端异常断开时的错误捕获与失效监听器及时清理。
|
|
38
|
+
- 增强了 `resolvePiBin` 的跨平台检测路径,支持 macOS Homebrew、Linux `~/.local/bin`、`~/.cargo/bin` 等常见环境。
|
|
39
|
+
- **Web 端多端同步与渲染优化 (Web UI & Markdown Rendering)**:
|
|
40
|
+
- 修复了 `remote_user_prompt` 事件未同步呈现多端上传的 `images` 附件的问题。
|
|
41
|
+
- 修复了 Markdown 解析器中 URL 包含查询参数时因多次转义造成的双重转义(Double Escaping)问题,并支持 `mailto:` 链接解析。
|
|
42
|
+
- **协议库与 TypeScript 类型定义完善 (`@liguoshuai/pi-chat-protocol`)**:
|
|
43
|
+
- 新增 `createSetSessionNameMessage`、`createGetEntriesMessage`、`createGetStateMessage`、`createGetAvailableModelsMessage`、`createExtensionUiResponseMessage` 辅助构造方法。
|
|
44
|
+
- 增强了 `SET_SESSION_NAME` 与 `SET_THINKING_LEVEL` 的数据校验与 TypeScript 类型声明。
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
10
48
|
## [2.1.0] - 2026-08-30
|
|
11
49
|
|
|
12
50
|
### Fixed & Improved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
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",
|
|
@@ -40,12 +40,13 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@liguoshuai/pi-chat-protocol": "2.
|
|
44
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
43
|
+
"@liguoshuai/pi-chat-protocol": "2.3.0",
|
|
44
|
+
"@liguoshuai/pi-chat-server": "2.3.0",
|
|
45
45
|
"express": "^4.21.2",
|
|
46
46
|
"ws": "^8.18.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
+
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/app.js",
|
|
49
50
|
"start": "node server.js",
|
|
50
51
|
"dev": "node --watch server.js",
|
|
51
52
|
"test": "node --test"
|
package/public/app.js
CHANGED
|
@@ -456,7 +456,7 @@ function sanitizeUrl(url) {
|
|
|
456
456
|
if (!url) return "#";
|
|
457
457
|
const trimmed = url.trim();
|
|
458
458
|
if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
|
|
459
|
-
return
|
|
459
|
+
return trimmed.replace(/"/g, """).replace(/'/g, "'");
|
|
460
460
|
}
|
|
461
461
|
return "#";
|
|
462
462
|
}
|
|
@@ -474,7 +474,7 @@ function mdInlineBlock(text) {
|
|
|
474
474
|
// italic (underscore): only match boundary/space so identifier_names are preserved
|
|
475
475
|
s = s.replace(/(^|[\s(\[<,;:])_([^_\s](?:[^_]*?[^_\s])?)_(?=[\s)\]>,;:!?.]|$)/g, "$1<em>$2</em>");
|
|
476
476
|
// links [txt](url) - strictly sanitize URL
|
|
477
|
-
s = s.replace(/\[([^\]]+)\]\((https
|
|
477
|
+
s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (match, txt, url) => {
|
|
478
478
|
const safeUrl = sanitizeUrl(url);
|
|
479
479
|
return `<a href="${safeUrl}" target="_blank" rel="noopener">${txt}</a>`;
|
|
480
480
|
});
|
|
@@ -546,6 +546,17 @@ function renderSidebar(sessions) {
|
|
|
546
546
|
},
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
+
const isRunning = Boolean(s.isStreaming || (s.file === state.currentSessionFile && state.streaming));
|
|
550
|
+
const runningBadge = isRunning ? el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
551
|
+
el("span", { class: "spinner-dot" }),
|
|
552
|
+
el("span", { text: "运行中" }),
|
|
553
|
+
]) : null;
|
|
554
|
+
|
|
555
|
+
const titleEl = el("div", { class: "session-item-name" }, [
|
|
556
|
+
el("span", { text: title }),
|
|
557
|
+
...(runningBadge ? [runningBadge] : []),
|
|
558
|
+
]);
|
|
559
|
+
|
|
549
560
|
const item = el("div", {
|
|
550
561
|
class: "session-item" + (s.file === state.currentSessionFile ? " active" : ""),
|
|
551
562
|
dataset: { file: s.file },
|
|
@@ -553,7 +564,7 @@ function renderSidebar(sessions) {
|
|
|
553
564
|
onclick: () => loadSession(s.file),
|
|
554
565
|
}, [
|
|
555
566
|
el("div", { class: "title" }, [
|
|
556
|
-
|
|
567
|
+
titleEl,
|
|
557
568
|
el("div", { class: "meta", text: `${when} · ${s.messageCount || 0} 条` }),
|
|
558
569
|
]),
|
|
559
570
|
btnDelete,
|
|
@@ -587,19 +598,25 @@ function filterSessions(query) {
|
|
|
587
598
|
}
|
|
588
599
|
}
|
|
589
600
|
|
|
590
|
-
function startNewSession(
|
|
591
|
-
|
|
592
|
-
if (askConfirm && !confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
|
|
593
|
-
abortGeneration();
|
|
594
|
-
}
|
|
601
|
+
function startNewSession() {
|
|
602
|
+
const wasStreaming = state.streaming;
|
|
595
603
|
clearChat();
|
|
596
604
|
showEmptyState(true);
|
|
597
605
|
state.currentSessionFile = null;
|
|
606
|
+
state.streaming = false;
|
|
607
|
+
state.aborting = false;
|
|
608
|
+
state.streamingItems = [];
|
|
609
|
+
state.streamingMsg = null;
|
|
610
|
+
state.activeToolCalls.clear();
|
|
611
|
+
setComposerAborting(false);
|
|
598
612
|
try {
|
|
599
613
|
window.history.replaceState({}, "", window.location.pathname);
|
|
600
614
|
} catch {}
|
|
601
615
|
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
602
616
|
$("#topSessionName").textContent = "新对话";
|
|
617
|
+
if (wasStreaming) {
|
|
618
|
+
showToast("前一个会话已转入后台继续运行");
|
|
619
|
+
}
|
|
603
620
|
// Mobile: close sidebar on new session
|
|
604
621
|
if (window.innerWidth <= 768) closeSidebar();
|
|
605
622
|
refreshSessions();
|
|
@@ -683,8 +700,8 @@ async function syncSessionHistory(file, force = false) {
|
|
|
683
700
|
const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
|
|
684
701
|
$("#topSessionName").textContent = topName;
|
|
685
702
|
|
|
686
|
-
// Only overwrite chat if
|
|
687
|
-
if (force || !state.streaming) {
|
|
703
|
+
// Only overwrite chat if not actively backfilling
|
|
704
|
+
if (!state.isBackfilling && (force || !state.streaming)) {
|
|
688
705
|
clearChat();
|
|
689
706
|
const msgs = reconstructFromEntries(data.entries || []);
|
|
690
707
|
showEmptyState(msgs.length === 0);
|
|
@@ -700,9 +717,8 @@ async function syncSessionHistory(file, force = false) {
|
|
|
700
717
|
}
|
|
701
718
|
|
|
702
719
|
async function loadSession(file) {
|
|
703
|
-
if (state.
|
|
704
|
-
|
|
705
|
-
}
|
|
720
|
+
if (file === state.currentSessionFile) return;
|
|
721
|
+
const wasStreaming = state.streaming;
|
|
706
722
|
state.currentSessionFile = file;
|
|
707
723
|
state.streaming = false;
|
|
708
724
|
state.aborting = false;
|
|
@@ -721,6 +737,9 @@ async function loadSession(file) {
|
|
|
721
737
|
await syncSessionHistory(file, true);
|
|
722
738
|
// Reconnect websocket pointed at this session so new prompts continue history.
|
|
723
739
|
connectWs({ session: file });
|
|
740
|
+
if (wasStreaming) {
|
|
741
|
+
showToast("前一个会话已转入后台继续运行");
|
|
742
|
+
}
|
|
724
743
|
}
|
|
725
744
|
|
|
726
745
|
function reconstructFromEntries(entries) {
|
|
@@ -1670,14 +1689,15 @@ function sendWs(obj) {
|
|
|
1670
1689
|
|
|
1671
1690
|
function handlePiMessage(obj) {
|
|
1672
1691
|
// Automatically bind to the session file as soon as pi allocates it on disk
|
|
1673
|
-
|
|
1674
|
-
|
|
1692
|
+
const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
|
|
1693
|
+
if (sessionFile && sessionFile !== state.currentSessionFile) {
|
|
1694
|
+
state.currentSessionFile = sessionFile;
|
|
1675
1695
|
try {
|
|
1676
|
-
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(
|
|
1677
|
-
window.history.replaceState({ session:
|
|
1696
|
+
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
|
|
1697
|
+
window.history.replaceState({ session: sessionFile }, "", newUrl);
|
|
1678
1698
|
} catch {}
|
|
1679
1699
|
if ($("#topSessionName").textContent === "新对话") {
|
|
1680
|
-
$("#topSessionName").textContent = baseName(
|
|
1700
|
+
$("#topSessionName").textContent = baseName(sessionFile);
|
|
1681
1701
|
}
|
|
1682
1702
|
refreshSessions();
|
|
1683
1703
|
}
|
|
@@ -1780,7 +1800,7 @@ function handlePiMessage(obj) {
|
|
|
1780
1800
|
}
|
|
1781
1801
|
break;
|
|
1782
1802
|
case "remote_user_prompt":
|
|
1783
|
-
appendMessageNode("user", { text: obj.message, isSteer: obj.isSteer });
|
|
1803
|
+
appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer });
|
|
1784
1804
|
break;
|
|
1785
1805
|
case "agent_start":
|
|
1786
1806
|
state.streaming = true;
|
|
@@ -2786,12 +2806,15 @@ async function init() {
|
|
|
2786
2806
|
}
|
|
2787
2807
|
|
|
2788
2808
|
const ta = $("#composer");
|
|
2809
|
+
let isComposing = false;
|
|
2810
|
+
ta.addEventListener("compositionstart", () => { isComposing = true; });
|
|
2811
|
+
ta.addEventListener("compositionend", () => { isComposing = false; });
|
|
2789
2812
|
ta.addEventListener("input", () => {
|
|
2790
2813
|
autoResize();
|
|
2791
2814
|
updateComposerUI();
|
|
2792
2815
|
});
|
|
2793
2816
|
ta.addEventListener("keydown", (e) => {
|
|
2794
|
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
|
2817
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && !isComposing && e.keyCode !== 229) {
|
|
2795
2818
|
e.preventDefault();
|
|
2796
2819
|
if (state.streaming) {
|
|
2797
2820
|
if (ta.value.trim()) {
|
|
@@ -3080,6 +3103,7 @@ async function init() {
|
|
|
3080
3103
|
|
|
3081
3104
|
document.addEventListener("visibilitychange", () => {
|
|
3082
3105
|
if (document.visibilityState === "visible") {
|
|
3106
|
+
refreshSessions();
|
|
3083
3107
|
if (!state.wsConnected) {
|
|
3084
3108
|
reconnectAttempts = 0;
|
|
3085
3109
|
scheduleReconnect(0);
|
|
@@ -3093,6 +3117,7 @@ async function init() {
|
|
|
3093
3117
|
});
|
|
3094
3118
|
|
|
3095
3119
|
window.addEventListener("pageshow", () => {
|
|
3120
|
+
refreshSessions();
|
|
3096
3121
|
if (!state.wsConnected) {
|
|
3097
3122
|
reconnectAttempts = 0;
|
|
3098
3123
|
scheduleReconnect(0);
|
|
@@ -3101,6 +3126,13 @@ async function init() {
|
|
|
3101
3126
|
}
|
|
3102
3127
|
});
|
|
3103
3128
|
|
|
3129
|
+
// Background session status polling
|
|
3130
|
+
setInterval(() => {
|
|
3131
|
+
if (document.visibilityState === "visible") {
|
|
3132
|
+
refreshSessions();
|
|
3133
|
+
}
|
|
3134
|
+
}, 10000);
|
|
3135
|
+
|
|
3104
3136
|
// Global event delegation for code block copy buttons
|
|
3105
3137
|
document.addEventListener("click", async (e) => {
|
|
3106
3138
|
const btn = e.target.closest(".btn-copy-code");
|
package/public/style.css
CHANGED
|
@@ -141,10 +141,45 @@ body {
|
|
|
141
141
|
line-height: 1.35;
|
|
142
142
|
}
|
|
143
143
|
.session-item .session-item-name {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 6px;
|
|
144
147
|
overflow: hidden;
|
|
145
148
|
text-overflow: ellipsis;
|
|
146
149
|
white-space: nowrap;
|
|
147
150
|
}
|
|
151
|
+
.session-item .session-item-name > span:first-child {
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
text-overflow: ellipsis;
|
|
154
|
+
white-space: nowrap;
|
|
155
|
+
flex: 1;
|
|
156
|
+
min-width: 0;
|
|
157
|
+
}
|
|
158
|
+
.session-running-badge {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 4px;
|
|
162
|
+
font-size: 10px;
|
|
163
|
+
line-height: 1;
|
|
164
|
+
color: var(--accent, #3b82f6);
|
|
165
|
+
background: rgba(59, 130, 246, 0.12);
|
|
166
|
+
border: 1px solid rgba(59, 130, 246, 0.25);
|
|
167
|
+
padding: 2px 5px;
|
|
168
|
+
border-radius: 9999px;
|
|
169
|
+
font-weight: 500;
|
|
170
|
+
flex-shrink: 0;
|
|
171
|
+
}
|
|
172
|
+
.session-running-badge .spinner-dot {
|
|
173
|
+
width: 6px;
|
|
174
|
+
height: 6px;
|
|
175
|
+
border-radius: 50%;
|
|
176
|
+
background-color: var(--accent, #3b82f6);
|
|
177
|
+
animation: pulse-dot 1.2s infinite ease-in-out;
|
|
178
|
+
}
|
|
179
|
+
@keyframes pulse-dot {
|
|
180
|
+
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
181
|
+
50% { opacity: 1; transform: scale(1.2); }
|
|
182
|
+
}
|
|
148
183
|
.session-item .meta {
|
|
149
184
|
font-size: 11px;
|
|
150
185
|
color: var(--text-dim);
|