@liguoshuai/pi-web-chat 2.3.0 → 2.4.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.
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.5 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.3.0 版本)。
4
4
 
5
5
  ---
6
6
 
@@ -39,7 +39,7 @@
39
39
  **核心设计原则**:
40
40
  - **按 Session Key 池化进程**:以 `${cwd}:${resolvedSessionPath}` 作为唯一 Key,多标签页或多设备连接同一个 Session 时共享同一个后台 `PiAgent` 进程。
41
41
  - **后台持久化运行(Headless Persistence)**:网页断开(如关闭标签页)不会立即终止进程。只要 Agent 处于流式生成或有未响应请求状态,后台进程会坚持跑完当前任务。
42
- - **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认 2000 条)。客户端重连时通过 `backfill_start` → 离线事件 → `backfill_end` 进行增量补齐。
42
+ - **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认 5000 条)。客户端重连时通过 `backfill_start` → 离线事件 → `backfill_end` 进行增量补齐。
43
43
  - **真正空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“进程彻底进入 idle(非 streaming、无挂起 RPC)”时,才启动 `IDLE_TIMEOUT_MS` 空闲回收倒计时。
44
44
  - **磁盘 JSONL 为唯一真理(Single Source of Truth)**:会话历史与树状分支全由 pi 本身维护在磁盘 `.jsonl` 文件中,Web 端通过 REST 接口与 RPC 消息与文件保持同步。
45
45
  - **`pi` 可执行文件自动探查与派生**:服务端通过 `resolvePiBin()` 自动定位全局安装的 `pi` 可执行文件(优先使用 `PI_BIN` 环境变量 -> 检查 `~/.npm-global/bin/pi` -> `/usr/local/bin/pi` -> `/usr/bin/pi` -> 系统 `PATH`),派生 `pi --mode rpc --session-dir ...` 子进程。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.3.0",
3
+ "version": "2.4.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",
@@ -40,8 +40,8 @@
40
40
  "node": ">=18"
41
41
  },
42
42
  "dependencies": {
43
- "@liguoshuai/pi-chat-protocol": "2.3.0",
44
- "@liguoshuai/pi-chat-server": "2.3.0",
43
+ "@liguoshuai/pi-chat-protocol": "2.4.0",
44
+ "@liguoshuai/pi-chat-server": "2.4.0",
45
45
  "express": "^4.21.2",
46
46
  "ws": "^8.18.0"
47
47
  },
package/public/app.js CHANGED
@@ -448,7 +448,10 @@ function renderInlineMd(text) {
448
448
  flushList();
449
449
  flushPara();
450
450
  // restore inline code
451
- outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => `<code>${escapeHtml(codeChunks[+n].slice(1, -1))}</code>`);
451
+ outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
452
+ const chunk = codeChunks[+n];
453
+ return chunk ? `<code>${escapeHtml(chunk.slice(1, -1))}</code>` : "";
454
+ });
452
455
  return outHtml;
453
456
  }
454
457
 
@@ -517,22 +520,90 @@ const el = (tag, props = {}, children = []) => {
517
520
  return n;
518
521
  };
519
522
 
523
+ // ---- Message Time Formatter ----
524
+ function formatMessageTime(ts) {
525
+ if (!ts) return "";
526
+ const d = (typeof ts === "number" || typeof ts === "string") ? new Date(ts) : ts;
527
+ if (!d || isNaN(d.getTime())) return "";
528
+
529
+ const now = new Date();
530
+ const isToday = d.getFullYear() === now.getFullYear() &&
531
+ d.getMonth() === now.getMonth() &&
532
+ d.getDate() === now.getDate();
533
+
534
+ const timeStr = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
535
+ if (isToday) {
536
+ return timeStr;
537
+ }
538
+ const isThisYear = d.getFullYear() === now.getFullYear();
539
+ if (isThisYear) {
540
+ const month = d.getMonth() + 1;
541
+ const date = d.getDate();
542
+ return `${month}月${date}日 ${timeStr}`;
543
+ }
544
+ const year = d.getFullYear();
545
+ const month = String(d.getMonth() + 1).padStart(2, "0");
546
+ const date = String(d.getDate()).padStart(2, "0");
547
+ return `${year}-${month}-${date} ${timeStr}`;
548
+ }
549
+
520
550
  // ---- Sidebar / sessions ----
