@liguoshuai/pi-web-chat 1.7.3 → 1.7.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.
package/README.md CHANGED
@@ -154,6 +154,10 @@ pi-web-chat/
154
154
  │ ├── index.html 单页 UI
155
155
  │ ├── app.js 前端逻辑与状态机
156
156
  │ └── style.css ChatGPT/Gemini 风格样式
157
+ ├── scripts/ Systemd 服务安装与管理脚本
158
+ │ ├── pi-web-chat.service Systemd user unit 模板
159
+ │ ├── install-service.sh 一键安装与启动脚本
160
+ │ └── uninstall-service.sh 一键卸载脚本
157
161
  └── docs/ 项目文档库
158
162
  ├── ARCHITECTURE.md 架构设计文档
159
163
  ├── DESIGN.md 详细设计与决策文档
@@ -210,6 +214,10 @@ journalctl --user -u pi-web-chat -f
210
214
 
211
215
  卸载服务:
212
216
  ```bash
217
+ # 使用一键卸载脚本
218
+ ./scripts/uninstall-service.sh
219
+
220
+ # 或手动清理:
213
221
  systemctl --user disable --now pi-web-chat
214
222
  rm ~/.config/systemd/user/pi-web-chat.service
215
223
  systemctl --user daemon-reload
@@ -37,15 +37,43 @@ Examples:
37
37
  }
38
38
 
39
39
  function parseArgs(argv) {
40
- const opts = { port: process.env.PORT || 3000, cwd: process.env.HOME };
40
+ const opts = { port: process.env.PORT || 3000, cwd: process.env.HOME || os.homedir() };
41
41
  for (let i = 0; i < argv.length; i++) {
42
42
  const a = argv[i];
43
43
  if (a === "-h" || a === "--help") { printHelp(); process.exit(0); }
44
- if (a === "-p" || a === "--port") opts.port = Number(argv[++i]);
45
- else if (a.startsWith("--port=")) opts.port = Number(a.split("=")[1]);
46
- else if (a === "-c" || a === "--cwd") opts.cwd = argv[++i];
47
- else if (a.startsWith("--cwd=")) opts.cwd = a.split("=")[1];
48
- else { console.error(`Unknown option: ${a}`); printHelp(); process.exit(1); }
44
+ if (a === "-p" || a === "--port") {
45
+ const val = argv[++i];
46
+ if (!val || isNaN(Number(val))) {
47
+ console.error(`Error: --port requires a valid number`);
48
+ process.exit(1);
49
+ }
50
+ opts.port = Number(val);
51
+ } else if (a.startsWith("--port=")) {
52
+ const val = a.split("=")[1];
53
+ if (!val || isNaN(Number(val))) {
54
+ console.error(`Error: --port requires a valid number`);
55
+ process.exit(1);
56
+ }
57
+ opts.port = Number(val);
58
+ } else if (a === "-c" || a === "--cwd") {
59
+ const val = argv[++i];
60
+ if (!val) {
61
+ console.error(`Error: --cwd requires a path`);
62
+ process.exit(1);
63
+ }
64
+ opts.cwd = val;
65
+ } else if (a.startsWith("--cwd=")) {
66
+ const val = a.split("=")[1];
67
+ if (!val) {
68
+ console.error(`Error: --cwd requires a path`);
69
+ process.exit(1);
70
+ }
71
+ opts.cwd = val;
72
+ } else {
73
+ console.error(`Unknown option: ${a}`);
74
+ printHelp();
75
+ process.exit(1);
76
+ }
49
77
  }
50
78
  return opts;
51
79
  }
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.7.3 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.7.5 版本)。
4
4
 
5
5
  ---
6
6
 
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.7.5] - 2026-07-26
11
+
12
+ ### Fixed
13
+ - **初始化 CWD 配置加载与 WebSocket 建立竞态修复 (Async CWD Init Race Condition)**:
14
+ - 前端 `init()` 声明为异步函数,优先 `await loadServerConfig()` 完成服务器配置(`serverCwd` / `homeDir`)与本地缓存读取后,再发起 WebSocket 连接或加载会话,彻底消除首次加载时 agent 子进程工作目录与前端显示不一致的问题。
15
+ - 服务端 `normalizeCwd(dir)` 在 `dir` 为空时默认使用 `process.cwd()`(服务启动目录),保证前后端默认工作目录始终精确统一。
16
+ - **错误信息二次 HTML 转义修复 (Double Escape on Error Messages)**:移除 `message_end` 中冗余的 `escapeHtml()` 调用,避免错误信息包含特殊符号时被 Markdown 渲染器二次转义显示为实体编码字符。
17
+ - **CLI 参数解析健壮性增强 (CLI Arguments Validation)**:`bin/pi-web-chat.js` 增加对 `-p/--port` 与 `-c/--cwd` 选项值的合法性检验与防越界保护,避免 `NaN` 或 `undefined`。
18
+ - **输入法合成事件优化 (IME Keycode Handling)**:输入框 `keydown` 事件追加 `e.keyCode !== 229` 判断,进一步增强各平台中文输入法选词回车时的兼容性。
19
+ - **Systemd 安装脚本优化 (Systemd Absolute ExecStart Path)**:`scripts/install-service.sh` 生成 unit 文件时将 `ExecStart` 明确为绝对路径 `$NODE_BIN "$PROJECT_DIR/server.js"`。
20
+
21
+ ---
22
+
23
+ ## [1.7.4] - 2026-07-26
24
+
25
+ ### Added
26
+ - **模型菜单实时搜索与过滤 (Model Search & Instant Filter)**:
27
+ - 顶栏模型下拉菜单增加置顶固定的搜索过滤输入框,支持根据 Provider 提供商名称、模型名称、模型 ID 进行多维度实时模糊过滤。
28
+ - 打开模型下拉框时自动聚焦并全选搜索框,支持一键清空(`×`)、按 `Escape` 快捷关闭、按 `Enter` 快捷选中。
29
+ - 优化模型列表项布局,同时展示友好显示名与底层 Model ID,解决模型列表过长时翻找困难的问题。
30
+ - 移动端与桌面端自适应滚动与粘性搜索栏布局。
31
+
32
+ ---
33
+
10
34
  ## [1.7.3] - 2026-07-26
11
35
 
12
36
  ### Fixed
package/docs/ISSUES.md CHANGED
@@ -99,4 +99,27 @@
99
99
  - 给 `.session-list` 增加 `min-height: 0`,给 `.sidebar-bottom` 增加 `flex-shrink: 0`。
100
100
  - 引入 `safe-area-inset-bottom` 底部安全区自适应内边距,并将 z-index 提升至 105,确保手机端完整可见。
101
101
 
102
+ ---
103
+
104
+ ## 10. 页面初次加载时服务端配置加载与 WebSocket 连接的竞态
105
+
106
+ **症状**:在未设置 localStorage CWD 时打开页面,前端顶栏胶囊显示为当前服务启动目录(`serverCwd`),但后端生成的 agent 子进程的工作目录实际上是用户的 home 目录(`~`),导致执行文件操作或命令时位置不一致。
107
+
108
+ **根因**:前端 `init()` 中 `loadServerConfig()` 异步发起 `/api/config` 请求,但没有等待返回就同步调用了 `connectWs({})`。此时 `state.cwd` 尚为空字符串 `""`,WebSocket URL 为 `/ws?cwd=`,后端 fallback 成了 `home()`。等到 fetch 返回后 `state.cwd` 虽被赋予 `serverCwd`,但 WebSocket 连接和 `PiAgent` 已经按 `home()` 创建。
109
+
110
+ **修复**:
111
+ - 前端 `init()` 改为异步函数,显式 `await loadServerConfig()` 保证配置解析完毕后再建立 WebSocket 连接或加载会话。
112
+ - 后端 `normalizeCwd(dir)` 在 `dir` 为空时优先返回 `process.cwd()`,其次回退到 `home()`,确保前后端默认工作目录始终精确一致。
113
+
114
+ ---
115
+
116
+ ## 11. 模型异常响应错误信息二次转义 (Double Escape)
117
+
118
+ **症状**:当模型返回错误(如 stopReason="error")且错误信息中含有 `<`、`&` 等字符时,聊天框中显示为 `&lt;`、`&amp;` 字面量。
119
+
120
+ **根因**:`public/app.js` 在 `message_end` 处理中对 `errMsg` 调用了一次 `escapeHtml()`,随后 `refreshStreamingContent()` 调用 `renderMarkdown()` 时又内部进行了一次 `escapeHtml()`。
121
+
122
+ **修复**:移除 `message_end` 中冗余的 `escapeHtml()`,统一由 Markdown 渲染器进行安全转义。
123
+
124
+
102
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.7.3",
3
+ "version": "1.7.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
@@ -1226,7 +1226,7 @@ function handlePiMessage(obj) {
1226
1226
  const parsed = JSON.parse(errMsg);
1227
1227
  if (parsed.error?.message) errMsg = parsed.error.message;
1228
1228
  } catch {}
1229
- state.streamingItems.push({ type: "text", text: `⚠️ **${escapeHtml(errMsg)}**` });
1229
+ state.streamingItems.push({ type: "text", text: `⚠️ **${errMsg}**` });
1230
1230
  refreshStreamingContent();
1231
1231
  }
1232
1232
  break;
@@ -1439,29 +1439,112 @@ function renderModelPill() {
1439
1439
  pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
1440
1440
  }
1441
1441
 
1442
+ let modelSearchQuery = "";
1443
+
1442
1444
  function renderModelMenu() {
1443
1445
  const menu = $("#modelMenu");
1444
- menu.innerHTML = "";
1445
- // group by provider
1446
+ if (!menu) return;
1447
+
1448
+ let searchInput = $("#modelSearchInput", menu);
1449
+ let listContainer = $(".model-menu-list", menu);
1450
+
1451
+ if (!searchInput || !listContainer) {
1452
+ menu.innerHTML = "";
1453
+ const searchWrap = el("div", { class: "model-search-wrap" });
1454
+ searchInput = el("input", {
1455
+ type: "text",
1456
+ id: "modelSearchInput",
1457
+ class: "model-search-input",
1458
+ placeholder: "搜索模型 (如 claude, deepseek, 4o)…",
1459
+ autocomplete: "off",
1460
+ spellcheck: "false",
1461
+ value: modelSearchQuery,
1462
+ });
1463
+
1464
+ searchInput.addEventListener("click", (e) => e.stopPropagation());
1465
+ searchInput.addEventListener("input", (e) => {
1466
+ modelSearchQuery = e.target.value;
1467
+ renderModelList(listContainer);
1468
+ const clearBtn = $(".btn-clear-model-search", searchWrap);
1469
+ if (clearBtn) clearBtn.style.display = modelSearchQuery ? "block" : "none";
1470
+ });
1471
+ searchInput.addEventListener("keydown", (e) => {
1472
+ if (e.key === "Escape") {
1473
+ menu.classList.remove("open");
1474
+ } else if (e.key === "Enter") {
1475
+ const visibleOpts = listContainer.querySelectorAll(".opt");
1476
+ if (visibleOpts.length > 0) {
1477
+ visibleOpts[0].click();
1478
+ }
1479
+ }
1480
+ });
1481
+
1482
+ const clearBtn = el("button", {
1483
+ class: "btn-clear-model-search",
1484
+ text: "×",
1485
+ type: "button",
1486
+ title: "清空搜索",
1487
+ style: `display: ${modelSearchQuery ? "block" : "none"}`,
1488
+ onclick: (e) => {
1489
+ e.stopPropagation();
1490
+ modelSearchQuery = "";
1491
+ searchInput.value = "";
1492
+ clearBtn.style.display = "none";
1493
+ searchInput.focus();
1494
+ renderModelList(listContainer);
1495
+ }
1496
+ });
1497
+
1498
+ searchWrap.appendChild(searchInput);
1499
+ searchWrap.appendChild(clearBtn);
1500
+ menu.appendChild(searchWrap);
1501
+
1502
+ listContainer = el("div", { class: "model-menu-list" });
1503
+ menu.appendChild(listContainer);
1504
+ }
1505
+
1506
+ renderModelList(listContainer);
1507
+ }
1508
+
1509
+ function renderModelList(listContainer) {
1510
+ if (!listContainer) return;
1511
+ listContainer.innerHTML = "";
1512
+ const q = modelSearchQuery.trim().toLowerCase();
1513
+
1514
+ const filteredModels = state.models.filter(m => {
1515
+ if (!q) return true;
1516
+ const idMatch = (m.id || "").toLowerCase().includes(q);
1517
+ const nameMatch = (m.name || "").toLowerCase().includes(q);
1518
+ const providerMatch = (m.provider || "").toLowerCase().includes(q);
1519
+ return idMatch || nameMatch || providerMatch;
1520
+ });
1521
+
1522
+ if (filteredModels.length === 0) {
1523
+ listContainer.appendChild(el("div", { class: "model-empty", text: "未找到匹配的模型" }));
1524
+ return;
1525
+ }
1526
+
1446
1527
  const groups = {};
1447
- for (const m of state.models) {
1528
+ for (const m of filteredModels) {
1448
1529
  const p = m.provider || "other";
1449
1530
  (groups[p] = groups[p] || []).push(m);
1450
1531
  }
1532
+
1451
1533
  for (const [provider, items] of Object.entries(groups).sort()) {
1452
- menu.appendChild(el("div", { class: "group-label", text: provider }));
1534
+ listContainer.appendChild(el("div", { class: "group-label", text: provider }));
1453
1535
  for (const m of items) {
1454
1536
  const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
1455
- menu.appendChild(el("div", {
1537
+ listContainer.appendChild(el("div", {
1456
1538
  class: "opt" + (active ? " active" : ""),
1457
1539
  onclick: () => {
1458
1540
  $("#modelPill").textContent = "切换中…";
1459
1541
  sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
1460
- menu.classList.remove("open");
1542
+ $("#modelMenu").classList.remove("open");
1461
1543
  },
1462
1544
  }, [
1463
1545
  el("span", { class: "check", html: active ? "✓ " : "" }),
1464
- document.createTextNode(`${m.name || m.id}`),
1546
+ el("span", { class: "model-name", text: `${m.name || m.id}` }),
1547
+ (m.name && m.id && m.name !== m.id) ? el("span", { class: "model-id-sub", text: m.id }) : null,
1465
1548
  ]));
1466
1549
  }
1467
1550
  }
@@ -1585,11 +1668,13 @@ function autoResize() {
1585
1668
  }
1586
1669
 
1587
1670
  // ---- Init ----
1588
- function init() {
1671
+ async function init() {
1589
1672
  // Default cwd to home (server uses home default too).
1590
1673
  state.cwd = document.body.dataset.cwd || "";
1591
1674
 
1592
- // event listeners
1675
+ // Load server config & restore saved CWD before connecting WebSocket
1676
+ await loadServerConfig();
1677
+
1593
1678
  // event listeners
1594
1679
  $("#btnNew").addEventListener("click", () => {
1595
1680
  if (state.streaming) {
@@ -1633,7 +1718,7 @@ function init() {
1633
1718
  updateComposerUI();
1634
1719
  });
1635
1720
  ta.addEventListener("keydown", (e) => {
1636
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1721
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
1637
1722
  e.preventDefault();
1638
1723
  if (state.streaming) {
1639
1724
  if (ta.value.trim()) {
@@ -1649,7 +1734,18 @@ function init() {
1649
1734
  $("#modelPill").addEventListener("click", (e) => {
1650
1735
  e.stopPropagation();
1651
1736
  sendWs({ type: "get_available_models" });
1652
- $("#modelMenu").classList.toggle("open");
1737
+ const menu = $("#modelMenu");
1738
+ const willOpen = !menu.classList.contains("open");
1739
+ menu.classList.toggle("open");
1740
+ if (willOpen) {
1741
+ setTimeout(() => {
1742
+ const input = $("#modelSearchInput");
1743
+ if (input) {
1744
+ input.focus();
1745
+ input.select();
1746
+ }
1747
+ }, 50);
1748
+ }
1653
1749
  });
1654
1750
  document.addEventListener("click", (e) => {
1655
1751
  if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
@@ -1732,9 +1828,6 @@ function init() {
1732
1828
  });
1733
1829
  }
1734
1830
 
1735
- // Load server config & restore saved CWD
1736
- loadServerConfig();
1737
-
1738
1831
  // Status badge click to reconnect
1739
1832
  const connStatusEl = $("#connStatus");
1740
1833
  if (connStatusEl) {
package/public/style.css CHANGED
@@ -798,18 +798,97 @@ body {
798
798
  /* Model selector dropdown */
799
799
  .model-menu {
800
800
  position: absolute; top: 52px; right: 16px;
801
- background: var(--bg-input); border: 1px solid var(--border);
802
- border-radius: 12px; padding: 8px; width: 320px; max-height: 360px; overflow-y: auto;
803
- box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 200; display: none;
801
+ background: var(--bg-sidebar); border: 1px solid var(--border);
802
+ border-radius: 14px; padding: 0; width: 340px; max-height: 440px;
803
+ box-shadow: 0 12px 32px rgba(0,0,0,.5); z-index: 200; display: none;
804
+ flex-direction: column; overflow: hidden;
804
805
  }
805
- .model-menu.open { display: block; }
806
- .model-menu .group-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; padding: 6px 8px 2px; }
807
- .model-menu .opt {
808
- padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--text);
806
+ .model-menu.open { display: flex; }
807
+
808
+ .model-search-wrap {
809
+ padding: 8px 10px;
810
+ border-bottom: 1px solid var(--border);
811
+ background: var(--bg-sidebar);
812
+ position: relative;
813
+ display: flex;
814
+ align-items: center;
815
+ flex-shrink: 0;
816
+ }
817
+ .model-search-input {
818
+ width: 100%;
819
+ background: var(--bg-input);
820
+ border: 1px solid var(--border);
821
+ border-radius: 8px;
822
+ padding: 7px 12px;
823
+ padding-right: 28px;
824
+ color: var(--text);
825
+ font-size: 13px;
826
+ outline: none;
827
+ transition: border-color 0.15s ease;
828
+ }
829
+ .model-search-input:focus {
830
+ border-color: var(--accent);
831
+ }
832
+ .btn-clear-model-search {
833
+ position: absolute;
834
+ right: 18px;
835
+ background: transparent;
836
+ border: none;
837
+ color: var(--text-muted);
838
+ font-size: 16px;
839
+ cursor: pointer;
840
+ line-height: 1;
841
+ padding: 2px;
842
+ }
843
+ .btn-clear-model-search:hover {
844
+ color: var(--text);
845
+ }
846
+
847
+ .model-menu-list {
848
+ flex: 1;
849
+ min-height: 0;
850
+ overflow-y: auto;
851
+ padding: 6px;
852
+ max-height: 360px;
853
+ }
854
+ .model-menu-list .group-label {
855
+ font-size: 11px;
856
+ color: var(--text-dim);
857
+ text-transform: uppercase;
858
+ padding: 6px 8px 3px;
859
+ font-weight: 600;
860
+ letter-spacing: 0.5px;
861
+ }
862
+ .model-menu-list .opt {
863
+ padding: 8px 10px;
864
+ border-radius: 8px;
865
+ cursor: pointer;
866
+ font-size: 13px;
867
+ color: var(--text);
868
+ display: flex;
869
+ align-items: center;
870
+ gap: 6px;
871
+ transition: background 0.12s;
872
+ }
873
+ .model-menu-list .opt:hover { background: var(--bg-hover); }
874
+ .model-menu-list .opt.active { background: var(--bg-hover); color: var(--accent); }
875
+ .model-menu-list .opt .check { color: var(--accent); width: 14px; flex-shrink: 0; font-size: 12px; }
876
+ .model-menu-list .opt .model-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
877
+ .model-menu-list .opt .model-id-sub {
878
+ font-size: 11px;
879
+ color: var(--text-dim);
880
+ font-family: "SF Mono", Menlo, Consolas, monospace;
881
+ max-width: 120px;
882
+ overflow: hidden;
883
+ text-overflow: ellipsis;
884
+ white-space: nowrap;
885
+ }
886
+ .model-empty {
887
+ padding: 24px 12px;
888
+ text-align: center;
889
+ font-size: 13px;
890
+ color: var(--text-dim);
809
891
  }
810
- .model-menu .opt:hover { background: var(--bg-hover); }
811
- .model-menu .opt.active { background: var(--bg-hover); }
812
- .model-menu .opt .check { color: var(--accent); margin-right: 6px; }
813
892
 
814
893
 
815
894
  /* ==========================================================================
@@ -942,6 +1021,9 @@ body {
942
1021
  max-height: calc(100dvh - 70px);
943
1022
  border-radius: 12px;
944
1023
  }
1024
+ .model-menu-list {
1025
+ max-height: calc(100dvh - 130px);
1026
+ }
945
1027
 
946
1028
  .mobile-toolbar-fab {
947
1029
  display: flex;
package/server.js CHANGED
@@ -37,7 +37,7 @@ const PI_BIN = resolvePiBin();
37
37
  function home() { return os.homedir(); }
38
38
 
39
39
  function normalizeCwd(dir) {
40
- if (!dir) return home();
40
+ if (!dir) return process.cwd() || home();
41
41
  let resolved = dir;
42
42
  if (dir.startsWith("~")) {
43
43
  resolved = path.join(home(), dir.slice(1));