pi-web-ui 0.23.0 → 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 CHANGED
@@ -9,6 +9,65 @@ built-in terminal, manage models, tweak the system prompt, toggle skills and
9
9
  extensions on/off, and save/apply settings presets — all from a settings panel.
10
10
  Requires Node.js ≥ 22.19 and a configured pi install.
11
11
 
12
+ ## Features
13
+
14
+ **Chat**
15
+
16
+ - Streaming agent chat over WebSocket — the pi SDK runs in-process; events are pushed as snapshots (60 ms throttled) and the browser renders them.
17
+ - Thinking blocks, tool-call cards and bash outputs with live status (running → finished · waiting for the model · duration).
18
+ - **补充 (steer)** — send a follow-up while the agent is replying; it is queued and injected as soon as the current turn's tool calls settle (the "Interrupt" equivalent of the pi CLI).
19
+ - **Slash commands** — `/` opens a command picker (built-in / extension / template / skill); built-ins include `/new /model /compact /cwd /thinking /resume`, plus `/help` (command list) and `/copy` (copy last reply).
20
+ - **Multiple conversations per project** — each conversation gets its own agent runtime and keeps running in the background after you switch away; the "Running conversations" list shows stream progress and lets you switch back.
21
+ - **Edit & re-ask** — fork any past question into a new branch and re-prompt; the original conversation stays untouched.
22
+ - Long threads auto-collapse messages older than 30 into lazy summary rows (click to expand).
23
+ - Question navigation — a floating rail plus per-question tags to jump between questions.
24
+
25
+ **Files, images & attachments**
26
+
27
+ - Three attachment modes: `inline` (≤12 KB), `reference` (path only), `lines` (selected ranges) — over-limit ones degrade automatically.
28
+ - Paste / drag-drop / upload images — resized client-side and sent as image content when the model supports vision (warning otherwise).
29
+ - **Vision bridge** — when the current model is text-only, images are transcribed into text evidence by an auto-discovered vision model (cached per batch; model & on/off configurable in Settings).
30
+ - Attach arbitrary files without a workspace path — stored in a global uploads dir, inlined when small, referenced by absolute path otherwise.
31
+ - File preview — line numbers, click/drag/Shift selection (add to chat as `lines`), GBK fallback decoding, binary hex view, media preview over HTTP with Range support, and a download button.
32
+ - Live file tree — the server watches the listed directory (fs.watch) and re-lists on change; oversized directories show a truncation warning.
33
+
34
+ **Terminal & Git**
35
+
36
+ - Built-in terminal (xterm.js + node-pty) with per-client PTY management; Windows auto-selects Git Bash (busybox fallback).
37
+ - **Source control (Git) panel** — status / branch / diff / untracked files via a hidden query terminal; commit, switch branch, push and pull run in the visible terminal and auto-switch to the terminal view.
38
+
39
+ **Models & settings**
40
+
41
+ - Model management — edit `models.json` in the UI and set per-provider API keys (keys/headers never leave the server).
42
+ - Thinking level per model (only the levels the model actually supports are shown).
43
+ - First-run setup wizard.
44
+ - Settings panel — system prompt (append or replace), toggle skills/extensions on/off with immediate effect, save/apply/delete settings presets, and vision-bridge model & switch.
45
+
46
+ **Goal mode**
47
+
48
+ - Goal bar — set a target with a review model, max rounds and a lock switch.
49
+ - Goal wizard ("AI 提炼") — turns a raw request into a concrete goal through a guided questionnaire.
50
+ - Automatic review loop — after each turn an independent review session checks the goal against the final text and `git diff HEAD`; on fail the feedback is injected as steer until it passes (or the round cap is hit).
51
+
52
+ **Background tasks**
53
+
54
+ - Background-task panel — servers launched by the agent are detected via port snapshots and listed (port/pid/name); stop one or kill all.
55
+ - Tool watchdog — a tool call running over 20 minutes is aborted automatically.
56
+ - **Stop bash command only** — abort a running bash tool without killing the conversation.
57
+
58
+ **Safety & operations**
59
+
60
+ - Loopback-only by default; set `PI_WEB_HOST=0.0.0.0` for LAN / containers.
61
+ - WebSocket Origin/Host same-authority check — cross-origin pages are rejected (403); `PI_WEB_ALLOW_ORIGINS` whitelist for reverse proxies.
62
+ - Quiesce drain mode via a local control socket (`server status|quiesce|unquiesce`).
63
+ - Credentials stay server-side — provider headers are never sent to the browser.
64
+ - Sound alerts, Chinese/English UI, and a recent-projects list (click to switch workspace).
65
+
66
+ **Deploy & update**
67
+
68
+ - Foreground, global npm install, Docker (docker-compose), macOS launchd, Linux systemd, Windows Task Scheduler, and a desktop shortcut (`server shortcut`).
69
+ - In-app self-update — checks the npm registry, installs and auto-restarts the service.
70
+
12
71
  ## Screenshots
