pi-web-ui 0.91.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +39 -8
  5. package/dist/server/agent-service.js +938 -163
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +115 -0
  8. package/dist/server/claim-store.js +301 -0
  9. package/dist/server/client-state.js +114 -6
  10. package/dist/server/conversation-read-tool.js +371 -28
  11. package/dist/server/conversation-touches.js +357 -0
  12. package/dist/server/delegate-task.js +10 -3
  13. package/dist/server/dsh/dsh-agent-service.js +50 -1
  14. package/dist/server/files-service.js +78 -26
  15. package/dist/server/http-proxy.js +72 -0
  16. package/dist/server/index.js +94 -11
  17. package/dist/server/model-admin.js +124 -2
  18. package/dist/server/model-enrich.js +478 -0
  19. package/dist/server/patch-node-pty.js +11 -0
  20. package/dist/server/plugin-schedule.js +27 -2
  21. package/dist/server/plugins.js +66 -10
  22. package/dist/server/present-files-tool.js +303 -0
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +125 -0
  25. package/dist/server/resolve-global-sdk.js +72 -0
  26. package/dist/server/sdk-origin.js +64 -0
  27. package/dist/server/serialize.js +80 -22
  28. package/dist/server/settings-service.js +19 -1
  29. package/dist/server/skill-tool.js +2 -1
  30. package/dist/server/subagents.js +30 -13
  31. package/dist/server/terminals.js +38 -18
  32. package/dist/server/text-sniff.js +15 -0
  33. package/dist/server/themes.js +32 -2
  34. package/dist/server/tool-info.js +89 -0
  35. package/dist/server/tool-manager.js +110 -11
  36. package/package.json +2 -2
  37. package/plugin-sdk/README.md +1 -0
  38. package/plugins/catalog.json +20 -0
  39. package/themes/ayu-light.css +136 -0
  40. package/themes/catppuccin-latte.css +140 -0
  41. package/themes/catppuccin.css +144 -0
  42. package/themes/codex.css +136 -0
  43. package/themes/cyberpunk.css +4 -0
  44. package/themes/dazzle.css +4 -0
  45. package/themes/everforest-light.css +136 -0
  46. package/themes/geist.css +136 -0
  47. package/themes/gruvbox-light.css +136 -0
  48. package/themes/kanagawa-lotus.css +136 -0
  49. package/themes/md-preview.css +4 -0
  50. package/themes/mist.css +4 -0
  51. package/themes/nord.css +134 -0
  52. package/themes/one-dark.css +135 -0
  53. package/themes/paper.css +16 -12
  54. package/themes/rose-pine-dawn.css +136 -0
  55. package/themes/sakura.css +4 -0
  56. package/themes/solarized-light.css +135 -0
  57. package/themes/tokyo-night.css +144 -0
  58. package/themes/white.css +4 -0
  59. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
  60. package/web/dist/assets/index-B48QWf0I.js +364 -0
  61. package/web/dist/assets/index-DGL5OInG.css +1 -0
  62. package/web/dist/index.html +2 -2
  63. package/web/dist/assets/index-CdsKltOS.css +0 -1
  64. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -136,7 +136,15 @@ export function parseCronSpec(spec) {
136
136
  function isFull(values, min, max) {
137
137
  return values.length === max - min + 1;
138
138
  }
139
- /** 下一次触发毫秒时间戳(从 fromMs 的下一分钟开始扫,上限约一年)。纯函数,单测覆盖。 */
139
+ /**
140
+ * 下一次触发毫秒时间戳(从 fromMs 的下一分钟开始扫,上限约一年);**一年内没有下一次回 null**
141
+ * (例如 `0 0 31 2 *` 这种永远不存在的日子)。
142
+ *
143
+ * 为什么不是回一个「一年后的哨兵值」:调用方 `armCron` 拿它做 `setTimeout(next - now)`,
144
+ * 而 Node 的 setTimeout 延迟超过 2^31-1ms(≈24.8 天)会**溢出成 1ms** —— 哨兵值配上溢出
145
+ * 就是「1ms 后触发 → 再排下一次」的死循环(每次触发还伴随插件回调与写盘)。回 null 之后,
146
+ * 调用方能明确区分「还有很久」与「永远不会发生」。纯函数,单测覆盖。
147
+ */
140
148
  export function nextCronFire(parts, fromMs) {
141
149
  const minuteSet = new Set(parts.minute);
142
150
  const hourSet = new Set(parts.hour);
@@ -163,7 +171,24 @@ export function nextCronFire(parts, fromMs) {
163
171
  continue;
164
172
  return t;
165
173
  }
166
- return limit;
174
+ return null;
175
+ }
176
+ /**
177
+ * setTimeout 的安全延迟上限(毫秒):Node 与浏览器都按 32 位有符号整数存延迟,
178
+ * 超过 2^31-1 会被截断(Node 会**静默变成 1ms** 并打一条 TimeoutOverflowWarning)。
179
+ */
180
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
181
+ /**
182
+ * 把「距离下次触发的毫秒数」切成一段安全的 setTimeout 延迟(纯函数,单测覆盖)。
183
+ *
184
+ * 超过上限就只等一个分片(默认 6 小时)再重新计算 —— 重新计算这一步是关键:
185
+ * 过期声明、时钟回拨、系统休眠回来都能自然纠正,比一次性排一个超长定时器稳。
186
+ */
187
+ export function armDelay(nextAtMs, nowMs, maxChunkMs = 6 * 60 * 60 * 1000) {
188
+ const raw = Math.max(0, Number(nextAtMs) - Number(nowMs));
189
+ if (!Number.isFinite(raw))
190
+ return 0;
191
+ return Math.min(raw, Math.max(1, Math.min(maxChunkMs, MAX_TIMEOUT_MS)));
167
192
  }
168
193
  export function scheduleFile(pluginDir) {
169
194
  return join(pluginDir, "schedules.json");
@@ -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
  *
@@ -181,6 +185,7 @@ const UI_SLOTS = new Set([
181
185
  "contextmenu.message",
182
186
  "contextmenu.session",
183
187
  "contextmenu.file",
188
+ "contextmenu.toolcall",
184
189
  "settings.pages",
185
190
  "leftpanel.sessions",
186
191
  "chat.header",
@@ -573,7 +578,12 @@ function parseSettingsSchema(raw) {
573
578
  continue;
574
579
  const o = f;
575
580
  const key = typeof o.key === "string" ? o.key.trim() : "";
576
- const type = typeof o.type === "string" ? o.type : "";
581
+ // 宿主数据源(models / thinkingLevels):合法值才认,非法当成没写(回落静态 options)。
582
+ // 只写 optionsFrom 没写 type = 视为 select(少让作者踩坑,与 options 的写法一致)。
583
+ const optionsFrom = typeof o.optionsFrom === "string" && SETTING_OPTIONS_FROM.has(o.optionsFrom)
584
+ ? o.optionsFrom
585
+ : undefined;
586
+ const type = (typeof o.type === "string" && o.type ? o.type : optionsFrom ? "select" : "");
577
587
  if (!key || !SETTING_TYPES.has(type) || out.some((x) => x.key === key))
578
588
  continue;
579
589
  const field = {
@@ -584,6 +594,7 @@ function parseSettingsSchema(raw) {
584
594
  ...(typeof o.min === "number" ? { min: o.min } : {}),
585
595
  ...(typeof o.max === "number" ? { max: o.max } : {}),
586
596
  ...(Array.isArray(o.options) ? { options: o.options.filter((x) => typeof x === "string") } : {}),
597
+ ...(type === "select" && optionsFrom ? { optionsFrom } : {}),
587
598
  ...(typeof o.hint === "string" ? { hint: o.hint } : {}),
588
599
  };
589
600
  out.push(field);
@@ -670,14 +681,29 @@ lang, secrets) {
670
681
  clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
671
682
  }
672
683
  else if (f.type === "select") {
673
- if (v !== undefined && !f.options?.includes(String(v)))
684
+ const s = v === undefined ? "" : String(v);
685
+ // optionsFrom(宿主数据源):候选值在浏览器侧现算,服务端无从校验,
686
+ // 只做个长度护栏;非法值由用的时候(如 host.chat 切模型)报错。
687
+ if (f.optionsFrom) {
688
+ if (s.length > 200) {
689
+ return {
690
+ error: pick(l, `${f.label} 过长`, `${f.label} too long`, "plugins.settings.too.long", {
691
+ "f.label": f.label,
692
+ }),
693
+ clean,
694
+ };
695
+ }
696
+ clean[f.key] = v === undefined ? (f.default ?? "") : s;
697
+ continue;
698
+ }
699
+ if (v !== undefined && !f.options?.includes(s))
674
700
  return {
675
701
  error: pick(l, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
676
702
  "f.label": f.label,
677
703
  }),
678
704
  clean,
679
705
  };
680
- clean[f.key] = v === undefined ? f.default : String(v);
706
+ clean[f.key] = v === undefined ? f.default : s;
681
707
  }
682
708
  else if (f.type === "secret") {
683
709
  // 空串/缺省 = 不改(浏览器侧回显的本来就是有无布尔,前端把“没碰”发成空串)。
@@ -1799,6 +1825,9 @@ export class PluginManager {
1799
1825
  : undefined,
1800
1826
  // 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
1801
1827
  view: typeof m.view === "boolean" ? m.view : true,
1828
+ // 客户端 bundle 是否常驻加载(manifest "preload",缺省 false):无视图
1829
+ // (view:false)却要顶层代码一直跑(提醒轮询/快捷键/常驻浮窗…)的插件用它。
1830
+ preload: m.preload === true,
1802
1831
  // 插件对宿主 UI 的贡献(manifest "ui":slot 框架 + 整理意图,issue #146)。
1803
1832
  // 权限:与 activate 的 can("ui") **同一口径**(严格模式 = 声明了 permissions
1804
1833
  // 或 apiVersion>=2):严格模式下必须含 "ui" 族,否则整份忽略;旧全权格式放行。
@@ -2741,10 +2770,12 @@ export class PluginManager {
2741
2770
  let timer;
2742
2771
  let grace;
2743
2772
  let cancelled = false;
2773
+ /** 下一次触发时刻的展示串;`null` = 一年内没有下一次(如 2 月 31 日)。 */
2744
2774
  const nextText = () => {
2745
2775
  if (parts) {
2746
2776
  try {
2747
- return new Date(nextCronFire(parts, Date.now())).toLocaleString();
2777
+ const at = nextCronFire(parts, Date.now());
2778
+ return at === null ? null : new Date(at).toLocaleString();
2748
2779
  }
2749
2780
  catch {
2750
2781
  return specText;
@@ -2752,6 +2783,10 @@ export class PluginManager {
2752
2783
  }
2753
2784
  return `每 ${Math.round(ms / 1000)}s`;
2754
2785
  };
2786
+ const statusText = () => {
2787
+ const next = nextText();
2788
+ return next === null ? "不再触发(表达式在一年内不会命中)" : `下次 ${next}`;
2789
+ };
2755
2790
  // 后台面板条目(持久任务独有):看得见下次时间,停止=删声明(不再复活)。
2756
2791
  let bgRefresh;
2757
2792
  let bgUnreg;
@@ -2762,7 +2797,7 @@ export class PluginManager {
2762
2797
  id: taskId,
2763
2798
  label: `⏰ ${label ?? sid}`,
2764
2799
  since: Date.now(),
2765
- status: `下次 ${nextText()}`,
2800
+ status: statusText(),
2766
2801
  stop: () => off(),
2767
2802
  };
2768
2803
  bgTaskTable.set(taskId, entry);
@@ -2778,7 +2813,7 @@ export class PluginManager {
2778
2813
  bgRefresh = () => {
2779
2814
  if (!bgTaskTable.has(taskId))
2780
2815
  return;
2781
- entry.status = `下次 ${nextText()}`;
2816
+ entry.status = statusText();
2782
2817
  fireBg();
2783
2818
  };
2784
2819
  bgUnreg = () => {
@@ -2812,14 +2847,30 @@ export class PluginManager {
2812
2847
  }
2813
2848
  bgRefresh?.();
2814
2849
  };
2850
+ /**
2851
+ * 排下一次触发。两个要点:
2852
+ * - `nextCronFire` 回 null = 一年内没有下一次(如 `0 0 31 2 *`)→ 不再排;
2853
+ * - 延迟走 `armDelay` 分片(默认 ≤6 小时),因为 Node 的 setTimeout 延迟超过
2854
+ * 2^31-1ms(≈24.8 天)会**溢出成 1ms**,配上「下次在 42 天/一年后」的
2855
+ * 合法 cron 就是「1ms 后再触发」的死循环(触发还会回调插件 → 写盘 + 广播)。
2856
+ * 分片醒来后重新计算,真到点才 fire。
2857
+ */
2815
2858
  const armCron = () => {
2816
2859
  if (cancelled || !parts)
2817
2860
  return;
2818
2861
  const next = nextCronFire(parts, Date.now());
2862
+ if (next === null)
2863
+ return;
2819
2864
  timer = setTimeout(() => {
2820
- fire();
2865
+ if (cancelled)
2866
+ return;
2867
+ const at = nextCronFire(parts, Date.now());
2868
+ if (at === null)
2869
+ return;
2870
+ if (at <= Date.now() + 1000)
2871
+ fire();
2821
2872
  armCron();
2822
- }, Math.max(0, next - Date.now()));
2873
+ }, armDelay(next, Date.now()));
2823
2874
  timer.unref?.();
2824
2875
  };
2825
2876
  if (parts)
@@ -2831,7 +2882,12 @@ export class PluginManager {
2831
2882
  // 漏跑补跑:以上次触发(没跑过按创建时间)为锚,下一次已在过去=漏了。
2832
2883
  if (persistent && catchUp === "once") {
2833
2884
  const refTime = loadScheduleRecords(dir)[sid]?.lastRun ?? loadScheduleRecords(dir)[sid]?.createdAt ?? Date.now();
2834
- const missed = parts ? nextCronFire(parts, refTime) <= Date.now() : refTime + ms <= Date.now();
2885
+ const missed = parts
2886
+ ? (() => {
2887
+ const at = nextCronFire(parts, refTime);
2888
+ return at !== null && at <= Date.now(); // null = 一年内没有下一次,不算漏跑
2889
+ })()
2890
+ : refTime + ms <= Date.now();
2835
2891
  if (missed) {
2836
2892
  // 15s 缓冲:刚启动时模型/网络可能还没就绪,补跑不等那 15 秒可能白跑。
2837
2893
  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
+ }
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 18;
11
+ export const PROTOCOL_VERSION = 19;
@@ -0,0 +1,125 @@
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 { Type } from "typebox";
29
+ import { bilingual, pick } from "./i18n.js";
30
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
31
+ /**
32
+ * 目录判定用的路径归一:~ / @ 前缀、Unicode 空格、相对 → 绝对(对齐 SDK
33
+ * resolveToCwd 的主要语义)。这里只是「猜」,猜不中(例如 macOS 截图名的
34
+ * 变体路径)就走内置实现,不会比现状更差。
35
+ */
36
+ export function resolvePathForDirCheck(input, cwd) {
37
+ let p = String(input ?? "").replace(UNICODE_SPACES, " ");
38
+ if (p.startsWith("@"))
39
+ p = p.slice(1);
40
+ if (p === "~")
41
+ return homedir();
42
+ if (p.startsWith("~/") || p.startsWith("~\\"))
43
+ return join(homedir(), p.slice(2));
44
+ return isAbsolute(p) ? p : nodeResolve(cwd, p);
45
+ }
46
+ /** 路径是不是目录(不存在/无权限/非目录一律 false → 交回内置实现)。 */
47
+ export async function isDirectoryPath(absolutePath) {
48
+ try {
49
+ const st = await stat(absolutePath);
50
+ return st.isDirectory();
51
+ }
52
+ catch {
53
+ return false;
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
+ }
84
+ /**
85
+ * 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
86
+ * (会话工作区)。
87
+ */
88
+ export function makeReadDirTool(fallbackCwd, options = {}) {
89
+ const base = createReadToolDefinition(fallbackCwd);
90
+ const ls = createLsToolDefinition(fallbackCwd);
91
+ const dirEnabled = options.dirEnabled ?? (() => true);
92
+ const getLang = options.getLang ?? (() => "en");
93
+ return defineTool({
94
+ ...base,
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 忽略)。`),
96
+ promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
97
+ promptGuidelines: [
98
+ ...(base.promptGuidelines ?? []),
99
+ bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
100
+ ],
101
+ parameters: readDirSchema,
102
+ prepareArguments: prepareReadArguments,
103
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
104
+ const input = (params ?? {});
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 : "";
108
+ if (path && dirEnabled()) {
109
+ const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
110
+ if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
111
+ const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
112
+ // 列目录本体完全复用 SDK 的 ls(排序/`/` 后缀/截断提示口径一致)。
113
+ const listed = await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx);
114
+ const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
115
+ // 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
116
+ // details 不需要 ls 的字段。
117
+ const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
118
+ return { content, details: undefined };
119
+ }
120
+ }
121
+ // 转发内置实现时带上归一后的 path(模型可能只给了 file_path)。
122
+ return base.execute(toolCallId, { ...input, path }, signal, onUpdate, ctx);
123
+ },
124
+ });
125
+ }