@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 +72 -17
- package/bin/pi-web-chat.js +2 -1
- package/package.json +1 -1
- package/public/app.js +492 -38
- package/public/index.html +5 -1
- package/public/style.css +147 -14
- package/server.js +150 -16
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** | 安全相关修复 |
|
package/bin/pi-web-chat.js
CHANGED
|
@@ -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
|
-
|
|
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
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, "
|
|
175
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
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
|
|
483
|
-
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
963
|
+
|
|
713
964
|
ws.onclose = () => {
|
|
965
|
+
stopPingInterval();
|
|
966
|
+
if (ws._gen !== wsGen) return;
|
|
967
|
+
isConnecting = false;
|
|
714
968
|
if (ws._suppressOnclose) return;
|
|
715
|
-
|
|
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
|
-
|
|
982
|
+
|
|
718
983
|
ws.onmessage = (ev) => {
|
|
719
|
-
if (ws._gen !== wsGen) return;
|
|
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
|
-
|
|
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
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
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> 
|
|
25
|
+
<div id="connStatus" class="conn-status" title="点击可重新连接"><span id="connDot" style="color: var(--danger);">●</span> <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:
|
|
167
|
-
position:
|
|
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:
|
|
693
|
-
padding: 0
|
|
694
|
-
gap:
|
|
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:
|
|
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:
|
|
834
|
+
padding: 4px 8px;
|
|
704
835
|
font-size: 11px;
|
|
705
|
-
max-width:
|
|
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:
|
|
844
|
+
padding: 4px 8px;
|
|
713
845
|
font-size: 11px;
|
|
714
|
-
max-width:
|
|
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 -
|
|
723
|
-
right:
|
|
724
|
-
left:
|
|
725
|
-
top:
|
|
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.
|
|
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
|
|
97
|
+
this.sockets.add(ws);
|
|
63
98
|
this.cancelCleanup();
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
detachWs(ws) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// Keep process alive for
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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":
|