13
72
 
14
73
  ![pi-web-ui main interface](https://cdn.jsdelivr.net/gh/xing-shuyin/pi-web-ui@main/assets/shot.jpeg)
@@ -105,3 +164,4 @@ port/cwd.
105
164
  ## License
106
165
 
107
166
  MIT
167
+
package/README.zh-CN.md CHANGED
@@ -7,6 +7,65 @@
7
7
  模型管理,以及设置面板(自定义系统提示词、技能/插件开关、设置预设一键应用)等功能。
8
8
  需要 Node.js ≥ 22.19 及配置好的 pi 环境。
9
9
 
10
+ ## 功能特性
11
+
12
+ **对话**
13
+
14
+ - WebSocket 流式聊天 —— pi SDK 在服务端进程内运行,事件以快照(60ms 节流)推送,浏览器按快照渲染。
15
+ - 思考块、工具调用卡片、bash 输出,实时显示状态(执行中 → 已结束 · 等模型 · 耗时)。
16
+ - **补充(steer)** —— 回复流式中可排队发送跟进消息,当前回合工具结算后立即注入(对应 pi CLI 的 Enter 打断语义)。
17
+ - **斜杠命令** —— 输入 `/` 弹出命令选择器(内置 / 扩展 / 模板 / 技能);内置 `/new /model /compact /cwd /thinking /resume`,另有 `/help`(命令清单)与 `/copy`(复制上一条回复)。
18
+ - **每项目多对话并发** —— 每个对话独立 agent runtime,切走后仍在后台运行;「运行的对话」列表显示流式进度,可随时切回。
19
+ - **编辑重问** —— 把任意历史问题 fork 成新分支重新提问,原对话不受影响。
20
+ - 超过 30 条的消息自动折叠为摘要行(惰性渲染,点击展开)。
21
+ - 问题导航 —— 右侧浮动导航条 + 每个问题顶部的序号标签,一键跳转。
22
+
23
+ **文件、图片与附件**
24
+
25
+ - 三种附件模式:`inline`(≤12KB 内联)、`reference`(仅路径引用)、`lines`(选中行),超限自动降级。
26
+ - 粘贴 / 拖拽 / 上传图片 —— 浏览器端自动缩放,模型支持识图时作为图片内容发送(不支持时提示警告)。
27
+ - **视觉桥** —— 当前模型不支持识图时,把图片交给自动发现的视觉模型转写成文字证据(按批次缓存,可在设置里指定模型/开关)。
28
+ - 免工作区路径附加任意文件 —— 存入全局上传目录,小文件内联,其余以绝对路径引用。
29
+ - 文件预览 —— 行号、点选/拖拽/Shift 选区(可添加到对话为 lines 附件)、GBK 回退解码、二进制十六进制视图、媒体 HTTP 预览(支持 Range)、下载按钮。
30
+ - 实时文件树 —— 服务端对当前列出目录 fs.watch,改动即静默重列;超大目录显示截断提示。
31
+
32
+ **终端与 Git**
33
+
34
+ - 内置终端(xterm.js + node-pty),每客户端独立 PTY 管理;Windows 自动选择 Git Bash(busybox 兜底)。
35
+ - **源代码管理(Git)面板** —— 经隐藏查询终端展示 status / branch / diff / 未跟踪文件;提交、切换分支、推送、拉取复用可见终端并自动切换到终端视图。
36
+
37
+ **模型与设置**
38
+
39
+ - 模型管理 —— UI 里编辑 models.json、按 provider 设置 API key(密钥/headers 永不下发浏览器)。
40
+ - 思考强度(thinking level)按模型切换(只显示该模型实际支持的档位)。
41
+ - 首次配置引导(PiSetupModal)。
42
+ - 设置面板 —— 系统提示词(追加或整体替换)、技能/插件一键开关(即时生效)、设置预设保存/应用/删除、视觉桥模型与开关。
43
+
44
+ **目标(Goal)模式**
45
+
46
+ - GoalBar 目标栏 —— 设置目标 + 审查模型 + 最大轮数 + 锁定开关。
47
+ - 目标调研向导(「AI 提炼」)—— 通过引导式问卷把原始需求收敛成明确目标。
48
+ - 自动审查循环 —— 每轮结束后用独立审查会话核对「目标 + 最终文本 + git diff HEAD」;不达标就把审查意见作为 steer 注入重改,直到通过或达到轮数上限。
49
+
50
+ **后台任务**
51
+
52
+ - 后台任务面板 —— 通过端口快照检测 agent 启动的服务(端口/pid/名称),可单独停止或全部关闭。
53
+ - 工具看门狗 —— 单个工具调用超过 20 分钟自动中断会话。
54
+ - **只停止 bash 命令** —— 中止运行中的 bash 工具而不打断对话。
55
+
56
+ **安全与运维**
57
+
58
+ - 默认只绑 loopback;局域网 / 容器需显式 `PI_WEB_HOST=0.0.0.0`。
59
+ - WebSocket Origin/Host 同权威校验 —— 跨源页面直接拒绝(403);反代场景用 `PI_WEB_ALLOW_ORIGINS` 白名单。
60
+ - 本地控制 socket 提供 `server status|quiesce|unquiesce`(排空模式:拒绝新工作、存量跑完)。
61
+ - 凭据不下发浏览器 —— provider headers(可能含 Authorization/API key)永不发送到前端。
62
+ - 声音提醒、中英文界面、最近项目列表(点击即切换工作目录)。
63
+
64
+ **部署与更新**
65
+
66
+ - 前台运行 / 全局 npm 安装 / Docker(docker-compose)/ macOS launchd / Linux systemd / Windows 计划任务 / 桌面快捷方式(`server shortcut`)。
67
+ - 界面内自更新 —— 对比 npm registry 版本,安装后自动重启服务。
68
+
10
69
  ## 界面截图
11
70
 
12
71
  ![pi-web-ui 主界面](https://cdn.jsdelivr.net/gh/xing-shuyin/pi-web-ui@main/assets/shot.jpeg)
@@ -18,6 +18,7 @@ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentS
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;
@@ -230,6 +231,42 @@ function decodeText(buf) {
230
231
  }
231
232
  }
232
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
+ }
233
270
  /** Windows persona appendix — appended to the SDK system prompt on win32 only.
234
271
  * Two failure modes it guards against: (1) the SDK bash tool has NO default
235
272
  * timeout, so a long-running command hangs the whole conversation forever;
@@ -868,6 +905,8 @@ class ClientStateStore {
868
905
  customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
869
906
  disabledSkills: s?.settings?.disabledSkills ?? [],
870
907
  disabledExtensions: s?.settings?.disabledExtensions ?? [],
908
+ visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
909
+ visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
871
910
  };
872
911
  }
873
912
  /** Persist the client's settings-panel state (partial merge). */
