@liguoshuai/pi-web-chat 1.8.4 → 1.8.5

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.5",
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);
@@ -2064,8 +2076,24 @@ function updateComposerUI() {
2064
2076
  const text = ta ? ta.value.trim() : "";
2065
2077
  const sendBtn = $("#sendBtn");
2066
2078
  const steerBtn = $("#steerBtn");
2079
+ const inner = $("#composerInner");
2067
2080
 
2068
- if (state.streaming) {
2081
+ if (inner) {
2082
+ inner.classList.toggle("aborting", !!state.aborting);
2083
+ }
2084
+
2085
+ if (state.aborting) {
2086
+ if (sendBtn) {
2087
+ sendBtn.classList.add("stop");
2088
+ sendBtn.disabled = true;
2089
+ sendBtn.textContent = "⏳";
2090
+ sendBtn.title = "中止中…";
2091
+ }
2092
+ if (steerBtn) {
2093
+ steerBtn.style.display = "none";
2094
+ }
2095
+ if (ta) ta.placeholder = "正在中止当前任务…";
2096
+ } else if (state.streaming) {
2069
2097
  if (sendBtn) {
2070
2098
  sendBtn.classList.add("stop");
2071
2099
  sendBtn.disabled = !state.wsConnected;
@@ -2096,10 +2124,34 @@ function updateComposerUI() {
2096
2124
  }
2097
2125
 
2098
2126
  function setComposerAborting(yes) {
2127
+ if (!yes) {
2128
+ state.aborting = false;
2129
+ const inner = $("#composerInner");
2130
+ if (inner) inner.classList.remove("aborting");
2131
+ const hint = $(".composer-hint");
2132
+ if (hint && hint.textContent.includes("中止")) {
2133
+ hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
2134
+ }
2135
+ }
2099
2136
  updateComposerUI();
2100
2137
  renderModelPill();
2101
2138
  }
2102
2139
 
2140
+ function abortGeneration() {
2141
+ if (!state.streaming && !state.aborting) return;
2142
+ if (!state.wsConnected) {
2143
+ const hint = $(".composer-hint");
2144
+ if (hint) hint.textContent = "操作失败:WebSocket 未连接。正在尝试重连…";
2145
+ scheduleReconnect(0);
2146
+ return;
2147
+ }
2148
+ state.aborting = true;
2149
+ updateComposerUI();
2150
+ const hint = $(".composer-hint");
2151
+ if (hint) hint.textContent = "中止当前任务中…";
2152
+ sendWs({ type: "abort" });
2153
+ }
2154
+
2103
2155
  function submitSteer() {
2104
2156
  const ta = $("#composer");
2105
2157
  const text = ta.value.trim();
@@ -2126,8 +2178,19 @@ function submitSteer() {
2126
2178
  }
2127
2179
 
2128
2180
  function submitPrompt() {
2181
+ if (state.streaming) {
2182
+ const ta = $("#composer");
2183
+ const text = ta ? ta.value.trim() : "";
2184
+ if (text) {
2185
+ submitSteer();
2186
+ } else {
2187
+ abortGeneration();
2188
+ }
2189
+ return;
2190
+ }
2191
+
2129
2192
  const ta = $("#composer");
2130
- const text = ta.value.trim();
2193
+ const text = ta ? ta.value.trim() : "";
2131
2194
  const hint = $(".composer-hint");
2132
2195
  if (!text) return;
2133
2196
  if (!state.wsConnected) {
@@ -2137,16 +2200,7 @@ function submitPrompt() {
2137
2200
  if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
2138
2201
  return;
2139
2202
  }
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
-
2203
+
2150
2204
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
2151
2205
  // Render the user's message locally for instant feedback.
2152
2206
  appendMessageNode("user", { text });
@@ -2154,6 +2208,7 @@ function submitPrompt() {
2154
2208
  autoResize();
2155
2209
 
2156
2210
  state.streaming = true;
2211
+ state.aborting = false;
2157
2212
  setComposerAborting(true);
2158
2213
  ensureStreamingMsg();
2159
2214
  refreshStreamingContent();
@@ -2183,7 +2238,7 @@ async function init() {
2183
2238
  $("#btnNew").addEventListener("click", () => {
2184
2239
  if (state.streaming) {
2185
2240
  if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
2186
- sendWs({ type: "abort" });
2241
+ abortGeneration();
2187
2242
  }
2188
2243
  clearChat();
2189
2244
  showEmptyState(true);
@@ -2200,12 +2255,7 @@ async function init() {
2200
2255
 
2201
2256
  $("#sendBtn").addEventListener("click", () => {
2202
2257
  if (state.streaming) {
2203
- const ta = $("#composer");
2204
- if (ta && ta.value.trim()) {
2205
- submitSteer();
2206
- } else {
2207
- submitPrompt();
2208
- }
2258
+ abortGeneration();
2209
2259
  } else {
2210
2260
  submitPrompt();
2211
2261
  }
@@ -2259,11 +2309,17 @@ async function init() {
2259
2309
  });
2260
2310
  }
2261
2311
 
2262
- // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector
2312
+ // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
2263
2313
  window.addEventListener("keydown", (e) => {
2264
2314
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
2265
2315
  e.preventDefault();
2266
2316
  toggleModelMenu();
2317
+ } else if (e.key === "Escape" && state.streaming) {
2318
+ const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2319
+ const isModalOpen = $("#cwdModal")?.style.display === "flex";
2320
+ if (!isMenuOpen && !isModalOpen) {
2321
+ abortGeneration();
2322
+ }
2267
2323
  }
2268
2324
  });
2269
2325
 
package/public/style.css CHANGED
@@ -708,7 +708,10 @@ body {
708
708
  transition: border-color .15s;
709
709
  }
710
710
  .composer-inner:focus-within { border-color: var(--text-dim); }
711
- .composer-inner.aborting { border-color: var(--danger); }
711
+ .composer-inner.aborting {
712
+ border-color: var(--danger);
713
+ box-shadow: 0 0 0 1px var(--danger);
714
+ }
712
715
  .composer textarea {
713
716
  flex: 1; background: transparent; border: none; outline: none; resize: none;
714
717
  color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
@@ -717,10 +720,12 @@ body {
717
720
  .composer .send-btn {
718
721
  width: 36px; height: 32px; border-radius: 18px; border: none; cursor: pointer;
719
722
  background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
720
- flex-shrink: 0; transition: background .15s, opacity .15s;
723
+ flex-shrink: 0; transition: background .15s, opacity .15s, transform .1s;
721
724
  }
722
725
  .composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
723
726
  .composer .send-btn.stop { background: var(--danger); }
727
+ .composer .send-btn.stop:hover:not(:disabled) { background: #f87171; }
728
+ .composer .send-btn.stop:active:not(:disabled) { transform: scale(0.95); }
724
729
  .composer .steer-btn {
725
730
  display: inline-flex;
726
731
  align-items: center;