@liguoshuai/pi-web-chat 1.1.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.5.0] - 2026-07-26
11
+
12
+ ### Added
13
+ - **Idle timeout & concurrency control (内存保护增强)**:
14
+ - 新增 `IDLE_TIMEOUT_MS` 环境变量,可配置浏览器断开后保留 pi RPC 子进程的时间(默认 300000ms = 5 分钟,设为 0 即关闭自动清理)。
15
+ - 新增 `MAX_CONCURRENT_AGENTS` 环境变量,限制并发 pi 代理进程数(默认 0 = 无限制)。超限时新 WebSocket 连接返回 1013 (capacity) 并在控制台警告,防止多标签页把小内存机器挤爆。
16
+ - 新增 `IDLE_DROP_HEAP=1` 环境变量,空闲时尝试调用 `global.gc()` 主动释放 V8 堆,配合 `--expose-gc` 让内存更快回收到 OS,而非被动 swap。
17
+
18
+ ### Changed
19
+ - `bin/pi-web-chat.js` 启动 server 时加入 `--expose-gc` 标志,支持上述主动内存释放。
20
+
21
+ ---
22
+
23
+ ## [1.4.2] - 2026-07-26
24
+
25
+ ### Fixed
26
+ - **移动端顶栏常驻与布局优化 (Sticky Topbar & Mobile Layout)**:
27
+ - 顶栏 `.topbar` 设置 `position: sticky; top: 0; flex-shrink: 0; z-index: 20;`,确保在手机端滚动聊天、弹起软键盘或屏高变化时**始终常驻固定在屏幕最上方**,绝不随聊天区域滚动或被挤压。
28
+ - 采用 Dynamic Viewport Height(`100dvh`),解决 iOS Safari 与 Android 移动浏览器地址栏/底部导航栏显示隐藏时的页面抖动与错位。
29
+ - 优化移动端工作目录胶囊(CWD Pill)、模型胶囊(Model Pill)与会话标题的宽度挤压与弹性截断(`text-overflow: ellipsis`),小屏手机下依然精致美观。
30
+
31
+ ---
32
+
33
+ ## [1.4.1] - 2026-07-26
34
+
35
+ ### Fixed
36
+ - **切换/加载历史会话模型精准显示**:
37
+ - 修复加载历史会话时由于后台进程启动时与 `get_state` 异步竞态导致右上角模型 Pill 显示不准确的问题。
38
+ - REST `/api/session` 接口现在自动从该会话的 JSONL 历史链中精确提取最后使用的模型 (`model`)。
39
+ - 切换会话时,前端能够零延迟(Instant)同步并渲染该会话对应的正确模型,同时支持 `model_select` 事件实时联动。
40
+
41
+ ---
42
+
43
+ ## [1.4.0] - 2026-07-26
44
+
45
+ ### Added
46
+ - **多端与多浏览器同步协同 (Multi-Device & Multi-Browser Session Sharing)**:
47
+ - **WebSocket 多连接池**:服务端 `PiAgent` 进程升级为多连接池(`Set<WebSocket>`),电脑、手机、平板可同时挂载到同一个正在运行的会话,所有端实时同步接收 AI 流式回答。
48
+ - **多端消息双向广播**:手机端发送 Prompt 或插入指导指令(Steer)时,电脑端无需刷新即可实时呈现用户消息并同步流式输出 AI 答复。
49
+ - **URL 参数关联与一键分享**:选择会话时自动更新浏览器 URL 参数(`?session=...`),分享 URL 或在手机侧边栏选中同个会话即可直接加入同一会话同步协同。
50
+
51
+ ---
52
+
53
+ ## [1.3.0] - 2026-07-26
54
+
55
+ ### Added
56
+ - **运行时实时插入/拦截指令 (Steer Instructions During Agent Turns)**:
57
+ - AI 正在思考、读取文件或执行命令(`streaming`)时,可以在输入框输入补充或修正要求。
58
+ - 输入框右侧会自动展示 **`🧭 插入指令`** 按钮,按 Enter 或点击按钮可实时将指令发送给 `pi` 代理进程(发送 `steer` 消息)。
59
+ - 聊天界面中插入的指令会展示带 **`🧭 指导指令`** 徽章的突出气泡,`pi` 接收后能在当前轮次中即时调整执行方向。
60
+ - 未输入内容时,按钮保持原有的 `■` 停止按钮功能。
61
+
62
+ ---
63
+
64
+ ## [1.2.0] - 2026-07-26
65
+
66
+ ### Added
67
+ - **断线自动重连 (Auto Reconnect with Exponential Backoff)**:网页连接断开后前端启动指数退避自动重连(1s ~ 15s),并在侧边栏底部指示灯实时显示重连状态(`重连中 (x)`)。重连成功弹出“网络连接已恢复”提示。
68
+ - **双向心跳检测 (Heartbeat Ping/Pong)**:服务端每 30 秒发送 WS Protocol Ping 清理僵尸连接,前端每 15 秒发送应用层 Ping,超时 45 秒无响应自动切断重连。
69
+ - **重连会话状态同步 (State Restoration)**:重连后自动向服务端获取 `isStreaming` 状态。若生成已在后台完成则载入最新对话历史,若仍生成中则自动平滑接续流式输出。
70
+ - **一键复制功能 (Copy Buttons)**:
71
+ - **代码块/命令复制**:所有 Markdown 渲染的代码块顶部增加语言标签与“复制”按钮。
72
+ - **工具调用指令复制**:在工具调用(如 `bash` 命令执行)卡片标题栏增加“复制”按钮,方便快速复制命令。
73
+ - **回答全文复制**:在 `pi` 角色标签右侧新增“复制全文”按钮。
74
+
75
+ ---
76
+
10
77
  ## [1.1.0] - 2026-07-26
11
78
 
12
79
  ### Added
@@ -30,27 +97,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
97
  - **工具调用参数显示不全**:新增 `toolcall_delta` / `toolcall_end` 处理,用 `toolcall_end` 的最终 `toolCall.arguments` 刷新头部 args 显示。
31
98
  - **同一 agent 多轮 agent message 互相污染**:`message_start`(assistant) 重置本块的 text/thinking 累加器,避免上一轮工具调用与下一轮文本串到一起。
32
99
 
33
- ### Added
34
- - 会话切换时右侧完整历史恢复(通过 REST `/api/session` 重建 parent 链)
35
- - 模型切换器(顶栏 pill,支持所有 pi 配置的模型)
36
- - Tool-call 折叠块(实时输出、可展开/折叠)
37
- - Thinking 折叠块
38
- - 发送按钮双态(发送/停止)+ 红色停止态
39
- - 新会话后侧边栏在 `agent_settled` 立即刷新(无需手动刷新页面)
40
-
41
- ### Changed
42
- - 侧边栏会话项去除无意义的 `●` 圆点,改为 tooltip 显示完整 jsonl 路径
43
- - 发送按钮禁用逻辑:仅在 wsConnected=false 时禁用,而非 streaming 时
44
-
45
100
  ---
46
101
 
47
102
  ## [0.2.0] - 2026-07-19
48
103
 
49
104
  ### Added
