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
|
@@ -14,15 +14,28 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
|
|
15
15
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
20
20
|
import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
|
|
21
|
+
import { findVisionModels, transcribeImages, } from "./vision-bridge.js";
|
|
21
22
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
22
23
|
const WIDGET_REFRESH_MS = 2000;
|
|
23
24
|
const WIDGET_WIDTH = 80;
|
|
24
25
|
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
25
26
|
const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
27
|
+
/** Thrown when the service is quiesced (draining) and the request is NEW work
|
|
28
|
+
* the admission controller refuses: a brand-new client attach, a prompt,
|
|
29
|
+
* a fork, a session resume, or a goal wizard start. index.ts closes the
|
|
30
|
+
* WebSocket with 4403 so the browser reconnect loop can retry after the
|
|
31
|
+
* server reopens admission (see AgentService.quiesce). */
|
|
32
|
+
export class QuiesceRejectedError extends Error {
|
|
33
|
+
code = "QUIESCED";
|
|
34
|
+
constructor(detail) {
|
|
35
|
+
super(`服务器正在排空存量工作(quiesce)——${detail}`);
|
|
36
|
+
this.name = "QuiesceRejectedError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
26
39
|
const PREVIEW_IMAGE_EXTS = new Set([
|
|
27
40
|
"png",
|
|
28
41
|
"jpg",
|
|
@@ -218,6 +231,42 @@ function decodeText(buf) {
|
|
|
218
231
|
}
|
|
219
232
|
}
|
|
220
233
|
}
|
|
234
|
+
/** Sniff an image MIME type from magic bytes (extension is only a hint).
|
|
235
|
+
* Returns null when the bytes don't look like a known raster format —
|
|
236
|
+
* callers keep such files as plain path references. */
|
|
237
|
+
function sniffImageMime(buf, ext) {
|
|
238
|
+
if (buf.length >= 8 &&
|
|
239
|
+
buf[0] === 0x89 &&
|
|
240
|
+
buf[1] === 0x50 &&
|
|
241
|
+
buf[2] === 0x4e &&
|
|
242
|
+
buf[3] === 0x47) {
|
|
243
|
+
return "image/png";
|
|
244
|
+
}
|
|
245
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
246
|
+
return "image/jpeg";
|
|
247
|
+
}
|
|
248
|
+
const head = buf.slice(0, 6).toString("ascii");
|
|
249
|
+
if (head === "GIF87a" || head === "GIF89a")
|
|
250
|
+
return "image/gif";
|
|
251
|
+
if (buf.length >= 12 &&
|
|
252
|
+
buf.slice(0, 4).toString("ascii") === "RIFF" &&
|
|
253
|
+
buf.slice(8, 12).toString("ascii") === "WEBP") {
|
|
254
|
+
return "image/webp";
|
|
255
|
+
}
|
|
256
|
+
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
|
|
257
|
+
return "image/bmp";
|
|
258
|
+
// Unknown but raster-looking extension — trust the extension so existing
|
|
259
|
+
// image attachments keep working.
|
|
260
|
+
const known = {
|
|
261
|
+
".png": "image/png",
|
|
262
|
+
".jpg": "image/jpeg",
|
|
263
|
+
".jpeg": "image/jpeg",
|
|
264
|
+
".gif": "image/gif",
|
|
265
|
+
".webp": "image/webp",
|
|
266
|
+
".bmp": "image/bmp",
|
|
267
|
+
};
|
|
268
|
+
return known[ext] ?? null;
|
|
269
|
+
}
|
|
221
270
|
/** Windows persona appendix — appended to the SDK system prompt on win32 only.
|
|
222
271
|
* Two failure modes it guards against: (1) the SDK bash tool has NO default
|
|
223
272
|
* timeout, so a long-running command hangs the whole conversation forever;
|
|
@@ -856,6 +905,8 @@ class ClientStateStore {
|
|
|
856
905
|
customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
|
|
857
906
|
disabledSkills: s?.settings?.disabledSkills ?? [],
|
|
858
907
|
disabledExtensions: s?.settings?.disabledExtensions ?? [],
|
|
908
|
+
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
909
|
+
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
859
910
|
};
|
|
860
911
|
}
|
|
861
912
|
/** Persist the client's settings-panel state (partial merge). */
|
|
@@ -868,6 +919,8 @@ class ClientStateStore {
|
|
|
868
919
|
customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
|
|
869
920
|
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
870
921
|
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
922
|
+
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
923
|
+
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
871
924
|
};
|
|
872
925
|
this.save();
|
|
873
926
|
}
|
|
@@ -931,6 +984,9 @@ function conversationTitle(session) {
|
|
|
931
984
|
}
|
|
932
985
|
export class ClientSession {
|
|
933
986
|
clientId;
|
|
987
|
+
/** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
|
|
988
|
+
* (server draining — new work rejected). Default false for direct use. */
|
|
989
|
+
isQuiesced = () => false;
|
|
934
990
|
cwd;
|
|
935
991
|
/** pi config dir (auth/models/skills). */
|
|
936
992
|
agentDir;
|
|
@@ -1046,6 +1102,12 @@ export class ClientSession {
|
|
|
1046
1102
|
}
|
|
1047
1103
|
/** PTY terminals for this client (killed when the last socket detaches). */
|
|
1048
1104
|
terminals = new TerminalManager((msg) => this.emit(msg));
|
|
1105
|
+
/**
|
|
1106
|
+
* Vision-bridge transcript cache (batch hash → text). A re-sent / re-asked
|
|
1107
|
+
* prompt with the same images skips the vision API call entirely — editing
|
|
1108
|
+
* a question doesn't re-burn tokens on re-transcribing identical screenshots.
|
|
1109
|
+
*/
|
|
1110
|
+
visionBridgeCache = new Map();
|
|
1049
1111
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
1050
1112
|
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
1051
1113
|
widgetsTimer = null;
|
|
@@ -2042,7 +2104,8 @@ export class ClientSession {
|
|
|
2042
2104
|
baseUrl: p.baseUrl,
|
|
2043
2105
|
apiKey: p.apiKey,
|
|
2044
2106
|
authHeader: p.authHeader,
|
|
2045
|
-
headers
|
|
2107
|
+
// headers are intentionally NOT sent to the browser — they may
|
|
2108
|
+
// contain Authorization / API-key values; kept server-side only.
|
|
2046
2109
|
models,
|
|
2047
2110
|
};
|
|
2048
2111
|
});
|
|
@@ -2075,14 +2138,17 @@ export class ClientSession {
|
|
|
2075
2138
|
}
|
|
2076
2139
|
try {
|
|
2077
2140
|
const { providers } = this.readModelsConfig();
|
|
2141
|
+
// headers never reach the browser, so the incoming config can't carry
|
|
2142
|
+
// them — preserve the previously stored values when they are absent.
|
|
2143
|
+
const prevHeaders = providers[pid]?.headers;
|
|
2078
2144
|
providers[pid] = {
|
|
2079
2145
|
...(config.name?.trim() ? { name: config.name.trim() } : {}),
|
|
2080
2146
|
...(config.api?.trim() ? { api: config.api.trim() } : {}),
|
|
2081
2147
|
...(config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {}),
|
|
2082
2148
|
...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
|
|
2083
2149
|
...(config.authHeader ? { authHeader: true } : {}),
|
|
2084
|
-
...(
|
|
2085
|
-
? { headers:
|
|
2150
|
+
...(prevHeaders && Object.keys(prevHeaders).length > 0
|
|
2151
|
+
? { headers: prevHeaders }
|
|
2086
2152
|
: {}),
|
|
2087
2153
|
models,
|
|
2088
2154
|
};
|
|
@@ -2463,6 +2529,9 @@ export class ClientSession {
|
|
|
2463
2529
|
settings: {
|
|
2464
2530
|
promptMode: this.settings.promptMode,
|
|
2465
2531
|
customSystemPrompt: this.settings.customSystemPrompt,
|
|
2532
|
+
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
2533
|
+
visionBridgeModel: this.settings.visionBridgeModel,
|
|
2534
|
+
visionModels: this.collectVisionModels(),
|
|
2466
2535
|
disabledSkills: [...this.settings.disabledSkills],
|
|
2467
2536
|
disabledExtensions: [...this.settings.disabledExtensions],
|
|
2468
2537
|
skills,
|
|
@@ -2471,8 +2540,26 @@ export class ClientSession {
|
|
|
2471
2540
|
},
|
|
2472
2541
|
});
|
|
2473
2542
|
}
|
|
2543
|
+
/** Vision-capable configured models, for the settings-panel picker. */
|
|
2544
|
+
collectVisionModels() {
|
|
2545
|
+
try {
|
|
2546
|
+
return findVisionModels(this.session.modelRuntime).map((m) => ({
|
|
2547
|
+
provider: m.provider,
|
|
2548
|
+
id: m.id,
|
|
2549
|
+
label: m.label,
|
|
2550
|
+
}));
|
|
2551
|
+
}
|
|
2552
|
+
catch {
|
|
2553
|
+
// Session not ready yet — the picker stays empty until next push.
|
|
2554
|
+
return [];
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2474
2557
|
/** Persist + apply a partial settings update (prompt text/mode, toggles). */
|
|
2475
2558
|
async setSettings(partial) {
|
|
2559
|
+
const needsReload = partial.promptMode !== undefined ||
|
|
2560
|
+
partial.customSystemPrompt !== undefined ||
|
|
2561
|
+
partial.disabledSkills !== undefined ||
|
|
2562
|
+
partial.disabledExtensions !== undefined;
|
|
2476
2563
|
if (partial.promptMode !== undefined)
|
|
2477
2564
|
this.settings.promptMode = partial.promptMode;
|
|
2478
2565
|
if (partial.customSystemPrompt !== undefined) {
|
|
@@ -2484,9 +2571,16 @@ export class ClientSession {
|
|
|
2484
2571
|
if (partial.disabledExtensions !== undefined) {
|
|
2485
2572
|
this.settings.disabledExtensions = partial.disabledExtensions;
|
|
2486
2573
|
}
|
|
2574
|
+
if (partial.visionBridgeEnabled !== undefined) {
|
|
2575
|
+
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
2576
|
+
}
|
|
2577
|
+
if (partial.visionBridgeModel !== undefined) {
|
|
2578
|
+
this.settings.visionBridgeModel = partial.visionBridgeModel ?? null;
|
|
2579
|
+
}
|
|
2487
2580
|
this.stateStore.saveSettings(this.clientId, this.settings);
|
|
2488
2581
|
this.pushSettings();
|
|
2489
|
-
|
|
2582
|
+
if (needsReload)
|
|
2583
|
+
await this.applyRuntimeSettings();
|
|
2490
2584
|
}
|
|
2491
2585
|
/** Save the CURRENT settings as a named preset (overwrites if exists). */
|
|
2492
2586
|
async savePreset(name) {
|
|
@@ -2522,6 +2616,9 @@ export class ClientSession {
|
|
|
2522
2616
|
customSystemPrompt: p.customSystemPrompt,
|
|
2523
2617
|
disabledSkills: [...p.disabledSkills],
|
|
2524
2618
|
disabledExtensions: [...p.disabledExtensions],
|
|
2619
|
+
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
2620
|
+
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
2621
|
+
visionBridgeModel: this.settings.visionBridgeModel,
|
|
2525
2622
|
};
|
|
2526
2623
|
this.stateStore.saveSettings(this.clientId, this.settings);
|
|
2527
2624
|
this.pushSettings();
|
|
@@ -2572,7 +2669,52 @@ export class ClientSession {
|
|
|
2572
2669
|
// ---------------------------------------------------------------------------
|
|
2573
2670
|
// Commands
|
|
2574
2671
|
// ---------------------------------------------------------------------------
|
|
2575
|
-
|
|
2672
|
+
/** True when the service is draining (quiesced): emits a rejection notice
|
|
2673
|
+
* and returns true. Guards every NEW-work entry point (prompt / new chat /
|
|
2674
|
+
* edit-resend / session resume / goal wizard) — existing runs keep going.
|
|
2675
|
+
* Called BEFORE any LLM/token work starts so quiesce is a hard admission
|
|
2676
|
+
* gate, not a best-effort hint. */
|
|
2677
|
+
quiesceBlocked() {
|
|
2678
|
+
if (!this.isQuiesced())
|
|
2679
|
+
return false;
|
|
2680
|
+
this.emit({
|
|
2681
|
+
type: "notice",
|
|
2682
|
+
level: "error",
|
|
2683
|
+
text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-web-ui server unquiesce 可恢复。",
|
|
2684
|
+
});
|
|
2685
|
+
this.flushSnapshot();
|
|
2686
|
+
return true;
|
|
2687
|
+
}
|
|
2688
|
+
/** Conversations with an in-flight run — active work for quiesce status. */
|
|
2689
|
+
activeConversations() {
|
|
2690
|
+
let n = 0;
|
|
2691
|
+
for (const c of this.convs.values()) {
|
|
2692
|
+
try {
|
|
2693
|
+
if (c.session.isStreaming)
|
|
2694
|
+
n += 1;
|
|
2695
|
+
}
|
|
2696
|
+
catch {
|
|
2697
|
+
// session being replaced — not running
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return n;
|
|
2701
|
+
}
|
|
2702
|
+
/** Messages queued in the SDK (steer + follow-up) — pending work for
|
|
2703
|
+
* quiesce status. Quiesce refuses to add more, so this only drains. */
|
|
2704
|
+
pendingMessages() {
|
|
2705
|
+
let n = 0;
|
|
2706
|
+
for (const c of this.convs.values())
|
|
2707
|
+
n += c.queueFollowUp + c.queueSteering;
|
|
2708
|
+
return n;
|
|
2709
|
+
}
|
|
2710
|
+
async prompt(text, attachments,
|
|
2711
|
+
/**
|
|
2712
|
+
* true = followUp: while streaming, queue the prompt and deliver it only
|
|
2713
|
+
* after the WHOLE run finishes (补充 button — "AI 生成结束才发送").
|
|
2714
|
+
* false/undefined = steer: the pi CLI Enter semantic — injected right
|
|
2715
|
+
* after the current turn settles, skipping remaining planned tool calls.
|
|
2716
|
+
*/
|
|
2717
|
+
queue = false) {
|
|
2576
2718
|
try {
|
|
2577
2719
|
const s = this.session;
|
|
2578
2720
|
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
@@ -2583,6 +2725,11 @@ export class ClientSession {
|
|
|
2583
2725
|
this.flushSnapshot();
|
|
2584
2726
|
return;
|
|
2585
2727
|
}
|
|
2728
|
+
// Native commands above are pure config tweaks (no tokens) — allow them
|
|
2729
|
+
// even while quiesced. Everything that reaches the SDK is NEW work and
|
|
2730
|
+
// is refused until admission reopens.
|
|
2731
|
+
if (this.quiesceBlocked())
|
|
2732
|
+
return;
|
|
2586
2733
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
2587
2734
|
// user message stays clean; they render as separate attachment cards.
|
|
2588
2735
|
const asides = await this.buildAttachmentMessages(attachments);
|
|
@@ -2590,13 +2737,19 @@ export class ClientSession {
|
|
|
2590
2737
|
await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
|
|
2591
2738
|
}
|
|
2592
2739
|
if (s.isStreaming) {
|
|
2593
|
-
//
|
|
2594
|
-
// after the
|
|
2595
|
-
//
|
|
2596
|
-
//
|
|
2597
|
-
//
|
|
2740
|
+
// queue=true (补充 button) → followUp: the message is delivered only
|
|
2741
|
+
// after the whole run finishes — the agent finishes what it started,
|
|
2742
|
+
// then responds to the queued message. queue=false/undefined
|
|
2743
|
+
// (plain Enter) → steer: interrupts the current run — the message
|
|
2744
|
+
// is delivered right after the current assistant turn settles
|
|
2745
|
+
// (remaining planned tool calls are skipped) and the agent
|
|
2746
|
+
// immediately responds to it. This is the pi CLI
|
|
2747
|
+
// Enter-during-streaming semantic (docs/usage: Enter queues a
|
|
2748
|
+
// steering message); followUp would wait for the whole run
|
|
2598
2749
|
// to finish, which users perceive as ordinary queueing.
|
|
2599
|
-
await s.prompt(text, {
|
|
2750
|
+
await s.prompt(text, {
|
|
2751
|
+
streamingBehavior: queue ? "followUp" : "steer",
|
|
2752
|
+
});
|
|
2600
2753
|
}
|
|
2601
2754
|
else {
|
|
2602
2755
|
await s.prompt(text);
|
|
@@ -2665,9 +2818,147 @@ export class ClientSession {
|
|
|
2665
2818
|
".svg": "image/svg+xml",
|
|
2666
2819
|
};
|
|
2667
2820
|
const out = [];
|
|
2821
|
+
// -- Vision bridge ------------------------------------------------------
|
|
2822
|
+
// When the active model can't accept images (DeepSeek, GLM, …), pasted
|
|
2823
|
+
// images are transcribed by a configured vision model first and the
|
|
2824
|
+
// transcript is fed to the text-only model as text evidence (see
|
|
2825
|
+
// vision-bridge.ts — any model in models.json whose input includes
|
|
2826
|
+
// "image" works, zero extra config). Vision-capable main models keep
|
|
2827
|
+
// the raw image-content path untouched.
|
|
2828
|
+
const mainModel = this.session.model;
|
|
2829
|
+
const mainSupportsVision = mainModel?.input?.includes("image") ?? false;
|
|
2830
|
+
const bridgedImages = [];
|
|
2831
|
+
/** Raw image bytes for path-referenced image files (idx → info), pre-read
|
|
2832
|
+
* so the loop below doesn't re-read them. SVG stays a plain text file —
|
|
2833
|
+
* the model reads its source, far more useful than a rasterized blob. */
|
|
2834
|
+
const pathImageData = new Map();
|
|
2835
|
+
/** Cap for path images (fully read + base64'd); larger ones fall back to
|
|
2836
|
+
* a plain path reference (the model can still attempt to read them). */
|
|
2837
|
+
const MAX_PATH_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
2838
|
+
for (const [idx, att] of attachments.entries()) {
|
|
2839
|
+
if (att.imageData) {
|
|
2840
|
+
const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
|
|
2841
|
+
const mimeType = att.mimeType?.startsWith("image/")
|
|
2842
|
+
? att.mimeType
|
|
2843
|
+
: "image/png";
|
|
2844
|
+
const bytes = Buffer.byteLength(raw, "base64");
|
|
2845
|
+
// Only images that would actually be sent (non-empty, under the cap).
|
|
2846
|
+
if (bytes > 0 && bytes <= 2 * 1024 * 1024) {
|
|
2847
|
+
if (!mainSupportsVision) {
|
|
2848
|
+
bridgedImages.push({ idx, att, raw, mimeType, bytes });
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
continue;
|
|
2852
|
+
}
|
|
2853
|
+
if (att.fileData || !att.path)
|
|
2854
|
+
continue;
|
|
2855
|
+
const ext = extname(att.path).toLowerCase();
|
|
2856
|
+
if (!IMAGE_EXT.has(ext) || ext === ".svg")
|
|
2857
|
+
continue;
|
|
2858
|
+
const abs = resolve(root, att.path);
|
|
2859
|
+
const rawRel = relative(root, abs);
|
|
2860
|
+
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
|
|
2861
|
+
continue;
|
|
2862
|
+
let st;
|
|
2863
|
+
try {
|
|
2864
|
+
st = await fs.stat(abs);
|
|
2865
|
+
}
|
|
2866
|
+
catch {
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2869
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_PATH_IMAGE_BYTES) {
|
|
2870
|
+
continue;
|
|
2871
|
+
}
|
|
2872
|
+
const buf = await fs.readFile(abs);
|
|
2873
|
+
const mime = sniffImageMime(buf, ext);
|
|
2874
|
+
if (!mime)
|
|
2875
|
+
continue;
|
|
2876
|
+
const raw = buf.toString("base64");
|
|
2877
|
+
pathImageData.set(idx, { raw, mimeType: mime, bytes: st.size });
|
|
2878
|
+
if (!mainSupportsVision) {
|
|
2879
|
+
bridgedImages.push({ idx, att, raw, mimeType: mime, bytes: st.size });
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
/** Transcript per attachment index (filled below, keyed by bridgedImages idx). */
|
|
2883
|
+
const bridgeTranscripts = new Map();
|
|
2884
|
+
if (bridgedImages.length > 0) {
|
|
2885
|
+
if (!this.settings.visionBridgeEnabled) {
|
|
2886
|
+
this.emit({
|
|
2887
|
+
type: "notice",
|
|
2888
|
+
level: "warning",
|
|
2889
|
+
text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且视觉桥已在设置中关闭:图片将原样发送、可能被忽略。`,
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
else {
|
|
2893
|
+
const visionModels = findVisionModels(this.session.modelRuntime);
|
|
2894
|
+
// Preferred model from settings ("provider/id") — validated to exist
|
|
2895
|
+
// and actually accept images; falls back to the first auto-detected.
|
|
2896
|
+
let chosen = visionModels[0] ?? null;
|
|
2897
|
+
const pref = this.settings.visionBridgeModel;
|
|
2898
|
+
if (pref) {
|
|
2899
|
+
const spec = this.resolveReviewModel(pref);
|
|
2900
|
+
if (spec) {
|
|
2901
|
+
const pm = this.session.modelRuntime.getModel(spec.provider, spec.id);
|
|
2902
|
+
if (pm?.input?.includes("image")) {
|
|
2903
|
+
chosen = {
|
|
2904
|
+
provider: spec.provider,
|
|
2905
|
+
id: spec.id,
|
|
2906
|
+
label: `${pm.name ?? pm.id} (${spec.provider})`,
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
if (!chosen) {
|
|
2912
|
+
this.emit({
|
|
2913
|
+
type: "notice",
|
|
2914
|
+
level: "warning",
|
|
2915
|
+
text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且未找到可用的视觉模型:图片将原样发送、可能被忽略。在模型配置里添加任意支持图片的模型(如 qwen-vl、GLM-4V、Gemini)即可自动启用视觉桥转写。`,
|
|
2916
|
+
});
|
|
2917
|
+
}
|
|
2918
|
+
else {
|
|
2919
|
+
// Batch hash so re-sending identical images (edit & re-ask) reuses
|
|
2920
|
+
// the transcript instead of re-burning tokens on the vision API.
|
|
2921
|
+
const batchHash = bridgedImages
|
|
2922
|
+
.map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`)
|
|
2923
|
+
.join("|");
|
|
2924
|
+
let transcript = this.visionBridgeCache.get(batchHash);
|
|
2925
|
+
if (transcript === undefined) {
|
|
2926
|
+
this.emit({
|
|
2927
|
+
type: "notice",
|
|
2928
|
+
level: "info",
|
|
2929
|
+
text: `当前模型不支持识图,正在用视觉桥(${chosen.label})转写 ${bridgedImages.length} 张图片…`,
|
|
2930
|
+
});
|
|
2931
|
+
try {
|
|
2932
|
+
const chosenModel = this.session.modelRuntime.getModel(chosen.provider, chosen.id);
|
|
2933
|
+
transcript = await transcribeImages(this.session.modelRuntime, bridgedImages.map((b) => ({
|
|
2934
|
+
data: b.raw,
|
|
2935
|
+
mimeType: b.mimeType,
|
|
2936
|
+
name: b.att.name,
|
|
2937
|
+
})), { model: chosenModel ?? undefined });
|
|
2938
|
+
this.visionBridgeCache.set(batchHash, transcript);
|
|
2939
|
+
this.emit({
|
|
2940
|
+
type: "notice",
|
|
2941
|
+
level: "info",
|
|
2942
|
+
text: `✅ 图片已由视觉桥转写完成(${chosen.label})`,
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
catch (err) {
|
|
2946
|
+
transcript = "";
|
|
2947
|
+
this.emit({
|
|
2948
|
+
type: "notice",
|
|
2949
|
+
level: "error",
|
|
2950
|
+
text: `图片转写失败(${chosen.label}):${err.message}。图片将原样发送、可能被忽略。`,
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
for (const b of bridgedImages)
|
|
2955
|
+
bridgeTranscripts.set(b.idx, transcript ?? "");
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2668
2959
|
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
2669
2960
|
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
2670
|
-
for (const att of attachments) {
|
|
2961
|
+
for (const [idx, att] of attachments.entries()) {
|
|
2671
2962
|
// Raw pasted/dropped/uploaded image — no workspace path involved (the
|
|
2672
2963
|
// browser downscales client-side; this guard only prevents abuse).
|
|
2673
2964
|
if (att.imageData) {
|
|
@@ -2691,6 +2982,32 @@ export class ClientSession {
|
|
|
2691
2982
|
});
|
|
2692
2983
|
continue;
|
|
2693
2984
|
}
|
|
2985
|
+
const transcript = bridgeTranscripts.get(idx);
|
|
2986
|
+
if (transcript) {
|
|
2987
|
+
// Bridged: the text-only main model can't see images, so it gets the
|
|
2988
|
+
// vision model's transcript as text evidence; the image block is
|
|
2989
|
+
// kept so the card still shows the original thumbnail.
|
|
2990
|
+
out.push({
|
|
2991
|
+
message: {
|
|
2992
|
+
customType: "file",
|
|
2993
|
+
content: [
|
|
2994
|
+
{
|
|
2995
|
+
type: "text",
|
|
2996
|
+
text: `\n<vision-bridge>\n${transcript}\n</vision-bridge>`,
|
|
2997
|
+
},
|
|
2998
|
+
{ type: "image", data: raw, mimeType },
|
|
2999
|
+
],
|
|
3000
|
+
display: true,
|
|
3001
|
+
details: {
|
|
3002
|
+
name: att.name ?? "image.png",
|
|
3003
|
+
path: undefined,
|
|
3004
|
+
mode: "bridged",
|
|
3005
|
+
size: bytes,
|
|
3006
|
+
},
|
|
3007
|
+
},
|
|
3008
|
+
});
|
|
3009
|
+
continue;
|
|
3010
|
+
}
|
|
2694
3011
|
out.push({
|
|
2695
3012
|
message: {
|
|
2696
3013
|
customType: "file",
|
|
@@ -2843,8 +3160,63 @@ export class ClientSession {
|
|
|
2843
3160
|
continue;
|
|
2844
3161
|
}
|
|
2845
3162
|
const ext = extname(att.path).toLowerCase();
|
|
2846
|
-
if (IMAGE_EXT.has(ext)) {
|
|
2847
|
-
|
|
3163
|
+
if (IMAGE_EXT.has(ext) && ext !== ".svg") {
|
|
3164
|
+
const pathImg = pathImageData.get(idx);
|
|
3165
|
+
const transcript = bridgeTranscripts.get(idx);
|
|
3166
|
+
if (transcript) {
|
|
3167
|
+
// Text-only main model: the vision bridge transcribed this image —
|
|
3168
|
+
// the model gets the transcript as text evidence (+ thumbnail).
|
|
3169
|
+
out.push({
|
|
3170
|
+
message: {
|
|
3171
|
+
customType: "file",
|
|
3172
|
+
content: [
|
|
3173
|
+
{
|
|
3174
|
+
type: "text",
|
|
3175
|
+
text: `
|
|
3176
|
+
<vision-bridge>
|
|
3177
|
+
${transcript}
|
|
3178
|
+
</vision-bridge>`,
|
|
3179
|
+
},
|
|
3180
|
+
...(pathImg
|
|
3181
|
+
? ([{
|
|
3182
|
+
type: "image",
|
|
3183
|
+
data: pathImg.raw,
|
|
3184
|
+
mimeType: pathImg.mimeType,
|
|
3185
|
+
}])
|
|
3186
|
+
: []),
|
|
3187
|
+
],
|
|
3188
|
+
display: true,
|
|
3189
|
+
details: {
|
|
3190
|
+
name,
|
|
3191
|
+
path: rel,
|
|
3192
|
+
mode: "bridged",
|
|
3193
|
+
size: stat.size,
|
|
3194
|
+
},
|
|
3195
|
+
},
|
|
3196
|
+
});
|
|
3197
|
+
continue;
|
|
3198
|
+
}
|
|
3199
|
+
if (pathImg) {
|
|
3200
|
+
// Vision-capable main model (or bridge failed): send the raw image
|
|
3201
|
+
// content straight from the pre-read bytes.
|
|
3202
|
+
out.push({
|
|
3203
|
+
message: {
|
|
3204
|
+
customType: "file",
|
|
3205
|
+
content: [
|
|
3206
|
+
{
|
|
3207
|
+
type: "image",
|
|
3208
|
+
data: pathImg.raw,
|
|
3209
|
+
mimeType: pathImg.mimeType,
|
|
3210
|
+
},
|
|
3211
|
+
],
|
|
3212
|
+
display: true,
|
|
3213
|
+
details: { name, path: rel, mode: "image", size: stat.size },
|
|
3214
|
+
},
|
|
3215
|
+
});
|
|
3216
|
+
continue;
|
|
3217
|
+
}
|
|
3218
|
+
// Pre-read failed (unsupported sniff / too large): fall back to the
|
|
3219
|
+
// legacy inline-cap behavior.
|
|
2848
3220
|
if (stat.size > MAX_ATTACHMENT_BYTES) {
|
|
2849
3221
|
this.emit({
|
|
2850
3222
|
type: "notice",
|
|
@@ -3255,6 +3627,8 @@ export class ClientSession {
|
|
|
3255
3627
|
}
|
|
3256
3628
|
}
|
|
3257
3629
|
async newChat() {
|
|
3630
|
+
if (this.quiesceBlocked())
|
|
3631
|
+
return;
|
|
3258
3632
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
3259
3633
|
// on every click: if the active chat has no messages it IS the new chat
|
|
3260
3634
|
// (focus already on it); otherwise switch to the first blank one (under
|
|
@@ -3438,6 +3812,8 @@ export class ClientSession {
|
|
|
3438
3812
|
}
|
|
3439
3813
|
/** Switch the active session to a persisted one (from listSessions). */
|
|
3440
3814
|
async switchSession(path) {
|
|
3815
|
+
if (this.quiesceBlocked())
|
|
3816
|
+
return;
|
|
3441
3817
|
try {
|
|
3442
3818
|
await this.runtime.switchSession(path);
|
|
3443
3819
|
await this.bindSession();
|
|
@@ -3496,6 +3872,8 @@ export class ClientSession {
|
|
|
3496
3872
|
* session list, so nothing is ever lost.
|
|
3497
3873
|
*/
|
|
3498
3874
|
async editMessage(messageId, text) {
|
|
3875
|
+
if (this.quiesceBlocked())
|
|
3876
|
+
return;
|
|
3499
3877
|
const trimmed = text.trim();
|
|
3500
3878
|
if (!trimmed) {
|
|
3501
3879
|
this.emit({
|
|
@@ -4035,6 +4413,8 @@ export class ClientSession {
|
|
|
4035
4413
|
* Mutually exclusive with the review loop.
|
|
4036
4414
|
*/
|
|
4037
4415
|
async startGoalWizard(text, opts) {
|
|
4416
|
+
if (this.quiesceBlocked())
|
|
4417
|
+
return;
|
|
4038
4418
|
const draft = (text ?? "").trim();
|
|
4039
4419
|
if (!draft)
|
|
4040
4420
|
return;
|
|
@@ -4726,6 +5106,15 @@ export class ClientSession {
|
|
|
4726
5106
|
export class AgentService {
|
|
4727
5107
|
cwd;
|
|
4728
5108
|
clients = new Map();
|
|
5109
|
+
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
5110
|
+
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
5111
|
+
* once existing runs finish. Controlled via the local control socket:
|
|
5112
|
+
* `pi-web-ui server quiesce|unquiesce`. */
|
|
5113
|
+
quiesced = false;
|
|
5114
|
+
quiescedAt = 0;
|
|
5115
|
+
/** Attached browser sockets (reported by index.ts on open/close) — the
|
|
5116
|
+
* control socket reports real sockets, not cached client-session objects. */
|
|
5117
|
+
socketCount = 0;
|
|
4729
5118
|
pending = new Map();
|
|
4730
5119
|
stateStore;
|
|
4731
5120
|
/**
|
|
@@ -4740,6 +5129,60 @@ export class AgentService {
|
|
|
4740
5129
|
this.stateStore = new ClientStateStore(stateFile);
|
|
4741
5130
|
}
|
|
4742
5131
|
/** Get or create the session for a client, racing attach calls safely. */
|
|
5132
|
+
/** True while the service is draining — new work is refused. */
|
|
5133
|
+
isQuiesced() {
|
|
5134
|
+
return this.quiesced;
|
|
5135
|
+
}
|
|
5136
|
+
/** Enter quiesce: stop admitting new work. Existing runs keep going. */
|
|
5137
|
+
quiesce() {
|
|
5138
|
+
this.quiesced = true;
|
|
5139
|
+
this.quiescedAt = Date.now();
|
|
5140
|
+
}
|
|
5141
|
+
/** Leave quiesce: admit new work again. */
|
|
5142
|
+
unquiesce() {
|
|
5143
|
+
this.quiesced = false;
|
|
5144
|
+
this.quiescedAt = 0;
|
|
5145
|
+
}
|
|
5146
|
+
/** Snapshot for the control socket / status command. */
|
|
5147
|
+
quiesceInfo() {
|
|
5148
|
+
return this.quiesced
|
|
5149
|
+
? { quiesced: true, quiescedSince: this.quiescedAt }
|
|
5150
|
+
: { quiesced: false };
|
|
5151
|
+
}
|
|
5152
|
+
/** Aggregate across every client session: conversations with in-flight runs. */
|
|
5153
|
+
activeConversations() {
|
|
5154
|
+
let n = 0;
|
|
5155
|
+
for (const cs of this.clients.values())
|
|
5156
|
+
n += cs.activeConversations();
|
|
5157
|
+
return n;
|
|
5158
|
+
}
|
|
5159
|
+
/** Aggregate across every client session: messages queued in the SDK. */
|
|
5160
|
+
pendingMessages() {
|
|
5161
|
+
let n = 0;
|
|
5162
|
+
for (const cs of this.clients.values())
|
|
5163
|
+
n += cs.pendingMessages();
|
|
5164
|
+
return n;
|
|
5165
|
+
}
|
|
5166
|
+
/** index.ts calls this when a browser socket opens/closes. */
|
|
5167
|
+
noteSocketOpen() {
|
|
5168
|
+
this.socketCount += 1;
|
|
5169
|
+
}
|
|
5170
|
+
noteSocketClose() {
|
|
5171
|
+
this.socketCount = Math.max(0, this.socketCount - 1);
|
|
5172
|
+
}
|
|
5173
|
+
/** Full status for the control socket / `server status` command. */
|
|
5174
|
+
serviceStatus() {
|
|
5175
|
+
return {
|
|
5176
|
+
pid: process.pid,
|
|
5177
|
+
version: VERSION,
|
|
5178
|
+
cwd: this.cwd,
|
|
5179
|
+
...this.quiesceInfo(),
|
|
5180
|
+
connectedClients: this.socketCount,
|
|
5181
|
+
activeConversations: this.activeConversations(),
|
|
5182
|
+
pendingMessages: this.pendingMessages(),
|
|
5183
|
+
};
|
|
5184
|
+
}
|
|
5185
|
+
/** Get or create the session for a client, racing attach calls safely. */
|
|
4743
5186
|
async attach(clientId, send) {
|
|
4744
5187
|
let cs = this.clients.get(clientId);
|
|
4745
5188
|
if (!cs) {
|
|
@@ -4749,6 +5192,13 @@ export class AgentService {
|
|
|
4749
5192
|
}
|
|
4750
5193
|
else {
|
|
4751
5194
|
// Restore this client's last-used workspace when it still exists;
|
|
5195
|
+
// Admission gate: while quiesced, only clients with an EXISTING
|
|
5196
|
+
// session may attach (they can watch their runs drain); brand-new
|
|
5197
|
+
// clients are refused — index.ts closes their socket (4403) and the
|
|
5198
|
+
// browser reconnect loop retries after admission reopens.
|
|
5199
|
+
if (this.quiesced) {
|
|
5200
|
+
throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
|
|
5201
|
+
}
|
|
4752
5202
|
// otherwise fall back to the server's configured default cwd.
|
|
4753
5203
|
let cwd = this.cwd;
|
|
4754
5204
|
const saved = this.stateStore.get(clientId);
|
|
@@ -4783,6 +5233,7 @@ export class AgentService {
|
|
|
4783
5233
|
// Forward hooks (set once by index.ts) to every session.
|
|
4784
5234
|
cs.onUpdateReady = this.onUpdateReady;
|
|
4785
5235
|
cs.onQuit = this.onQuit;
|
|
5236
|
+
cs.isQuiesced = () => this.quiesced;
|
|
4786
5237
|
return cs;
|
|
4787
5238
|
}
|
|
4788
5239
|
/** Remove a socket from a client's broadcast set (called on socket close). */
|