@liguoshuai/pi-web-chat 1.8.9 → 1.9.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
+ ## [1.9.0] - 2026-08-29
11
+
12
+ ### Added
13
+ - **历史对话删除功能 (Session Deletion & Auto Process Cleanup)**:
14
+ - 后端新增 `DELETE /api/session` 接口,支持通过文件路径删除历史 JSONL 会话记录,并带有严格的会话目录越界与路径穿越安全校验。
15
+ - 删除会话时自动清理后端内存元数据缓存,并自动停止与回收当前会话所绑定的常驻后台 Pi 代理子进程。
16
+ - 前端侧边栏会话列表新增垃圾桶删除按钮(桌面端 Hover 显示,移动端常驻展示),点击后带有确认拦截提示,防止误删。
17
+ - 删除当前正在查看/进行的会话时,自动重置为空白新会话并清理 URL 参数与流式状态;删除其他会话时无感刷新会话列表。
18
+ - **左侧栏拖拽缩放宽度特性 (Resizable Sidebar with Dragging)**:
19
+ - 左侧边栏新增右侧边缘拖拽把手(`#sidebarResizer`),支持鼠标及触控指针拖拽自由调节侧边栏宽度(180px - 自适应上限)。
20
+ - 基于 Pointer Capture API 与 `--sidebar-width` 动态 CSS 变量实现 60fps 丝滑拖拽缩放体验,并消除文本误选与拖动延迟。
21
+ - 侧边栏折叠与展开动画完全自适应动态设置的侧边栏宽度,避免折叠时发生截断或残留。
22
+ - 支持双击拖拽把手快速重置回默认宽度(260px),并通过 `localStorage` 自动持久化保存用户自定宽度偏好。
23
+ - 优化移动端响应式,移动端抽屉侧栏下自动禁用并隐藏桌面端拖拽把手。
24
+
25
+ ---
26
+
10
27
  ## [1.8.9] - 2026-08-29
11
28
 
