@liguoshuai/pi-web-chat 1.7.2 → 1.7.4

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.7.0 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.7.4 版本)。
4
4
 
5
5
  ---
6
6
 
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ---
9
+
10
+ ## [1.7.4] - 2026-07-26
11
+
12
+ ### Added
13
+ - **模型菜单实时搜索与过滤 (Model Search & Instant Filter)**:
14
+ - 顶栏模型下拉菜单增加置顶固定的搜索过滤输入框,支持根据 Provider 提供商名称、模型名称、模型 ID 进行多维度实时模糊过滤。
15
+ - 打开模型下拉框时自动聚焦并全选搜索框,支持一键清空(`×`)、按 `Escape` 快捷关闭、按 `Enter` 快捷选中。
16
+ - 优化模型列表项布局,同时展示友好显示名与底层 Model ID,解决模型列表过长时翻找困难的问题。
17
+ - 移动端与桌面端自适应滚动与粘性搜索栏布局。
18
+
19
+ ---
20
+
21
+ ## [1.7.3] - 2026-07-26
22
+
23
+ ### Fixed
24
+ - **新建会话即时绑定会话文件与 URL (Instant Session Binding)**:在 `public/app.js` 中自动捕获 RPC 消息中的 `sessionFile`,新建会话发送第一条 Prompt 后立即更新浏览器 URL、当前状态与侧边栏高亮,防止页面刷新丢失当前对话。
25
+ - **服务端历史文本提取空指针保护 (Null Safety in extractText)**:在 `server.js` 的 `extractText` 增加对分片项为 null/undefined 的防守式过滤,避免异常历史记录导致元数据读取崩溃。
26
+ - **移动端侧边栏底部连接状态与版本号显示修复 (Mobile Sidebar Bottom & Safe Area)**:修复手机端侧边栏 `100vh` 溢出导致底部“已连接/版本号”不可见的问题;增加 `100dvh`、`min-height: 0`、`flex-shrink: 0` 及全面屏 `safe-area-inset-bottom` 适配。
27
+
28
+
8
29
  ---
9
30
 
10
31
  ## [1.7.0] - 2026-07-26
package/docs/ISSUES.md CHANGED
@@ -64,3 +64,39 @@
64
64
  **修复**:
65
65
  - 以 Session Key 为粒度管理 `PiAgent` 实例。
66
66
  - 任何一个客户端发送 `prompt` 或 `steer` 指令时,通过 `agent.wsSend(..., senderWs)` 广播同步给连入该 Session 的其他客户端。
67
+
68
+ ---
69
+
70
+ ## 7. 新建会话时 URL、侧边栏高亮与会话状态未即时绑定
71
+
72
+ **症状**:新建对话并发送消息后,底层已生成 session jsonl,但浏览器 URL 仍为 `/`,`state.currentSessionFile` 为空,刷新页面后丢失当前会话视图,侧边栏也无法立即高亮当前项。
73
+
74
+ **根因**:前端仅在显式点击侧边栏会话或调用 `switch_session` 时才绑定 `state.currentSessionFile`,未在 `prompt` 返回包含 `sessionFile` 的 RPC 数据时即时同步。
75
+
76
+ **修复**:在前端 `handlePiMessage` 入口增加对 `obj.data?.sessionFile` 的自动感知与绑定,一旦检测到新分配的 sessionFile,即时更新 `state.currentSessionFile`、通过 `history.replaceState` 同步浏览器 URL 并刷新侧边栏高亮。
77
+
78
+ ---
79
+
80
+ ## 8. 历史会话内容反序列化时潜在的 null 分片过滤崩溃
81
+
82
+ **症状**:当历史会话 jsonl 中偶发由于中断或格式异常导致 content 数组中包含 `null`/`undefined` 元素时,`extractText` 会触发 `TypeError: Cannot read properties of null (reading 'type')`。
83
+
84
+ **修复**:在 `server.js` 的 `extractText` 过滤器中加入判空保护 `c && (c.type === "text" || typeof c === "string")`。
85
+
86
+ ---
87
+
88
+ ## 9. 移动端侧边栏底部连接状态与版本号被遮挡/不可见
89
+
90
+ **症状**:在手机端浏览器打开左侧抽屉(Sidebar)时,底部的“已连接 / 版本号”信息看不到或被系统手势栏/底栏遮挡。
91
+
92
+ **根因**:
93
+ 1. 移动端 `.sidebar` 样式设置了固定 `height: 100vh`,而手机浏览器 `100vh` 包含了底部工具栏高度,导致底部区域被挤出屏幕外。
94
+ 2. `.session-list` 缺少 `min-height: 0` 约束,会话数量多时 flexbox 未能限制高度,将 `.sidebar-bottom` 顶出视口。
95
+ 3. 缺少 `env(safe-area-inset-bottom)` 安全区域边距,导致贴底元素被全面屏手势条遮挡。
96
+
97
+ **修复**:
98
+ - 移动端 `.sidebar` 改用 `height: 100dvh; max-height: 100dvh; overflow: hidden;`。
99
+ - 给 `.session-list` 增加 `min-height: 0`,给 `.sidebar-bottom` 增加 `flex-shrink: 0`。
100
+ - 引入 `safe-area-inset-bottom` 底部安全区自适应内边距,并将 z-index 提升至 105,确保手机端完整可见。
101
+
102
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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
@@ -1107,6 +1107,19 @@ function sendWs(obj) {
1107
1107
  }