@@ -880,6 +919,8 @@ class ClientStateStore {
880
919
  customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
881
920
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
882
921
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
922
+ visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
923
+ visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
883
924
  };
884
925
  this.save();
885
926
  }
@@ -1061,6 +1102,12 @@ export class ClientSession {
1061
1102
  }
1062
1103
  /** PTY terminals for this client (killed when the last socket detaches). */
1063
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();
1064
1111
  /** Web-facing extension UI context (widgets, notifications). */
1065
1112
  webUi = new WebUIContext((msg) => this.emit(msg));
1066
1113
  widgetsTimer = null;
@@ -2482,6 +2529,9 @@ export class ClientSession {
2482
2529
  settings: {
2483
2530
  promptMode: this.settings.promptMode,
2484
2531
  customSystemPrompt: this.settings.customSystemPrompt,
2532
+ visionBridgeEnabled: this.settings.visionBridgeEnabled,
2533
+ visionBridgeModel: this.settings.visionBridgeModel,
2534
+ visionModels: this.collectVisionModels(),
2485
2535
  disabledSkills: [...this.settings.disabledSkills],
2486
2536
  disabledExtensions: [...this.settings.disabledExtensions],
2487
2537
  skills,
@@ -2490,8 +2540,26 @@ export class ClientSession {
2490
2540
  },
2491
2541
  });
2492
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
+ }
2493
2557
  /** Persist + apply a partial settings update (prompt text/mode, toggles). */
