pi-web-ui 0.92.0 → 0.93.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/CHANGELOG.md +58 -2
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +18 -6
- package/dist/server/agent-service.js +497 -120
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +6 -7
- package/dist/server/client-state.js +65 -6
- package/dist/server/conversation-read-tool.js +2 -1
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +43 -1
- package/dist/server/files-service.js +78 -26
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +62 -7
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugins.js +1 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +37 -3
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +59 -21
- package/dist/server/settings-service.js +12 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/themes.js +15 -0
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +107 -14
- package/package.json +2 -2
- package/plugins/catalog.json +10 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +4 -0
- package/themes/catppuccin.css +4 -0
- package/themes/codex.css +136 -0
- package/themes/cyberpunk.css +4 -0
- package/themes/dazzle.css +4 -0
- package/themes/everforest-light.css +136 -0
- package/themes/geist.css +136 -0
- package/themes/gruvbox-light.css +136 -0
- package/themes/kanagawa-lotus.css +136 -0
- package/themes/md-preview.css +4 -0
- package/themes/mist.css +4 -0
- package/themes/nord.css +4 -0
- package/themes/one-dark.css +4 -0
- package/themes/paper.css +4 -0
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +4 -0
- package/themes/tokyo-night.css +4 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-C1A23aI-.js} +1 -1
- package/web/dist/assets/index-B48QWf0I.js +364 -0
- package/web/dist/assets/index-DGL5OInG.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BgrnP9Jz.js +0 -364
- package/web/dist/assets/index-Cy8dSTZt.css +0 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/http-proxy.ts
|
|
3
|
+
*
|
|
4
|
+
* Propagates HTTP proxy settings from ~/.pi/agent/settings.json and environment
|
|
5
|
+
* variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) to process.env and sets up
|
|
6
|
+
* undici's global dispatcher with EnvHttpProxyAgent, ensuring that Node's
|
|
7
|
+
* native fetch() and SDK HTTP calls route through the configured proxy.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import * as undici from "undici";
|
|
13
|
+
/** Read proxy settings from settings.json and environment. */
|
|
14
|
+
export function resolveProxySettings(agentDir) {
|
|
15
|
+
let httpProxy;
|
|
16
|
+
const settingsPath = join(agentDir, "settings.json");
|
|
17
|
+
if (existsSync(settingsPath)) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
20
|
+
if (typeof parsed.httpProxy === "string" && parsed.httpProxy.trim()) {
|
|
21
|
+
httpProxy = parsed.httpProxy.trim();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* ignore parse error */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
httpProxy,
|
|
30
|
+
envHttpProxy: process.env.HTTP_PROXY,
|
|
31
|
+
envHttpsProxy: process.env.HTTPS_PROXY,
|
|
32
|
+
envAllProxy: process.env.ALL_PROXY,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Initialize HTTP proxy support:
|
|
37
|
+
* 1. If httpProxy is present in settings.json and env is not set, populate HTTP_PROXY and HTTPS_PROXY.
|
|
38
|
+
* 2. If any proxy is configured (settings or env), set up undici's global dispatcher with EnvHttpProxyAgent
|
|
39
|
+
* so that global fetch() uses the proxy.
|
|
40
|
+
*/
|
|
41
|
+
export function initHttpProxy(agentDir) {
|
|
42
|
+
const config = resolveProxySettings(agentDir);
|
|
43
|
+
if (config.httpProxy) {
|
|
44
|
+
process.env.HTTP_PROXY ??= config.httpProxy;
|
|
45
|
+
process.env.HTTPS_PROXY ??= config.httpProxy;
|
|
46
|
+
}
|
|
47
|
+
const effectiveProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY;
|
|
48
|
+
if (!effectiveProxy) {
|
|
49
|
+
return { active: false };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const dispatcher = withUndiciErrorListener(new undici.EnvHttpProxyAgent({
|
|
53
|
+
allowH2: false,
|
|
54
|
+
proxyTunnel: true,
|
|
55
|
+
bodyTimeout: 300_000,
|
|
56
|
+
headersTimeout: 300_000,
|
|
57
|
+
}));
|
|
58
|
+
undici.setGlobalDispatcher(dispatcher);
|
|
59
|
+
undici.install?.();
|
|
60
|
+
return { active: true, proxyUrl: effectiveProxy };
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.warn("[proxy] Failed to configure global undici proxy dispatcher:", err);
|
|
64
|
+
return { active: false, proxyUrl: effectiveProxy };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function withUndiciErrorListener(dispatcher) {
|
|
68
|
+
if (dispatcher instanceof EventEmitter) {
|
|
69
|
+
dispatcher.on("error", () => { });
|
|
70
|
+
}
|
|
71
|
+
return dispatcher;
|
|
72
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import express from "express";
|
|
|
28
28
|
import compression from "compression";
|
|
29
29
|
import { WebSocket, WebSocketServer } from "ws";
|
|
30
30
|
import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { sdkCopies, sdkOriginNote } from "./sdk-origin.js";
|
|
31
32
|
import { PROTOCOL_VERSION } from "./protocol-version.js";
|
|
32
33
|
import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
|
|
33
34
|
import { WS_MAX_PAYLOAD_BYTES, isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
@@ -49,7 +50,8 @@ import { McpBridge } from "./mcp-bridge.js";
|
|
|
49
50
|
import { createMcpHotReload } from "./mcp-hot-reload.js";
|
|
50
51
|
import { createHostMetricsSampler } from "./host-metrics.js";
|
|
51
52
|
import { SchedulerStore } from "./scheduler-tasks.js";
|
|
52
|
-
import {
|
|
53
|
+
import { initHttpProxy } from "./http-proxy.js";
|
|
54
|
+
import { buildPiWebTokenCookie, decodeCookieToken, isTlsRequest } from "./auth-cookie.js";
|
|
53
55
|
/** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
|
|
54
56
|
* `npm_lifecycle_*` into every child). Anything this server spawns — shells,
|
|
55
57
|
* `pi update` — would otherwise inherit `npm_config_allow_scripts`, which npm
|
|
@@ -173,6 +175,11 @@ function buildId() {
|
|
|
173
175
|
// <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
|
|
174
176
|
// honors PI_CODING_AGENT_DIR).
|
|
175
177
|
const SESSION_DIR_ROOT = join(getAgentDir(), "sessions");
|
|
178
|
+
// Propagate pi agent's httpProxy setting and environment proxies to undici/fetch
|
|
179
|
+
const proxyInfo = initHttpProxy(getAgentDir());
|
|
180
|
+
if (proxyInfo.active) {
|
|
181
|
+
console.log(`[proxy] Outbound HTTP proxy enabled: ${proxyInfo.proxyUrl}`);
|
|
182
|
+
}
|
|
176
183
|
// Windows 轻量 bash 兜底:把 <home>/.pi-web/bin 前置到 PATH(SDK 的 bash 工具经
|
|
177
184
|
// findBashOnPath 会找到其中的 bash.exe),并在无 Git Bash 时后台下载 busybox-w32。
|
|
178
185
|
// 终端面板的 shell 探测链也已包含该目录(见 terminals.ts resolveShell)。
|
|
@@ -203,8 +210,17 @@ function requestTokens(req) {
|
|
|
203
210
|
if (typeof cookie === "string") {
|
|
204
211
|
for (const part of cookie.split(";")) {
|
|
205
212
|
const [k, ...rest] = part.trim().split("=");
|
|
206
|
-
if (k
|
|
207
|
-
|
|
213
|
+
if (k !== "pi_web_token")
|
|
214
|
+
continue;
|
|
215
|
+
// 我们下发的是 encodeURIComponent 后的值(issue #261),而手写 / 旧客户端的
|
|
216
|
+
// 明文 cookie 也可能出现(`=` 在值里合法,按首个 = 切分);两种候选都进池子。
|
|
217
|
+
const raw = rest.join("=").trim();
|
|
218
|
+
if (!raw)
|
|
219
|
+
continue;
|
|
220
|
+
out.push(raw);
|
|
221
|
+
const decoded = decodeCookieToken(raw);
|
|
222
|
+
if (decoded !== raw)
|
|
223
|
+
out.push(decoded);
|
|
208
224
|
}
|
|
209
225
|
}
|
|
210
226
|
return out.filter(Boolean);
|
|
@@ -212,7 +228,9 @@ function requestTokens(req) {
|
|
|
212
228
|
function tokenOk(req) {
|
|
213
229
|
return requestTokens(req).includes(AUTH_TOKEN);
|
|
214
230
|
}
|
|
215
|
-
/** 请求携带的 pi_web_token cookie
|
|
231
|
+
/** 请求携带的 pi_web_token cookie 的**口令值**(未带/损坏时为空串)。
|
|
232
|
+
* 已解码:下发时是 `encodeURIComponent` 过的(issue #261),所以这里拿到的是
|
|
233
|
+
* 可直接与 `AUTH_TOKEN` 比较的原文。 */
|
|
216
234
|
function cookieToken(req) {
|
|
217
235
|
const cookie = req.headers.cookie;
|
|
218
236
|
if (typeof cookie !== "string")
|
|
@@ -220,7 +238,7 @@ function cookieToken(req) {
|
|
|
220
238
|
for (const part of cookie.split(";")) {
|
|
221
239
|
const [k, ...rest] = part.trim().split("=");
|
|
222
240
|
if (k === "pi_web_token")
|
|
223
|
-
return rest.join("=").trim();
|
|
241
|
+
return decodeCookieToken(rest.join("=").trim());
|
|
224
242
|
}
|
|
225
243
|
return "";
|
|
226
244
|
}
|
|
@@ -247,7 +265,10 @@ if (AUTH_TOKEN) {
|
|
|
247
265
|
// Secure 只在 TLS 连接上加:常加会让明文 HTTP(默认 loopback)收不到 cookie。
|
|
248
266
|
const secure = isTlsRequest(req);
|
|
249
267
|
if (ok) {
|
|
250
|
-
|
|
268
|
+
// cookieToken 已解码成原文(issue #261),所以直接和原始口令比 ——
|
|
269
|
+
// 以前拿 `encodeURIComponent(AUTH_TOKEN)` 比,含 `=` / 非 ASCII 的口令
|
|
270
|
+
// 永远不相等(于是每个请求都重发 cookie,且带 cookie 的请求反而 401)。
|
|
271
|
+
if (cookie !== AUTH_TOKEN) {
|
|
251
272
|
res.setHeader("Set-Cookie", buildPiWebTokenCookie(encodeURIComponent(AUTH_TOKEN), 31536000, secure));
|
|
252
273
|
}
|
|
253
274
|
}
|
|
@@ -284,7 +305,16 @@ const SERVICE_INFO = toServiceInfo(ORIGIN);
|
|
|
284
305
|
const TABS = parseTabs();
|
|
285
306
|
registerFileTransferRoutes(app, (clientId) => service.get(clientId)?.cwd);
|
|
286
307
|
app.get("/api/health", (_req, res) => {
|
|
287
|
-
res.json({
|
|
308
|
+
res.json({
|
|
309
|
+
ok: true,
|
|
310
|
+
piVersion: VERSION,
|
|
311
|
+
// issue #260:服务实际加载的是自带副本,不是全局 pi CLI 那份。这里把两份都报出来,
|
|
312
|
+
// 用户就不用猜「为什么升了全局 SDK 不生效」。(纯新增字段,piVersion 语义不变。)
|
|
313
|
+
piSdkCopies: sdkCopies(),
|
|
314
|
+
cwd: CWD,
|
|
315
|
+
pid: process.pid,
|
|
316
|
+
engine: ENGINE,
|
|
317
|
+
});
|
|
288
318
|
});
|
|
289
319
|
/**
|
|
290
320
|
* Stream a workspace file over HTTP.
|
|
@@ -1498,6 +1528,17 @@ wss.on("connection", (ws) => {
|
|
|
1498
1528
|
case "get_commands":
|
|
1499
1529
|
void cs.pushSlashCommands();
|
|
1500
1530
|
break;
|
|
1531
|
+
case "get_tool_info":
|
|
1532
|
+
// 工具卡右键菜单的「显示工具详细信息」:按需取一次工具定义(不进快照)。
|
|
1533
|
+
// 引擎没实现(或旧版服务端)时回一条 unsupported,前端据此显示「不支持」
|
|
1534
|
+
// 而不是永远转圈。
|
|
1535
|
+
if (typeof cs.getToolInfo === "function") {
|
|
1536
|
+
void cs.getToolInfo(msg.name);
|
|
1537
|
+
}
|
|
1538
|
+
else {
|
|
1539
|
+
send({ type: "tool_info", name: msg.name, found: false, unsupported: true });
|
|
1540
|
+
}
|
|
1541
|
+
break;
|
|
1501
1542
|
case "list_sessions":
|
|
1502
1543
|
void cs.refreshSessions();
|
|
1503
1544
|
break;
|
|
@@ -1519,6 +1560,11 @@ wss.on("connection", (ws) => {
|
|
|
1519
1560
|
case "dismiss_conversation":
|
|
1520
1561
|
void cs.dismissConversation(msg.id, msg.withFinishedSubagents, msg.force);
|
|
1521
1562
|
break;
|
|
1563
|
+
case "persist_conversation":
|
|
1564
|
+
if (typeof cs.persistConversation === "function") {
|
|
1565
|
+
void cs.persistConversation(msg.id);
|
|
1566
|
+
}
|
|
1567
|
+
break;
|
|
1522
1568
|
case "dismiss_finished_subagents":
|
|
1523
1569
|
void cs.dismissFinishedSubagents(msg.parentId);
|
|
1524
1570
|
break;
|
|
@@ -1841,6 +1887,7 @@ wss.on("connection", (ws) => {
|
|
|
1841
1887
|
terminalToolsEnabled: msg.terminalToolsEnabled,
|
|
1842
1888
|
terminalBash: msg.terminalBash,
|
|
1843
1889
|
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
1890
|
+
toolWatchdogTimeoutMs: msg.toolWatchdogTimeoutMs,
|
|
1844
1891
|
readDirEnabled: msg.readDirEnabled,
|
|
1845
1892
|
editSoftEnabled: msg.editSoftEnabled,
|
|
1846
1893
|
questionnaireEnabled: msg.questionnaireEnabled,
|
|
@@ -1848,6 +1895,7 @@ wss.on("connection", (ws) => {
|
|
|
1848
1895
|
goalModeEnabled: msg.goalModeEnabled,
|
|
1849
1896
|
thinkingWrap: msg.thinkingWrap,
|
|
1850
1897
|
toolsWrap: msg.toolsWrap,
|
|
1898
|
+
toolImagesEnabled: msg.toolImagesEnabled,
|
|
1851
1899
|
devNoCache: msg.devNoCache,
|
|
1852
1900
|
autoReload: msg.autoReload,
|
|
1853
1901
|
skillsFullText: msg.skillsFullText,
|
|
@@ -2322,6 +2370,13 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
2322
2370
|
console.log(` workspace : ${CWD}`);
|
|
2323
2371
|
console.log(` session dir : ${SESSION_DIR_ROOT}`);
|
|
2324
2372
|
console.log(` pi SDK : v${VERSION}`);
|
|
2373
|
+
// issue #260:全局那份 pi SDK 不是服务在用的那份(自带副本赢在 Node 解析顺序上)。
|
|
2374
|
+
// 不提示的话,用户会以为 `npm i -g @earendil-works/pi-coding-agent@latest` 生效了。
|
|
2375
|
+
const sdkNote = sdkOriginNote(sdkCopies(), VERSION);
|
|
2376
|
+
if (sdkNote) {
|
|
2377
|
+
console.log(` pi SDK note : ${sdkNote}`);
|
|
2378
|
+
console.log(` Upgrading the global pi CLI does not change this server — upgrade pi-web-ui instead.`);
|
|
2379
|
+
}
|
|
2325
2380
|
console.log(` bind : ${HOST}:${PORT}`);
|
|
2326
2381
|
console.log("");
|
|
2327
2382
|
});
|
|
@@ -76,5 +76,16 @@ function applyPatches() {
|
|
|
76
76
|
" }",
|
|
77
77
|
" });",
|
|
78
78
|
].join("\n"));
|
|
79
|
+
// 3. conpty_console_list_agent: tolerate dead shell PID (AttachConsole failed)
|
|
80
|
+
// when process already exited or was killed — return [] instead of crashing.
|
|
81
|
+
patchFile(pkgDir, "lib/conpty_console_list_agent.js", "var consoleProcessList = getConsoleProcessList(shellPid);\nprocess.send({ consoleProcessList: consoleProcessList });\nprocess.exit(0);", [
|
|
82
|
+
"try {",
|
|
83
|
+
" var consoleProcessList = getConsoleProcessList(shellPid);",
|
|
84
|
+
" process.send({ consoleProcessList: consoleProcessList });",
|
|
85
|
+
"} catch (e) {",
|
|
86
|
+
" process.send({ consoleProcessList: [] });",
|
|
87
|
+
"}",
|
|
88
|
+
"process.exit(0);",
|
|
89
|
+
].join("\n"));
|
|
79
90
|
}
|
|
80
91
|
applyPatches();
|
package/dist/server/plugins.js
CHANGED
package/dist/server/read-tool.js
CHANGED
|
@@ -25,6 +25,7 @@ import { stat } from "node:fs/promises";
|
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { isAbsolute, join, resolve as nodeResolve } from "node:path";
|
|
27
27
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { Type } from "typebox";
|
|
28
29
|
import { bilingual, pick } from "./i18n.js";
|
|
29
30
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
30
31
|
/**
|
|
@@ -52,6 +53,34 @@ export async function isDirectoryPath(absolutePath) {
|
|
|
52
53
|
return false;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
/** 覆盖定义的参数 schema:内置的 path/offset/limit + file_path 别名。 */
|
|
57
|
+
const readDirSchema = Type.Object({
|
|
58
|
+
path: Type.String({
|
|
59
|
+
description: bilingual("Path to the file (or directory) to read (relative or absolute)", "要读取的文件(或目录)路径(相对或绝对)"),
|
|
60
|
+
}),
|
|
61
|
+
file_path: Type.Optional(Type.String({
|
|
62
|
+
description: bilingual("Alias of `path` — some clients/models emit file_path; if both are given, `path` wins", "`path` 的别名 —— 部分客户端/模型习惯发 file_path;两者都给时以 `path` 为准"),
|
|
63
|
+
})),
|
|
64
|
+
offset: Type.Optional(Type.Number({
|
|
65
|
+
description: bilingual("Line number to start reading from (1-indexed)", "从第几行开始读(从 1 起算)"),
|
|
66
|
+
})),
|
|
67
|
+
limit: Type.Optional(Type.Number({
|
|
68
|
+
description: bilingual("Maximum number of lines to read (for a directory path: maximum number of entries)", "最多读多少行(路径是目录时 = 最多列多少条目)"),
|
|
69
|
+
})),
|
|
70
|
+
}, {});
|
|
71
|
+
/**
|
|
72
|
+
* 校验前归一(SDK 的 prepareArguments 在 schema 校验前执行):只有 file_path 时
|
|
73
|
+
* 把它当 path(path 在 schema 里仍必填),两者都给时以 path 为准。
|
|
74
|
+
*/
|
|
75
|
+
export function prepareReadArguments(raw) {
|
|
76
|
+
const args = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {};
|
|
77
|
+
const primary = typeof args.path === "string" ? args.path : "";
|
|
78
|
+
const alias = typeof args.file_path === "string" ? args.file_path : "";
|
|
79
|
+
if (!primary.trim() && alias.trim())
|
|
80
|
+
args.path = alias;
|
|
81
|
+
// 两者都没给时这里仍缺 path(静态类型是谎,运行时交给 schema 校验报错)。
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
55
84
|
/**
|
|
56
85
|
* 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
|
|
57
86
|
* (会话工作区)。
|
|
@@ -63,15 +92,19 @@ export function makeReadDirTool(fallbackCwd, options = {}) {
|
|
|
63
92
|
const getLang = options.getLang ?? (() => "en");
|
|
64
93
|
return defineTool({
|
|
65
94
|
...base,
|
|
66
|
-
description: bilingual(`${base.description} If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit
|
|
95
|
+
description: bilingual(`${base.description} Also accepts \`file_path\` as an alias of \`path\`. If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit 续读。路径也可用 \`file_path\` 传(path 的别名,两者都给时以 path 为准)。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
|
|
67
96
|
promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
|
|
68
97
|
promptGuidelines: [
|
|
69
98
|
...(base.promptGuidelines ?? []),
|
|
70
99
|
bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
|
|
71
100
|
],
|
|
101
|
+
parameters: readDirSchema,
|
|
102
|
+
prepareArguments: prepareReadArguments,
|
|
72
103
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
73
104
|
const input = (params ?? {});
|
|
74
|
-
|
|
105
|
+
// 兜底(不依赖 prepareArguments 一定跑过):path 缺省/空时用 file_path。
|
|
106
|
+
const rawPath = typeof input.path === "string" && input.path.trim() ? input.path : input.file_path;
|
|
107
|
+
const path = typeof rawPath === "string" ? rawPath : "";
|
|
75
108
|
if (path && dirEnabled()) {
|
|
76
109
|
const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
|
|
77
110
|
if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
|
|
@@ -85,7 +118,8 @@ export function makeReadDirTool(fallbackCwd, options = {}) {
|
|
|
85
118
|
return { content, details: undefined };
|
|
86
119
|
}
|
|
87
120
|
}
|
|
88
|
-
|
|
121
|
+
// 转发内置实现时带上归一后的 path(模型可能只给了 file_path)。
|
|
122
|
+
return base.execute(toolCallId, { ...input, path }, signal, onUpdate, ctx);
|
|
89
123
|
},
|
|
90
124
|
});
|
|
91
125
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve-global-sdk — 可选的「优先用全局/祖先那份 pi SDK」解析钩子(issue #260)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:pi-web-ui 依赖 `@earendil-works/pi-coding-agent`,而 npm 全局安装会把依赖
|
|
5
|
+
* **嵌在** `<npm root -g>/pi-web-ui/node_modules/`(不 hoist,实测),Node 又「嵌套优先于
|
|
6
|
+
* 祖先」—— 于是用户 `npm i -g @earendil-works/pi-coding-agent@latest` 改的是全局那份,
|
|
7
|
+
* 服务加载的仍是自带那份,表现为「升了 0.86.1,横幅和 /api/health 还显示 0.85.1」。
|
|
8
|
+
*
|
|
9
|
+
* 本模块把解析顺序反过来,但**只在显式开启时**:
|
|
10
|
+
*
|
|
11
|
+
* PI_WEB_SDK=global → 祖先链上存在一份版本**更新**的 SDK 时,把裸标识符重定向到它;
|
|
12
|
+
* 否则原样不动(同版本也不折腾 —— 没意义)。
|
|
13
|
+
* (缺省 / 其它值) → 什么都不做,用自带副本。
|
|
14
|
+
*
|
|
15
|
+
* 为什么默认关:服务运行的 SDK 版本会变成「用户机器上装了什么」,同一个 pi-web-ui 版本在
|
|
16
|
+
* 不同机器上跑不同 SDK,报 bug 时无法复现;自带副本可复现、且是 CI 覆盖的那一份。
|
|
17
|
+
*
|
|
18
|
+
* 为什么只做「必须更新」这一道门:仓库没有 semver 依赖,手写完整范围解析容易出错,
|
|
19
|
+
* 「祖先那份比自带那份新」是保守且够用的判据(旧的 / 同版本的都不采用)。
|
|
20
|
+
*
|
|
21
|
+
* 安全约定:整个注册流程包在 try/catch 里,任何异常都退回默认解析 —— 这个模块**绝不允许**
|
|
22
|
+
* 让服务起不来。它必须在任何 SDK 静态 import **之前**被加载(`--import <本文件>`,
|
|
23
|
+
* 或前台启动时在 import 服务入口之前先 import 它)。
|
|
24
|
+
*/
|
|
25
|
+
import { registerHooks } from "node:module";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
import { compareVersions, sdkCopies } from "./sdk-origin.js";
|
|
28
|
+
const PKG = "@earendil-works/pi-coding-agent";
|
|
29
|
+
/**
|
|
30
|
+
* 该用哪一份对外解析(纯函数,便于单测)。
|
|
31
|
+
* `copies` 按 Node 的解析顺序([0] = 自带的、实际会被用到的),返回 null = 保持默认。
|
|
32
|
+
*/
|
|
33
|
+
export function pickGlobalSdk(copies, mode) {
|
|
34
|
+
if ((mode ?? "bundled").trim().toLowerCase() !== "global")
|
|
35
|
+
return null;
|
|
36
|
+
const bundled = copies[0];
|
|
37
|
+
// 只考虑祖先链上的副本,且必须是**更新**的(旧的或同版本都不折腾)。
|
|
38
|
+
return copies.slice(1).find((c) => !bundled || compareVersions(c.version, bundled.version) > 0) ?? null;
|
|
39
|
+
}
|
|
40
|
+
/** 注册钩子;返回实际选中的副本(null = 没启用 / 没找到可用的)。 */
|
|
41
|
+
export function registerGlobalSdkPreference(mode = process.env.PI_WEB_SDK, fromFile = import.meta.url) {
|
|
42
|
+
try {
|
|
43
|
+
const copies = sdkCopies(fromFile);
|
|
44
|
+
const chosen = pickGlobalSdk(copies, mode);
|
|
45
|
+
if (!chosen)
|
|
46
|
+
return null;
|
|
47
|
+
registerHooks({
|
|
48
|
+
resolve(specifier, context, nextResolve) {
|
|
49
|
+
if (specifier !== PKG)
|
|
50
|
+
return nextResolve(specifier, context);
|
|
51
|
+
// 以目标副本的 package.json 为父重新解析:Node 从那里往上找,
|
|
52
|
+
// 第一个命中的就是它自己 —— 等价于「从那个包内部 import 自己」。
|
|
53
|
+
try {
|
|
54
|
+
return nextResolve(specifier, { ...context, parentURL: pathToFileURL(chosen.path).href });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return nextResolve(specifier, context);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return chosen;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// 钩子注册失败(Node 太老 / 环境怪异)→ 静默退回默认解析,绝不影响启动。
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ---- 副作用:被 `--import` 加载时立刻生效 --------------------------------
|
|
69
|
+
const used = registerGlobalSdkPreference();
|
|
70
|
+
if (used) {
|
|
71
|
+
console.log(`[pi-web-ui] PI_WEB_SDK=global → using the pi SDK at ${used.path} (v${used.version})`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sdk-origin — 本进程实际加载的是哪一份 pi SDK,以及被它遮蔽的副本(issue #260)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:npm 全局安装会把依赖**嵌在** `<npm root -g>/pi-web-ui/node_modules/`(不 hoist,
|
|
5
|
+
* 实测),而 Node 的解析顺序是「嵌套优先于祖先」。于是用户按更新面板/README 的提示
|
|
6
|
+
* `npm i -g @earendil-works/pi-coding-agent@latest` 升级全局那份时,服务实际加载的
|
|
7
|
+
* 仍是自带副本 —— 表现为「升了 0.86.1,横幅和 /api/health 还显示 0.85.1」。
|
|
8
|
+
*
|
|
9
|
+
* 这里把「所有能被解析到的副本」按 Node 的顺序算出来([0] = 生效的那份),
|
|
10
|
+
* 让启动横幅与健康检查说实话,而不是让用户去猜。
|
|
11
|
+
*
|
|
12
|
+
* 纯函数 + 零副作用,便于单测(见 tests/unit/sdk-origin.test.ts)。
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
const PKG = "@earendil-works/pi-coding-agent";
|
|
18
|
+
/** 从某个文件出发,逐级向上查找 `<dir>/node_modules/<pkg>/package.json`。
|
|
19
|
+
* `fromFile` 收 URL(`import.meta.url`)或普通文件路径(便于单测)。 */
|
|
20
|
+
export function sdkCopies(fromFile = import.meta.url) {
|
|
21
|
+
const out = [];
|
|
22
|
+
let dir = dirname(fromFile.startsWith("file:") ? fileURLToPath(fromFile) : fromFile);
|
|
23
|
+
for (;;) {
|
|
24
|
+
const pj = join(dir, "node_modules", PKG, "package.json");
|
|
25
|
+
if (existsSync(pj)) {
|
|
26
|
+
try {
|
|
27
|
+
const version = JSON.parse(readFileSync(pj, "utf8")).version;
|
|
28
|
+
if (typeof version === "string" && version)
|
|
29
|
+
out.push({ path: pj, version });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// 坏 JSON:跳过这一份,不影响其它候选(best-effort,同 process-utils 的口径)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const parent = dirname(dir);
|
|
36
|
+
if (parent === dir)
|
|
37
|
+
break;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** 点分版本号比较(够用即可:只比数字段,缺位当 0)。 */
|
|
43
|
+
export function compareVersions(a, b) {
|
|
44
|
+
const pa = a.split(".");
|
|
45
|
+
const pb = b.split(".");
|
|
46
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
47
|
+
const na = Number.parseInt(pa[i] ?? "0", 10) || 0;
|
|
48
|
+
const nb = Number.parseInt(pb[i] ?? "0", 10) || 0;
|
|
49
|
+
if (na !== nb)
|
|
50
|
+
return na < nb ? -1 : 1;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 启动横幅要打印的一行(不含前缀),以及是否需要提示「你升的不是服务在用的那份」。
|
|
56
|
+
* `effective` 为空时(没找到副本)返回空串,调用方照旧只打印版本号。
|
|
57
|
+
*/
|
|
58
|
+
export function sdkOriginNote(copies, effectiveVersion) {
|
|
59
|
+
const shadowed = copies.slice(1);
|
|
60
|
+
const newer = shadowed.filter((c) => compareVersions(c.version, effectiveVersion) > 0);
|
|
61
|
+
if (newer.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
return `a newer pi SDK is installed elsewhere (${newer.map((c) => `v${c.version}`).join(", ")}) but pi-web-ui runs its own bundled copy (v${effectiveVersion}).`;
|
|
64
|
+
}
|
package/dist/server/serialize.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
const TEXT_CAP = 200_000;
|
|
2
2
|
const TOOL_OUTPUT_CAP = 100_000;
|
|
3
3
|
const ARGS_CAP = 20_000;
|
|
4
|
+
/**
|
|
5
|
+
* toolResult 里单张图片进快照的上限(dataUrl 字符数 ≈ 1.5MB 二进制)。
|
|
6
|
+
* 视口/元素截图随便过;超大整页截图回落成占位文本(模型侧不受影响 —— 图它
|
|
7
|
+
* 已经看过,只是浏览器这边的缩略图不带)。超限截断 base64 会得到一张坏图,
|
|
8
|
+
* 所以是整张丢、不是截一半。
|
|
9
|
+
*/
|
|
10
|
+
const TOOL_RESULT_IMAGE_CAP = 2_000_000;
|
|
11
|
+
/** 单条 toolResult 最多带几张图进快照(防图片刷屏把快照撑爆)。 */
|
|
12
|
+
const TOOL_RESULT_IMAGE_MAX = 8;
|
|
4
13
|
/**
|
|
5
14
|
* toolResult.details 的体积上限。details 是给 UI 用的结构化元数据(如
|
|
6
15
|
* present_files 的卡片数据、ask_user_question 的答案),快照每 60ms 推一次,
|
|
@@ -13,30 +22,38 @@ function truncate(s, cap) {
|
|
|
13
22
|
return { text: s, truncated: false };
|
|
14
23
|
return { text: `${s.slice(0, cap)}\n\n… [truncated]`, truncated: true };
|
|
15
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* SDK/工具的图片块 → 前端可直接 <img> 的 UiImageBlock。
|
|
27
|
+
* Canonical ImageContent shape is { type, data, mimeType }; tolerate the
|
|
28
|
+
* legacy { source } wrapper too.
|
|
29
|
+
* cap: dataUrl 超过该字符数回 undefined(调用方按占位文本处理);默认不限
|
|
30
|
+
* (用户粘贴图走 image-paste 的缩放管线,尺寸本来就有界)。
|
|
31
|
+
*/
|
|
32
|
+
function imageBlockToUi(b, cap = Number.POSITIVE_INFINITY) {
|
|
33
|
+
const img = b;
|
|
34
|
+
if (typeof img.data === "string" && img.data.length > 0) {
|
|
35
|
+
const dataUrl = `data:${img.mimeType ?? "image/png"};base64,${img.data}`;
|
|
36
|
+
if (dataUrl.length > cap)
|
|
37
|
+
return undefined;
|
|
38
|
+
return { type: "image", dataUrl, mimeType: img.mimeType };
|
|
39
|
+
}
|
|
40
|
+
const src = img.source;
|
|
41
|
+
if (src?.type === "base64" && src.data) {
|
|
42
|
+
const dataUrl = `data:${src.mediaType ?? "image/png"};base64,${src.data}`;
|
|
43
|
+
if (dataUrl.length > cap)
|
|
44
|
+
return undefined;
|
|
45
|
+
return { type: "image", dataUrl, mimeType: src.mediaType };
|
|
46
|
+
}
|
|
47
|
+
if (typeof src?.url === "string" && src.url)
|
|
48
|
+
return { type: "image", dataUrl: src.url };
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
16
51
|
function serializeUserContent(content) {
|
|
17
52
|
if (typeof content === "string")
|
|
18
53
|
return [{ type: "text", text: content }];
|
|
19
54
|
return content.map((b) => {
|
|
20
55
|
if (b.type === "image") {
|
|
21
|
-
|
|
22
|
-
// Canonical ImageContent shape is { type, data, mimeType }; tolerate the
|
|
23
|
-
// legacy { source } wrapper too.
|
|
24
|
-
if (typeof img.data === "string" && img.data.length > 0) {
|
|
25
|
-
return {
|
|
26
|
-
type: "image",
|
|
27
|
-
dataUrl: `data:${img.mimeType ?? "image/png"};base64,${img.data}`,
|
|
28
|
-
mimeType: img.mimeType,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
const src = img.source;
|
|
32
|
-
if (src?.type === "base64" && src.data) {
|
|
33
|
-
return {
|
|
34
|
-
type: "image",
|
|
35
|
-
dataUrl: `data:${src.mediaType ?? "image/png"};base64,${src.data}`,
|
|
36
|
-
mimeType: src.mediaType,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
return { type: "image", dataUrl: src?.url };
|
|
56
|
+
return imageBlockToUi(b) ?? { type: "image", dataUrl: undefined };
|
|
40
57
|
}
|
|
41
58
|
return { type: "text", text: String(b.text ?? "") };
|
|
42
59
|
});
|
|
@@ -107,16 +124,37 @@ export function serializeMessage(m, seq) {
|
|
|
107
124
|
timestamp: m.timestamp,
|
|
108
125
|
model: m.model,
|
|
109
126
|
provider: m.provider,
|
|
127
|
+
usageCost: typeof m.usage?.cost?.total === "number" ? m.usage.cost.total : undefined,
|
|
110
128
|
stopReason: m.stopReason,
|
|
111
129
|
errorMessage: m.errorMessage,
|
|
112
130
|
};
|
|
113
131
|
case "toolResult": {
|
|
114
|
-
|
|
132
|
+
// 工具结果里的图片(web_shot 截图、read 读到的图……)要下发浏览器:
|
|
133
|
+
// 卡片里直接显示缩略图、点开放大(见 ToolCallBlock)。以前这里统一丢成
|
|
134
|
+
// "[image result]",用户只能看到占位文本。超限/超数的图仍回落占位文本。
|
|
135
|
+
const textParts = [];
|
|
136
|
+
const images = [];
|
|
137
|
+
for (const c of m.content) {
|
|
138
|
+
if (c.type === "text") {
|
|
139
|
+
textParts.push(c.text);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (c.type === "image" && images.length < TOOL_RESULT_IMAGE_MAX) {
|
|
143
|
+
const ui = imageBlockToUi(c, TOOL_RESULT_IMAGE_CAP);
|
|
144
|
+
if (ui) {
|
|
145
|
+
images.push(ui);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
textParts.push("[image result]");
|
|
150
|
+
}
|
|
151
|
+
const raw = textParts.join("\n");
|
|
115
152
|
const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
|
|
153
|
+
const content = raw || images.length === 0 ? [{ type: "text", text, truncated }, ...images] : [...images];
|
|
116
154
|
const msg = {
|
|
117
155
|
id: `t-${m.toolCallId}`,
|
|
118
156
|
role: "toolResult",
|
|
119
|
-
content
|
|
157
|
+
content,
|
|
120
158
|
toolCallId: m.toolCallId,
|
|
121
159
|
toolName: m.toolName,
|
|
122
160
|
isError: m.isError,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
10
|
import { basename, dirname, join } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, normalizeUiLayout, } from "./client-state.js";
|
|
12
|
+
import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, normalizeToolWatchdogTimeoutMs, normalizeUiLayout, } from "./client-state.js";
|
|
13
13
|
import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
|
|
14
14
|
import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
|
|
15
15
|
import { COMMITMSG_SYSTEM_PROMPT } from "./scm-commitmsg.js";
|
|
@@ -289,6 +289,7 @@ export class SettingsService {
|
|
|
289
289
|
terminalToolsEnabled: legacyTools.terminalToolsEnabled,
|
|
290
290
|
terminalBash: this.settings.terminalBash,
|
|
291
291
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
292
|
+
toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
|
|
292
293
|
readDirEnabled: this.settings.readDirEnabled !== false,
|
|
293
294
|
editSoftEnabled: legacyTools.editSoftEnabled,
|
|
294
295
|
questionnaireEnabled: legacyTools.questionnaireEnabled,
|
|
@@ -298,6 +299,7 @@ export class SettingsService {
|
|
|
298
299
|
autoReload: this.settings.autoReload ?? this.defaultDevNoCache(),
|
|
299
300
|
thinkingWrap: this.settings.thinkingWrap,
|
|
300
301
|
toolsWrap: this.settings.toolsWrap,
|
|
302
|
+
toolImagesEnabled: this.settings.toolImagesEnabled ?? true,
|
|
301
303
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
302
304
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
303
305
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
@@ -459,6 +461,9 @@ export class SettingsService {
|
|
|
459
461
|
if (partial.terminalBashIdleMs !== undefined) {
|
|
460
462
|
this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
|
|
461
463
|
}
|
|
464
|
+
if (partial.toolWatchdogTimeoutMs !== undefined) {
|
|
465
|
+
this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
|
|
466
|
+
}
|
|
462
467
|
// read 读目录开关:覆盖定义每次调用实时读取,改动即时生效,无需 reload。
|
|
463
468
|
if (partial.readDirEnabled !== undefined) {
|
|
464
469
|
this.settings.readDirEnabled = partial.readDirEnabled;
|
|
@@ -483,6 +488,9 @@ export class SettingsService {
|
|
|
483
488
|
if (partial.toolsWrap !== undefined) {
|
|
484
489
|
this.settings.toolsWrap = partial.toolsWrap;
|
|
485
490
|
}
|
|
491
|
+
if (partial.toolImagesEnabled !== undefined) {
|
|
492
|
+
this.settings.toolImagesEnabled = partial.toolImagesEnabled;
|
|
493
|
+
}
|
|
486
494
|
// 编排模式 / skill 全文注入:before_agent_start 逐 run 实时读取(agent-service
|
|
487
495
|
// composeInputs + 指导块追加),开关下一轮即生效,无需 reload runtime。
|
|
488
496
|
if (partial.skillsFullText !== undefined) {
|
|
@@ -630,6 +638,8 @@ export class SettingsService {
|
|
|
630
638
|
terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
|
|
631
639
|
// read 读目录是纯运行行为开关,不进预设——保留当前值。
|
|
632
640
|
readDirEnabled: this.settings.readDirEnabled !== false,
|
|
641
|
+
// toolWatchdogTimeoutMs 是纯运行行为参数,不进预设——保留当前值。
|
|
642
|
+
toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
|
|
633
643
|
editSoftEnabled: presetLegacy.editSoftEnabled,
|
|
634
644
|
// 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
|
|
635
645
|
retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
|
|
@@ -651,6 +661,7 @@ export class SettingsService {
|
|
|
651
661
|
autoReload: this.settings.autoReload,
|
|
652
662
|
thinkingWrap: this.settings.thinkingWrap,
|
|
653
663
|
toolsWrap: this.settings.toolsWrap,
|
|
664
|
+
toolImagesEnabled: this.settings.toolImagesEnabled ?? true,
|
|
654
665
|
// UI 布局偏好也不进预设——保留当前值。
|
|
655
666
|
uiLayout: normalizeUiLayout(this.settings.uiLayout),
|
|
656
667
|
// Presets don't capture vision-bridge prefs — keep the current ones.
|