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.
- package/README.md +83 -0
- package/README.zh-CN.md +76 -0
- package/bin/pi-web-ui.mjs +98 -2
- package/dist/server/agent-service.js +466 -15
- package/dist/server/control-socket.js +177 -0
- package/dist/server/index.js +116 -4
- package/dist/server/vision-bridge.js +113 -0
- package/package.json +2 -2
- package/web/dist/assets/index-BfpjVv5g.css +41 -0
- package/web/dist/assets/{index-DqEHUXqU.js → index-CCePsQTu.js} +57 -57
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B8AkogcB.css +0 -41
|
@@ -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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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({
|
|
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.
|
|
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",
|