521
551
  async function refreshSessions() {
522
552
  const cwd = state.cwd || "";
523
- const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
524
- const data = await res.json();
525
- renderSidebar(data.sessions || []);
553
+ try {
554
+ const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
555
+ const data = await res.json();
556
+ renderSidebar(data.sessions || []);
557
+ } catch (err) {
558
+ console.warn("refreshSessions error:", err);
559
+ renderSidebar([]);
560
+ }
526
561
  }
527
562
 
528
563
  function renderSidebar(sessions) {
529
564
  const list = $("#sessionList");
565
+ if (!list) return;
530
566
  list.innerHTML = "";
531
- if (sessions.length === 0) {
567
+
568
+ const hasCurrentInSessions = Boolean(state.currentSessionFile && sessions.some(s => s.file === state.currentSessionFile));
569
+
570
+ // If current session is a new/draft session not yet in the session list from REST API:
571
+ if (!hasCurrentInSessions && (!state.currentSessionFile || $("#emptyState")?.style.display !== "none" || state.streaming)) {
572
+ const draftTitle = $("#topSessionName")?.textContent || "新对话";
573
+ const isRunning = Boolean(state.streaming);
574
+ const runningBadge = isRunning ? el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
575
+ el("span", { class: "spinner-dot" }),
576
+ el("span", { text: "运行中" }),
577
+ ]) : null;
578
+
579
+ const titleEl = el("div", { class: "session-item-name" }, [
580
+ el("span", { text: draftTitle }),
581
+ ...(runningBadge ? [runningBadge] : []),
582
+ ]);
583
+
584
+ const draftItem = el("div", {
585
+ class: "session-item active draft-session",
586
+ dataset: { file: state.currentSessionFile || "" },
587
+ title: state.currentSessionFile || "新对话",
588
+ onclick: () => {
589
+ if (state.currentSessionFile) loadSession(state.currentSessionFile);
590
+ },
591
+ }, [
592
+ el("div", { class: "title" }, [
593
+ titleEl,
594
+ el("div", { class: "meta", text: `刚刚 · ${state.streaming ? 1 : 0} 条` }),
595
+ ]),
596
+ ]);
597
+ list.appendChild(draftItem);
598
+ }
599
+
600
+ if (sessions.length === 0 && list.children.length === 0) {
532
601
  list.appendChild(el("div", { class: "sidebar-empty", text: "没有会话记录" }));
533
602
  return;
534
603
  }
604
+
535
605
  sessions.forEach((s) => {
606
+ if (s.file === state.currentSessionFile && !hasCurrentInSessions) return;
536
607
  const title = s.sessionName || s.firstUser || "新对话";
537
608
  const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
538
609
 
@@ -612,13 +683,22 @@ function startNewSession() {
612
683
  try {
613
684
  window.history.replaceState({}, "", window.location.pathname);
614
685
  } catch {}
615
- connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
616
686
  $("#topSessionName").textContent = "新对话";
617
687
  if (wasStreaming) {
618
688
  showToast("前一个会话已转入后台继续运行");
619
689
  }
620
690
  // Mobile: close sidebar on new session
621
691
  if (window.innerWidth <= 768) closeSidebar();
692
+
693
+ // If WebSocket is already live, request new session directly without reconnect overhead
694
+ if (state.ws && state.ws.readyState === 1) {
695
+ sendWs({ type: "new_session" });
696
+ sendWs({ type: "get_state" });
697
+ } else {
698
+ connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
699
+ }
700
+ // Instantly render optimistic new session at the top of left sidebar
701
+ renderSidebar([]);
622
702
  refreshSessions();
623
703
  }
624
704
 
@@ -771,7 +851,7 @@ function reconstructFromEntries(entries) {
771
851
  url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
772
852
  }));
773
853
  }
