pi-web-ui 0.28.2 → 0.29.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/LICENSE +21 -21
- package/README.md +295 -295
- package/README.zh-CN.md +279 -279
- package/bin/pi-web-ui.mjs +0 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +408 -3851
- package/dist/server/attachments.js +621 -0
- package/dist/server/bg-servers.js +138 -0
- package/dist/server/client-state.js +148 -0
- package/dist/server/files-service.js +633 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +144 -8
- package/dist/server/model-admin.js +727 -0
- package/dist/server/process-utils.js +86 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/settings-service.js +268 -0
- package/dist/server/slash-commands.js +245 -0
- package/dist/server/terminals.js +98 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/webui-context.js +208 -0
- package/extensions/webui.ts +192 -192
- package/package.json +94 -87
- package/themes/light.css +6318 -6318
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-B-bsYqea.js +2 -0
- package/web/dist/assets/index-BsrqFaSZ.js +13 -0
- package/web/dist/assets/index-Dsb8Bak1.css +10 -0
- package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
- package/web/dist/assets/react-C9ovnpIm.js +24 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +17 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-BnDkdKFN.css +0 -41
- package/web/dist/assets/index-DmmSVSzk.js +0 -129
package/dist/server/index.js
CHANGED
|
@@ -21,15 +21,19 @@ import { stat } from "node:fs/promises";
|
|
|
21
21
|
import { createServer } from "node:http";
|
|
22
22
|
import { createConnection } from "node:net";
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
|
-
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
24
|
+
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
27
|
import { randomUUID } from "node:crypto";
|
|
28
28
|
import express from "express";
|
|
29
|
+
import compression from "compression";
|
|
29
30
|
import { WebSocket, WebSocketServer } from "ws";
|
|
30
31
|
import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import {
|
|
32
|
+
import { PROTOCOL_VERSION } from "./protocol-version.js";
|
|
33
|
+
import { AgentService, workspacePath, QuiesceRejectedError, } from "./agent-service.js";
|
|
34
|
+
import { previewKind } from "./text-sniff.js";
|
|
32
35
|
import { startControlServer } from "./control-socket.js";
|
|
36
|
+
import { scheduleUploadCleanup } from "./uploads.js";
|
|
33
37
|
import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
|
|
34
38
|
import { listThemes, resolveThemeFile } from "./themes.js";
|
|
35
39
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
@@ -52,6 +56,10 @@ const ALLOW_ORIGINS = (process.env.PI_WEB_ALLOW_ORIGINS ?? "")
|
|
|
52
56
|
.split(",")
|
|
53
57
|
.map((s) => s.trim().toLowerCase())
|
|
54
58
|
.filter(Boolean);
|
|
59
|
+
/** 可选共享口令(PI_WEB_TOKEN):设置后所有 HTTP/WS 请求必须携带——
|
|
60
|
+
* Authorization: Bearer / X-PI-Token 头、?token= 查询参数或 pi_web_token cookie
|
|
61
|
+
* 任一匹配即可;供 0.0.0.0 / 反代等暴露场景兜底,未设置则行为不变。 */
|
|
62
|
+
const AUTH_TOKEN = process.env.PI_WEB_TOKEN?.trim() ?? "";
|
|
55
63
|
// Root of the SDK default per-project session dirs — chat transcripts live in
|
|
56
64
|
// <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
|
|
57
65
|
// honors PI_CODING_AGENT_DIR).
|
|
@@ -65,6 +73,50 @@ if (process.platform === "win32") {
|
|
|
65
73
|
}
|
|
66
74
|
const app = express();
|
|
67
75
|
app.use(express.json({ limit: "10mb" }));
|
|
76
|
+
/** 从请求中提取候选 token:头 / 查询参数 / cookie(浏览器导航场景靠 cookie 续命)。 */
|
|
77
|
+
function requestTokens(req) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const auth = req.headers.authorization;
|
|
80
|
+
if (typeof auth === "string" && auth.startsWith("Bearer "))
|
|
81
|
+
out.push(auth.slice(7).trim());
|
|
82
|
+
const header = req.headers["x-pi-token"];
|
|
83
|
+
if (typeof header === "string")
|
|
84
|
+
out.push(header.trim());
|
|
85
|
+
try {
|
|
86
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams.get("token");
|
|
87
|
+
if (q)
|
|
88
|
+
out.push(q.trim());
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* ignore malformed url */
|
|
92
|
+
}
|
|
93
|
+
const cookie = req.headers.cookie;
|
|
94
|
+
if (typeof cookie === "string") {
|
|
95
|
+
for (const part of cookie.split(";")) {
|
|
96
|
+
const [k, ...rest] = part.trim().split("=");
|
|
97
|
+
if (k === "pi_web_token")
|
|
98
|
+
out.push(rest.join("=").trim());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out.filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
function tokenOk(req) {
|
|
104
|
+
return requestTokens(req).includes(AUTH_TOKEN);
|
|
105
|
+
}
|
|
106
|
+
if (AUTH_TOKEN) {
|
|
107
|
+
// /api/health 保持开放:无敏感信息,容器/监控探针需要它
|
|
108
|
+
app.use((req, res, next) => {
|
|
109
|
+
if (req.path === "/api/health" || tokenOk(req)) {
|
|
110
|
+
// 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数
|
|
111
|
+
if (!req.headers.cookie?.includes("pi_web_token=")) {
|
|
112
|
+
res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
|
|
113
|
+
}
|
|
114
|
+
next();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.status(401).send("unauthorized: PI_WEB_TOKEN required (?token=…)");
|
|
118
|
+
});
|
|
119
|
+
}
|
|
68
120
|
app.get("/api/health", (_req, res) => {
|
|
69
121
|
res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
|
|
70
122
|
});
|
|
@@ -162,7 +214,18 @@ app.get("/themes/:id.css", (req, res) => {
|
|
|
162
214
|
const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
|
|
163
215
|
const webDist = join(pkgRoot, "web", "dist");
|
|
164
216
|
if (existsSync(webDist)) {
|
|
165
|
-
|
|
217
|
+
// gzip/deflate 响应压缩:前端 bundle ~1MB,局域网/反代场景传输量降到 ~1/4;
|
|
218
|
+
// 对 API JSON 同样生效,WS 升级不受影响
|
|
219
|
+
app.use(compression());
|
|
220
|
+
app.use(express.static(webDist, {
|
|
221
|
+
// Vite 产物文件名带内容 hash,可永久强缓存——业务发版后 hash 变化自然失效,
|
|
222
|
+
// index.html 由下方 catch-all 处理(sendFile 不走这里)
|
|
223
|
+
setHeaders(res, filePath) {
|
|
224
|
+
if (filePath.includes(`${sep}assets${sep}`)) {
|
|
225
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
166
229
|
app.get(/^\/(?!api\/|ws).*/, (_req, res) => {
|
|
167
230
|
// Callback form: a failed stat here (npm i -g is mid-replacement of the
|
|
168
231
|
// package dir) responds 503 instead of crashing the request pipeline
|
|
@@ -184,7 +247,13 @@ else if (process.env[RESTART_CHILD_ENV]) {
|
|
|
184
247
|
process.exit(1);
|
|
185
248
|
}
|
|
186
249
|
const httpServer = createServer(app);
|
|
187
|
-
const wss = new WebSocketServer({
|
|
250
|
+
const wss = new WebSocketServer({
|
|
251
|
+
noServer: true,
|
|
252
|
+
// Per-message deflate: big-session snapshots serialize to multi-MB JSON
|
|
253
|
+
// strings; wire-level compression cuts that several-fold. threshold keeps
|
|
254
|
+
// tiny messages (notices/heartbeats) uncompressed to save CPU.
|
|
255
|
+
perMessageDeflate: { threshold: 16 * 1024 },
|
|
256
|
+
});
|
|
188
257
|
// ---------------------------------------------------------------------------
|
|
189
258
|
// Origin / Host admission for WebSocket upgrades.
|
|
190
259
|
//
|
|
@@ -230,7 +299,6 @@ function originAllowed(req) {
|
|
|
230
299
|
// do not accept them. (Dev-mode proxying is handled by PI_WEB_ALLOW_ORIGINS
|
|
231
300
|
// set in the dev:server script; LAN/reverse-proxy setups add their origin.)
|
|
232
301
|
return false;
|
|
233
|
-
return false;
|
|
234
302
|
}
|
|
235
303
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
236
304
|
let pathname = "/";
|
|
@@ -251,6 +319,11 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
251
319
|
socket.destroy();
|
|
252
320
|
return;
|
|
253
321
|
}
|
|
322
|
+
if (AUTH_TOKEN && !tokenOk(req)) {
|
|
323
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
324
|
+
socket.destroy();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
254
327
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
255
328
|
wss.emit("connection", ws, req);
|
|
256
329
|
});
|
|
@@ -332,6 +405,25 @@ function scheduleQuit() {
|
|
|
332
405
|
return true;
|
|
333
406
|
}
|
|
334
407
|
service.onQuit = scheduleQuit;
|
|
408
|
+
/** 背压阈值:socket 未发送积压超过此值时丢弃 snapshot(issue #11)。
|
|
409
|
+
* 1MB ≈ 两三份全量 snapshot 的量,足够吸收网络抖动,又远低于 OOM 级堆积。 */
|
|
410
|
+
const SNAPSHOT_BACKPRESSURE_BYTES = 1_000_000;
|
|
411
|
+
/**
|
|
412
|
+
* Multi-tab serialization sharing: emit() hands the SAME message object to
|
|
413
|
+
* every socket of a client, but each send() used to JSON.stringify it
|
|
414
|
+
* separately — N open tabs serialized the same multi-MB snapshot N times per
|
|
415
|
+
* push. Keyed by object identity (WeakMap): a new snapshot is a new object,
|
|
416
|
+
* so the cache self-invalidates and never grows.
|
|
417
|
+
*/
|
|
418
|
+
const serializedCache = new WeakMap();
|
|
419
|
+
function serializeShared(msg) {
|
|
420
|
+
let s = serializedCache.get(msg);
|
|
421
|
+
if (s === undefined) {
|
|
422
|
+
s = JSON.stringify(msg);
|
|
423
|
+
serializedCache.set(msg, s);
|
|
424
|
+
}
|
|
425
|
+
return s;
|
|
426
|
+
}
|
|
335
427
|
wss.on("connection", (ws) => {
|
|
336
428
|
// Count attached sockets (the control socket reports REAL sockets, not
|
|
337
429
|
// cached client-session objects).
|
|
@@ -340,10 +432,29 @@ wss.on("connection", (ws) => {
|
|
|
340
432
|
let closed = false;
|
|
341
433
|
/** Commands received while the session is still being created — replayed after attach. */
|
|
342
434
|
let pending = [];
|
|
435
|
+
// 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
|
|
436
|
+
// exception 打崩整个进程(issue #11 附带发现)。记日志并按坏连接关闭。
|
|
437
|
+
ws.on("error", (err) => {
|
|
438
|
+
console.error(`[ws] socket error${clientId ? ` (${clientId})` : ""}:`, err.message);
|
|
439
|
+
try {
|
|
440
|
+
ws.close();
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
/* already closing */
|
|
444
|
+
}
|
|
445
|
+
});
|
|
343
446
|
const send = (msg) => {
|
|
344
|
-
if (
|
|
345
|
-
|
|
447
|
+
if (closed || ws.readyState !== WebSocket.OPEN)
|
|
448
|
+
return;
|
|
449
|
+
// 发送背压(issue #11):socket 消费不过来时(前端慢/网络差),堆里会堆积
|
|
450
|
+
// 每份 ~10MB 的全量 snapshot 字符串,低内存主机直接 OOM。snapshot 是全量
|
|
451
|
+
// 幂等的且 60ms 后必有更新的一份,可以安全丢弃——在序列化之前丢,连
|
|
452
|
+
// stringify 的分配都省掉。ready/notice/error/tool_delta 等消息必须送达。
|
|
453
|
+
if (msg.type === "snapshot" &&
|
|
454
|
+
ws.bufferedAmount > SNAPSHOT_BACKPRESSURE_BYTES) {
|
|
455
|
+
return;
|
|
346
456
|
}
|
|
457
|
+
ws.send(serializeShared(msg));
|
|
347
458
|
};
|
|
348
459
|
const dispatch = (msg) => {
|
|
349
460
|
if (!clientId) {
|
|
@@ -408,6 +519,18 @@ wss.on("connection", (ws) => {
|
|
|
408
519
|
case "list_files":
|
|
409
520
|
void cs.listFiles(msg.path);
|
|
410
521
|
break;
|
|
522
|
+
case "scm_status":
|
|
523
|
+
void cs.scmQuery("status", msg.reqId);
|
|
524
|
+
break;
|
|
525
|
+
case "scm_history":
|
|
526
|
+
void cs.scmQuery("history", msg.reqId);
|
|
527
|
+
break;
|
|
528
|
+
case "scm_filediff":
|
|
529
|
+
void cs.scmQuery("filediff", msg.reqId, { path: msg.path });
|
|
530
|
+
break;
|
|
531
|
+
case "scm_commit":
|
|
532
|
+
void cs.scmQuery("commit", msg.reqId, { hash: msg.hash });
|
|
533
|
+
break;
|
|
411
534
|
case "read_file":
|
|
412
535
|
void cs.readFile(msg.path);
|
|
413
536
|
break;
|
|
@@ -444,6 +567,9 @@ wss.on("connection", (ws) => {
|
|
|
444
567
|
case "set_provider_api_key":
|
|
445
568
|
void cs.setProviderApiKey(msg.provider, msg.apiKey);
|
|
446
569
|
break;
|
|
570
|
+
case "clear_provider_api_key":
|
|
571
|
+
void cs.clearProviderApiKey(msg.provider);
|
|
572
|
+
break;
|
|
447
573
|
case "list_models_config":
|
|
448
574
|
void cs.listModelsConfig();
|
|
449
575
|
break;
|
|
@@ -459,6 +585,9 @@ wss.on("connection", (ws) => {
|
|
|
459
585
|
case "fetch_models":
|
|
460
586
|
void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
|
|
461
587
|
break;
|
|
588
|
+
case "refresh_provider_models":
|
|
589
|
+
void cs.refreshProviderModels(msg.providerId, msg.reqId);
|
|
590
|
+
break;
|
|
462
591
|
case "terminal_create": {
|
|
463
592
|
const tm = cs.getTerminalManager(msg.conversationId);
|
|
464
593
|
if (tm)
|
|
@@ -553,7 +682,12 @@ wss.on("connection", (ws) => {
|
|
|
553
682
|
.then((cs) => {
|
|
554
683
|
if (closed)
|
|
555
684
|
return;
|
|
556
|
-
send({
|
|
685
|
+
send({
|
|
686
|
+
type: "ready",
|
|
687
|
+
clientId: cid,
|
|
688
|
+
serverVersion: VERSION,
|
|
689
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
690
|
+
});
|
|
557
691
|
cs.flushSnapshot();
|
|
558
692
|
// Replay anything that arrived while the session was starting.
|
|
559
693
|
const queued = pending;
|
|
@@ -626,6 +760,8 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
626
760
|
console.log(` bind : ${HOST}:${PORT}`);
|
|
627
761
|
console.log("");
|
|
628
762
|
});
|
|
763
|
+
// 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
|
|
764
|
+
scheduleUploadCleanup();
|
|
629
765
|
// Local control socket (status / quiesce / unquiesce) — same data dir the
|
|
630
766
|
// CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
|
|
631
767
|
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
|