@liguoshuai/pi-web-chat 2.2.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 CHANGED
@@ -7,6 +7,23 @@ 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
+
10
27
  ## [2.2.0] - 2026-08-30
11
28
 
12
29
  ### Fixed & Improved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.2.0",
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.2.0",
44
- "@liguoshuai/pi-chat-server": "2.2.0",
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
@@ -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
- el("div", { class: "session-item-name", text: title }),
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(askConfirm = true) {
591
- if (state.streaming) {
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 force is true or we're not actively streaming mid-turn live
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.streaming) {
704
- abortGeneration();
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
- if (obj.data?.sessionFile && obj.data.sessionFile !== state.currentSessionFile) {
1674
- state.currentSessionFile = obj.data.sessionFile;
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(obj.data.sessionFile);
1677
- window.history.replaceState({ session: obj.data.sessionFile }, "", newUrl);
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(obj.data.sessionFile);
1700
+ $("#topSessionName").textContent = baseName(sessionFile);
1681
1701
  }
1682
1702
  refreshSessions();
1683
1703
  }
@@ -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);