1108
1108
 
1109
1109
  function handlePiMessage(obj) {
1110
+ // Automatically bind to the session file as soon as pi allocates it on disk
1111
+ if (obj.data?.sessionFile && obj.data.sessionFile !== state.currentSessionFile) {
1112
+ state.currentSessionFile = obj.data.sessionFile;
1113
+ try {
1114
+ const newUrl = window.location.pathname + "?session=" + encodeURIComponent(obj.data.sessionFile);
1115
+ window.history.replaceState({ session: obj.data.sessionFile }, "", newUrl);
1116
+ } catch {}
1117
+ if ($("#topSessionName").textContent === "新对话") {
1118
+ $("#topSessionName").textContent = baseName(obj.data.sessionFile);
1119
+ }
1120
+ refreshSessions();
1121
+ }
1122
+
1110
1123
  // Backfill markers emitted by the server when it replays buffered events
1111
1124
  // that happened in the background while no browser was attached.
1112
1125
  if (obj.type === "backfill_start") {
@@ -1426,29 +1439,112 @@ function renderModelPill() {
1426
1439
  pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
1427
1440
  }
1428
1441
 
1442
+ let modelSearchQuery = "";
1443
+
1429
1444
  function renderModelMenu() {
1430
1445
  const menu = $("#modelMenu");
1431
- menu.innerHTML = "";
1432
- // 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
+
1433
1527
  const groups = {};
1434
- for (const m of state.models) {
1528
+ for (const m of filteredModels) {
1435
1529
  const p = m.provider || "other";
1436
1530
  (groups[p] = groups[p] || []).push(m);
1437
1531
  }
1532
+
1438
1533
  for (const [provider, items] of Object.entries(groups).sort()) {
1439
- menu.appendChild(el("div", { class: "group-label", text: provider }));
1534
+ listContainer.appendChild(el("div", { class: "group-label", text: provider }));
1440
1535
  for (const m of items) {
1441
1536
  const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
1442
- menu.appendChild(el("div", {
1537
+ listContainer.appendChild(el("div", {
1443
1538
  class: "opt" + (active ? " active" : ""),
1444
1539
  onclick: () => {
1445
1540
  $("#modelPill").textContent = "切换中…";
1446
1541
  sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
1447
- menu.classList.remove("open");
1542
+ $("#modelMenu").classList.remove("open");
1448
1543
  },
1449
1544
  }, [
1450
1545
  el("span", { class: "check", html: active ? "✓ " : "" }),
1451
- 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,
1452
1548
  ]));
1453
1549
  }
1454
1550
  }
@@ -1636,7 +1732,18 @@ function init() {
1636
1732
  $("#modelPill").addEventListener("click", (e) => {
1637
1733
  e.stopPropagation();
1638
1734
  sendWs({ type: "get_available_models" });
1639
- $("#modelMenu").classList.toggle("open");
1735
+ const menu = $("#modelMenu");
1736
+ const willOpen = !menu.classList.contains("open");
1737
+ menu.classList.toggle("open");
1738
+ if (willOpen) {
1739
+ setTimeout(() => {
1740
+ const input = $("#modelSearchInput");
1741
+ if (input) {
1742
+ input.focus();
1743
+ input.select();
1744
+ }
1745
+ }, 50);
1746
+ }
1640
1747
  });
1641
1748
  document.addEventListener("click", (e) => {
1642
1749
  if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
package/public/style.css CHANGED
@@ -51,6 +51,7 @@ body {
51
51
  height: 100%;
52
52
  transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
53
53
  z-index: 100;
54
+ overflow: hidden;
54
55
  }
55
56
 
56
57
  /* Sidebar collapsed state on desktop */
@@ -74,6 +75,7 @@ body {
74
75
  display: flex;
75
76
  gap: 8px;
76
77
  align-items: center;
78
+ flex-shrink: 0;
77
79
  }
78
80
  .btn-new {
79
81
  flex: 1;
@@ -94,6 +96,7 @@ body {
94
96
 
95
97
  .sidebar-search {
96
98
  padding: 0 10px 8px;
99
+ flex-shrink: 0;
97
100
  }
98
101
  .sidebar-search input {
99
102
  width: 100%;
@@ -109,6 +112,7 @@ body {
109
112
 
110
113
  .session-list {
111
114
  flex: 1;
115
+ min-height: 0;
112
116
  overflow-y: auto;
113
117
  padding: 4px 8px 16px;
114
118
  }
@@ -134,10 +138,13 @@ body {
134
138
  .sidebar-empty { color: var(--text-dim); font-size: 13px; padding: 20px 12px; text-align: center; }
135
139
 
136
140
  .sidebar-bottom {
141
+ flex-shrink: 0;
137
142
  border-top: 1px solid var(--border);
138
- padding: 10px;
143
+ padding: 10px 12px;
144
+ padding-bottom: max(10px, env(safe-area-inset-bottom, 10px));
139
145
  font-size: 12px;
140
146
  color: var(--text-muted);
147
+ background: var(--bg-sidebar);
141
148
  }
142
149
  .sidebar-bottom a { color: var(--text-muted); text-decoration: none; }
143
150
  .sidebar-bottom a:hover { color: var(--text); }
@@ -149,7 +156,7 @@ body {
149
156
  }
150
157
  .app-version {
151
158
  font-size: 11px;
152
- color: var(--text-dim);
159
+ color: var(--text-muted);
153
160
  user-select: none;
154
161
  }
155
162
  .conn-status {
@@ -791,18 +798,97 @@ body {
791
798
  /* Model selector dropdown */
792
799
  .model-menu {
793
800
  position: absolute; top: 52px; right: 16px;
794
- background: var(--bg-input); border: 1px solid var(--border);
795
- border-radius: 12px; padding: 8px; width: 320px; max-height: 360px; overflow-y: auto;
796
- 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;
805
+ }
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);
797
831
  }
798
- .model-menu.open { display: block; }
799
- .model-menu .group-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; padding: 6px 8px 2px; }
800
- .model-menu .opt {
801
- padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--text);
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);
802
891
  }
803
- .model-menu .opt:hover { background: var(--bg-hover); }
804
- .model-menu .opt.active { background: var(--bg-hover); }
805
- .model-menu .opt .check { color: var(--accent); margin-right: 6px; }
806
892
 
807
893
 
808
894
  /* ==========================================================================
@@ -827,12 +913,26 @@ body {
827
913
  left: 0;
828
914
  bottom: 0;
829
915
  width: 280px; /* Wider and more usable drawer on mobile */
916
+ height: 100%;
830
917
  height: 100vh;
918
+ height: 100dvh;
919
+ max-height: 100dvh;
831
920
  margin-left: 0 !important; /* Cancel desktop margin-left sliding */
832
921
  transform: translateX(-100%);
833
922
  transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
834
923
  box-shadow: 4px 0 25px rgba(0, 0, 0, 0.5);
835
924
  border-right: 1px solid var(--border);
925
+ display: flex;
926
+ flex-direction: column;
927
+ overflow: hidden;
928
+ z-index: 105;
929
+ }
930
+
931
+ .sidebar-bottom {
932
+ flex-shrink: 0;
933
+ padding: 12px 14px;
934
+ padding-bottom: max(16px, calc(10px + env(safe-area-inset-bottom, 0px)));
935
+ background: var(--bg-sidebar);
836
936
  }
837
937
 
838
938
  .app.sidebar-open .sidebar {
@@ -921,6 +1021,9 @@ body {
921
1021
  max-height: calc(100dvh - 70px);
922
1022
  border-radius: 12px;
923
1023
  }
1024
+ .model-menu-list {
1025
+ max-height: calc(100dvh - 130px);
1026
+ }
924
1027
 
925
1028
  .mobile-toolbar-fab {
926
1029
  display: flex;
package/server.js CHANGED
@@ -584,7 +584,7 @@ function extractText(content) {
584
584
  if (typeof content === "string") return content;
585
585
  if (!Array.isArray(content)) return "";
586
586
  return content
587
- .filter(c => c.type === "text" || typeof c === "string")
587
+ .filter(c => c && (c.type === "text" || typeof c === "string"))
588
588
  .map(c => typeof c === "string" ? c : c.text)
589
589
  .join("");
590
590
  }