@liguoshuai/pi-web-chat 1.8.4 → 1.8.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.
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.4 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.5 版本)。
4
4
 
5
5
  ---
6
6
 
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.8.5] - 2026-08-23
11
+
12
+ ### Fixed
13
+ - **中止/终止生成按钮逻辑修复 (Abort Button Logic & UX Fix)**:
14
+ - 修复生成过程中点击终止按钮(`■`)时,因输入框为空触发提前返回(`if (!text) return`)导致无法发出 `abort` 信号的问题。
15
+ - 抽离独立的 `abortGeneration()` 统一处理中断生成流程,确保点击停止按钮无条件触发任务终止。
16
+ - 修复输入框有草稿时点击停止按钮误触发 `steer`(插入指令)的问题,明确区分停止按钮与插入指令按钮职责。
17
+ - 新增中止中即时视觉反馈(按钮变为等待状态 `⏳`,输入框边缘高亮变红,提示文字变更为“中止当前任务中…”),并在底层 `agent_settled` 或 `abort` 响应后安全恢复。
18
+ - 支持在生成过程中按 `Escape` 键快速中止当前任务(模态框或菜单打开时除外)。
19
+ - 在新建会话及切换会话时,若当前任务正在运行中,自动先行中断当前流式任务以避免后台状态错乱。
20
+
21
+ ---
22
+
10
23
  ## [1.8.4] - 2026-07-26
11
24
 
12
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.8.4",
3
+ "version": "1.8.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",
package/public/app.js CHANGED
@@ -42,6 +42,7 @@ const state = {
42
42
  thinkingLevel: "medium",
43
43
  sessionId: null,
44
44
  isBackfilling: false,
45
+ aborting: false,
45
46
  };
46
47
 
47
48
  let toastTimer = null;
@@ -576,8 +577,12 @@ async function syncSessionHistory(file, force = false) {
576
577
  }
577
578
 
578
579
  async function loadSession(file) {
580
+ if (state.streaming) {
581
+ abortGeneration();
582
+ }
579
583
  state.currentSessionFile = file;
580
584
  state.streaming = false;
585
+ state.aborting = false;
581
586
  state.streamingItems = [];
582
587
  state.streamingMsg = null;
583
588
  state.activeToolCalls.clear();
@@ -1295,6 +1300,9 @@ function handlePiMessage(obj) {
1295
1300
  renderThinkingPill();
1296
1301
  updateEmptyStateModelInfo();
1297
1302
  }
1303
+ } else if (obj.command === "abort") {
1304
+ state.aborting = false;
1305
+ setComposerAborting(false);
1298
1306
  }