774
- out.push({ role: "user", text: extractContentText(m.content), images, ts: m.timestamp });
854
+ out.push({ role: "user", text: extractContentText(m.content), images, ts: m.timestamp || e.timestamp });
775
855
  } else if (m.role === "assistant") {
776
856
  const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
777
857
  const content = rawContent.map(part => {
@@ -789,7 +869,7 @@ function reconstructFromEntries(entries) {
789
869
  } catch {}
790
870
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
791
871
  }
792
- out.push({ role: "assistant", content, ts: m.timestamp, usage: m.usage });
872
+ out.push({ role: "assistant", content, ts: m.timestamp || e.timestamp, usage: m.usage });
793
873
  }
794
874
  // toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
795
875
  }
@@ -1080,13 +1160,15 @@ function exportCurrentSession() {
1080
1160
 
1081
1161
  const msgs = chatInner.querySelectorAll(".msg");
1082
1162
  msgs.forEach(msg => {
1163
+ const time = msg.querySelector(".msg-time")?.innerText || "";
1164
+ const timeInfo = time ? ` (${time})` : "";
1083
1165
  if (msg.classList.contains("user")) {
1084
1166
  const isSteer = !!msg.querySelector(".steer-badge");
1085
1167
  const bubble = msg.querySelector(".bubble");
1086
1168
  const text = bubble ? bubble.innerText.replace("🧭 指导指令", "").trim() : "";
1087
- md += `### 👤 User${isSteer ? " (🧭 指导指令)" : ""}\n\n${text}\n\n`;
1169
+ md += `### 👤 user${timeInfo}${isSteer ? " (🧭 指导指令)" : ""}\n\n${text}\n\n`;
1088
1170
  } else if (msg.classList.contains("assistant")) {
1089
- md += `### 🤖 pi\n\n`;
1171
+ md += `### 🤖 pi${timeInfo}\n\n`;
1090
1172
  const thinking = msg.querySelector(".thinking-body");
1091
1173
  if (thinking && thinking.innerText.trim()) {
1092
1174
  md += `<details><summary>💭 思考过程</summary>\n\n${thinking.innerText.trim()}\n\n</details>\n\n`;
@@ -1121,6 +1203,10 @@ function exportCurrentSession() {
1121
1203
  function appendMessageNode(role, m) {
1122
1204
  if (role === "user") {
1123
1205
  const isSteer = m.isSteer || false;
1206
+ const ts = m.ts || m.timestamp || Date.now();
1207
+ const timeStr = formatMessageTime(ts);
1208
+ const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
1209
+
1124
1210
  const steerBadge = isSteer ? el("span", { class: "steer-badge", text: "🧭 指导指令" }) : null;
1125
1211
  const bubbleChildren = [steerBadge];
1126
1212
  if (m.text) {
@@ -1143,7 +1229,16 @@ function appendMessageNode(role, m) {
1143
1229
  bubbleChildren.push(imgsContainer);
1144
1230
  }
1145
1231
  const bubble = el("div", { class: "bubble" + (isSteer ? " steer" : "") }, bubbleChildren);
1146
- const node = el("div", { class: "msg user" }, [bubble]);
1232
+
1233
+ const roleChildren = [
1234
+ el("span", { class: "role-name", text: "user" }),
1235
+ ];
1236
+ if (timeStr) {
1237
+ roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
1238
+ }
1239
+ const roleTag = el("div", { class: "role-tag user-role-tag" }, roleChildren);
1240
+
1241
+ const node = el("div", { class: "msg user" }, [roleTag, bubble]);
1147
1242
  $("#chat-inner").appendChild(node);
1148
1243
  scrollBottom();
1149
1244
  return node;
@@ -1152,6 +1247,10 @@ function appendMessageNode(role, m) {
1152
1247
  }
1153
1248
 
1154
1249
  function renderAssistantBlock(m) {
1250
+ const ts = m.ts || m.timestamp || null;
1251
+ const timeStr = formatMessageTime(ts);
1252
+ const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
1253
+
1155
1254
  // m.content is array of {type:text|thinking|toolCall}
1156
1255
  const fullText = extractContentText(m.content);
1157
1256
  const copyMsgBtn = el("button", {
@@ -1169,11 +1268,16 @@ function renderAssistantBlock(m) {
1169
1268
  el("span", { text: "复制全文" })
1170
1269
  ]);
1171
1270
 
1271
+ const roleChildren = [
1272
+ el("span", { class: "role-name", text: "pi" }),
1273
+ ];
1274
+ if (timeStr) {
1275
+ roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
1276
+ }
1277
+ roleChildren.push(copyMsgBtn);
1278
+
1172
1279
  const node = el("div", { class: "msg assistant" }, [
1173
- el("div", { class: "role-tag" }, [
1174
- el("span", { text: "pi" }),
1175
- copyMsgBtn
1176
- ]),
1280
+ el("div", { class: "role-tag" }, roleChildren),
1177
1281
  el("div", { class: "content" }),
1178
1282
  ]);
1179
1283
  const content = node.querySelector(".content");
@@ -1374,28 +1478,38 @@ function getStreamingFullText() {
1374
1478
  }
1375
1479
 
1376
1480
  // ---- Streaming: handle live assistant message ----
1377
- function ensureStreamingMsg() {
1481
+ function ensureStreamingMsg(ts = Date.now()) {
1378
1482
  if (state.streamingMsg) return state.streamingMsg;
1379
1483
  showEmptyState(false);
1484
+ const timeStr = formatMessageTime(ts);
1485
+ const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
1486
+
1487
+ const copyBtn = el("button", {
1488
+ class: "btn-copy-msg",
1489
+ type: "button",
1490
+ title: "复制回答全文",
1491
+ onclick: async (e) => {
1492
+ e.stopPropagation();
1493
+ const text = getStreamingFullText();
1494
+ if (await copyToClipboard(text)) {
1495
+ showToast("已复制回答全文");
1496
+ }
1497
+ }
1498
+ }, [
1499
+ el("svg", { html: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' }),
1500
+ el("span", { text: "复制全文" })
1501
+ ]);
1502
+
1503
+ const roleChildren = [
1504
+ el("span", { class: "role-name", text: "pi" }),
1505
+ ];
1506
+ if (timeStr) {
1507
+ roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
1508
+ }
1509
+ roleChildren.push(copyBtn);
1510
+
1380
1511
  const node = el("div", { class: "msg assistant" }, [
1381
- el("div", { class: "role-tag" }, [
1382
- el("span", { text: "pi" }),
1383
- el("button", {
1384
- class: "btn-copy-msg",
1385
- type: "button",
1386
- title: "复制回答全文",
1387
- onclick: async (e) => {
1388
- e.stopPropagation();
1389
- const text = getStreamingFullText();
1390
- if (await copyToClipboard(text)) {
1391
- showToast("已复制回答全文");
1392
- }
1393
- }
1394
- }, [
1395
- el("svg", { html: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' }),
1396
- el("span", { text: "复制全文" })
1397
- ])
1398
- ]),
1512
+ el("div", { class: "role-tag" }, roleChildren),
1399
1513
  el("div", { class: "content" }),
1400
1514
  ]);
1401
1515
  state.streamingMsg = node;
@@ -1640,8 +1754,8 @@ function connectWs(opts = {}) {
1640
1754
  wasDisconnected = false;
1641
1755
  reconnectAttempts = 0;
1642
1756
 
1643
- setTimeout(() => sendWs({ type: "get_state" }), 300);
1644
- setTimeout(() => sendWs({ type: "get_available_models" }), 500);
1757
+ sendWs({ type: "get_state" });
1758
+ sendWs({ type: "get_available_models" });
1645
1759
  };
1646
1760
 
1647
1761
  ws.onclose = () => {
@@ -1800,7 +1914,7 @@ function handlePiMessage(obj) {
1800
1914
  }
1801
1915
  break;
1802
1916
  case "remote_user_prompt":
1803
- appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer });
1917
+ appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer, ts: obj.timestamp || Date.now() });
1804
1918
  break;
1805
1919
  case "agent_start":
1806
1920
  state.streaming = true;
@@ -2644,7 +2758,7 @@ function submitSteer() {
2644
2758
  return;
2645
2759
  }
2646
2760
 
2647
- appendMessageNode("user", { text, isSteer: true });
2761
+ appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
2648
2762
  ta.value = "";
2649
2763
  autoResize();
2650
2764
  updateComposerUI();
@@ -2690,8 +2804,9 @@ function submitPrompt() {
2690
2804
  }
2691
2805
 
2692
2806
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
2807
+ const now = Date.now();
2693
2808
  // Render the user's message locally for instant feedback.
2694
- appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [] });
2809
+ appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
2695
2810
 
2696
2811
  ta.value = "";
2697
2812
  state.attachedImages = [];
@@ -2701,12 +2816,34 @@ function submitPrompt() {
2701
2816
  state.streaming = true;
2702
2817
  state.aborting = false;
2703
2818
  setComposerAborting(true);
2704
- ensureStreamingMsg();
2819
+ ensureStreamingMsg(now);
2705
2820
  refreshStreamingContent();
2706
2821
 
2707
2822
  // Set session name from the first prompt of a brand-new session.
2708
2823
  if (state.currentSessionFile == null && text) {
2709
- sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
2824
+ const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
2825
+ $("#topSessionName").textContent = promptTitle;
2826
+ sendWs({ type: "set_session_name", name: promptTitle });
2827
+
2828
+ // Update draft session item in sidebar immediately
2829
+ const list = $("#sessionList");
2830
+ if (list) {
2831
+ const draftItem = list.querySelector(".session-item.draft-session") || list.querySelector(".session-item.active");
2832
+ if (draftItem) {
2833
+ const nameSpan = draftItem.querySelector(".session-item-name span");
2834
+ if (nameSpan) nameSpan.textContent = promptTitle;
2835
+ let badge = draftItem.querySelector(".session-running-badge");
2836
+ if (!badge) {
2837
+ badge = el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
2838
+ el("span", { class: "spinner-dot" }),
2839
+ el("span", { text: "运行中" }),
2840
+ ]);
2841
+ draftItem.querySelector(".session-item-name")?.appendChild(badge);
2842
+ }
2843
+ const meta = draftItem.querySelector(".meta");
2844
+ if (meta) meta.textContent = "刚刚 · 1 条";
2845
+ }
2846
+ }
2710
2847
  }
2711
2848
  sendWs({ type: "prompt", message: text, images: imagesToSend });
2712
2849
  }
package/public/style.css CHANGED
@@ -529,6 +529,17 @@ body {
529
529
  .msg.user {
530
530
  align-items: flex-end;
531
531
  }
532
+ .msg.user .role-tag {
533
+ font-size: 12px;
534
+ color: var(--text-dim);
535
+ font-weight: 600;
536
+ letter-spacing: .3px;
537
+ display: flex;
538
+ align-items: center;
539
+ gap: 8px;
540
+ margin-bottom: 2px;
541
+ padding-right: 4px;
542
+ }
532
543
  .msg.user .bubble {
533
544
  background: var(--bg-user-bubble);
534
545
  color: var(--user-text);
@@ -554,9 +565,19 @@ body {
554
565
  .msg.assistant { align-items: stretch; }
555
566
  .msg.assistant .role-tag {
556
567
  font-size: 12px; color: var(--text-dim); font-weight: 600; letter-spacing: .3px;
557
- display: flex; align-items: center; gap: 6px;
568
+ display: flex; align-items: center; gap: 8px;
558
569
  margin-bottom: 2px;
559
570
  }
571
+ .role-name {
572
+ font-weight: 600;
573
+ }
574
+ .msg-time {
575
+ font-size: 11px;
576
+ font-weight: 400;
577
+ color: var(--text-muted);
578
+ letter-spacing: 0;
579
+ user-select: none;
580
+ }
560
581
  .msg.assistant .content {
561
582
  font-size: 15px; line-height: 1.65; color: var(--text);
562
583
  max-width: 100%; min-width: 0;
@@ -1795,10 +1816,15 @@ body {
1795
1816
  border-radius: 16px;
1796
1817
  }
1797
1818
 
1819
+ .msg.user .role-tag,
1798
1820
  .msg.assistant .role-tag {
1799
1821
  font-size: 11px;
1800
1822
  }
1801
1823
 
1824
+ .msg-time {
1825
+ font-size: 10px;
1826
+ }
1827
+
1802
1828
  .msg.assistant .content {
1803
1829
  font-size: 14px;
1804
1830
  line-height: 1.6;