pi-web-ui 0.91.0 → 0.92.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 +34 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +443 -45
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +49 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/dsh/dsh-agent-service.js +7 -0
- package/dist/server/index.js +32 -4
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +65 -10
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/read-tool.js +91 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +7 -0
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +7 -1
- package/package.json +1 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +10 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CdsKltOS.css +0 -1
- package/web/dist/assets/index-OoULNF70.js +0 -363
package/dist/server/plugins.js
CHANGED
|
@@ -22,7 +22,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
22
22
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
23
23
|
import { pick } from "./i18n.js";
|
|
24
24
|
import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
|
|
25
|
-
import { parseCronSpec, nextCronFire, loadScheduleRecords, saveScheduleRecords, } from "./plugin-schedule.js";
|
|
25
|
+
import { parseCronSpec, armDelay, nextCronFire, loadScheduleRecords, saveScheduleRecords, } from "./plugin-schedule.js";
|
|
26
26
|
import { PluginPermissionStore } from "./plugin-permissions.js";
|
|
27
27
|
import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
|
|
28
28
|
import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
|
|
@@ -162,6 +162,10 @@ export function readHostVersion() {
|
|
|
162
162
|
// 声明式设置 schema(manifest "settings")
|
|
163
163
|
// ---------------------------------------------------------------------------
|
|
164
164
|
const SETTING_TYPES = new Set(["text", "password", "number", "boolean", "select", "secret"]);
|
|
165
|
+
/** select 字段可由宿主现算的候选数据源(manifest `optionsFrom`):
|
|
166
|
+
* models = 已配置鉴权的模型;thinkingLevels = SDK 思考强度档位。
|
|
167
|
+
* 清单在浏览器侧现算(模型配置会变,静态表会过期),服务端不做候选值校验。 */
|
|
168
|
+
const SETTING_OPTIONS_FROM = new Set(["models", "thinkingLevels"]);
|
|
165
169
|
/**
|
|
166
170
|
* 解析 manifest "ui" 的某个 slot 数组 → 规范化条目(issue #146 完整版)。
|
|
167
171
|
*
|
|
@@ -573,7 +577,12 @@ function parseSettingsSchema(raw) {
|
|
|
573
577
|
continue;
|
|
574
578
|
const o = f;
|
|
575
579
|
const key = typeof o.key === "string" ? o.key.trim() : "";
|
|
576
|
-
|
|
580
|
+
// 宿主数据源(models / thinkingLevels):合法值才认,非法当成没写(回落静态 options)。
|
|
581
|
+
// 只写 optionsFrom 没写 type = 视为 select(少让作者踩坑,与 options 的写法一致)。
|
|
582
|
+
const optionsFrom = typeof o.optionsFrom === "string" && SETTING_OPTIONS_FROM.has(o.optionsFrom)
|
|
583
|
+
? o.optionsFrom
|
|
584
|
+
: undefined;
|
|
585
|
+
const type = (typeof o.type === "string" && o.type ? o.type : optionsFrom ? "select" : "");
|
|
577
586
|
if (!key || !SETTING_TYPES.has(type) || out.some((x) => x.key === key))
|
|
578
587
|
continue;
|
|
579
588
|
const field = {
|
|
@@ -584,6 +593,7 @@ function parseSettingsSchema(raw) {
|
|
|
584
593
|
...(typeof o.min === "number" ? { min: o.min } : {}),
|
|
585
594
|
...(typeof o.max === "number" ? { max: o.max } : {}),
|
|
586
595
|
...(Array.isArray(o.options) ? { options: o.options.filter((x) => typeof x === "string") } : {}),
|
|
596
|
+
...(type === "select" && optionsFrom ? { optionsFrom } : {}),
|
|
587
597
|
...(typeof o.hint === "string" ? { hint: o.hint } : {}),
|
|
588
598
|
};
|
|
589
599
|
out.push(field);
|
|
@@ -670,14 +680,29 @@ lang, secrets) {
|
|
|
670
680
|
clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
|
|
671
681
|
}
|
|
672
682
|
else if (f.type === "select") {
|
|
673
|
-
|
|
683
|
+
const s = v === undefined ? "" : String(v);
|
|
684
|
+
// optionsFrom(宿主数据源):候选值在浏览器侧现算,服务端无从校验,
|
|
685
|
+
// 只做个长度护栏;非法值由用的时候(如 host.chat 切模型)报错。
|
|
686
|
+
if (f.optionsFrom) {
|
|
687
|
+
if (s.length > 200) {
|
|
688
|
+
return {
|
|
689
|
+
error: pick(l, `${f.label} 过长`, `${f.label} too long`, "plugins.settings.too.long", {
|
|
690
|
+
"f.label": f.label,
|
|
691
|
+
}),
|
|
692
|
+
clean,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
clean[f.key] = v === undefined ? (f.default ?? "") : s;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (v !== undefined && !f.options?.includes(s))
|
|
674
699
|
return {
|
|
675
700
|
error: pick(l, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
|
|
676
701
|
"f.label": f.label,
|
|
677
702
|
}),
|
|
678
703
|
clean,
|
|
679
704
|
};
|
|
680
|
-
clean[f.key] = v === undefined ? f.default :
|
|
705
|
+
clean[f.key] = v === undefined ? f.default : s;
|
|
681
706
|
}
|
|
682
707
|
else if (f.type === "secret") {
|
|
683
708
|
// 空串/缺省 = 不改(浏览器侧回显的本来就是有无布尔,前端把“没碰”发成空串)。
|
|
@@ -1799,6 +1824,9 @@ export class PluginManager {
|
|
|
1799
1824
|
: undefined,
|
|
1800
1825
|
// 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
|
|
1801
1826
|
view: typeof m.view === "boolean" ? m.view : true,
|
|
1827
|
+
// 客户端 bundle 是否常驻加载(manifest "preload",缺省 false):无视图
|
|
1828
|
+
// (view:false)却要顶层代码一直跑(提醒轮询/快捷键/常驻浮窗…)的插件用它。
|
|
1829
|
+
preload: m.preload === true,
|
|
1802
1830
|
// 插件对宿主 UI 的贡献(manifest "ui":slot 框架 + 整理意图,issue #146)。
|
|
1803
1831
|
// 权限:与 activate 的 can("ui") **同一口径**(严格模式 = 声明了 permissions
|
|
1804
1832
|
// 或 apiVersion>=2):严格模式下必须含 "ui" 族,否则整份忽略;旧全权格式放行。
|
|
@@ -2741,10 +2769,12 @@ export class PluginManager {
|
|
|
2741
2769
|
let timer;
|
|
2742
2770
|
let grace;
|
|
2743
2771
|
let cancelled = false;
|
|
2772
|
+
/** 下一次触发时刻的展示串;`null` = 一年内没有下一次(如 2 月 31 日)。 */
|
|
2744
2773
|
const nextText = () => {
|
|
2745
2774
|
if (parts) {
|
|
2746
2775
|
try {
|
|
2747
|
-
|
|
2776
|
+
const at = nextCronFire(parts, Date.now());
|
|
2777
|
+
return at === null ? null : new Date(at).toLocaleString();
|
|
2748
2778
|
}
|
|
2749
2779
|
catch {
|
|
2750
2780
|
return specText;
|
|
@@ -2752,6 +2782,10 @@ export class PluginManager {
|
|
|
2752
2782
|
}
|
|
2753
2783
|
return `每 ${Math.round(ms / 1000)}s`;
|
|
2754
2784
|
};
|
|
2785
|
+
const statusText = () => {
|
|
2786
|
+
const next = nextText();
|
|
2787
|
+
return next === null ? "不再触发(表达式在一年内不会命中)" : `下次 ${next}`;
|
|
2788
|
+
};
|
|
2755
2789
|
// 后台面板条目(持久任务独有):看得见下次时间,停止=删声明(不再复活)。
|
|
2756
2790
|
let bgRefresh;
|
|
2757
2791
|
let bgUnreg;
|
|
@@ -2762,7 +2796,7 @@ export class PluginManager {
|
|
|
2762
2796
|
id: taskId,
|
|
2763
2797
|
label: `⏰ ${label ?? sid}`,
|
|
2764
2798
|
since: Date.now(),
|
|
2765
|
-
status:
|
|
2799
|
+
status: statusText(),
|
|
2766
2800
|
stop: () => off(),
|
|
2767
2801
|
};
|
|
2768
2802
|
bgTaskTable.set(taskId, entry);
|
|
@@ -2778,7 +2812,7 @@ export class PluginManager {
|
|
|
2778
2812
|
bgRefresh = () => {
|
|
2779
2813
|
if (!bgTaskTable.has(taskId))
|
|
2780
2814
|
return;
|
|
2781
|
-
entry.status =
|
|
2815
|
+
entry.status = statusText();
|
|
2782
2816
|
fireBg();
|
|
2783
2817
|
};
|
|
2784
2818
|
bgUnreg = () => {
|
|
@@ -2812,14 +2846,30 @@ export class PluginManager {
|
|
|
2812
2846
|
}
|
|
2813
2847
|
bgRefresh?.();
|
|
2814
2848
|
};
|
|
2849
|
+
/**
|
|
2850
|
+
* 排下一次触发。两个要点:
|
|
2851
|
+
* - `nextCronFire` 回 null = 一年内没有下一次(如 `0 0 31 2 *`)→ 不再排;
|
|
2852
|
+
* - 延迟走 `armDelay` 分片(默认 ≤6 小时),因为 Node 的 setTimeout 延迟超过
|
|
2853
|
+
* 2^31-1ms(≈24.8 天)会**溢出成 1ms**,配上「下次在 42 天/一年后」的
|
|
2854
|
+
* 合法 cron 就是「1ms 后再触发」的死循环(触发还会回调插件 → 写盘 + 广播)。
|
|
2855
|
+
* 分片醒来后重新计算,真到点才 fire。
|
|
2856
|
+
*/
|
|
2815
2857
|
const armCron = () => {
|
|
2816
2858
|
if (cancelled || !parts)
|
|
2817
2859
|
return;
|
|
2818
2860
|
const next = nextCronFire(parts, Date.now());
|
|
2861
|
+
if (next === null)
|
|
2862
|
+
return;
|
|
2819
2863
|
timer = setTimeout(() => {
|
|
2820
|
-
|
|
2864
|
+
if (cancelled)
|
|
2865
|
+
return;
|
|
2866
|
+
const at = nextCronFire(parts, Date.now());
|
|
2867
|
+
if (at === null)
|
|
2868
|
+
return;
|
|
2869
|
+
if (at <= Date.now() + 1000)
|
|
2870
|
+
fire();
|
|
2821
2871
|
armCron();
|
|
2822
|
-
},
|
|
2872
|
+
}, armDelay(next, Date.now()));
|
|
2823
2873
|
timer.unref?.();
|
|
2824
2874
|
};
|
|
2825
2875
|
if (parts)
|
|
@@ -2831,7 +2881,12 @@ export class PluginManager {
|
|
|
2831
2881
|
// 漏跑补跑:以上次触发(没跑过按创建时间)为锚,下一次已在过去=漏了。
|
|
2832
2882
|
if (persistent && catchUp === "once") {
|
|
2833
2883
|
const refTime = loadScheduleRecords(dir)[sid]?.lastRun ?? loadScheduleRecords(dir)[sid]?.createdAt ?? Date.now();
|
|
2834
|
-
const missed = parts
|
|
2884
|
+
const missed = parts
|
|
2885
|
+
? (() => {
|
|
2886
|
+
const at = nextCronFire(parts, refTime);
|
|
2887
|
+
return at !== null && at <= Date.now(); // null = 一年内没有下一次,不算漏跑
|
|
2888
|
+
})()
|
|
2889
|
+
: refTime + ms <= Date.now();
|
|
2835
2890
|
if (missed) {
|
|
2836
2891
|
// 15s 缓冲:刚启动时模型/网络可能还没就绪,补跑不等那 15 秒可能白跑。
|
|
2837
2892
|
grace = setTimeout(() => fire(), 15_000);
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* present-files-tool.ts —— AI 主动把文件「展示」给用户(present_files)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:AI 生成截图/图表/录屏/报告/日志后,只能在正文里写一句路径,用户还得
|
|
5
|
+
* 自己去右栏一层层点开;图片、视频更是根本没机会出现在对话里。
|
|
6
|
+
*
|
|
7
|
+
* 做法:注册一个第一方 customTool(与 read 覆盖、edit_soft、conversation_read
|
|
8
|
+
* 同机制)。模型给出路径清单,工具只做**只读探测**——stat + 未知扩展嗅探前
|
|
9
|
+
* 4KB + 文本摘录——把结构化 items 放进 tool result 的 `details`(经
|
|
10
|
+
* serialize.ts 下发浏览器,并随会话文件持久化),前端 ToolCallBlock 把它渲染
|
|
11
|
+
* 成预览卡片:图片/视频/音频内联直接看,文本/markdown/HTML 一键开预览弹窗,
|
|
12
|
+
* 每个条目带「预览 / 本地打开 / 在文件夹中显示 / 下载 / 复制路径」。
|
|
13
|
+
*
|
|
14
|
+
* 本工具**不打开任何窗口、不弹通知**:所有系统级动作都由用户在卡片上点击触发
|
|
15
|
+
* (走既有的 file_open_default / file_reveal 协议,issue #187)。工具本身只读,
|
|
16
|
+
* 能看到的文件与 read 工具完全一致,不新增任何权限。
|
|
17
|
+
*
|
|
18
|
+
* 路径口径与 read 一致(~ / @ / 绝对 / 相对,复用 read-tool 的
|
|
19
|
+
* resolvePathForDirCheck);回传的 `path` 是线形绝对路径("C:/…" / "/…"),
|
|
20
|
+
* 前端拿它直接打 /api/file、file_reveal、file_open_default,不受会话 cwd 影响。
|
|
21
|
+
*
|
|
22
|
+
* 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
|
|
23
|
+
* 结果文本走 pick(lang, zh, en, key, vars),缺表回落英文内联。
|
|
24
|
+
*
|
|
25
|
+
* DSH 引擎无 customTool 注册面(工具来自 shipped preset),本工具只服务 pi 引擎。
|
|
26
|
+
*/
|
|
27
|
+
import { open, stat } from "node:fs/promises";
|
|
28
|
+
import { basename, extname, sep } from "node:path";
|
|
29
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { Type } from "typebox";
|
|
31
|
+
import { bilingual, pick } from "./i18n.js";
|
|
32
|
+
import { resolvePathForDirCheck } from "./read-tool.js";
|
|
33
|
+
import { decodeText, isAudioFile, looksLikeText, previewKind } from "./text-sniff.js";
|
|
34
|
+
import { PRESENT_FILES_TOOL_NAME } from "./tool-manager.js";
|
|
35
|
+
/** 展示文件工具名(唯一登记见 tool-manager.ts;此处导出供 import 方沿用)。 */
|
|
36
|
+
export { PRESENT_FILES_TOOL_NAME };
|
|
37
|
+
/** 单次展示的条目上限:再多就不是「给你看」而是文件列表了(卡片的纵向预算也有限)。 */
|
|
38
|
+
export const MAX_PRESENT_ITEMS = 12;
|
|
39
|
+
/** 单条文本摘录字符数上限。 */
|
|
40
|
+
export const MAX_EXCERPT_CHARS = 1200;
|
|
41
|
+
/** 一次调用里所有摘录的字符总量上限(details 会进快照与转录,必须封顶)。 */
|
|
42
|
+
export const MAX_EXCERPT_TOTAL_CHARS = 6000;
|
|
43
|
+
/** 摘录只读文件头这么多字节(UTF-8 3 字节/汉字,8KB 足够撑满 1200 字符)。 */
|
|
44
|
+
const EXCERPT_READ_BYTES = 8192;
|
|
45
|
+
/** 未知扩展名文件的内容嗅探字节数(判断文本还是二进制)。 */
|
|
46
|
+
const SNIFF_BYTES = 4096;
|
|
47
|
+
/** 摘录/嗅探的体积闸门:超过就不读内容(几十 MB 的日志摘一句没意义)。 */
|
|
48
|
+
const MAX_SNIFFABLE_BYTES = 4 * 1024 * 1024;
|
|
49
|
+
const MARKDOWN_EXTS = new Set(["md", "markdown", "mdx"]);
|
|
50
|
+
const HTML_EXTS = new Set(["html", "htm", "xhtml"]);
|
|
51
|
+
/**
|
|
52
|
+
* 归一化模型给的 items:非数组/元素缺 path 一律丢弃;trim + 反斜杠折成正斜杠
|
|
53
|
+
* (Windows 模型常写 "a\b\c.png");按归一化后的路径去重;最多 MAX_PRESENT_ITEMS 条。
|
|
54
|
+
*/
|
|
55
|
+
export function normalizePresentItems(raw, max = MAX_PRESENT_ITEMS) {
|
|
56
|
+
if (!Array.isArray(raw))
|
|
57
|
+
return [];
|
|
58
|
+
const out = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const entry of raw) {
|
|
61
|
+
if (typeof entry === "string") {
|
|
62
|
+
const path = normPath(entry);
|
|
63
|
+
if (!path || seen.has(path))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(path);
|
|
66
|
+
out.push({ path });
|
|
67
|
+
}
|
|
68
|
+
else if (entry && typeof entry === "object") {
|
|
69
|
+
const e = entry;
|
|
70
|
+
if (typeof e.path !== "string")
|
|
71
|
+
continue;
|
|
72
|
+
const path = normPath(e.path);
|
|
73
|
+
if (!path || seen.has(path))
|
|
74
|
+
continue;
|
|
75
|
+
seen.add(path);
|
|
76
|
+
const item = { path };
|
|
77
|
+
if (typeof e.caption === "string" && e.caption.trim())
|
|
78
|
+
item.caption = e.caption.trim();
|
|
79
|
+
if (e.focus === true)
|
|
80
|
+
item.focus = true;
|
|
81
|
+
out.push(item);
|
|
82
|
+
}
|
|
83
|
+
if (out.length >= max)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** trim + 反斜杠折成正斜杠(列上是 wire 口径:Windows 路径在协议里一律 "/")+ 去掉
|
|
89
|
+
* 结尾斜杠(保留 posix 根 "/" 与盘符根 "C:/"),与 files-service 的 normWirePath 同语义。 */
|
|
90
|
+
export function normPath(p) {
|
|
91
|
+
const w = String(p ?? "")
|
|
92
|
+
.trim()
|
|
93
|
+
.replace(/\\/g, "/");
|
|
94
|
+
if (w === "/" || w === "")
|
|
95
|
+
return w;
|
|
96
|
+
if (w.endsWith("/")) {
|
|
97
|
+
const trimmed = w.replace(/\/+$/, "");
|
|
98
|
+
// "C:/" → 保留盘符根形式(去尾斜杠会变成 "C:",语义不同)。
|
|
99
|
+
return /^[A-Za-z]:$/.test(trimmed) ? `${trimmed}/` : trimmed;
|
|
100
|
+
}
|
|
101
|
+
return w;
|
|
102
|
+
}
|
|
103
|
+
/** 原生绝对路径 → 线形绝对路径(协议/前端统一口径)。 */
|
|
104
|
+
export function toWirePath(abs) {
|
|
105
|
+
return sep === "/" ? abs : abs.split(sep).join("/");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 按文件名判类(纯函数,无 I/O):markdown/html/pdf/audio 先认,其余借
|
|
109
|
+
* text-sniff 的 previewKind 认图片/视频/文本;未知扩展(含 exe/zip/无扩展名的
|
|
110
|
+
* 二进制)归 "binary",由调用方按需读文件头嗅探成文本。
|
|
111
|
+
*/
|
|
112
|
+
export function classifyPresentKind(name) {
|
|
113
|
+
const ext = extname(name).toLowerCase().replace(/^\./, "");
|
|
114
|
+
if (MARKDOWN_EXTS.has(ext))
|
|
115
|
+
return "markdown";
|
|
116
|
+
if (HTML_EXTS.has(ext))
|
|
117
|
+
return "html";
|
|
118
|
+
if (ext === "pdf")
|
|
119
|
+
return "pdf";
|
|
120
|
+
if (isAudioFile(name))
|
|
121
|
+
return "audio";
|
|
122
|
+
const base = previewKind(name);
|
|
123
|
+
if (base === "image")
|
|
124
|
+
return "image";
|
|
125
|
+
if (base === "video")
|
|
126
|
+
return "video";
|
|
127
|
+
if (base === "text")
|
|
128
|
+
return "text";
|
|
129
|
+
return "binary";
|
|
130
|
+
}
|
|
131
|
+
/** 该类别是否值得读文件头(二进制/未知扩展 → 嗅探成文本;其余按扩展名已定)。 */
|
|
132
|
+
export function shouldSniff(kind) {
|
|
133
|
+
return kind === "binary";
|
|
134
|
+
}
|
|
135
|
+
/** 该类别是否带文本摘录(图片/视频/音频/二进制不读内容)。 */
|
|
136
|
+
export function hasExcerpt(kind) {
|
|
137
|
+
return kind === "text" || kind === "markdown" || kind === "html";
|
|
138
|
+
}
|
|
139
|
+
/** 人类可读体积(工具结果文本与卡片提示共用口径)。 */
|
|
140
|
+
export function formatBytes(n) {
|
|
141
|
+
if (!Number.isFinite(n) || n < 0)
|
|
142
|
+
return "?";
|
|
143
|
+
if (n < 1024)
|
|
144
|
+
return `${Math.round(n)} B`;
|
|
145
|
+
if (n < 1024 * 1024)
|
|
146
|
+
return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
|
|
147
|
+
if (n < 1024 * 1024 * 1024)
|
|
148
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
149
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
150
|
+
}
|
|
151
|
+
/** 读文件头(关不上就返回 null:读不到内容不算错误,卡片照样出)。 */
|
|
152
|
+
async function readHead(abs, bytes) {
|
|
153
|
+
let handle;
|
|
154
|
+
try {
|
|
155
|
+
handle = await open(abs, "r");
|
|
156
|
+
const buf = Buffer.alloc(bytes);
|
|
157
|
+
const { bytesRead } = await handle.read(buf, 0, bytes, 0);
|
|
158
|
+
return buf.subarray(0, bytesRead);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await handle?.close().catch(() => undefined);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** 给一条文本类条目补上摘录(预算耗尽或文件过大则跳过)。`native` = 原生绝对路径。 */
|
|
168
|
+
async function attachExcerpt(item, budget, native) {
|
|
169
|
+
if (!hasExcerpt(item.kind) || item.size === undefined || item.size === 0)
|
|
170
|
+
return;
|
|
171
|
+
if (item.size > MAX_SNIFFABLE_BYTES || budget.left <= 0)
|
|
172
|
+
return;
|
|
173
|
+
const head = await readHead(native, EXCERPT_READ_BYTES);
|
|
174
|
+
if (!head || head.length === 0)
|
|
175
|
+
return;
|
|
176
|
+
const text = decodeText(head).replace(/\u0000+$/, "");
|
|
177
|
+
if (!text)
|
|
178
|
+
return;
|
|
179
|
+
const cap = Math.min(MAX_EXCERPT_CHARS, budget.left);
|
|
180
|
+
const clipped = text.length > cap;
|
|
181
|
+
const excerpt = clipped ? text.slice(0, cap) : text;
|
|
182
|
+
item.excerpt = excerpt;
|
|
183
|
+
item.excerptTruncated = clipped || item.size > head.length;
|
|
184
|
+
budget.left -= excerpt.length;
|
|
185
|
+
}
|
|
186
|
+
/** 逐条探测:stat → 判类 → (未知扩展)嗅探 → 摘录。 */
|
|
187
|
+
export async function probePresentItem(raw, cwd, budget) {
|
|
188
|
+
const native = resolvePathForDirCheck(raw.path, cwd);
|
|
189
|
+
const abs = toWirePath(native);
|
|
190
|
+
const name = basename(native) || raw.path;
|
|
191
|
+
const base = { ...raw, name, abs, kind: "missing" };
|
|
192
|
+
const st = await stat(native).catch(() => null);
|
|
193
|
+
if (!st)
|
|
194
|
+
return base;
|
|
195
|
+
if (st.isDirectory())
|
|
196
|
+
return { ...base, kind: "dir", mtime: st.mtimeMs };
|
|
197
|
+
if (!st.isFile())
|
|
198
|
+
return base;
|
|
199
|
+
let kind = classifyPresentKind(name);
|
|
200
|
+
if (shouldSniff(kind) && st.size > 0 && st.size <= MAX_SNIFFABLE_BYTES) {
|
|
201
|
+
const head = await readHead(native, SNIFF_BYTES);
|
|
202
|
+
if (head && head.length > 0 && looksLikeText(head))
|
|
203
|
+
kind = "text";
|
|
204
|
+
}
|
|
205
|
+
const item = { ...base, kind, size: st.size, mtime: st.mtimeMs };
|
|
206
|
+
await attachExcerpt(item, budget, native);
|
|
207
|
+
return item;
|
|
208
|
+
}
|
|
209
|
+
/** 工具结果里给模型的正文:哪些展示了、哪些没找到、卡片上能做什么。 */
|
|
210
|
+
export function buildPresentResultText(items, lang, title) {
|
|
211
|
+
const shown = items.filter((i) => i.kind !== "missing");
|
|
212
|
+
const missing = items.filter((i) => i.kind === "missing");
|
|
213
|
+
const lines = [];
|
|
214
|
+
const head = pick(lang, `已把 ${shown.length} 个文件作为预览卡片展示给用户${title ? `(${title})` : ""}:`, `Presented ${shown.length} file(s) to the user as preview cards${title ? ` (${title})` : ""}:`, "present.files.result.head", { n: shown.length, title: title ?? "" });
|
|
215
|
+
lines.push(head);
|
|
216
|
+
for (const [i, it] of shown.entries()) {
|
|
217
|
+
const meta = it.kind === "dir"
|
|
218
|
+
? pick(lang, "目录", "directory", "present.files.result.kindDir")
|
|
219
|
+
: [it.kind, it.size !== undefined ? formatBytes(it.size) : ""].filter(Boolean).join(", ");
|
|
220
|
+
const caption = it.caption ? ` — ${it.caption}` : "";
|
|
221
|
+
lines.push(`${i + 1}. ${it.path} (${meta})${caption}`);
|
|
222
|
+
}
|
|
223
|
+
if (missing.length > 0) {
|
|
224
|
+
lines.push(pick(lang, `未展示(路径不存在或不可读):${missing.map((m) => m.path).join("、")}`, `Not shown (path missing or unreadable): ${missing.map((m) => m.path).join(", ")}`, "present.files.result.missing", { paths: missing.map((m) => m.path).join(", ") }));
|
|
225
|
+
}
|
|
226
|
+
lines.push(pick(lang, "用户在卡片上可以直接看图/播放、打开预览、在本机打开、在文件管理器中显示、下载或复制路径;不要再把文件内容贴一遍。", "The user can view/play media, open the preview dialog, open the file locally, reveal it in the file manager, download it or copy its path from the card — do not paste the file contents again.", "present.files.result.tail"));
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
|
229
|
+
/** 空结果/全部缺失时的错误文案(模型看到 error 才会改策略)。 */
|
|
230
|
+
function missingAllError(lang, paths) {
|
|
231
|
+
return pick(lang, `这些路径都不存在或不可读,没有任何卡片展示出去:${paths.join("、")}`, `None of these paths exist or are readable, nothing was shown: ${paths.join(", ")}`, "present.files.result.allMissing", { paths: paths.join(", ") });
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* present_files 工具定义。cwd 仅供创建时固定;执行时优先 ctx.cwd(会话工作区)。
|
|
235
|
+
*/
|
|
236
|
+
export function makePresentFilesTool(fallbackCwd, options = {}) {
|
|
237
|
+
const enabled = options.enabled ?? (() => true);
|
|
238
|
+
const getLang = options.getLang ?? (() => "en");
|
|
239
|
+
return defineTool({
|
|
240
|
+
name: PRESENT_FILES_TOOL_NAME,
|
|
241
|
+
label: "Show files to the user",
|
|
242
|
+
description: bilingual("Show files to the user as preview cards in the chat. Each item renders as a card: images/videos/audio are displayed inline, text/markdown/HTML can be opened in the preview dialog, and every card carries buttons to open the file locally, reveal it in the file manager, download it or copy its path. " +
|
|
243
|
+
"Use it whenever the user should LOOK at an artifact you produced or changed: a screenshot, chart, diagram, generated video/audio, report, log, build output. " +
|
|
244
|
+
"Give workspace-relative paths (or absolute ones); up to 12 items per call; `title` and `note` are shown above the cards, `caption` under the file name, and `focus: true` makes the client open that item in the preview dialog right away. " +
|
|
245
|
+
"Do not use it for files you merely read while reasoning, and do not repeat the file contents in your reply afterwards.", "把文件作为预览卡片展示给用户。每个条目渲染成一张卡片:图片/视频/音频直接在对话里显示,文本/markdown/HTML 可一键打开预览弹窗,每张卡片都带「本地打开 / 在文件管理器中显示 / 下载 / 复制路径」按钮。" +
|
|
246
|
+
"适合用户**应该看一眼**的产物:截图、图表、示意图、生成的视频音频、报告、日志、构建产物。" +
|
|
247
|
+
"路径写工作区相对路径(或绝对路径);单次最多 12 条;`title`/`note` 显示在卡片上方,`caption` 显示在文件名旁,`focus: true` 让客户端立刻用预览弹窗打开该条目。" +
|
|
248
|
+
"只是自己读文件来推理时不要调用它,调用后也不要在回复里把文件内容再贴一遍。"),
|
|
249
|
+
promptSnippet: bilingual("show images/videos/text files to the user as preview cards", "把图片/视频/文本文件作为预览卡片展示给用户"),
|
|
250
|
+
promptGuidelines: [
|
|
251
|
+
bilingual("After producing something visual or user-facing (screenshot, chart, video, report, log, build output), call present_files so the user can actually see it instead of only printing the path", "产出可视化或面向用户的文件后(截图、图表、视频、报告、日志、构建产物),调 present_files 让用户真的看到,而不是只打印一行路径"),
|
|
252
|
+
bilingual("Do not call present_files for ordinary source edits the user did not ask to see, and never call it twice for the same file in one turn", "用户没要求看的普通源码改动不要用 present_files;同一轮里不要为同一个文件调两次"),
|
|
253
|
+
],
|
|
254
|
+
parameters: Type.Object({
|
|
255
|
+
title: Type.Optional(Type.String({
|
|
256
|
+
description: "Optional card title shown above the file cards (short, e.g. 'Q3 revenue chart').",
|
|
257
|
+
})),
|
|
258
|
+
note: Type.Optional(Type.String({
|
|
259
|
+
description: "Optional one-line note above the cards (markdown, e.g. what changed / which one to look at first).",
|
|
260
|
+
})),
|
|
261
|
+
items: Type.Array(Type.Object({
|
|
262
|
+
path: Type.String({ description: "File path (workspace-relative like 'docs/chart.png', or absolute)." }),
|
|
263
|
+
caption: Type.Optional(Type.String({ description: "Optional short caption shown next to the file name." })),
|
|
264
|
+
focus: Type.Optional(Type.Boolean({
|
|
265
|
+
description: "true → the client opens this item in the preview dialog immediately (use for the one file that matters most).",
|
|
266
|
+
})),
|
|
267
|
+
}), { description: `Files to show (1-${MAX_PRESENT_ITEMS}).` }),
|
|
268
|
+
}),
|
|
269
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
270
|
+
const lang = getLang();
|
|
271
|
+
if (!enabled()) {
|
|
272
|
+
throw new Error(pick(lang, "用户的设置里关闭了 present_files 工具。", "The user disabled the present_files tool in settings.", "present.files.result.disabled"));
|
|
273
|
+
}
|
|
274
|
+
const p = params;
|
|
275
|
+
const items = normalizePresentItems(p.items);
|
|
276
|
+
if (items.length === 0) {
|
|
277
|
+
throw new Error(pick(lang, "present_files 至少需要一个带 path 的条目。", "present_files needs at least one item with a path.", "present.files.result.noItems"));
|
|
278
|
+
}
|
|
279
|
+
const cwd = typeof ctx?.cwd === "string" && ctx.cwd ? ctx.cwd : fallbackCwd;
|
|
280
|
+
const budget = { left: MAX_EXCERPT_TOTAL_CHARS };
|
|
281
|
+
const probed = [];
|
|
282
|
+
for (const item of items) {
|
|
283
|
+
if (signal?.aborted)
|
|
284
|
+
throw new Error("aborted");
|
|
285
|
+
probed.push(await probePresentItem(item, cwd, budget));
|
|
286
|
+
}
|
|
287
|
+
if (probed.every((i) => i.kind === "missing")) {
|
|
288
|
+
throw new Error(missingAllError(lang, probed.map((i) => i.path)));
|
|
289
|
+
}
|
|
290
|
+
const title = typeof p.title === "string" && p.title.trim() ? p.title.trim() : undefined;
|
|
291
|
+
const note = typeof p.note === "string" && p.note.trim() ? p.note.trim() : undefined;
|
|
292
|
+
const details = { items: probed };
|
|
293
|
+
if (title)
|
|
294
|
+
details.title = title;
|
|
295
|
+
if (note)
|
|
296
|
+
details.note = note;
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: "text", text: buildPresentResultText(probed, lang, title) }],
|
|
299
|
+
details,
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-tool.ts —— 覆盖 SDK 内置 read:路径是目录时列出目录条目。
|
|
3
|
+
*
|
|
4
|
+
* 背景:SDK 内置 `read` 只处理文件,`read('server')` 直接抛
|
|
5
|
+
* `EISDIR: illegal operation on a directory, read`;模型想「看一眼这个目录」
|
|
6
|
+
* 只能改用 bash(`ls`)。SDK 自带的 `ls` 工具不在默认活跃集里
|
|
7
|
+
* (默认 `["read","bash","edit","write"]`),模型并不总能想到它。
|
|
8
|
+
*
|
|
9
|
+
* 做法(与 bash 覆盖同一机制):customTools 按 name 覆盖内置定义 —— 用
|
|
10
|
+
* `createReadToolDefinition(cwd)` 拿原实现当基底,只在「路径确实是目录」时
|
|
11
|
+
* 分流到 `createLsToolDefinition(cwd)`(排序、目录 `/` 后缀、条目/字节截断
|
|
12
|
+
* 与 SDK ls 完全一致);其余情况(文件、图片、路径不存在、读取报错)原样
|
|
13
|
+
* 转发基底,行为与内置完全一致。
|
|
14
|
+
*
|
|
15
|
+
* 开关:`readDirEnabled`(设置面板「工具」页,默认开)。**行为开关**不是
|
|
16
|
+
* ActiveSet 开关(read 本体不可关,关了 agent 就残了),因此不进
|
|
17
|
+
* tool-manager 的 AGENT_TOOL_CATALOG;每次调用实时读设置,改动即时生效。
|
|
18
|
+
*
|
|
19
|
+
* 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
|
|
20
|
+
* 返回文本(目录头)按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
|
|
21
|
+
*
|
|
22
|
+
* DSH 引擎无 customTool 注册面(工具来自 shipped preset),本覆盖只服务 pi 引擎。
|
|
23
|
+
*/
|
|
24
|
+
import { stat } from "node:fs/promises";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { isAbsolute, join, resolve as nodeResolve } from "node:path";
|
|
27
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { bilingual, pick } from "./i18n.js";
|
|
29
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
30
|
+
/**
|
|
31
|
+
* 目录判定用的路径归一:~ / @ 前缀、Unicode 空格、相对 → 绝对(对齐 SDK
|
|
32
|
+
* resolveToCwd 的主要语义)。这里只是「猜」,猜不中(例如 macOS 截图名的
|
|
33
|
+
* 变体路径)就走内置实现,不会比现状更差。
|
|
34
|
+
*/
|
|
35
|
+
export function resolvePathForDirCheck(input, cwd) {
|
|
36
|
+
let p = String(input ?? "").replace(UNICODE_SPACES, " ");
|
|
37
|
+
if (p.startsWith("@"))
|
|
38
|
+
p = p.slice(1);
|
|
39
|
+
if (p === "~")
|
|
40
|
+
return homedir();
|
|
41
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
42
|
+
return join(homedir(), p.slice(2));
|
|
43
|
+
return isAbsolute(p) ? p : nodeResolve(cwd, p);
|
|
44
|
+
}
|
|
45
|
+
/** 路径是不是目录(不存在/无权限/非目录一律 false → 交回内置实现)。 */
|
|
46
|
+
export async function isDirectoryPath(absolutePath) {
|
|
47
|
+
try {
|
|
48
|
+
const st = await stat(absolutePath);
|
|
49
|
+
return st.isDirectory();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
|
|
57
|
+
* (会话工作区)。
|
|
58
|
+
*/
|
|
59
|
+
export function makeReadDirTool(fallbackCwd, options = {}) {
|
|
60
|
+
const base = createReadToolDefinition(fallbackCwd);
|
|
61
|
+
const ls = createLsToolDefinition(fallbackCwd);
|
|
62
|
+
const dirEnabled = options.dirEnabled ?? (() => true);
|
|
63
|
+
const getLang = options.getLang ?? (() => "en");
|
|
64
|
+
return defineTool({
|
|
65
|
+
...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 续读。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
|
|
67
|
+
promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
|
|
68
|
+
promptGuidelines: [
|
|
69
|
+
...(base.promptGuidelines ?? []),
|
|
70
|
+
bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
|
|
71
|
+
],
|
|
72
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
73
|
+
const input = (params ?? {});
|
|
74
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
75
|
+
if (path && dirEnabled()) {
|
|
76
|
+
const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
|
|
77
|
+
if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
|
|
78
|
+
const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
|
|
79
|
+
// 列目录本体完全复用 SDK 的 ls(排序/`/` 后缀/截断提示口径一致)。
|
|
80
|
+
const listed = await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx);
|
|
81
|
+
const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
|
|
82
|
+
// 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
|
|
83
|
+
// details 不需要 ls 的字段。
|
|
84
|
+
const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
|
|
85
|
+
return { content, details: undefined };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return base.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
package/dist/server/serialize.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
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.details 的体积上限。details 是给 UI 用的结构化元数据(如
|
|
6
|
+
* present_files 的卡片数据、ask_user_question 的答案),快照每 60ms 推一次,
|
|
7
|
+
* 不能无节制。超过上限时**整个丢掉**(而不是截断 —— 截断后的 JSON 不可解析,
|
|
8
|
+
* 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
|
|
9
|
+
*/
|
|
10
|
+
const TOOL_DETAILS_CAP = 64_000;
|
|
4
11
|
function truncate(s, cap) {
|
|
5
12
|
if (s.length <= cap)
|
|
6
13
|
return { text: s, truncated: false };
|
|
@@ -106,7 +113,7 @@ export function serializeMessage(m, seq) {
|
|
|
106
113
|
case "toolResult": {
|
|
107
114
|
const raw = m.content.map((c) => (c.type === "text" ? c.text : "[image result]")).join("\n");
|
|
108
115
|
const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
|
|
109
|
-
|
|
116
|
+
const msg = {
|
|
110
117
|
id: `t-${m.toolCallId}`,
|
|
111
118
|
role: "toolResult",
|
|
112
119
|
content: [{ type: "text", text, truncated }],
|
|
@@ -115,6 +122,19 @@ export function serializeMessage(m, seq) {
|
|
|
115
122
|
isError: m.isError,
|
|
116
123
|
timestamp: m.timestamp,
|
|
117
124
|
};
|
|
125
|
+
// 结构化元数据(tool result details)也要下发:present_files 的预览卡片
|
|
126
|
+
// 就靠它拿 kind/size/excerpt(前端不能靠再打一次 HTTP 才知类型),
|
|
127
|
+
// ask_user_question/todo_list 同理。体积封顶,超限整丢(见 TOOL_DETAILS_CAP)。
|
|
128
|
+
if (m.details !== undefined) {
|
|
129
|
+
try {
|
|
130
|
+
if (JSON.stringify(m.details).length <= TOOL_DETAILS_CAP)
|
|
131
|
+
msg.details = m.details;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return msg;
|
|
118
138
|
}
|
|
119
139
|
case "bashExecution": {
|
|
120
140
|
const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
|
|
@@ -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
|
+
readDirEnabled: this.settings.readDirEnabled !== false,
|
|
292
293
|
editSoftEnabled: legacyTools.editSoftEnabled,
|
|
293
294
|
questionnaireEnabled: legacyTools.questionnaireEnabled,
|
|
294
295
|
parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
|
|
@@ -458,6 +459,10 @@ export class SettingsService {
|
|
|
458
459
|
if (partial.terminalBashIdleMs !== undefined) {
|
|
459
460
|
this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
|
|
460
461
|
}
|
|
462
|
+
// read 读目录开关:覆盖定义每次调用实时读取,改动即时生效,无需 reload。
|
|
463
|
+
if (partial.readDirEnabled !== undefined) {
|
|
464
|
+
this.settings.readDirEnabled = partial.readDirEnabled;
|
|
465
|
+
}
|
|
461
466
|
// 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
|
|
462
467
|
if (partial.goalModeEnabled !== undefined) {
|
|
463
468
|
this.settings.goalModeEnabled = partial.goalModeEnabled;
|
|
@@ -623,6 +628,8 @@ export class SettingsService {
|
|
|
623
628
|
// 终端接管偏好随预设走;旧预设缺字段时保留当前值。
|
|
624
629
|
terminalBash: p.terminalBash ?? this.settings.terminalBash,
|
|
625
630
|
terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
|
|
631
|
+
// read 读目录是纯运行行为开关,不进预设——保留当前值。
|
|
632
|
+
readDirEnabled: this.settings.readDirEnabled !== false,
|
|
626
633
|
editSoftEnabled: presetLegacy.editSoftEnabled,
|
|
627
634
|
// 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
|
|
628
635
|
retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
|