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.
@@ -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: p.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
- ...(config.headers && Object.keys(config.headers).length > 0
2085
- ? { headers: config.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
- await this.applyRuntimeSettings();
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
- async prompt(text, attachments) {
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
- // Steer: interrupts the current run — the message is delivered right
2594
- // after the current assistant turn settles (remaining planned tool
2595
- // calls are skipped) and the agent immediately responds to it. This
2596
- // is the pi CLI Enter-during-streaming semantic (docs/usage: Enter
2597
- // queues a steering message); followUp would wait for the whole run
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, { streamingBehavior: "steer" });
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
- // Images can't be referenced — they must be inlined, so keep a hard cap.
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). */