2494
2558
  async setSettings(partial) {
2559
+ const needsReload = partial.promptMode !== undefined ||
2560
+ partial.customSystemPrompt !== undefined ||
2561
+ partial.disabledSkills !== undefined ||
2562
+ partial.disabledExtensions !== undefined;
2495
2563
  if (partial.promptMode !== undefined)
2496
2564
  this.settings.promptMode = partial.promptMode;
2497
2565
  if (partial.customSystemPrompt !== undefined) {
@@ -2503,9 +2571,16 @@ export class ClientSession {
2503
2571
  if (partial.disabledExtensions !== undefined) {
2504
2572
  this.settings.disabledExtensions = partial.disabledExtensions;
2505
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
+ }
2506
2580
  this.stateStore.saveSettings(this.clientId, this.settings);
2507
2581
  this.pushSettings();
2508
- await this.applyRuntimeSettings();
2582
+ if (needsReload)
2583
+ await this.applyRuntimeSettings();
2509
2584
  }
2510
2585
  /** Save the CURRENT settings as a named preset (overwrites if exists). */
2511
2586
  async savePreset(name) {
@@ -2541,6 +2616,9 @@ export class ClientSession {
2541
2616
  customSystemPrompt: p.customSystemPrompt,
2542
2617
  disabledSkills: [...p.disabledSkills],
2543
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,
2544
2622
  };
2545
2623
  this.stateStore.saveSettings(this.clientId, this.settings);
2546
2624
  this.pushSettings();
@@ -2629,7 +2707,14 @@ export class ClientSession {
2629
2707
  n += c.queueFollowUp + c.queueSteering;
2630
2708
  return n;
2631
2709
  }
2632
- async prompt(text, attachments) {
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) {
2633
2718
  try {
2634
2719
  const s = this.session;
2635
2720
  // Native slash commands (see NATIVE_COMMANDS) are executed here and
@@ -2652,13 +2737,19 @@ export class ClientSession {
2652
2737
  await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
2653
2738
  }
2654
2739
  if (s.isStreaming) {
2655
- // Steer: interrupts the current run the message is delivered right
2656
- // after the current assistant turn settles (remaining planned tool
2657
- // calls are skipped) and the agent immediately responds to it. This
2658
- // is the pi CLI Enter-during-streaming semantic (docs/usage: Enter
2659
- // 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
2660
2749
  // to finish, which users perceive as ordinary queueing.
2661
- await s.prompt(text, { streamingBehavior: "steer" });
2750
+ await s.prompt(text, {
2751
+ streamingBehavior: queue ? "followUp" : "steer",
2752
+ });
2662
2753
  }
2663
2754
  else {
2664
2755
  await s.prompt(text);
@@ -2727,9 +2818,147 @@ export class ClientSession {
2727
2818
  ".svg": "image/svg+xml",
2728
2819
  };
2729
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
+ }
2730
2959
  /** Cap for reading a file in "lines" mode (selected slice is inlined). */
2731
2960
  const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
2732
- for (const att of attachments) {
2961
+ for (const [idx, att] of attachments.entries()) {
2733
2962
  // Raw pasted/dropped/uploaded image — no workspace path involved (the
2734
2963
  // browser downscales client-side; this guard only prevents abuse).
2735
2964
  if (att.imageData) {
@@ -2753,6 +2982,32 @@ export class ClientSession {
2753
2982
  });
2754
2983
  continue;
2755
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
+ }
2756
3011
  out.push({
2757
3012
  message: {
2758
3013
  customType: "file",
@@ -2905,8 +3160,63 @@ export class ClientSession {
2905
3160
  continue;
2906
3161
  }
2907
3162
  const ext = extname(att.path).toLowerCase();
2908
- if (IMAGE_EXT.has(ext)) {
2909
- // 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.
2910
3220
  if (stat.size > MAX_ATTACHMENT_BYTES) {
2911
3221
  this.emit({
2912
3222
  type: "notice",
@@ -323,7 +323,7 @@ wss.on("connection", (ws) => {
323
323
  }
324
324
  switch (msg.type) {
325
325
  case "prompt":
326
- void cs.prompt(msg.text, msg.attachments);
326
+ void cs.prompt(msg.text, msg.attachments, msg.queue);
327
327
  break;
328
328
  case "abort":
329
329
  void cs.abort();
@@ -472,6 +472,8 @@ wss.on("connection", (ws) => {
472
472
  customSystemPrompt: msg.customSystemPrompt,
473
473
  disabledSkills: msg.disabledSkills,
474
474
  disabledExtensions: msg.disabledExtensions,
475
+ visionBridgeEnabled: msg.visionBridgeEnabled,
476
+ visionBridgeModel: msg.visionBridgeModel,
475
477
  });
476
478
  break;
477
479
  case "save_preset":