pi-web-ui 0.22.1 → 0.24.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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Local control socket for the pi-web-ui server.
3
+ *
4
+ * Lets the CLI (and humans) query status and quiesce/unquiesce the server
5
+ * WITHOUT opening a network port or exposing an unauthenticated HTTP
6
+ * endpoint. Only the local OS user can reach it:
7
+ * - POSIX: a mode-0600 Unix domain socket at <dataDir>/pi-web-ui.sock
8
+ * - Windows: a named pipe \\.\pipe\pi-web-ui-<port>
9
+ *
10
+ * Protocol: one JSON object per line.
11
+ * → {"cmd":"status"} ← {"ok":true, ...serviceStatus}
12
+ * → {"cmd":"quiesce"} ← {"ok":true}
13
+ * → {"cmd":"unquiesce"} ← {"ok":true}
14
+ * → anything else ← {"ok":false,"error":"..."}
15
+ *
16
+ * Idle connections are closed after a short timeout so a stuck CLI never
17
+ * holds the socket.
18
+ */
19
+ import { createServer, createConnection } from "node:net";
20
+ import { chmodSync, existsSync, rmSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ /** How long a control connection may sit idle before the server closes it. */
23
+ const CONTROL_IDLE_TIMEOUT_MS = 5_000;
24
+ /** How long the CLI waits for a reply before giving up. */
25
+ const CONTROL_CLIENT_TIMEOUT_MS = 3_000;
26
+ /** Socket path (POSIX) or pipe name (Windows). */
27
+ export function controlPath(dataDir, port) {
28
+ return process.platform === "win32"
29
+ ? `\\\\.\\pipe\\pi-web-ui-${port}`
30
+ : join(dataDir, "pi-web-ui.sock");
31
+ }
32
+ /** Start the control socket; returns a stop function. */
33
+ export function startControlServer(opts) {
34
+ const { service, dataDir, port } = opts;
35
+ const path = controlPath(dataDir, port);
36
+ let server;
37
+ let stop = false;
38
+ if (process.platform === "win32") {
39
+ server = createServer(handleConnection);
40
+ }
41
+ else {
42
+ // Remove a stale socket left by a previous crash (only if it's ours —
43
+ // an existing socket file that refuses connections is stale).
44
+ if (existsSync(path)) {
45
+ try {
46
+ rmSync(path);
47
+ }
48
+ catch {
49
+ /* best-effort */
50
+ }
51
+ }
52
+ server = createServer(handleConnection);
53
+ }
54
+ // A second instance on the same data dir / port would fail to bind — don't
55
+ // crash the server over it, just log and run without a control socket.
56
+ server.on("error", (err) => {
57
+ if (err.code === "EADDRINUSE") {
58
+ console.warn(`[control] socket ${path} already in use — control socket disabled`);
59
+ }
60
+ else {
61
+ console.warn(`[control] socket error: ${err.message}`);
62
+ }
63
+ });
64
+ function handleConnection(sock) {
65
+ let buf = "";
66
+ const timer = setTimeout(() => {
67
+ sock.destroy();
68
+ }, CONTROL_IDLE_TIMEOUT_MS);
69
+ sock.on("data", (chunk) => {
70
+ buf += chunk.toString("utf8");
71
+ let nl;
72
+ while ((nl = buf.indexOf("\n")) >= 0) {
73
+ const line = buf.slice(0, nl).trim();
74
+ buf = buf.slice(nl + 1);
75
+ if (!line)
76
+ continue;
77
+ timer.refresh();
78
+ let req;
79
+ try {
80
+ req = JSON.parse(line);
81
+ }
82
+ catch {
83
+ sock.write(JSON.stringify({ ok: false, error: "bad json" }) + "\n");
84
+ continue;
85
+ }
86
+ let resp;
87
+ switch (req.cmd) {
88
+ case "status":
89
+ resp = { ok: true, ...service.serviceStatus() };
90
+ break;
91
+ case "quiesce":
92
+ service.quiesce();
93
+ resp = { ok: true };
94
+ break;
95
+ case "unquiesce":
96
+ service.unquiesce();
97
+ resp = { ok: true };
98
+ break;
99
+ default:
100
+ resp = { ok: false, error: `unknown cmd: ${String(req.cmd)}` };
101
+ break;
102
+ }
103
+ sock.write(JSON.stringify(resp) + "\n");
104
+ }
105
+ });
106
+ sock.on("error", () => {
107
+ /* client vanished */
108
+ });
109
+ sock.on("close", () => clearTimeout(timer));
110
+ }
111
+ if (process.platform === "win32") {
112
+ // net.Server on a named pipe: listen on the pipe name directly.
113
+ server.listen(path, () => {
114
+ console.log(` control : ${path}`);
115
+ });
116
+ }
117
+ else {
118
+ server.listen(path, () => {
119
+ try {
120
+ chmodSync(path, 0o600);
121
+ }
122
+ catch {
123
+ /* best-effort */
124
+ }
125
+ console.log(` control : ${path}`);
126
+ });
127
+ }
128
+ return () => {
129
+ stop = true;
130
+ server.close();
131
+ try {
132
+ if (process.platform !== "win32" && existsSync(path))
133
+ rmSync(path);
134
+ }
135
+ catch {
136
+ /* best-effort */
137
+ }
138
+ };
139
+ }
140
+ /**
141
+ * CLI-side client: send one command and return the parsed reply (or null if
142
+ * the server is unreachable / timed out).
143
+ */
144
+ export function sendControlCommand(dataDir, port, cmd) {
145
+ const path = controlPath(dataDir, port);
146
+ return new Promise((resolve) => {
147
+ const sock = createConnection(path);
148
+ let done = false;
149
+ const finish = (v) => {
150
+ if (done)
151
+ return;
152
+ done = true;
153
+ clearTimeout(timer);
154
+ sock.destroy();
155
+ resolve(v);
156
+ };
157
+ const timer = setTimeout(() => finish(null), CONTROL_CLIENT_TIMEOUT_MS);
158
+ let buf = "";
159
+ sock.on("connect", () => {
160
+ sock.write(JSON.stringify({ cmd }) + "\n");
161
+ });
162
+ sock.on("data", (chunk) => {
163
+ buf += chunk.toString("utf8");
164
+ const nl = buf.indexOf("\n");
165
+ if (nl >= 0) {
166
+ try {
167
+ finish(JSON.parse(buf.slice(0, nl)));
168
+ }
169
+ catch {
170
+ finish(null);
171
+ }
172
+ }
173
+ });
174
+ sock.on("error", () => finish(null));
175
+ sock.on("close", () => finish(null));
176
+ });
177
+ }
@@ -28,11 +28,29 @@ import { randomUUID } from "node:crypto";
28
28
  import express from "express";
29
29
  import { WebSocket, WebSocketServer } from "ws";
30
30
  import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
31
- import { AgentService, previewKind, workspacePath } from "./agent-service.js";
31
+ import { AgentService, previewKind, workspacePath, QuiesceRejectedError, } from "./agent-service.js";
32
+ import { startControlServer } from "./control-socket.js";
32
33
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
33
34
  const PORT = Number(process.env.PORT ?? 8787);
34
35
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
35
36
  const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
37
+ /** Bind address. Default is loopback ONLY — the service is a local personal
38
+ * tool and should not be reachable from the network unless explicitly asked
39
+ * (e.g. PI_WEB_HOST=0.0.0.0 for LAN access / Docker port mapping). */
40
+ const HOST = process.env.PI_WEB_HOST ?? "127.0.0.1";
41
+ /** Optional strict hostname allowlist (comma-separated) — only used when set.
42
+ * Origin / Host same-authority matching happens regardless. */
43
+ const ALLOW_HOSTS = (process.env.PI_WEB_ALLOW_HOSTS ?? "")
44
+ .split(",")
45
+ .map((s) => s.trim().toLowerCase())
46
+ .filter(Boolean);
47
+ /** Optional extra Origins allowed through the same-authority check (comma-
48
+ * separated, e.g. reverse-proxy setups where the browser origin differs
49
+ * from the Host the backend sees). */
50
+ const ALLOW_ORIGINS = (process.env.PI_WEB_ALLOW_ORIGINS ?? "")
51
+ .split(",")
52
+ .map((s) => s.trim().toLowerCase())
53
+ .filter(Boolean);
36
54
  // Root of the SDK default per-project session dirs — chat transcripts live in
37
55
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
38
56
  // honors PI_CODING_AGENT_DIR).
@@ -131,7 +149,77 @@ else if (process.env[RESTART_CHILD_ENV]) {
131
149
  process.exit(1);
132
150
  }
133
151
  const httpServer = createServer(app);
134
- const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
152
+ const wss = new WebSocketServer({ noServer: true });
153
+ // ---------------------------------------------------------------------------
154
+ // Origin / Host admission for WebSocket upgrades.
155
+ //
156
+ // Browsers attach an Origin header; non-browser clients (curl, ws scripts)
157
+ // usually don't — they're admitted by the network layer / reverse proxy.
158
+ // Rules (checked in order):
159
+ // 4. No Origin header → admit (non-browser client).
160
+ // 5. Anything else → 403 + close.
161
+ //
162
+ // Dev-mode note: the Vite dev server (:5173) proxies /ws to the backend on
163
+ // :8788, so their authorities differ — the dev:server script sets
164
+ // PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 for that.
165
+ // LAN / reverse-proxy setups add their own origin the same way.
166
+ // ---------------------------------------------------------------------------
167
+ /** "host" or "host:port" → { hostname, port }. */
168
+ function parseAuthority(a) {
169
+ try {
170
+ const u = new URL(`http://${a}`);
171
+ return { hostname: u.hostname.toLowerCase(), port: u.port || "80" };
172
+ }
173
+ catch {
174
+ return { hostname: "", port: "" };
175
+ }
176
+ }
177
+ function originAllowed(req) {
178
+ const hostHeader = (req.headers.host ?? "").toLowerCase();
179
+ const host = parseAuthority(hostHeader);
180
+ if (ALLOW_HOSTS.length > 0 && !ALLOW_HOSTS.includes(host.hostname)) {
181
+ return false;
182
+ }
183
+ const origin = req.headers.origin;
184
+ if (!origin)
185
+ return true; // non-browser client
186
+ const o = origin.toLowerCase();
187
+ if (ALLOW_ORIGINS.includes(o))
188
+ return true;
189
+ if (o === "null")
190
+ return false; // file:// pages etc. are not trusted
191
+ const ori = parseAuthority(o.replace(/^[a-z]+:\/\//, ""));
192
+ if (ori.hostname === host.hostname && ori.port === host.port)
193
+ return true;
194
+ // Browsers treat host:port pairs on the SAME host as different origins —
195
+ // do not accept them. (Dev-mode proxying is handled by PI_WEB_ALLOW_ORIGINS
196
+ // set in the dev:server script; LAN/reverse-proxy setups add their origin.)
197
+ return false;
198
+ return false;
199
+ }
200
+ httpServer.on("upgrade", (req, socket, head) => {
201
+ let pathname = "/";
202
+ try {
203
+ pathname = new URL(req.url ?? "/", "http://localhost").pathname;
204
+ }
205
+ catch {
206
+ /* fall through to the path check below */
207
+ }
208
+ if (pathname !== "/ws") {
209
+ socket.destroy();
210
+ return;
211
+ }
212
+ if (!originAllowed(req)) {
213
+ // Reject cross-origin browser pages outright. The browser sees a failed
214
+ // WS connect; the page's own reconnect loop then backs off and retries.
215
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
216
+ socket.destroy();
217
+ return;
218
+ }
219
+ wss.handleUpgrade(req, socket, head, (ws) => {
220
+ wss.emit("connection", ws, req);
221
+ });
222
+ });
135
223
  // Heartbeat: lets clients detect half-open connections (server killed without
136
224
  // closing sockets, sleep/wake, network partitions). Idle connections otherwise
137
225
  // carry no traffic and TCP keepalive defaults are far too slow (~2h).
@@ -210,6 +298,9 @@ function scheduleQuit() {
210
298
  }
211
299
  service.onQuit = scheduleQuit;
212
300
  wss.on("connection", (ws) => {
301
+ // Count attached sockets (the control socket reports REAL sockets, not
302
+ // cached client-session objects).
303
+ service.noteSocketOpen();
213
304
  let clientId = null;
214
305
  let closed = false;
215
306
  /** Commands received while the session is still being created — replayed after attach. */
@@ -232,7 +323,7 @@ wss.on("connection", (ws) => {
232
323
  }
233
324
  switch (msg.type) {
234
325
  case "prompt":
235
- void cs.prompt(msg.text, msg.attachments);
326
+ void cs.prompt(msg.text, msg.attachments, msg.queue);
236
327
  break;
237
328
  case "abort":
238
329
  void cs.abort();
@@ -381,6 +472,8 @@ wss.on("connection", (ws) => {
381
472
  customSystemPrompt: msg.customSystemPrompt,
382
473
  disabledSkills: msg.disabledSkills,
383
474
  disabledExtensions: msg.disabledExtensions,
475
+ visionBridgeEnabled: msg.visionBridgeEnabled,
476
+ visionBridgeModel: msg.visionBridgeModel,
384
477
  });
385
478
  break;
386
479
  case "save_preset":
@@ -421,6 +514,19 @@ wss.on("connection", (ws) => {
421
514
  dispatch(m);
422
515
  })
423
516
  .catch((err) => {
517
+ // Admission refused (quiesce): close the socket so the browser
518
+ // reconnect loop keeps retrying until admission reopens. Do NOT
519
+ // leave a half-alive connection that can only show an error.
520
+ if (err instanceof QuiesceRejectedError) {
521
+ closed = true;
522
+ if (ws.readyState === WebSocket.OPEN) {
523
+ ws.close(4403, "quiesced");
524
+ }
525
+ ws.terminate?.();
526
+ return;
527
+ }
528
+ // Real init failure (bad agent dir etc.) — keep the connection
529
+ // open so the user can see the error and fix it.
424
530
  send({
425
531
  type: "notice",
426
532
  level: "error",
@@ -432,6 +538,7 @@ wss.on("connection", (ws) => {
432
538
  dispatch(msg);
433
539
  });
434
540
  ws.on("close", () => {
541
+ service.noteSocketClose();
435
542
  closed = true;
436
543
  pending = [];
437
544
  if (clientId)
@@ -461,15 +568,19 @@ if (process.env[RESTART_CHILD_ENV] === "1") {
461
568
  await new Promise((r) => setTimeout(r, 300));
462
569
  }
463
570
  }
464
- httpServer.listen(PORT, () => {
571
+ httpServer.listen(PORT, HOST, () => {
465
572
  console.log("");
466
573
  console.log(" ⚡ pi-web-ui — web chat for the pi coding agent");
467
574
  console.log(` http://localhost:${PORT}`);
468
575
  console.log(` workspace : ${CWD}`);
469
576
  console.log(` session dir : ${SESSION_DIR_ROOT}`);
470
577
  console.log(` pi SDK : v${VERSION}`);
578
+ console.log(` bind : ${HOST}:${PORT}`);
471
579
  console.log("");
472
580
  });
581
+ // Local control socket (status / quiesce / unquiesce) — same data dir the
582
+ // CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
583
+ const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
473
584
  let shuttingDown = false;
474
585
  async function shutdown() {
475
586
  if (shuttingDown)
@@ -477,6 +588,7 @@ async function shutdown() {
477
588
  shuttingDown = true;
478
589
  console.log("\nshutting down…");
479
590
  clearInterval(heartbeatTimer);
591
+ stopControl();
480
592
  await service.disposeAll();
481
593
  wss.close();
482
594
  httpServer.close();
@@ -0,0 +1,113 @@
1
+ /** Per-batch timeout; a slow vision provider shouldn't stall a prompt forever. */
2
+ const TRANSCRIBE_TIMEOUT_MS = Number(process.env.PI_WEB_VISION_TIMEOUT_MS ?? 90_000);
3
+ /** Cap the transcript length so it doesn't blow up the main context. */
4
+ const MAX_TRANSCRIBE_TOKENS = 4000;
5
+ /**
6
+ * Scan every configured provider for models that accept image input.
7
+ * Providers the user already configured in pi (models.json + auth.json) are
8
+ * reused as-is — zero new credentials to set up.
9
+ */
10
+ export function findVisionModels(runtime) {
11
+ const out = [];
12
+ for (const p of runtime.getProviders()) {
13
+ // Only providers that actually have credentials — SDK built-ins like
14
+ // amazon-bedrock ship vision-capable models but are not configured
15
+ // unless the user added auth, and calling them would just fail.
16
+ if (!runtime.hasConfiguredAuth(p.id))
17
+ continue;
18
+ for (const m of runtime.getModels(p.id)) {
19
+ if (m.input?.includes("image")) {
20
+ out.push({
21
+ provider: p.id,
22
+ id: m.id,
23
+ label: `${m.name ?? m.id} (${p.id})`,
24
+ });
25
+ }
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ /**
31
+ * Evidence-first transcription prompt, modeled on modlens' output contract:
32
+ * full verbatim text, reading-order layout blocks, entities/relations, chart
33
+ * axes & data. Emphasizes honesty over hallucination.
34
+ */
35
+ const SYSTEM_PROMPT = `You are a vision bridge for a text-only language model. You receive one or more images and must transcribe them into precise, structured text evidence so another model that cannot see images can answer questions about them accurately.
36
+
37
+ Follow these rules:
38
+ 1. Transcribe ALL visible text verbatim, preserving wording, spelling, punctuation and line breaks. This is the most important part — the reader relies on your transcription, not on the image.
39
+ 2. Describe the layout in reading order: headers, paragraphs, lists, tables, buttons, panels — say what appears where.
40
+ 3. For tables/charts/diagrams: read axes, scales (note log scale), legend entries, series names, highlighted points and their coordinates, and any data values you can discern.
41
+ 4. Name entities: people, products, companies, colors, style, objects, actions.
42
+ 5. If part of the image is too blurry/low-resolution to read, say "(读不清)" or "unclear" for that part — NEVER invent or guess content you cannot see.
43
+ 6. If there are multiple images, address them in order (图 1 / Image 1, 图 2 / Image 2, ...).
44
+ 7. Output only the transcript. No preamble, no commentary about the image itself.`;
45
+ /** Per-batch user instruction appended after the images. */
46
+ function buildUserPrompt(count) {
47
+ if (count <= 1) {
48
+ return "请逐字转写这张图片的内容,并按上述规则输出结构化文字证据。";
49
+ }
50
+ return `请按图片顺序(图 1 到 图 ${count})逐张转写每张图片的内容,并按上述规则输出结构化文字证据。`;
51
+ }
52
+ /**
53
+ * Send one batch of images to a vision model and return its transcript.
54
+ * Throws on timeout, abort, provider error or an empty response.
55
+ */
56
+ export async function transcribeImages(runtime, images, options = {}) {
57
+ const model = options.model ??
58
+ (() => {
59
+ const found = findVisionModels(runtime);
60
+ if (found.length === 0) {
61
+ throw new Error("未找到可用的视觉模型(models.json 中没有任何 input 含 image 的模型)");
62
+ }
63
+ return runtime.getModel(found[0].provider, found[0].id);
64
+ })();
65
+ if (!model)
66
+ throw new Error("视觉模型不可用(ModelRuntime.getModel 返回空)");
67
+ const ac = new AbortController();
68
+ const timer = setTimeout(() => ac.abort(), TRANSCRIBE_TIMEOUT_MS);
69
+ const onOuterAbort = () => ac.abort();
70
+ options.signal?.addEventListener("abort", onOuterAbort);
71
+ try {
72
+ const imageBlocks = images.map((img) => ({
73
+ type: "image",
74
+ data: img.data.replace(/^data:[^;]*;base64,/, ""),
75
+ mimeType: img.mimeType?.startsWith("image/")
76
+ ? img.mimeType
77
+ : "image/png",
78
+ }));
79
+ const context = {
80
+ systemPrompt: SYSTEM_PROMPT,
81
+ messages: [
82
+ {
83
+ role: "user",
84
+ timestamp: Date.now(),
85
+ content: [
86
+ ...imageBlocks,
87
+ { type: "text", text: buildUserPrompt(images.length) },
88
+ ],
89
+ },
90
+ ],
91
+ };
92
+ const msg = await runtime.completeSimple(model, context, {
93
+ signal: ac.signal,
94
+ maxTokens: MAX_TRANSCRIBE_TOKENS,
95
+ });
96
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
97
+ throw new Error(msg.errorMessage || `视觉模型异常终止(${msg.stopReason})`);
98
+ }
99
+ const text = msg.content
100
+ .filter((b) => b.type === "text")
101
+ .map((b) => b.text ?? "")
102
+ .join("\n")
103
+ .trim();
104
+ if (!text) {
105
+ throw new Error("视觉模型返回了空的转写结果");
106
+ }
107
+ return text;
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ options.signal?.removeEventListener("abort", onOuterAbort);
112
+ }
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "prepublishOnly": "npm run build",
45
45
  "dev": "concurrently -k -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
46
- "dev:server": "cross-env PORT=8788 node --watch --import tsx server/index.ts",
46
+ "dev:server": "cross-env PORT=8788 PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 node --watch --import tsx server/index.ts",
47
47
  "dev:web": "vite --config web/vite.config.ts",
48
48
  "build": "npm run build:web && npm run build:server",
49
49
  "build:web": "vite build --config web/vite.config.ts",