1299
1307
  else if (obj.command === "switch_session" && obj.success) {
1300
1308
  // ask pi for current state so we can get session id, name
@@ -1329,6 +1337,7 @@ function handlePiMessage(obj) {
1329
1337
  break;
1330
1338
  case "agent_start":
1331
1339
  state.streaming = true;
1340
+ state.aborting = false;
1332
1341
  setComposerAborting(true);
1333
1342
  ensureStreamingMsg();
1334
1343
  refreshStreamingContent();
@@ -1340,6 +1349,7 @@ function handlePiMessage(obj) {
1340
1349
  case "agent_settled":
1341
1350
  finalizeStreamingMsg();
1342
1351
  state.streaming = false;
1352
+ state.aborting = false;
1343
1353
  setComposerAborting(false);
1344
1354
  sendWs({ type: "get_state" });
1345
1355
  refreshSessions(); // titles may have changed
@@ -1465,6 +1475,7 @@ function handlePiMessage(obj) {
1465
1475
  case "pi_exit":
1466
1476
  finalizeStreamingMsg();
1467
1477
  state.streaming = false;
1478
+ state.aborting = false;
1468
1479
  setComposerAborting(false);
1469
1480
  $("#connDot").style.color = "var(--danger)";
1470
1481
  break;
@@ -1560,6 +1571,7 @@ function updateState(d) {
1560
1571
  } else if (state.streaming) {
1561
1572
  finalizeStreamingMsg();
1562
1573
  state.streaming = false;
1574
+ state.aborting = false;
1563
1575
  setComposerAborting(false);
1564
1576
  if (state.currentSessionFile) {
1565
1577
  syncSessionHistory(state.currentSessionFile, true);
@@ -1947,14 +1959,12 @@ function renderModelList(listContainer) {
1947
1959
  const opt = el("div", {
1948
1960
  class: "opt" + (active ? " active" : ""),
1949
1961
  onclick: () => {
1950
- const prev = state.currentModel;
1951
1962
  $("#modelPillName").textContent = "切换中…";
1952
1963
  sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
1953
1964
  saveRecentModel(m);
1954
1965
  $("#modelMenu").classList.remove("open");
1955
- if (prev && (prev.id !== m.id || prev.provider !== m.provider)) {
1956
- appendSystemNotice(`已切换模型至 ${m.provider ? m.provider + " / " : ""}${m.name || m.id}`);
1957
- }
1966
+ // 成功后的“已切换模型至 …”提示统一由 set_model response 处理逻辑弹出,
1967
+ // 避免在这里乐观提示一次、响应到达后 pi 再提示一次(重复提示)。
1958
1968
  },
1959
1969
  }, [
1960
1970
  el("span", { class: "check", html: active ? "✓" : "" }),
@@ -2064,8 +2074,24 @@ function updateComposerUI() {
2064
2074
  const text = ta ? ta.value.trim() : "";
2065
2075
  const sendBtn = $("#sendBtn");
2066
2076
  const steerBtn = $("#steerBtn");
2077
+ const inner = $("#composerInner");
2067
2078
 
2068
- if (state.streaming) {
2079
+ if (inner) {
2080
+ inner.classList.toggle("aborting", !!state.aborting);
2081
+ }
2082
+
2083
+ if (state.aborting) {
2084
+ if (sendBtn) {
2085
+ sendBtn.classList.add("stop");
2086
+ sendBtn.disabled = true;
2087
+ sendBtn.textContent = "⏳";
2088
+ sendBtn.title = "中止中…";
2089
+ }
2090
+ if (steerBtn) {
2091
+ steerBtn.style.display = "none";
2092
+ }
2093
+ if (ta) ta.placeholder = "正在中止当前任务…";
2094
+ } else if (state.streaming) {
2069
2095
  if (sendBtn) {
2070
2096
  sendBtn.classList.add("stop");
2071
2097
  sendBtn.disabled = !state.wsConnected;
@@ -2096,10 +2122,34 @@ function updateComposerUI() {
2096
2122
  }
2097
2123
 
2098
2124
  function setComposerAborting(yes) {
2125
+ if (!yes) {
2126
+ state.aborting = false;
2127
+ const inner = $("#composerInner");
2128
+ if (inner) inner.classList.remove("aborting");
2129
+ const hint = $(".composer-hint");
2130
+ if (hint && hint.textContent.includes("中止")) {
2131
+ hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
2132
+ }
2133
+ }
2099
2134
  updateComposerUI();
2100
2135
  renderModelPill();
2101
2136
  }
2102
2137
 
2138
+ function abortGeneration() {
2139
+ if (!state.streaming && !state.aborting) return;
2140
+ if (!state.wsConnected) {
2141
+ const hint = $(".composer-hint");
2142
+ if (hint) hint.textContent = "操作失败:WebSocket 未连接。正在尝试重连…";
2143
+ scheduleReconnect(0);
2144
+ return;
2145
+ }
2146
+ state.aborting = true;
2147
+ updateComposerUI();
2148
+ const hint = $(".composer-hint");
2149
+ if (hint) hint.textContent = "中止当前任务中…";
2150
+ sendWs({ type: "abort" });
2151
+ }
2152
+
2103
2153
  function submitSteer() {
2104
2154
  const ta = $("#composer");
2105
2155
  const text = ta.value.trim();
@@ -2126,8 +2176,19 @@ function submitSteer() {
2126
2176
  }
2127
2177
 
2128
2178
  function submitPrompt() {
2179
+ if (state.streaming) {
2180
+ const ta = $("#composer");
2181
+ const text = ta ? ta.value.trim() : "";
2182
+ if (text) {
2183
+ submitSteer();
2184
+ } else {
2185
+ abortGeneration();
2186
+ }
2187
+ return;
2188
+ }
2189
+
2129
2190
  const ta = $("#composer");
2130
- const text = ta.value.trim();
2191
+ const text = ta ? ta.value.trim() : "";
2131
2192
  const hint = $(".composer-hint");
2132
2193
  if (!text) return;
2133
2194
  if (!state.wsConnected) {
@@ -2137,16 +2198,7 @@ function submitPrompt() {
2137
2198
  if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
2138
2199
  return;
2139
2200
  }
2140
- if (state.streaming) {
2141
- if (text) {
2142
- submitSteer();
2143
- return;
2144
- }
2145
- if (hint) hint.textContent = "中止当前生成中…";
2146
- sendWs({ type: "abort" });
2147
- return;
2148
- }
2149
-
2201
+
2150
2202
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
2151
2203
  // Render the user's message locally for instant feedback.
2152
2204
  appendMessageNode("user", { text });
@@ -2154,6 +2206,7 @@ function submitPrompt() {
2154
2206
  autoResize();
2155
2207
 
2156
2208
  state.streaming = true;
2209
+ state.aborting = false;
2157
2210
  setComposerAborting(true);
2158
2211
  ensureStreamingMsg();
2159
2212
  refreshStreamingContent();
@@ -2183,7 +2236,7 @@ async function init() {
2183
2236
  $("#btnNew").addEventListener("click", () => {
2184
2237
  if (state.streaming) {
2185
2238
  if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
2186
- sendWs({ type: "abort" });
2239
+ abortGeneration();
2187
2240
  }
2188
2241
  clearChat();
2189
2242
  showEmptyState(true);
@@ -2200,12 +2253,7 @@ async function init() {
2200
2253
 
2201
2254
  $("#sendBtn").addEventListener("click", () => {
2202
2255
  if (state.streaming) {
2203
- const ta = $("#composer");
2204
- if (ta && ta.value.trim()) {
2205
- submitSteer();
2206
- } else {
2207
- submitPrompt();
2208
- }
2256
+ abortGeneration();
2209
2257
  } else {
2210
2258
  submitPrompt();
2211
2259
  }
@@ -2259,11 +2307,17 @@ async function init() {
2259
2307
  });
2260
2308
  }
2261
2309
 
2262
- // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector
2310
+ // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
2263
2311
  window.addEventListener("keydown", (e) => {
2264
2312
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
2265
2313
  e.preventDefault();
2266
2314
  toggleModelMenu();
2315
+ } else if (e.key === "Escape" && state.streaming) {
2316
+ const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2317
+ const isModalOpen = $("#cwdModal")?.classList.contains("open");
2318
+ if (!isMenuOpen && !isModalOpen) {
2319
+ abortGeneration();
2320
+ }
2267
2321
  }
2268
2322
  });
2269
2323
 
package/public/style.css CHANGED
@@ -356,7 +356,11 @@ body {
356
356
  font-size: 15px; line-height: 1.65; color: var(--text);
357
357
  max-width: 100%; min-width: 0;
358
358
  }
359
- .msg.assistant .content p { margin-bottom: 12px; }
359
+ .msg.assistant .content p {
360
+ margin-bottom: 12px;
361
+ white-space: pre-wrap;
362
+ word-break: break-word;
363
+ }
360
364
  .msg.assistant .content p:last-child { margin-bottom: 0; }
361
365
  .msg.assistant .content pre {
362
366
  background: #0d0d0d; border: 1px solid var(--border);
@@ -708,7 +712,10 @@ body {
708
712
  transition: border-color .15s;
709
713
  }
710
714
  .composer-inner:focus-within { border-color: var(--text-dim); }
711
- .composer-inner.aborting { border-color: var(--danger); }
715
+ .composer-inner.aborting {
716
+ border-color: var(--danger);
717
+ box-shadow: 0 0 0 1px var(--danger);
718
+ }
712
719
  .composer textarea {
713
720
  flex: 1; background: transparent; border: none; outline: none; resize: none;
714
721
  color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
@@ -717,10 +724,12 @@ body {
717
724
  .composer .send-btn {
718
725
  width: 36px; height: 32px; border-radius: 18px; border: none; cursor: pointer;
719
726
  background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
720
- flex-shrink: 0; transition: background .15s, opacity .15s;
727
+ flex-shrink: 0; transition: background .15s, opacity .15s, transform .1s;
721
728
  }
722
729
  .composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
723
730
  .composer .send-btn.stop { background: var(--danger); }
731
+ .composer .send-btn.stop:hover:not(:disabled) { background: #f87171; }
732
+ .composer .send-btn.stop:active:not(:disabled) { transform: scale(0.95); }
724
733
  .composer .steer-btn {
725
734
  display: inline-flex;
726
735
  align-items: center;
@@ -22,11 +22,12 @@ SRC="$HERE/pi-web-chat.service"
22
22
  DEST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
23
23
  DEST="$DEST_DIR/pi-web-chat.service"
24
24
 
25
- PORT="${1:-3000}"
25
+ PORT="3000"
26
26
  RESTART_NOW="no"
27
27
  for arg in "$@"; do
28
28
  case "$arg" in
29
29
  --restart|--now) RESTART_NOW="yes" ;;
30
+ [0-9]*) PORT="$arg" ;;
30
31
  esac
31
32
  done
32
33
 
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // for listing sessions and reading session history from the JSONL store.
4
4
  import { spawn } from "child_process";
5
5
  import { randomUUID } from "crypto";
6
- import { readFile, readdir, stat, writeFile, mkdir } from "fs/promises";
6
+ import { readFile, readdir, stat, writeFile, mkdir, realpath as fsRealpath } from "fs/promises";
7
7
  import { readFileSync, existsSync } from "fs";
8
8
  import { StringDecoder } from "string_decoder";
9
9
  import express from "express";
@@ -213,14 +213,14 @@ class PiAgent {
213
213
  maybeScheduleIdleKill() {
214
214
  // Only arm the reclamation timer when: no client attached AND truly idle.
215
215
  // A background task that is still running must never be killed here.
216
- if (this.hasWs || this.isBusy) return;
216
+ if (!this.alive || this.hasWs || this.isBusy) return;
217
217
  if (IDLE_TIMEOUT_MS === 0) return; // disabled
218
218
  this.cancelIdleKill();
219
219
  const ms = IDLE_TIMEOUT_MS;
220
220
  this.idleTimer = setTimeout(() => {
221
221
  this.idleTimer = null;
222
222
  // re-check at fire time — a reconnect or new task may have started.
223
- if (this.hasWs || this.isBusy) return;
223
+ if (!this.alive || this.hasWs || this.isBusy) return;
224
224
  console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
225
225
  if (IDLE_DROP_HEAP) {
226
226
  try { if (typeof global.gc === "function") global.gc(); } catch {}
@@ -271,6 +271,10 @@ class PiAgent {
271
271
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
272
272
  this.cancelIdleKill();
273
273
  if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
274
+ for (const [id, resolve] of this.pending) {
275
+ resolve({ type: "response", id, success: false, error: err.message });
276
+ }
277
+ this.pending.clear();
274
278
  console.error(`[pi spawn error]`, err);
275
279
  this.wsSend({ type: "pi_exit", error: err.message });
276
280
  for (const s of this.sockets) { try { s.close(); } catch {} }
@@ -291,6 +295,10 @@ class PiAgent {
291
295
  console.log(`pi exited (code=${code})`);
292
296
  this.wsSend({ type: "pi_exit", code });
293
297
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
298
+ for (const [id, resolve] of this.pending) {
299
+ resolve({ type: "response", id, success: false, error: "pi exited" });
300
+ }
301
+ this.pending.clear();
294
302
  for (const s of this.sockets) { try { s.close(); } catch {} }
295
303
  this.sockets.clear();
296
304
  this.cancelIdleKill();
@@ -326,6 +334,10 @@ class PiAgent {
326
334
  this.setStreaming(false);
327
335
  this.eventBuffer = [];
328
336
  this.bufferHead = 0;
337
+ // If unkeyed, actively query state from pi so sessionKey is recorded
338
+ if (!this.sessionKey) {
339
+ this.send({ type: "get_state" });
340
+ }
329
341
  break;
330
342
  case "pi_exit": this.state = "idle"; break;
331
343
  }
@@ -386,7 +398,22 @@ class PiAgent {
386
398
  }
387
399
  const id = String(++this.reqId);
388
400
  const payload = { ...cmd, id };
389
- this.pending.set(id, resolve);
401
+ // Long-running commands (prompt/steer) are tracked for their whole
402
+ // lifetime: pi's response may legitimately take many minutes, and the
403
+ // pending entry doubles as the isBusy guard that protects a working
404
+ // agent from idle-kill. Fire-and-forget queries (get_state etc.) keep a
405
+ // safety timeout so a dropped response doesn't leak the promise.
406
+ const longRunning = cmd.type === "prompt" || cmd.type === "steer";
407
+ let timeoutId = null;
408
+ const settled = { done: false };
409
+ const complete = (obj) => {
410
+ if (settled.done) return;
411
+ settled.done = true;
412
+ this.pending.delete(id);
413
+ if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
414
+ resolve(obj);
415
+ };
416
+ this.pending.set(id, complete);
390
417
  this.markActivity();
391
418
  try {
392
419
  this.proc.stdin.write(JSON.stringify(payload) + "\n");
@@ -394,14 +421,14 @@ class PiAgent {
394
421
  this.pending.delete(id);
395
422
  return resolve({ type: "response", id, success: false, error: err.message });
396
423
  }
397
- // Safety: timeout so a dropped response doesn't leak the promise.
398
- setTimeout(() => {
399
- if (this.pending.has(id)) {
400
- this.pending.delete(id);
401
- this.markActivity();
402
- resolve({ type: "response", id, success: false, error: "timeout" });
403
- }
404
- }, 60000);
424
+ if (!longRunning) {
425
+ timeoutId = setTimeout(() => {
426
+ if (this.pending.has(id)) {
427
+ this.pending.delete(id);
428
+ resolve({ type: "response", id, success: false, error: "timeout" });
429
+ }
430
+ }, 60000);
431
+ }
405
432
  });
406
433
  }
407
434
 
@@ -459,6 +486,10 @@ class PiAgent {
459
486
  if (this.sessionKey) {
460
487
  activeAgents.delete(this.sessionKey);
461
488
  }
489
+ for (const [id, resolve] of this.pending) {
490
+ resolve({ type: "response", id, success: false, error: "pi process stopped" });
491
+ }
492
+ this.pending.clear();
462
493
  for (const s of this.sockets) { try { s.close(); } catch {} }
463
494
  this.sockets.clear();
464
495
  try { this.proc && this.proc.kill("SIGTERM"); } catch {}
@@ -727,13 +758,34 @@ app.get("/api/session", async (req, res) => {
727
758
  const file = req.query.file;
728
759
  if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
729
760
 
730
- // Security check: ensure the file path is within SESSIONS_DIR
731
- let resolvedFile = normalizePath(file);
761
+ // Security check: ensure the file path is within SESSIONS_DIR. We resolve the
762
+ // canonical (real) path rather than just the lexical one, otherwise a
763
+ // symlink placed inside SESSIONS_DIR could point outside and let a caller
764
+ // read arbitrary .jsonl files via the traversal check above.
732
765
  const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
733
- const relPath = path.relative(resolvedSessionsDir, resolvedFile);
766
+ const requestedPath = normalizePath(file);
767
+ const relPath = path.relative(resolvedSessionsDir, requestedPath);
734
768
  if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
735
769
  return res.status(403).json({ error: "Access denied" });
736
770
  }
771
+ // Canonicalize both target file and sessions directory to verify the real
772
+ // target still lives under the real SESSIONS_DIR even when symlinks exist.
773
+ let resolvedFile;
774
+ try {
775
+ resolvedFile = await fsRealpath(requestedPath);
776
+ } catch {
777
+ // Fall back to the lexical path if realpath fails (e.g. missing file);
778
+ // the read below will surface the actual error.
779
+ resolvedFile = requestedPath;
780
+ }
781
+ let canonicalSessionsDir = resolvedSessionsDir;
782
+ try {
783
+ canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
784
+ } catch {}
785
+ const realRel = path.relative(canonicalSessionsDir, resolvedFile);
786
+ if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
787
+ return res.status(403).json({ error: "Access denied" });
788
+ }
737
789
 
738
790
  const content = await readFile(resolvedFile, "utf8");
739
791
  const lines = content.split("\n").filter(Boolean);
@@ -751,11 +803,6 @@ app.get("/api/session", async (req, res) => {
751
803
  // Build a map and reconstruct the active path from root -> leaf.
752
804
  const byId = new Map();
753
805
  for (const e of entries) if (e.id) byId.set(e.id, e);
754
- let leaf = null;
755
- for (const e of entries) {
756
- // a leaf is one that nobody else has as parentId (and isn't a non-message like header)
757
- if (e.type === "message" || e.type === "message_summary") leaf = e.id;
758
- }
759
806
  // find true leaf = last entry with no children
760
807
  const childCount = new Map();
761
808
  for (const e of entries) {
@@ -905,7 +952,8 @@ wss.on("connection", (ws, req) => {
905
952
  agent.send({ type: "new_session" });
906
953
  break;
907
954
  case "switch_session":
908
- if (msg.sessionPath) agent.setSessionKey(cwd, msg.sessionPath);
955
+ if (!msg.sessionPath) break; // refuse malformed request; never forward an undefined path to pi
956
+ agent.setSessionKey(cwd, msg.sessionPath);
909
957
  agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
910
958
  break;
911
959
  case "steer":