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 +60 -0
- package/README.zh-CN.md +59 -0
- package/dist/server/agent-service.js +321 -11
- package/dist/server/index.js +3 -1
- package/dist/server/vision-bridge.js +113 -0
- package/package.json +1 -1
- 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
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
|

|
|
@@ -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
|

|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
2656
|
-
// after the
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
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, {
|
|
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
|
-
|
|
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",
|
package/dist/server/index.js
CHANGED
|
@@ -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":
|