50
- - **新建会话流程修复**:`btnNew` 点击后刷新侧边栏、清理流式状态、生成新 ws 代次
51
- - **WebSocket 代次机制**:`wsGen` 防止旧 socket 消息污染新上下文
52
- - **输入框红边提示**:ws 未连时提交会有 350ms 红色 flash
53
- - **连接状态文本动态更新**:「连接中…」→「已连接」/「已断开」
105
+ - **新建会话流程修复**:`btnNew` 点击后刷新侧边栏、清理流式状态、生成新 ws 代次。
106
+ - **WebSocket 代次机制**:`wsGen` 防止旧 socket 消息污染新上下文。
107
+ - **输入框红边提示**:ws 未连时提交会有 350ms 红色 flash
108
+ - **连接状态文本动态更新**:「连接中…」→「已连接」/「已断开」。
54
109
  - **提交**:`5a8f036` "Fix new-session flow: ws generation guard + sidebar refresh + drop input"
55
110
 
56
111
  ### Fixed
@@ -96,4 +151,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
96
151
  | **Deprecated** | 即将移除 |
97
152
  | **Removed** | 已移除 |
98
153
  | **Fixed** | Bug 修复 |
99
- | **Security** | 安全相关修复 |
154
+ | **Security** | 安全相关修复 |
@@ -52,7 +52,8 @@ function parseArgs(argv) {
52
52
  const opts = parseArgs(process.argv.slice(2));
53
53
 
54
54
  // Spawn server.js as a child so we can forward signals cleanly.
55
- const child = spawn("node", [SERVER], {
55
+ // Use --expose-gc so idle agents can call global.gc() to release heap (IDLE_DROP_HEAP=1).
56
+ const child = spawn("node", ["--expose-gc", SERVER], {
56
57
  cwd: opts.cwd,
57
58
  env: { ...process.env, PORT: String(opts.port) },
58
59
  stdio: "inherit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.1.0",
3
+ "version": "1.5.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
@@ -172,7 +172,35 @@ async function confirmCwdChange() {
172
172
 
173
173
  // ---- Markdown render (small, safe renderer) ----
174
174
  function escapeHtml(s) {
175
- return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
175
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
176
+ }
177
+
178
+ async function copyToClipboard(text) {
179
+ if (!text) return false;
180
+ if (navigator.clipboard && navigator.clipboard.writeText) {
181
+ try {
182
+ await navigator.clipboard.writeText(text);
183
+ return true;
184
+ } catch (e) {
185
+ console.warn("navigator.clipboard.writeText failed:", e);
186
+ }
187
+ }
188
+ try {
189
+ const ta = document.createElement("textarea");
190
+ ta.value = text;
191
+ ta.style.position = "fixed";
192
+ ta.style.left = "-9999px";
193
+ ta.style.top = "-9999px";
194
+ document.body.appendChild(ta);
195
+ ta.focus();
196
+ ta.select();
197
+ const ok = document.execCommand("copy");
198
+ document.body.removeChild(ta);
199
+ return ok;
200
+ } catch (e) {
201
+ console.error("Fallback copy error:", e);
202
+ return false;
203
+ }
176
204
  }
177
205
 
178
206
  function renderMarkdown(md) {
@@ -206,7 +234,18 @@ function renderMarkdown(md) {
206
234
  let html = "";
207
235
  for (const p of parts) {
208
236
  if (p.kind === "code") {
209
- html += `<pre><code data-lang="${escapeHtml(p.lang)}">${escapeHtml(p.code)}</code></pre>`;
237
+ const lang = p.lang || "";
238
+ const displayLang = lang || "code";
239
+ html += `<div class="code-block-wrapper">` +
240
+ `<div class="code-block-header">` +
241
+ `<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
242
+ `<button class="btn-copy-code" type="button" aria-label="复制代码" title="复制代码">` +
243
+ `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></svg>` +
244
+ `<span>复制</span>` +
245
+ `</button>` +
246
+ `</div>` +
247
+ `<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(p.code)}</code></pre>` +
248
+ `</div>`;
210
249
  } else {
211
250
  html += renderInlineMd(p.text);
212
251
  }
@@ -394,6 +433,10 @@ function closeSidebar() {
394
433
 
395
434
  async function loadSession(file) {
396
435
  state.currentSessionFile = file;
436
+ try {
437
+ const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
438
+ window.history.replaceState({ session: file }, "", newUrl);
439
+ } catch {}
397
440
  // Mobile: close sidebar on selection
398
441
  if (window.innerWidth <= 768) {
399
442
  closeSidebar();
@@ -401,6 +444,12 @@ async function loadSession(file) {
401
444
  // pull transcript from REST then connect a fresh WS pointed at this session
402
445
  const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
403
446
  const data = await res.json();
447
+
448
+ if (data.model) {
449
+ state.currentModel = data.model;
450
+ renderModelPill();
451
+ }
452
+
404
453
  clearChat();
405
454
  document.querySelector("#emptyState").style.display = "none";
406
455
  const chat = $("#chat-inner");
@@ -479,9 +528,13 @@ function showEmptyState(show) {
479
528
 
480
529
  function appendMessageNode(role, m) {
481
530
  if (role === "user") {
482
- const node = el("div", { class: "msg user" }, [
483
- el("div", { class: "bubble", text: m.text }),
531
+ const isSteer = m.isSteer || false;
532
+ const steerBadge = isSteer ? el("span", { class: "steer-badge", text: "🧭 指导指令" }) : null;
533
+ const bubble = el("div", { class: "bubble" + (isSteer ? " steer" : "") }, [
534
+ steerBadge,
535
+ document.createTextNode(m.text || "")
484
536
  ]);
537
+ const node = el("div", { class: "msg user" }, [bubble]);
485
538
  $("#chat-inner").appendChild(node);
486
539
  scrollBottom();
487
540
  return node;
@@ -491,8 +544,27 @@ function appendMessageNode(role, m) {
491
544
 
492
545
  function renderAssistantBlock(m) {
493
546
  // m.content is array of {type:text|thinking|toolCall}
547
+ const fullText = extractContentText(m.content);
548
+ const copyMsgBtn = el("button", {
549
+ class: "btn-copy-msg",
550
+ type: "button",
551
+ title: "复制回答全文",
552
+ onclick: async (e) => {
553
+ e.stopPropagation();
554
+ if (await copyToClipboard(fullText)) {
555
+ showToast("已复制回答全文");
556
+ }
557
+ }
558
+ }, [
559
+ 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>' }),
560
+ el("span", { text: "复制全文" })
561
+ ]);
562
+
494
563
  const node = el("div", { class: "msg assistant" }, [
495
- el("div", { class: "role-tag", text: "pi" }),
564
+ el("div", { class: "role-tag" }, [
565
+ el("span", { text: "pi" }),
566
+ copyMsgBtn
567
+ ]),
496
568
  el("div", { class: "content" }),
497
569
  ]);
498
570
  const content = node.querySelector(".content");
@@ -544,6 +616,19 @@ function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
544
616
  return block;
545
617
  }
546
618
 
619
+ function getToolCommandToCopy(call) {
620
+ if (!call) return "";
621
+ const args = call.arguments;
622
+ if (typeof args === "string") return args;
623
+ if (args && typeof args === "object") {
624
+ if (args.command) return String(args.command);
625
+ if (args.cmd) return String(args.cmd);
626
+ if (args.path) return String(args.path);
627
+ if (args.code) return String(args.code);
628
+ }
629
+ return summaryArgs(call.name, call.arguments);
630
+ }
631
+
547
632
  function makeToolBlockFromCall(call) {
548
633
  const block = el("div", { class: "tool-block" });
549
634
  const hasResult = Boolean(call.result);
@@ -557,10 +642,33 @@ function makeToolBlockFromCall(call) {
557
642
  const stateText = hasResult ? (isError ? "错误" : "完成") : "…";
558
643
  const stateClass = "state" + (hasResult && isError ? " error" : "");
559
644
 
645
+ const cmdToCopy = getToolCommandToCopy(call);
646
+ const copyBtn = cmdToCopy ? el("button", {
647
+ class: "btn-copy-tool",
648
+ type: "button",
649
+ title: "复制指令/参数",
650
+ onclick: async (e) => {
651
+ e.stopPropagation();
652
+ if (await copyToClipboard(cmdToCopy)) {
653
+ copyBtn.classList.add("copied");
654
+ const span = copyBtn.querySelector("span");
655
+ if (span) span.textContent = "已复制";
656
+ setTimeout(() => {
657
+ copyBtn.classList.remove("copied");
658
+ if (span) span.textContent = "复制";
659
+ }, 1500);
660
+ }
661
+ }
662
+ }, [
663
+ 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>' }),
664
+ el("span", { text: "复制" })
665
+ ]) : null;
666
+
560
667
  const head = el("div", { class: "tool-head" }, [
561
668
  el("span", { class: "ic", text: "⚙" }),
562
669
  el("span", { class: "name", text: call.name }),
563
670
  el("span", { class: "args", text: summaryArgs(call.name, call.arguments) }),
671
+ copyBtn,
564
672
  el("span", { class: stateClass, text: stateText }),
565
673
  ]);
566
674
 
@@ -609,7 +717,26 @@ function ensureStreamingMsg() {
609
717
  if (state.streamingMsg) return state.streamingMsg;
610
718
  showEmptyState(false);
611
719
  const node = el("div", { class: "msg assistant" }, [
612
- el("div", { class: "role-tag", text: "pi" }),
720
+ el("div", { class: "role-tag" }, [
721
+ el("span", { text: "pi" }),
722
+ el("button", {
723
+ class: "btn-copy-msg",
724
+ type: "button",
725
+ title: "复制回答全文",
726
+ onclick: async (e) => {
727
+ e.stopPropagation();
728
+ const contentEl = node.querySelector(".content");
729
+ if (!contentEl) return;
730
+ const text = contentEl.textContent || "";
731
+ if (await copyToClipboard(text)) {
732
+ showToast("已复制回答全文");
733
+ }
734
+ }
735
+ }, [
736
+ 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>' }),
737
+ el("span", { text: "复制全文" })
738
+ ])
739
+ ]),
613
740
  el("div", { class: "content" }),
614
741
  ]);
615
742
  state.streamingMsg = node;
@@ -672,8 +799,113 @@ function finalizeStreamingMsg() {
672
799
  // Each generation of WebSocket gets its own id; late stragglers from
673
800
  // a previous-generation ws are silently dropped to keep state consistent.
674
801
  let wsGen = 0;
802
+ let pingTimer = null;
803
+ let reconnectTimer = null;
804
+ let reconnectAttempts = 0;
805
+ let lastPongTime = Date.now();
806
+ let isConnecting = false;
807
+ let wasDisconnected = false;
808
+
809
+ function setConnStatus(status, text) {
810
+ // status: 'connected', 'reconnecting', 'disconnected'
811
+ state.wsConnected = (status === "connected");
812
+ const dot = $("#connDot");
813
+ const label = $("#connLabel");
814
+ const connStatus = $("#connStatus");
815
+
816
+ if (dot) {
817
+ if (status === "connected") {
818
+ dot.style.color = "var(--accent)";
819
+ } else if (status === "reconnecting") {
820
+ dot.style.color = "var(--warning)";
821
+ } else {
822
+ dot.style.color = "var(--danger)";
823
+ }
824
+ }
825
+
826
+ if (label) {
827
+ if (text) {
828
+ label.textContent = text;
829
+ } else if (status === "connected") {
830
+ label.textContent = "已连接";
831
+ } else if (status === "reconnecting") {
832
+ label.textContent = "正在重连…";
833
+ } else {
834
+ label.textContent = "已断开";
835
+ }
836
+ }
837
+
838
+ if (connStatus) {
839
+ connStatus.title = status === "connected" ? "WebSocket 已连接" : "连接已断开,点击尝试重连";
840
+ connStatus.style.cursor = status === "connected" ? "default" : "pointer";
841
+ }
842
+
843
+ const btn = $("#sendBtn");
844
+ if (btn && !state.streaming) {
845
+ btn.disabled = (status !== "connected");
846
+ }
847
+ }
848
+
849
+ function startPingInterval() {
850
+ stopPingInterval();
851
+ lastPongTime = Date.now();
852
+ pingTimer = setInterval(() => {
853
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
854
+ if (Date.now() - lastPongTime > 45000) {
855
+ console.warn("WebSocket 心跳超时,尝试断开并重连…");
856
+ try { state.ws.close(); } catch {}
857
+ return;
858
+ }
859
+ try {
860
+ state.ws.send(JSON.stringify({ type: "ping" }));
861
+ } catch {}
862
+ }
863
+ }, 15000);
864
+ }
865
+
866
+ function stopPingInterval() {
867
+ if (pingTimer) {
868
+ clearInterval(pingTimer);
869
+ pingTimer = null;
870
+ }
871
+ }
872
+
873
+ function scheduleReconnect(delayMs) {
874
+ if (reconnectTimer) {
875
+ if (delayMs === 0) {
876
+ clearTimeout(reconnectTimer);
877
+ reconnectTimer = null;
878
+ } else {
879
+ return;
880
+ }
881
+ }
882
+
883
+ if (delayMs === undefined) {
884
+ reconnectAttempts++;
885
+ const base = Math.min(1000 * Math.pow(1.5, reconnectAttempts - 1), 15000);
886
+ const jitter = Math.random() * 500;
887
+ delayMs = Math.round(base + jitter);
888
+ }
889
+
890
+ setConnStatus("reconnecting", reconnectAttempts > 0 ? `重连中 (${reconnectAttempts})` : "正在重连…");
891
+
892
+ reconnectTimer = setTimeout(() => {
893
+ reconnectTimer = null;
894
+ performReconnect();
895
+ }, delayMs);
896
+ }
897
+
898
+ function performReconnect() {
899
+ if (isConnecting) return;
900
+ connectWs({ isReconnect: true });
901
+ }
675
902
 
676
903
  function connectWs(opts = {}) {
904
+ if (reconnectTimer) {
905
+ clearTimeout(reconnectTimer);
906
+ reconnectTimer = null;
907
+ }
908
+
677
909
  if (state.ws) {
678
910
  try {
679
911
  // Suppress onclose so the connection indicator doesn't flicker to red
@@ -682,43 +914,78 @@ function connectWs(opts = {}) {
682
914
  state.ws.close();
683
915
  } catch {}
684
916
  }
917
+
918
+ isConnecting = true;
685
919
  const myGen = ++wsGen;
686
920
  const proto = location.protocol === "https:" ? "wss" : "ws";
687
921
  const cwd = encodeURIComponent(state.cwd || "");
688
- const sess = opts.session ? `&session=${encodeURIComponent(opts.session)}` : "";
922
+
923
+ // Preserve active session if not explicitly cleared or provided
924
+ let targetSession = opts.session;
925
+ if (opts.explicitNewSession) {
926
+ targetSession = null;
927
+ } else if (targetSession === undefined) {
928
+ targetSession = state.currentSessionFile;
929
+ }
930
+
931
+ const sess = targetSession ? `&session=${encodeURIComponent(targetSession)}` : "";
932
+
689
933
  // When user clicks "new session", pre-flush any "stuck streaming" state
690
934
  // from the previous connection so the new connection starts clean.
691
- if (!opts.session) {
935
+ if (opts.explicitNewSession) {
692
936
  state.streaming = false;
693
937
  state.streamingText = "";
694
938
  state.streamingThinking = "";
695
939
  state.streamingMsg = null;
696
940
  state.activeToolCalls.clear();
697
941
  }
942
+
698
943
  const url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}`;
699
944
  const ws = new WebSocket(url);
700
945
  state.ws = ws;
701
946
  ws._gen = myGen;
702
- const setConn = (ok) => {
703
- if (ws._gen !== wsGen) return; // ignore stragglers from a previous socket
704
- state.wsConnected = ok;
705
- const dot = $("#connDot");
706
- const label = $("#connLabel");
707
- if (dot) dot.style.color = ok ? "var(--accent)" : "var(--danger)";
708
- if (label) label.textContent = ok ? "已连接" : "已断开";
709
- const btn = $("#sendBtn");
710
- if (btn && !state.streaming) btn.disabled = !ok;
947
+
948
+ ws.onopen = () => {
949
+ if (ws._gen !== wsGen) return;
950
+ isConnecting = false;
951
+ setConnStatus("connected");
952
+ startPingInterval();
953
+
954
+ if (wasDisconnected || reconnectAttempts > 0) {
955
+ showToast("网络连接已恢复");
956
+ }
957
+ wasDisconnected = false;
958
+ reconnectAttempts = 0;
959
+
960
+ setTimeout(() => sendWs({ type: "get_state" }), 300);
961
+ setTimeout(() => sendWs({ type: "get_available_models" }), 500);
711
962
  };
712
- ws.onopen = () => setConn(true);
963
+
713
964
  ws.onclose = () => {
965
+ stopPingInterval();
966
+ if (ws._gen !== wsGen) return;
967
+ isConnecting = false;
714
968
  if (ws._suppressOnclose) return;
715
- setConn(false);
969
+
970
+ wasDisconnected = true;
971
+ setConnStatus("disconnected");
972
+ scheduleReconnect();
973
+ };
974
+
975
+ ws.onerror = () => {
976
+ stopPingInterval();
977
+ if (ws._gen !== wsGen) return;
978
+ isConnecting = false;
979
+ wasDisconnected = true;
980
+ setConnStatus("disconnected");
716
981
  };
717
- ws.onerror = () => setConn(false);
982
+
718
983
  ws.onmessage = (ev) => {
719
- if (ws._gen !== wsGen) return; // drop stragglers
984
+ if (ws._gen !== wsGen) return;
985
+ lastPongTime = Date.now();
720
986
  let obj;
721
987
  try { obj = JSON.parse(ev.data); } catch { return; }
988
+ if (obj.type === "pong") return;
722
989
  handlePiMessage(obj);
723
990
  };
724
991
  }
@@ -764,6 +1031,16 @@ function handlePiMessage(obj) {
764
1031
  }
765
1032
  // Events from pi.
766
1033
  switch (obj.type) {
1034
+ case "model_select":
1035
+ if (obj.model) {
1036
+ state.currentModel = obj.model;
1037
+ renderModelPill();
1038
+ renderModelMenu();
1039
+ }
1040
+ break;
1041
+ case "remote_user_prompt":
1042
+ appendMessageNode("user", { text: obj.message, isSteer: obj.isSteer });
1043
+ break;
767
1044
  case "agent_start":
768
1045
  state.streaming = true;
769
1046
  setComposerAborting(true);
@@ -919,11 +1196,31 @@ function updateState(d) {
919
1196
  if (d?.model) { state.currentModel = d.model; renderModelPill(); }
920
1197
  if (d?.thinkingLevel) state.thinkingLevel = d.thinkingLevel;
921
1198
  $("#topSessionName").textContent = d?.sessionName || (d?.sessionFile ? baseName(d.sessionFile) : "新对话");
1199
+
1200
+ // Sync streaming state upon state updates (e.g. after reconnect)
1201
+ if (d && typeof d.isStreaming === "boolean") {
1202
+ if (d.isStreaming) {
1203
+ if (!state.streaming) {
1204
+ state.streaming = true;
1205
+ setComposerAborting(true);
1206
+ ensureStreamingMsg();
1207
+ }
1208
+ } else if (state.streaming) {
1209
+ finalizeStreamingMsg();
1210
+ state.streaming = false;
1211
+ setComposerAborting(false);
1212
+ if (state.currentSessionFile) {
1213
+ loadSession(state.currentSessionFile);
1214
+ }
1215
+ refreshSessions();
1216
+ }
1217
+ }
922
1218
  }
923
1219
 
924
1220
  function updateModels(models) {
925
1221
  state.models = models;
926
1222
  renderModelMenu();
1223
+ renderModelPill();
927
1224
  }
928
1225
 
929
1226
  function renderModelPill() {
@@ -931,7 +1228,14 @@ function renderModelPill() {
931
1228
  const pill = $("#modelPill");
932
1229
  if (!m) { pill.textContent = "选择模型"; return; }
933
1230
  const provider = m.provider || "?";
934
- const name = m.name || m.id;
1231
+ let name = m.name || m.id;
1232
+ if (state.models && state.models.length > 0) {
1233
+ const found = state.models.find(item => item.id === m.id && item.provider === m.provider) ||
1234
+ state.models.find(item => item.id === m.id);
1235
+ if (found && found.name) {
1236
+ name = found.name;
1237
+ }
1238
+ }
935
1239
  pill.textContent = `${provider} / ${name}`;
936
1240
  pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
937
1241
  }
@@ -970,17 +1274,69 @@ function baseName(p) {
970
1274
  }
971
1275
 
972
1276
  // ---- Composer ----
973
- function setComposerAborting(yes) {
974
- const btn = $("#sendBtn");
975
- if (yes) {
976
- btn.classList.add("stop");
977
- btn.disabled = false;
978
- btn.textContent = "■";
1277
+ function updateComposerUI() {
1278
+ const ta = $("#composer");
1279
+ const text = ta ? ta.value.trim() : "";
1280
+ const sendBtn = $("#sendBtn");
1281
+ const steerBtn = $("#steerBtn");
1282
+
1283
+ if (state.streaming) {
1284
+ if (sendBtn) {
1285
+ sendBtn.classList.add("stop");
1286
+ sendBtn.disabled = !state.wsConnected;
1287
+ sendBtn.textContent = "■";
1288
+ sendBtn.title = "中止当前生成";
1289
+ }
1290
+ if (steerBtn) {
1291
+ if (text.length > 0) {
1292
+ steerBtn.style.display = "inline-flex";
1293
+ steerBtn.disabled = !state.wsConnected;
1294
+ } else {
1295
+ steerBtn.style.display = "none";
1296
+ }
1297
+ }
1298
+ if (ta) ta.placeholder = "AI 正在运行中… 可在此输入补充/指导指令,按 Enter 或点击【插入指令】实时调整";
979
1299
  } else {
980
- btn.classList.remove("stop");
981
- btn.disabled = false;
982
- btn.textContent = "↑";
1300
+ if (sendBtn) {
1301
+ sendBtn.classList.remove("stop");
1302
+ sendBtn.disabled = !state.wsConnected;
1303
+ sendBtn.textContent = "↑";
1304
+ sendBtn.title = "发送消息";
1305
+ }
1306
+ if (steerBtn) {
1307
+ steerBtn.style.display = "none";
1308
+ }
1309
+ if (ta) ta.placeholder = "给 pi 发消息… (Enter 发送,Shift+Enter 换行)";
1310
+ }
1311
+ }
1312
+
1313
+ function setComposerAborting(yes) {
1314
+ updateComposerUI();
1315
+ }
1316
+
1317
+ function submitSteer() {
1318
+ const ta = $("#composer");
1319
+ const text = ta.value.trim();
1320
+ const hint = $(".composer-hint");
1321
+ if (!text) return;
1322
+
1323
+ if (!state.wsConnected) {
1324
+ if (hint) hint.textContent = "发送失败:WebSocket 未连接。正在尝试重连…";
1325
+ scheduleReconnect(0);
1326
+ return;
983
1327
  }
1328
+
1329
+ appendMessageNode("user", { text, isSteer: true });
1330
+ ta.value = "";
1331
+ autoResize();
1332
+ updateComposerUI();
1333
+
1334
+ if (hint) hint.textContent = "已插入指导指令!pi 将在当前轮次中实时接收并调整方向。";
1335
+ setTimeout(() => {
1336
+ if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
1337
+ }, 4000);
1338
+
1339
+ sendWs({ type: "steer", message: text });
984
1340
  }
985
1341
 
986
1342
  function submitPrompt() {
@@ -990,11 +1346,16 @@ function submitPrompt() {
990
1346
  if (!text) return;
991
1347
  if (!state.wsConnected) {
992
1348
  if (hint) hint.textContent = "发送失败:WebSocket 未连接。正在尝试重连…";
1349
+ scheduleReconnect(0);
993
1350
  const box = $("#composerInner");
994
1351
  if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
995
1352
  return;
996
1353
  }
997
1354
  if (state.streaming) {
1355
+ if (text) {
1356
+ submitSteer();
1357
+ return;
1358
+ }
998
1359
  if (hint) hint.textContent = "中止当前生成中…";
999
1360
  sendWs({ type: "abort" });
1000
1361
  return;
@@ -1038,21 +1399,50 @@ function init() {
1038
1399
  }
1039
1400
  clearChat();
1040
1401
  showEmptyState(true);
1041
- connectWs({}); // no session -> pi creates a new one
1042
- $("#topSessionName").textContent = "新对话";
1043
1402
  state.currentSessionFile = null;
1403
+ try {
1404
+ window.history.replaceState({}, "", window.location.pathname);
1405
+ } catch {}
1406
+ connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
1407
+ $("#topSessionName").textContent = "新对话";
1044
1408
  // Mobile: close sidebar on new session
1045
1409
  if (window.innerWidth <= 768) closeSidebar();
1046
1410
  refreshSessions();
1047
1411
  });
1048
1412
 
1049
- $("#sendBtn").addEventListener("click", submitPrompt);
1413
+ $("#sendBtn").addEventListener("click", () => {
1414
+ if (state.streaming) {
1415
+ const ta = $("#composer");
1416
+ if (ta && ta.value.trim()) {
1417
+ submitSteer();
1418
+ } else {
1419
+ submitPrompt();
1420
+ }
1421
+ } else {
1422
+ submitPrompt();
1423
+ }
1424
+ });
1425
+
1426
+ const steerBtnEl = $("#steerBtn");
1427
+ if (steerBtnEl) {
1428
+ steerBtnEl.addEventListener("click", submitSteer);
1429
+ }
1430
+
1050
1431
  const ta = $("#composer");
1051
- ta.addEventListener("input", autoResize);
1432
+ ta.addEventListener("input", () => {
1433
+ autoResize();
1434
+ updateComposerUI();
1435
+ });
1052
1436
  ta.addEventListener("keydown", (e) => {
1053
1437
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1054
1438
  e.preventDefault();
1055
- submitPrompt();
1439
+ if (state.streaming) {
1440
+ if (ta.value.trim()) {
1441
+ submitSteer();
1442
+ }
1443
+ } else {
1444
+ submitPrompt();
1445
+ }
1056
1446
  }
1057
1447
  });
1058
1448
 
@@ -1146,6 +1536,63 @@ function init() {
1146
1536
  // Load server config & restore saved CWD
1147
1537
  loadServerConfig();
1148
1538
 
1539
+ // Status badge click to reconnect
1540
+ const connStatusEl = $("#connStatus");
1541
+ if (connStatusEl) {
1542
+ connStatusEl.addEventListener("click", () => {
1543
+ if (!state.wsConnected) {
1544
+ showToast("正在尝试重新连接…");
1545
+ reconnectAttempts = 0;
1546
+ scheduleReconnect(0);
1547
+ }
1548
+ });
1549
+ }
1550
+
1551
+ // Auto reconnect on network status / visibility change
1552
+ window.addEventListener("online", () => {
1553
+ if (!state.wsConnected) {
1554
+ reconnectAttempts = 0;
1555
+ scheduleReconnect(0);
1556
+ }
1557
+ });
1558
+
1559
+ document.addEventListener("visibilitychange", () => {
1560
+ if (document.visibilityState === "visible") {
1561
+ if (!state.wsConnected) {
1562
+ reconnectAttempts = 0;
1563
+ scheduleReconnect(0);
1564
+ } else {
1565
+ try { state.ws.send(JSON.stringify({ type: "ping" })); } catch {}
1566
+ }
1567
+ }
1568
+ });
1569
+
1570
+ // Global event delegation for code block copy buttons
1571
+ document.addEventListener("click", async (e) => {
1572
+ const btn = e.target.closest(".btn-copy-code");
1573
+ if (!btn) return;
1574
+ e.stopPropagation();
1575
+
1576
+ const wrapper = btn.closest(".code-block-wrapper");
1577
+ if (!wrapper) return;
1578
+
1579
+ const codeEl = wrapper.querySelector("pre code");
1580
+ if (!codeEl) return;
1581
+
1582
+ const codeText = codeEl.textContent;
1583
+ if (await copyToClipboard(codeText)) {
1584
+ btn.classList.add("copied");
1585
+ const span = btn.querySelector("span");
1586
+ if (span) span.textContent = "已复制!";
1587
+ setTimeout(() => {
1588
+ btn.classList.remove("copied");
1589
+ if (span) span.textContent = "复制";
1590
+ }, 1500);
1591
+ } else {
1592
+ showToast("复制失败");
1593
+ }
1594
+ });
1595
+
1149
1596
  refreshSessions();
1150
1597
  // start in the disconnected state; connectWs will flip to green on open.
1151
1598
  const initDot = $("#connDot");
@@ -1153,8 +1600,15 @@ function init() {
1153
1600
  if (initDot) initDot.style.color = "var(--danger)";
1154
1601
  if (initLabel) initLabel.textContent = "连接中…";
1155
1602
  $("#sendBtn").disabled = true;
1156
- connectWs({});
1157
- showEmptyState(true);
1603
+
1604
+ const urlParams = new URLSearchParams(window.location.search);
1605
+ const initialSession = urlParams.get("session") || urlParams.get("file");
1606
+ if (initialSession) {
1607
+ loadSession(initialSession);
1608
+ } else {
1609
+ connectWs({});
1610
+ showEmptyState(true);
1611
+ }
1158
1612
  // Pull the current pi state (model, session id, thinking level) once the
1159
1613
  // socket is open. connectWs() registers onopen asynchronously; defer long
1160
1614
  // enough that the writable is ready. (An earlier version wrote the `\n` as
package/public/index.html CHANGED
@@ -22,7 +22,7 @@
22
22
  </div>
23
23
  <div class="session-list" id="sessionList"></div>
24
24
  <div class="sidebar-bottom">
25
- <div><span id="connDot" style="color: var(--danger);">●</span>&nbsp; <span id="connLabel">连接中…</span></div>
25
+ <div id="connStatus" class="conn-status" title="点击可重新连接"><span id="connDot" style="color: var(--danger);">●</span>&nbsp;<span id="connLabel">连接中…</span></div>
26
26
  <div style="margin-top:4px;"><a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a></div>
27
27
  </div>
28
28
  </aside>
@@ -68,6 +68,10 @@
68
68
  <div class="composer">
69
69
  <div class="composer-inner" id="composerInner">
70
70
  <textarea id="composer" rows="1" placeholder="给 pi 发消息… (Enter 发送,Shift+Enter 换行)"></textarea>
71
+ <button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
72
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg>
73
+ <span>插入指令</span>
74
+ </button>
71
75
  <button class="send-btn" id="sendBtn" title="发送">↑</button>
72
76
  </div>
73
77
  <div class="composer-hint">pi 会执行命令与读写你的文件 —— 请注意操作内容。</div>
package/public/style.css CHANGED
@@ -17,6 +17,7 @@
17
17
  --thinking-bg: #1c1c1c;
18
18
  --thinking-border: #444;
19
19
  --danger: #ef4444;
20
+ --warning: #f59e0b;
20
21
  --radius: 14px;
21
22
  }
22
23
 
@@ -34,6 +35,7 @@ body {
34
35
  display: flex;
35
36
  width: 100vw;
36
37
  height: 100vh;
38
+ height: 100dvh;
37
39
  overflow: hidden;
38
40
  position: relative;
39
41
  background: var(--bg);
@@ -139,6 +141,15 @@ body {
139
141
  }
140
142
  .sidebar-bottom a { color: var(--text-muted); text-decoration: none; }
141
143
  .sidebar-bottom a:hover { color: var(--text); }
144
+ .conn-status {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ user-select: none;
148
+ transition: opacity 0.15s ease;
149
+ }
150
+ .conn-status[style*="cursor: pointer"]:hover {
151
+ opacity: 0.85;
152
+ }
142
153
 
143
154
  /* ---------- Sidebar Overlay (mobile-only drawer backdrop) ---------- */
144
155
  .sidebar-overlay {
@@ -163,8 +174,10 @@ body {
163
174
  padding: 0 16px;
164
175
  gap: 12px;
165
176
  background: var(--bg);
166
- z-index: 2;
167
- position: relative;
177
+ z-index: 20;
178
+ position: sticky;
179
+ top: 0;
180
+ flex-shrink: 0;
168
181
  }
169
182
  .topbar .session-name {
170
183
  font-size: 15px;
@@ -254,6 +267,17 @@ body {
254
267
  line-height: 1.55;
255
268
  font-size: 15px;
256
269
  }
270
+ .msg.user .bubble.steer {
271
+ border: 1px solid var(--accent);
272
+ background: rgba(16, 163, 127, 0.12);
273
+ }
274
+ .steer-badge {
275
+ display: block;
276
+ font-size: 11px;
277
+ font-weight: 600;
278
+ color: var(--accent);
279
+ margin-bottom: 4px;
280
+ }
257
281
  .msg.assistant { align-items: stretch; }
258
282
  .msg.assistant .role-tag {
259
283
  font-size: 12px; color: var(--text-dim); font-weight: 600; letter-spacing: .3px;
@@ -278,6 +302,73 @@ body {
278
302
  background: #2a2a2a; padding: 2px 6px; border-radius: 4px;
279
303
  }
280
304
  .msg.assistant .content pre code { background: transparent; padding: 0; }
305
+
306
+ /* Code block wrapper and copy button */
307
+ .code-block-wrapper {
308
+ position: relative;
309
+ background: #0d0d0d;
310
+ border: 1px solid var(--border);
311
+ border-radius: 10px;
312
+ margin: 12px 0;
313
+ overflow: hidden;
314
+ }
315
+ .code-block-header {
316
+ display: flex;
317
+ align-items: center;
318
+ justify-content: space-between;
319
+ padding: 6px 12px;
320
+ background: #181818;
321
+ border-bottom: 1px solid var(--border);
322
+ font-size: 12px;
323
+ color: var(--text-dim);
324
+ user-select: none;
325
+ }
326
+ .code-block-lang {
327
+ font-family: "SF Mono", Menlo, Consolas, monospace;
328
+ font-size: 11px;
329
+ text-transform: lowercase;
330
+ color: var(--text-muted);
331
+ }
332
+ .code-block-wrapper pre {
333
+ margin: 0 !important;
334
+ border: none !important;
335
+ border-radius: 0 !important;
336
+ background: transparent !important;
337
+ padding: 12px 14px;
338
+ overflow-x: auto;
339
+ }
340
+
341
+ .btn-copy-code, .btn-copy-msg, .btn-copy-tool {
342
+ display: inline-flex;
343
+ align-items: center;
344
+ gap: 4px;
345
+ background: transparent;
346
+ border: none;
347
+ color: var(--text-muted);
348
+ font-size: 11px;
349
+ cursor: pointer;
350
+ padding: 2px 6px;
351
+ border-radius: 4px;
352
+ transition: all 0.15s ease;
353
+ user-select: none;
354
+ }
355
+ .btn-copy-msg, .btn-copy-tool {
356
+ color: var(--text-dim);
357
+ }
358
+ .btn-copy-msg {
359
+ margin-left: auto;
360
+ }
361
+ .btn-copy-code:hover, .btn-copy-msg:hover, .btn-copy-tool:hover {
362
+ background: var(--bg-hover);
363
+ color: var(--text);
364
+ }
365
+ .btn-copy-code.copied, .btn-copy-msg.copied, .btn-copy-tool.copied {
366
+ color: var(--accent);
367
+ }
368
+ .btn-copy-code svg, .btn-copy-msg svg, .btn-copy-tool svg {
369
+ width: 12px;
370
+ height: 12px;
371
+ }
281
372
  .msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
282
373
  .msg.assistant .content li { margin: 4px 0; }
283
374
  .msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3 {
@@ -457,6 +548,35 @@ body {
457
548
  }
458
549
  .composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
459
550
  .composer .send-btn.stop { background: var(--danger); }
551
+ .composer .steer-btn {
552
+ display: inline-flex;
553
+ align-items: center;
554
+ gap: 5px;
555
+ height: 32px;
556
+ border-radius: 16px;
557
+ border: none;
558
+ cursor: pointer;
559
+ background: var(--accent);
560
+ color: #fff;
561
+ padding: 0 12px;
562
+ font-size: 12px;
563
+ font-weight: 500;
564
+ flex-shrink: 0;
565
+ transition: background .15s, opacity .15s, transform .1s;
566
+ user-select: none;
567
+ }
568
+ .composer .steer-btn:hover {
569
+ background: var(--accent-hover);
570
+ }
571
+ .composer .steer-btn:disabled {
572
+ background: #3a3a3a;
573
+ color: #6f6f6f;
574
+ cursor: not-allowed;
575
+ }
576
+ .composer .steer-btn svg {
577
+ width: 13px;
578
+ height: 13px;
579
+ }
460
580
  .composer-hint {
461
581
  text-align: center; color: var(--text-dim); font-size: 11px; margin-top: 10px; max-width: 820px; margin-left: auto; margin-right: auto;
462
582
  }
@@ -689,40 +809,53 @@ body {
689
809
 
690
810
  /* Topbar Mobile Adjustments */
691
811
  .topbar {
692
- height: 52px;
693
- padding: 0 12px;
694
- gap: 8px;
812
+ height: 50px;
813
+ padding: 0 8px;
814
+ gap: 6px;
815
+ flex-shrink: 0;
816
+ position: sticky;
817
+ top: 0;
818
+ z-index: 20;
819
+ background: var(--bg);
695
820
  }
696
821
 
697
822
  .topbar .session-name {
698
- font-size: 14px;
823
+ font-size: 13px;
699
824
  font-weight: 500;
825
+ min-width: 0;
826
+ flex: 1;
827
+ overflow: hidden;
828
+ text-overflow: ellipsis;
829
+ white-space: nowrap;
830
+ padding: 0 2px;
700
831
  }
701
832
 
702
833
  #cwdPillWrap {
703
- padding: 5px 8px;
834
+ padding: 4px 8px;
704
835
  font-size: 11px;
705
- max-width: 110px;
836
+ max-width: 95px;
706
837
  overflow: hidden;
707
838
  text-overflow: ellipsis;
708
839
  white-space: nowrap;
840
+ flex-shrink: 0;
709
841
  }
710
842
 
711
843
  .model-pill {
712
- padding: 5px 10px;
844
+ padding: 4px 8px;
713
845
  font-size: 11px;
714
- max-width: 100px;
846
+ max-width: 105px;
715
847
  overflow: hidden;
716
848
  text-overflow: ellipsis;
717
849
  white-space: nowrap;
850
+ flex-shrink: 0;
718
851
  }
719
852
 
720
853
  /* Model Dropdown Menu Mobile Adjustments */
721
854
  .model-menu {
722
- width: calc(100vw - 24px);
723
- right: 12px;
724
- left: 12px;
725
- top: 48px;
855
+ width: calc(100vw - 16px);
856
+ right: 8px;
857
+ left: 8px;
858
+ top: 46px;
726
859
  max-height: 280px;
727
860
  }
728
861
 
package/server.js CHANGED
@@ -42,12 +42,47 @@ function normalizeCwd(dir) {
42
42
  const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(home(), ".pi", "agent", "sessions");
43
43
  const PORT = process.env.PORT || 3000;
44
44
 
45
+ // Idle timeout (ms) before a detached (browser closed) pi RPC subprocess is killed.
46
+ // Replaces the previous hardcoded 5-minute timeout. Lower values free memory faster
47
+ // on memory-constrained hosts; set IDLE_TIMEOUT_MS=0 to disable cleanup entirely.
48
+ const IDLE_TIMEOUT_MS = (() => {
49
+ const raw = process.env.IDLE_TIMEOUT_MS;
50
+ if (raw === undefined || raw === "") return 5 * 60 * 1000;
51
+ const n = Number(raw);
52
+ if (!Number.isFinite(n) || n < 0) {
53
+ console.warn(`[pi-web-chat] Invalid IDLE_TIMEOUT_MS="${raw}", falling back to 300000`);
54
+ return 5 * 60 * 1000;
55
+ }
56
+ return n;
57
+ })();
58
+
59
+ // Maximum number of concurrently-pooled pi RPC subprocesses. New WebSocket
60
+ // connections beyond the cap are rejected with a clear message instead of
61
+ // silently exhausting memory. Set MAX_CONCURRENT_AGENTS=0 to disable.
62
+ const MAX_CONCURRENT_AGENTS = (() => {
63
+ const raw = process.env.MAX_CONCURRENT_AGENTS;
64
+ if (raw === undefined || raw === "") return 0; // 0 = unlimited (back-compat)
65
+ const n = Number(raw);
66
+ if (!Number.isInteger(n) || n < 0) {
67
+ console.warn(`[pi-web-chat] Invalid MAX_CONCURRENT_AGENTS="${raw}", falling back to 0 (unlimited)`);
68
+ return 0;
69
+ }
70
+ return n;
71
+ })();
72
+
73
+ // If true, when pi becomes idle (no WebSocket attached) we attempt to give the
74
+ // OS an early hint that this agent's memory is reclaimable. Node has no direct
75
+ // API for this, but --expose-gc + global.gc() drops the V8 heap lazily. When
76
+ // enabled, idle agents will voluntarily release heap before being killed by the
77
+ // idle timer, reducing pressure on small-memory hosts. Default: false.
78
+ const IDLE_DROP_HEAP = process.env.IDLE_DROP_HEAP === "1" || process.env.IDLE_DROP_HEAP === "true";
79
+
45
80
  // Active pi RPC processes pooled by session key (`${cwd}:${resolvedSessionPath}`)
46
81
  const activeAgents = new Map();
47
82
 
48
83
  class PiAgent {
49
84
  constructor(cwd) {
50
- this.ws = null;
85
+ this.sockets = new Set();
51
86
  this.cwd = normalizeCwd(cwd);
52
87
  this.sessionKey = null;
53
88
  this.reqId = 0;
@@ -59,15 +94,23 @@ class PiAgent {
59
94
  }
60
95
 
61
96
  attachWs(ws) {
62
- this.ws = ws;
97
+ this.sockets.add(ws);
63
98
  this.cancelCleanup();
64
99
  }
65
100
 
66
101
  detachWs(ws) {
67
- if (this.ws === ws) {
68
- this.ws = null;
69
- // Keep process alive for 5 minutes in case browser refreshes or reconnects
70
- this.scheduleCleanup(5 * 60 * 1000);
102
+ this.sockets.delete(ws);
103
+ if (this.sockets.size === 0) {
104
+ // Keep process alive for IDLE_TIMEOUT_MS so a browser refresh/reconnect
105
+ // can re-attach to the same pi RPC subprocess. If IDLE_TIMEOUT_MS is 0,
106
+ // scheduleCleanup still runs but stops the process immediately.
107
+ const idleMs = IDLE_TIMEOUT_MS === 0 ? 0 : IDLE_TIMEOUT_MS;
108
+ this.scheduleCleanup(idleMs);
109
+ if (IDLE_DROP_HEAP && idleMs > 0) {
110
+ // Give the OS a hint that this process is a candidate for early
111
+ // reclamation before the idle timer fires. Cheaper than swap pressure.
112
+ try { if (typeof global.gc === "function") global.gc(); } catch {}
113
+ }
71
114
  }
72
115
  }
73
116
 
@@ -84,8 +127,19 @@ class PiAgent {
84
127
 
85
128
  scheduleCleanup(delayMs = 300000) {
86
129
  this.cancelCleanup();
130
+ // delayMs === 0 means "kill the subprocess right now" — used when an
131
+ // explicit teardown is requested without bypassing the cleanup pipeline.
132
+ if (delayMs === 0) {
133
+ console.log(`Cleaning up pi agent immediately (key=${this.sessionKey || "unkeyed"})`);
134
+ this.stop();
135
+ return;
136
+ }
87
137
  this.cleanupTimer = setTimeout(() => {
88
- console.log(`Cleaning up inactive pi agent (key=${this.sessionKey || "unkeyed"})`);
138
+ console.log(`Cleaning up inactive pi agent after ${Math.round(delayMs / 1000)}s idle (key=${this.sessionKey || "unkeyed"})`);
139
+ // One final heap drop just before we kill the subprocess.
140
+ if (IDLE_DROP_HEAP) {
141
+ try { if (typeof global.gc === "function") global.gc(); } catch {}
142
+ }
89
143
  this.stop();
90
144
  }, delayMs);
91
145
  }
@@ -108,7 +162,8 @@ class PiAgent {
108
162
  this.alive = false;
109
163
  console.error(`[pi spawn error]`, err);
110
164
  this.wsSend({ type: "pi_exit", error: err.message });
111
- try { this.ws && this.ws.close(); } catch {}
165
+ for (const s of this.sockets) { try { s.close(); } catch {} }
166
+ this.sockets.clear();
112
167
  });
113
168
  this.proc.stdout.on("data", (d) => this.onStdout(d));
114
169
  this.proc.stderr.on("data", (d) => {
@@ -119,7 +174,8 @@ class PiAgent {
119
174
  console.log(`pi exited (code=${code})`);
120
175
  this.wsSend({ type: "pi_exit", code });
121
176
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
122
- try { this.ws && this.ws.close(); } catch {}
177
+ for (const s of this.sockets) { try { s.close(); } catch {} }
178
+ this.sockets.clear();
123
179
  });
124
180
  }
125
181
 
@@ -174,12 +230,15 @@ class PiAgent {
174
230
  this.proc.stdin.write(JSON.stringify(cmd) + "\n");
175
231
  }
176
232
 
177
- wsSend(obj) {
178
- if (this.ws && this.ws.readyState === 1) {
179
- try {
180
- this.ws.send(JSON.stringify(obj));
181
- } catch (e) {
182
- console.error("wsSend error", e);
233
+ wsSend(obj, excludeSocket = null) {
234
+ const payload = typeof obj === "string" ? obj : JSON.stringify(obj);
235
+ for (const ws of this.sockets) {
236
+ if (ws !== excludeSocket && ws.readyState === 1) {
237
+ try {
238
+ ws.send(payload);
239
+ } catch (e) {
240
+ console.error("wsSend error", e);
241
+ }
183
242
  }
184
243
  }
185
244
  }
@@ -190,6 +249,8 @@ class PiAgent {
190
249
  if (this.sessionKey) {
191
250
  activeAgents.delete(this.sessionKey);
192
251
  }
252
+ for (const s of this.sockets) { try { s.close(); } catch {} }
253
+ this.sockets.clear();
193
254
  try { this.proc && this.proc.kill("SIGTERM"); } catch {}
194
255
  }
195
256
  }
@@ -353,7 +414,30 @@ app.get("/api/session", async (req, res) => {
353
414
  entryChain.unshift(e);
354
415
  cur = e.parentId;
355
416
  }
356
- res.json({ header, entries: entryChain });
417
+
418
+ // Extract the active model used in this session chain
419
+ let sessionModel = null;
420
+ for (let i = entryChain.length - 1; i >= 0; i--) {
421
+ const e = entryChain[i];
422
+ if (e.type === "model_change" && (e.modelId || e.model)) {
423
+ sessionModel = {
424
+ provider: e.provider || "",
425
+ id: e.modelId || e.model,
426
+ name: e.modelId || e.model
427
+ };
428
+ break;
429
+ }
430
+ if (e.type === "message" && e.message && e.message.role === "assistant" && e.message.model) {
431
+ sessionModel = {
432
+ provider: e.message.provider || e.provider || "",
433
+ id: e.message.model,
434
+ name: e.message.model
435
+ };
436
+ break;
437
+ }
438
+ }
439
+
440
+ res.json({ header, entries: entryChain, model: sessionModel });
357
441
  } catch (e) {
358
442
  console.error(e);
359
443
  res.status(500).json({ error: String(e) });
@@ -366,7 +450,32 @@ const httpServer = app.listen(PORT, () => {
366
450
  });
367
451
 
368
452
  const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
453
+
454
+ // WebSocket server heartbeat (ping/pong at protocol level)
455
+ const heartbeatInterval = setInterval(() => {
456
+ wss.clients.forEach((ws) => {
457
+ if (ws.isAlive === false) {
458
+ console.log("Terminating unresponsive WebSocket client");
459
+ if (ws.piAgent) {
460
+ ws.piAgent.detachWs(ws);
461
+ }
462
+ return ws.terminate();
463
+ }
464
+ ws.isAlive = false;
465
+ try { ws.ping(); } catch {}
466
+ });
467
+ }, 30000);
468
+
469
+ wss.on("close", () => {
470
+ clearInterval(heartbeatInterval);
471
+ });
472
+
369
473
  wss.on("connection", (ws, req) => {
474
+ ws.isAlive = true;
475
+ ws.on("pong", () => {
476
+ ws.isAlive = true;
477
+ });
478
+
370
479
  const url = new URL(req.url, "http://x");
371
480
  const cwd = normalizeCwd(url.searchParams.get("cwd"));
372
481
  const session = url.searchParams.get("session") || null;
@@ -378,6 +487,23 @@ wss.on("connection", (ws, req) => {
378
487
  console.log(`ws re-attaching to existing pi process for key=${key}`);
379
488
  agent.attachWs(ws);
380
489
  } else {
490
+ // Enforce concurrent-agent cap before spawn. We count *all* live agents,
491
+ // not just this key, because each represents one pi RPC subprocess in
492
+ // memory. The cap protects small-memory hosts from runaway browser tabs.
493
+ if (MAX_CONCURRENT_AGENTS > 0) {
494
+ let live = 0;
495
+ for (const a of activeAgents.values()) if (a.alive) live++;
496
+ if (live >= MAX_CONCURRENT_AGENTS) {
497
+ const msg = `Server is at capacity (${live}/${MAX_CONCURRENT_AGENTS} pi agents). ` +
498
+ `Close another tab or raise MAX_CONCURRENT_AGENTS.`;
499
+ console.warn(`[pi-web-chat] refusing new ws: ${msg}`);
500
+ try {
501
+ ws.send(JSON.stringify({ type: "error", code: "capacity", message: msg }));
502
+ } catch {}
503
+ try { ws.close(1013, "capacity"); } catch {} // 1013 = "try again later"
504
+ return;
505
+ }
506
+ }
381
507
  agent = new PiAgent(cwd);
382
508
  agent.start();
383
509
  agent.attachWs(ws);
@@ -392,8 +518,14 @@ wss.on("connection", (ws, req) => {
392
518
  ws.on("message", (raw) => {
393
519
  let msg;
394
520
  try { msg = JSON.parse(raw.toString()); } catch { return; }
521
+ if (msg.type === "ping") {
522
+ try { ws.send(JSON.stringify({ type: "pong" })); } catch {}
523
+ return;
524
+ }
395
525
  switch (msg.type) {
396
526
  case "prompt":
527
+ // Sync prompt to other connected clients in the same session
528
+ agent.wsSend({ type: "remote_user_prompt", message: msg.message, images: msg.images }, ws);
397
529
  agent.send({ type: "prompt", message: msg.message, images: msg.images });
398
530
  break;
399
531
  case "abort":
@@ -407,6 +539,8 @@ wss.on("connection", (ws, req) => {
407
539
  agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
408
540
  break;
409
541
  case "steer":
542
+ // Sync steer instruction to other connected clients in the same session
543
+ agent.wsSend({ type: "remote_user_prompt", message: msg.message, isSteer: true }, ws);
410
544
  agent.send({ type: "steer", message: msg.message });
411
545
  break;
412
546
  case "set_session_name":