12
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.8.9",
3
+ "version": "1.9.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",
package/public/app.js CHANGED
@@ -491,6 +491,17 @@ function renderSidebar(sessions) {
491
491
  sessions.forEach((s) => {
492
492
  const title = s.sessionName || s.firstUser || "新对话";
493
493
  const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
494
+
495
+ const btnDelete = el("button", {
496
+ class: "btn-delete-session",
497
+ title: "删除会话",
498
+ html: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>`,
499
+ onclick: (e) => {
500
+ e.stopPropagation();
501
+ deleteSession(s.file, title);
502
+ },
503
+ });
504
+
494
505
  const item = el("div", {
495
506
  class: "session-item" + (s.file === state.currentSessionFile ? " active" : ""),
496
507
  dataset: { file: s.file },
@@ -498,14 +509,57 @@ function renderSidebar(sessions) {
498
509
  onclick: () => loadSession(s.file),
499
510
  }, [
500
511
  el("div", { class: "title" }, [
501
- el("div", { text: title }),
512
+ el("div", { class: "session-item-name", text: title }),
502
513
  el("div", { class: "meta", text: `${when} · ${s.messageCount || 0} 条` }),
503
514
  ]),
515
+ btnDelete,
504
516
  ]);
505
517
  list.appendChild(item);
506
518
  });
507
519
  }
508
520
 
521
+ function startNewSession(askConfirm = true) {
522
+ if (state.streaming) {
523
+ if (askConfirm && !confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
524
+ abortGeneration();
525
+ }
526
+ clearChat();
527
+ showEmptyState(true);
528
+ state.currentSessionFile = null;
529
+ try {
530
+ window.history.replaceState({}, "", window.location.pathname);
531
+ } catch {}
532
+ connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
533
+ $("#topSessionName").textContent = "新对话";
534
+ // Mobile: close sidebar on new session
535
+ if (window.innerWidth <= 768) closeSidebar();
536
+ refreshSessions();
537
+ }
538
+
539
+ async function deleteSession(file, title) {
540
+ if (!confirm(`确定要删除此会话记录吗?\n「${title || "新对话"}」\n删除后不可恢复。`)) {
541
+ return;
542
+ }
543
+ try {
544
+ const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`, {
545
+ method: "DELETE",
546
+ });
547
+ const data = await res.json();
548
+ if (!res.ok || data.error) {
549
+ showToast(`删除失败: ${data.error || "未知错误"}`);
550
+ return;
551
+ }
552
+ showToast("会话已删除");
553
+ if (state.currentSessionFile === file) {
554
+ startNewSession(false);
555
+ } else {
556
+ await refreshSessions();
557
+ }
558
+ } catch (err) {
559
+ showToast(`删除失败: ${err.message || "网络错误"}`);
560
+ }
561
+ }
562
+
509
563
  async function toggleSidebar() {
510
564
  const app = $(".app");
511
565
  const isOpen = app.classList.toggle("sidebar-open");
@@ -2224,6 +2278,70 @@ function autoResize() {
2224
2278
  ta.style.height = Math.min(ta.scrollHeight, 220) + "px";
2225
2279
  }
2226
2280
 
2281
+ function initSidebarResize() {
2282
+ const resizer = $("#sidebarResizer");
2283
+ if (!resizer) return;
2284
+
2285
+ // Restore saved width from localStorage
2286
+ const savedWidth = parseInt(localStorage.getItem("sidebarWidth"), 10);
2287
+ if (savedWidth && savedWidth >= 180 && savedWidth <= 800) {
2288
+ document.documentElement.style.setProperty("--sidebar-width", `${savedWidth}px`);
2289
+ }
2290
+
2291
+ let isDragging = false;
2292
+ let startX = 0;
2293
+ let startWidth = 260;
2294
+
2295
+ resizer.addEventListener("pointerdown", (e) => {
2296
+ if (e.button !== 0) return; // Only primary button
2297
+ if (window.innerWidth <= 768) return; // Ignore on mobile
2298
+
2299
+ isDragging = true;
2300
+ startX = e.clientX;
2301
+ const currentWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sidebar-width"), 10) || $(".sidebar")?.offsetWidth || 260;
2302
+ startWidth = currentWidth;
2303
+
2304
+ resizer.setPointerCapture(e.pointerId);
2305
+ document.body.classList.add("is-resizing");
2306
+ e.preventDefault();
2307
+ });
2308
+
2309
+ resizer.addEventListener("pointermove", (e) => {
2310
+ if (!isDragging) return;
2311
+ const deltaX = e.clientX - startX;
2312
+ let newWidth = startWidth + deltaX;
2313
+
2314
+ const minWidth = 180;
2315
+ const maxWidth = Math.min(650, Math.max(300, window.innerWidth - 250));
2316
+ newWidth = Math.max(minWidth, Math.min(newWidth, maxWidth));
2317
+
2318
+ document.documentElement.style.setProperty("--sidebar-width", `${newWidth}px`);
2319
+ });
2320
+
2321
+ const endDrag = (e) => {
2322
+ if (!isDragging) return;
2323
+ isDragging = false;
2324
+ try {
2325
+ resizer.releasePointerCapture(e.pointerId);
2326
+ } catch {}
2327
+ document.body.classList.remove("is-resizing");
2328
+
2329
+ const finalWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sidebar-width"), 10);
2330
+ if (finalWidth) {
2331
+ localStorage.setItem("sidebarWidth", finalWidth);
2332
+ }
2333
+ };
2334
+
2335
+ resizer.addEventListener("pointerup", endDrag);
2336
+ resizer.addEventListener("pointercancel", endDrag);
2337
+
2338
+ // Double-click to reset width to default 260px
2339
+ resizer.addEventListener("dblclick", () => {
2340
+ document.documentElement.style.setProperty("--sidebar-width", "260px");
2341
+ localStorage.removeItem("sidebarWidth");
2342
+ });
2343
+ }
2344
+
2227
2345
  // ---- Init ----
2228
2346
  async function init() {
2229
2347
  // Default cwd to home (server uses home default too).
@@ -2233,23 +2351,7 @@ async function init() {
2233
2351
  await loadServerConfig();
2234
2352
 
2235
2353
  // event listeners
2236
- $("#btnNew").addEventListener("click", () => {
2237
- if (state.streaming) {
2238
- if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
2239
- abortGeneration();
2240
- }
2241
- clearChat();
2242
- showEmptyState(true);
2243
- state.currentSessionFile = null;
2244
- try {
2245
- window.history.replaceState({}, "", window.location.pathname);
2246
- } catch {}
2247
- connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
2248
- $("#topSessionName").textContent = "新对话";
2249
- // Mobile: close sidebar on new session
2250
- if (window.innerWidth <= 768) closeSidebar();
2251
- refreshSessions();
2252
- });
2354
+ $("#btnNew").addEventListener("click", () => startNewSession(true));
2253
2355
 
2254
2356
  $("#sendBtn").addEventListener("click", () => {
2255
2357
  if (state.streaming) {
@@ -2348,7 +2450,9 @@ async function init() {
2348
2450
  $("#sidebarSearch").addEventListener("input", (e) => {
2349
2451
  const q = e.target.value.toLowerCase();
2350
2452
  document.querySelectorAll(".session-item").forEach((it) => {
2351
- it.style.display = it.textContent.toLowerCase().includes(q) ? "" : "none";
2453
+ const titleEl = it.querySelector(".title");
2454
+ const text = titleEl ? titleEl.textContent.toLowerCase() : it.textContent.toLowerCase();
2455
+ it.style.display = text.includes(q) ? "" : "none";
2352
2456
  });
2353
2457
  });
2354
2458
 
@@ -2479,6 +2583,9 @@ async function init() {
2479
2583
  // Mobile: floating button to jump back to the toolbar after long scrolls
2480
2584
  initMobileToolbarFab();
2481
2585
 
2586
+ // Sidebar resizer
2587
+ initSidebarResize();
2588
+
2482
2589
  refreshSessions();
2483
2590
  // start in the disconnected state; connectWs will flip to green on open.
2484
2591
  const initDot = $("#connDot");
package/public/index.html CHANGED
@@ -28,6 +28,7 @@
28
28
  <span id="appVersion" class="app-version" title="pi-web-chat 版本"></span>
29
29
  </div>
30
30
  </div>
31
+ <div class="sidebar-resizer" id="sidebarResizer" title="拖拽调整侧边栏宽度,双击恢复默认"></div>
31
32
  </aside>
32
33
 
33
34
  <!-- Sidebar Overlay -->
package/public/style.css CHANGED
@@ -1,4 +1,5 @@
1
1
  :root {
2
+ --sidebar-width: 260px;
2
3
  --bg: #212121;
3
4
  --bg-sidebar: #171717;
4
5
  --bg-hover: #2a2a2a;
@@ -42,7 +43,7 @@ body {
42
43
  }
43
44
 
44
45
  .sidebar {
45
- width: 260px;
46
+ width: var(--sidebar-width);
46
47
  flex-shrink: 0;
47
48
  background: var(--bg-sidebar);
48
49
  border-right: 1px solid var(--border);
@@ -52,11 +53,12 @@ body {
52
53
  transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
53
54
  z-index: 100;
54
55
  overflow: hidden;
56
+ position: relative;
55
57
  }
56
58
 
57
59
  /* Sidebar collapsed state on desktop */
58
60
  .app:not(.sidebar-open) .sidebar {
59
- margin-left: -260px;
61
+ margin-left: calc(-1 * var(--sidebar-width));
60
62
  }
61
63
 
62
64
  .main {
@@ -117,23 +119,103 @@ body {
117
119
  padding: 4px 8px 16px;
118
120
  }
119
121
  .session-item {
120
- padding: 9px 10px;
122
+ padding: 8px 10px;
121
123
  border-radius: 10px;
122
124
  cursor: pointer;
123
125
  font-size: 13px;
124
126
  color: var(--text);
125
127
  display: flex;
126
- align-items: flex-start;
128
+ align-items: center;
129
+ justify-content: space-between;
127
130
  gap: 8px;
128
131
  margin-bottom: 2px;
129
132
  transition: background .12s;
130
133
  word-break: break-word;
134
+ position: relative;
131
135
  }
132
136
  .session-item:hover { background: var(--bg-hover); }
133
137
  .session-item.active { background: var(--bg-hover); }
134
- .session-item .title { flex: 1; line-height: 1.35; }
138
+ .session-item .title {
139
+ flex: 1;
140
+ min-width: 0;
141
+ line-height: 1.35;
142
+ }
143
+ .session-item .session-item-name {
144
+ overflow: hidden;
145
+ text-overflow: ellipsis;
146
+ white-space: nowrap;
147
+ }
135
148
  .session-item .meta {
136
- font-size: 11px; color: var(--text-dim); margin-top: 3px;
149
+ font-size: 11px;
150
+ color: var(--text-dim);
151
+ margin-top: 2px;
152
+ overflow: hidden;
153
+ text-overflow: ellipsis;
154
+ white-space: nowrap;
155
+ }
156
+ .btn-delete-session {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ justify-content: center;
160
+ width: 26px;
161
+ height: 26px;
162
+ padding: 0;
163
+ border: none;
164
+ background: transparent;
165
+ color: var(--text-dim);
166
+ border-radius: 6px;
167
+ cursor: pointer;
168
+ opacity: 0;
169
+ flex-shrink: 0;
170
+ transition: opacity 0.15s ease, color 0.15s ease, background-color 0.15s ease;
171
+ }
172
+ .session-item:hover .btn-delete-session,
173
+ .session-item:focus-within .btn-delete-session {
174
+ opacity: 1;
175
+ }
176
+ .btn-delete-session:hover {
177
+ color: var(--danger, #ef4444);
178
+ background: rgba(239, 68, 68, 0.15);
179
+ }
180
+
181
+ /* Sidebar Resizer */
182
+ .sidebar-resizer {
183
+ position: absolute;
184
+ top: 0;
185
+ right: 0;
186
+ bottom: 0;
187
+ width: 6px;
188
+ cursor: col-resize;
189
+ z-index: 102;
190
+ user-select: none;
191
+ transition: background-color 0.15s ease;
192
+ }
193
+ .sidebar-resizer::after {
194
+ content: "";
195
+ position: absolute;
196
+ top: 0;
197
+ right: 0;
198
+ width: 2px;
199
+ height: 100%;
200
+ background: transparent;
201
+ transition: background-color 0.15s ease;
202
+ }
203
+ .sidebar-resizer:hover::after,
204
+ .is-resizing .sidebar-resizer::after {
205
+ background: var(--accent);
206
+ }
207
+ .sidebar-resizer:hover,
208
+ .is-resizing .sidebar-resizer {
209
+ background: rgba(16, 163, 127, 0.12);
210
+ }
211
+
212
+ .is-resizing,
213
+ .is-resizing * {
214
+ user-select: none !important;
215
+ cursor: col-resize !important;
216
+ }
217
+ .is-resizing .sidebar {
218
+ transition: none !important;
137
219
  }
138
220
  .sidebar-empty { color: var(--text-dim); font-size: 13px; padding: 20px 12px; text-align: center; }
139
221
 
@@ -1217,6 +1299,16 @@ body {
1217
1299
  z-index: 105;
1218
1300
  }
1219
1301
 
1302
+ .sidebar-resizer {
1303
+ display: none !important;
1304
+ }
1305
+
1306
+ .btn-delete-session {
1307
+ opacity: 0.7;
1308
+ width: 30px;
1309
+ height: 30px;
1310
+ }
1311
+
1220
1312
  .sidebar-bottom {
1221
1313
  flex-shrink: 0;
1222
1314
  padding: 12px 14px;
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, realpath as fsRealpath } from "fs/promises";
6
+ import { readFile, readdir, stat, writeFile, mkdir, realpath as fsRealpath, unlink } from "fs/promises";
7
7
  import { readFileSync, existsSync } from "fs";
8
8
  import { StringDecoder } from "string_decoder";
9
9
  import express from "express";
@@ -853,6 +853,65 @@ app.get("/api/session", async (req, res) => {
853
853
  }
854
854
  });
855
855
 
856
+ // Delete a session file from disk and clean up in-memory caches / active agents
857
+ app.delete("/api/session", async (req, res) => {
858
+ try {
859
+ const file = req.query.file || req.body?.file;
860
+ if (!file || !file.endsWith(".jsonl")) {
861
+ return res.status(400).json({ error: "bad file" });
862
+ }
863
+
864
+ const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
865
+ const requestedPath = normalizePath(file);
866
+ const relPath = path.relative(resolvedSessionsDir, requestedPath);
867
+ if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
868
+ return res.status(403).json({ error: "Access denied" });
869
+ }
870
+
871
+ let resolvedFile;
872
+ try {
873
+ resolvedFile = await fsRealpath(requestedPath);
874
+ } catch (err) {
875
+ if (err.code === "ENOENT") {
876
+ return res.status(404).json({ error: "File not found" });
877
+ }
878
+ resolvedFile = requestedPath;
879
+ }
880
+
881
+ let canonicalSessionsDir = resolvedSessionsDir;
882
+ try {
883
+ canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
884
+ } catch {}
885
+
886
+ const realRel = path.relative(canonicalSessionsDir, resolvedFile);
887
+ if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
888
+ return res.status(403).json({ error: "Access denied" });
889
+ }
890
+
891
+ // Stop and unpool any active PiAgent managing this session
892
+ for (const [key, agent] of activeAgents.entries()) {
893
+ if (key.endsWith(`:${requestedPath}`) || key.endsWith(`:${resolvedFile}`)) {
894
+ activeAgents.delete(key);
895
+ try {
896
+ agent.stop();
897
+ } catch {}
898
+ }
899
+ }
900
+
901
+ // Invalidate session cache
902
+ sessionMetadataCache.delete(requestedPath);
903
+ sessionMetadataCache.delete(resolvedFile);
904
+
905
+ // Delete the session JSONL file
906
+ await unlink(resolvedFile);
907
+
908
+ res.json({ success: true, file: requestedPath });
909
+ } catch (e) {
910
+ console.error("Delete session error:", e);
911
+ res.status(500).json({ error: String(e) });
912
+ }
913
+ });
914
+
856
915
  // ---- WebSocket: 1 browser conn = 1 pi RPC conn (with process persistence) ----
857
916
  const httpServer = app.listen(PORT, () => {
858
917
  console.log(`pi-web-chat on http://localhost:${PORT}`);