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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +295 -295
  3. package/README.zh-CN.md +279 -279
  4. package/bin/pi-web-ui.mjs +0 -0
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +71 -71
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +408 -3851
  10. package/dist/server/attachments.js +621 -0
  11. package/dist/server/bg-servers.js +138 -0
  12. package/dist/server/client-state.js +148 -0
  13. package/dist/server/files-service.js +633 -0
  14. package/dist/server/goal-service.js +869 -0
  15. package/dist/server/index.js +144 -8
  16. package/dist/server/model-admin.js +727 -0
  17. package/dist/server/process-utils.js +86 -0
  18. package/dist/server/protocol-version.js +11 -0
  19. package/dist/server/scm.js +298 -0
  20. package/dist/server/settings-service.js +268 -0
  21. package/dist/server/slash-commands.js +245 -0
  22. package/dist/server/terminals.js +98 -0
  23. package/dist/server/text-sniff.js +268 -0
  24. package/dist/server/uploads.js +107 -0
  25. package/dist/server/webui-context.js +208 -0
  26. package/extensions/webui.ts +192 -192
  27. package/package.json +94 -87
  28. package/themes/light.css +6318 -6318
  29. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  30. package/web/dist/assets/TerminalPanel-B-bsYqea.js +2 -0
  31. package/web/dist/assets/index-BsrqFaSZ.js +13 -0
  32. package/web/dist/assets/index-Dsb8Bak1.css +10 -0
  33. package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
  34. package/web/dist/assets/react-C9ovnpIm.js +24 -0
  35. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  36. package/web/dist/favicon.svg +8 -8
  37. package/web/dist/index.html +17 -15
  38. package/web/public/favicon.svg +8 -8
  39. package/web/dist/assets/index-BnDkdKFN.css +0 -41
  40. package/web/dist/assets/index-DmmSVSzk.js +0 -129
@@ -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 { AgentService, previewKind, workspacePath, QuiesceRejectedError, } from "./agent-service.js";
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
- app.use(express.static(webDist));
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({ noServer: true });
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 (!closed && ws.readyState === WebSocket.OPEN) {
345
- ws.send(JSON.stringify(msg));
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({ type: "ready", clientId: cid, serverVersion: VERSION });
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 });