pi-web-ui 0.90.1 → 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 +81 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +1206 -106
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +119 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +28 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +152 -34
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +112 -18
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +91 -0
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +47 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +13 -1
- package/package.json +2 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -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-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- 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-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
package/dist/server/plugins.js
CHANGED
|
@@ -22,10 +22,11 @@ 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";
|
|
29
|
+
import { normalizeIconSvg } from "./icon-svg.js";
|
|
29
30
|
import { PluginDomConsent, declarationWantsDom } from "./plugin-dom.js";
|
|
30
31
|
// 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
|
|
31
32
|
import { normalizeWorkspaceRoots } from "./client-state.js";
|
|
@@ -161,6 +162,10 @@ export function readHostVersion() {
|
|
|
161
162
|
// 声明式设置 schema(manifest "settings")
|
|
162
163
|
// ---------------------------------------------------------------------------
|
|
163
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"]);
|
|
164
169
|
/**
|
|
165
170
|
* 解析 manifest "ui" 的某个 slot 数组 → 规范化条目(issue #146 完整版)。
|
|
166
171
|
*
|
|
@@ -371,6 +376,9 @@ export function parseUiItem(raw, slot, diagnostics) {
|
|
|
371
376
|
...(align ? { align } : {}),
|
|
372
377
|
...(trimStr(o.labelEn, 60) ? { labelEn: trimStr(o.labelEn, 60) } : {}),
|
|
373
378
|
...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
|
|
379
|
+
...(normalizeIconSvg(o.iconSvg)
|
|
380
|
+
? { iconSvg: normalizeIconSvg(o.iconSvg) }
|
|
381
|
+
: {}),
|
|
374
382
|
...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
|
|
375
383
|
...(trimStr(o.hintEn, 200) ? { hintEn: trimStr(o.hintEn, 200) } : {}),
|
|
376
384
|
kind,
|
|
@@ -538,6 +546,9 @@ export function parseUiArrange(raw, diagnostics) {
|
|
|
538
546
|
...(trimStr(o.label, 60) ? { label: trimStr(o.label, 60) } : {}),
|
|
539
547
|
...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
|
|
540
548
|
...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
|
|
549
|
+
...(normalizeIconSvg(o.iconSvg)
|
|
550
|
+
? { iconSvg: normalizeIconSvg(o.iconSvg) }
|
|
551
|
+
: {}),
|
|
541
552
|
});
|
|
542
553
|
}
|
|
543
554
|
return out;
|
|
@@ -566,7 +577,12 @@ function parseSettingsSchema(raw) {
|
|
|
566
577
|
continue;
|
|
567
578
|
const o = f;
|
|
568
579
|
const key = typeof o.key === "string" ? o.key.trim() : "";
|
|
569
|
-
|
|
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" : "");
|
|
570
586
|
if (!key || !SETTING_TYPES.has(type) || out.some((x) => x.key === key))
|
|
571
587
|
continue;
|
|
572
588
|
const field = {
|
|
@@ -577,6 +593,7 @@ function parseSettingsSchema(raw) {
|
|
|
577
593
|
...(typeof o.min === "number" ? { min: o.min } : {}),
|
|
578
594
|
...(typeof o.max === "number" ? { max: o.max } : {}),
|
|
579
595
|
...(Array.isArray(o.options) ? { options: o.options.filter((x) => typeof x === "string") } : {}),
|
|
596
|
+
...(type === "select" && optionsFrom ? { optionsFrom } : {}),
|
|
580
597
|
...(typeof o.hint === "string" ? { hint: o.hint } : {}),
|
|
581
598
|
};
|
|
582
599
|
out.push(field);
|
|
@@ -663,14 +680,29 @@ lang, secrets) {
|
|
|
663
680
|
clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
|
|
664
681
|
}
|
|
665
682
|
else if (f.type === "select") {
|
|
666
|
-
|
|
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))
|
|
667
699
|
return {
|
|
668
700
|
error: pick(l, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
|
|
669
701
|
"f.label": f.label,
|
|
670
702
|
}),
|
|
671
703
|
clean,
|
|
672
704
|
};
|
|
673
|
-
clean[f.key] = v === undefined ? f.default :
|
|
705
|
+
clean[f.key] = v === undefined ? f.default : s;
|
|
674
706
|
}
|
|
675
707
|
else if (f.type === "secret") {
|
|
676
708
|
// 空串/缺省 = 不改(浏览器侧回显的本来就是有无布尔,前端把“没碰”发成空串)。
|
|
@@ -1582,15 +1614,15 @@ export class PluginManager {
|
|
|
1582
1614
|
}
|
|
1583
1615
|
}
|
|
1584
1616
|
/** 当前目录清单(重扫 manifest,不重新 import)。 */
|
|
1585
|
-
async list() {
|
|
1586
|
-
return this.scan();
|
|
1617
|
+
async list(lang) {
|
|
1618
|
+
return this.scan(lang);
|
|
1587
1619
|
}
|
|
1588
1620
|
/**
|
|
1589
1621
|
* attach 时调用:重扫目录 + 激活尚未加载的新插件。
|
|
1590
1622
|
* 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
|
|
1591
1623
|
*/
|
|
1592
1624
|
async ensureLoaded(lang) {
|
|
1593
|
-
const found = await this.scan();
|
|
1625
|
+
const found = await this.scan(lang);
|
|
1594
1626
|
for (const info of found) {
|
|
1595
1627
|
if (this.loaded.has(info.id) || this.attempted.has(info.id))
|
|
1596
1628
|
continue;
|
|
@@ -1607,10 +1639,13 @@ export class PluginManager {
|
|
|
1607
1639
|
}
|
|
1608
1640
|
return found.map((f) => {
|
|
1609
1641
|
const base = this.loaded.get(f.id)?.info ?? f;
|
|
1642
|
+
// 运行相位(设置面板清单用):宿主持有实例即 active(含激活失败的占位行;
|
|
1643
|
+
// 纯前端插件无 index.mjs、从未进 loaded 表,保持非 active)。
|
|
1644
|
+
const withPhase = { ...base, active: this.loaded.has(f.id) };
|
|
1610
1645
|
const agentTools = this.agentToolsSnapshot(f.id);
|
|
1611
1646
|
if (agentTools.length)
|
|
1612
|
-
return { ...
|
|
1613
|
-
const { agentTools: _drop, ...rest } =
|
|
1647
|
+
return { ...withPhase, agentTools };
|
|
1648
|
+
const { agentTools: _drop, ...rest } = withPhase;
|
|
1614
1649
|
return rest;
|
|
1615
1650
|
});
|
|
1616
1651
|
}
|
|
@@ -1702,7 +1737,8 @@ export class PluginManager {
|
|
|
1702
1737
|
this.pluginLogs.clear();
|
|
1703
1738
|
}
|
|
1704
1739
|
/** 读 manifest 清单;坏目录(无 manifest/id 非法)直接跳过。 */
|
|
1705
|
-
async scan() {
|
|
1740
|
+
async scan(lang) {
|
|
1741
|
+
const l = lang?.() ?? "en";
|
|
1706
1742
|
let names;
|
|
1707
1743
|
try {
|
|
1708
1744
|
names = await readdir(this.pluginsDir);
|
|
@@ -1731,6 +1767,7 @@ export class PluginManager {
|
|
|
1731
1767
|
version: typeof m.version === "string" ? m.version : undefined,
|
|
1732
1768
|
description: typeof m.description === "string" ? m.description : undefined,
|
|
1733
1769
|
icon: typeof m.icon === "string" && m.icon.trim() ? m.icon.trim() : undefined,
|
|
1770
|
+
iconSvg: normalizeIconSvg(m.iconSvg),
|
|
1734
1771
|
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
1735
1772
|
error: this.loaded.get(name)?.info.error,
|
|
1736
1773
|
// manifest 声明的能力清单(fs/net/tools…)——设置面板展示用
|
|
@@ -1787,6 +1824,9 @@ export class PluginManager {
|
|
|
1787
1824
|
: undefined,
|
|
1788
1825
|
// 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
|
|
1789
1826
|
view: typeof m.view === "boolean" ? m.view : true,
|
|
1827
|
+
// 客户端 bundle 是否常驻加载(manifest "preload",缺省 false):无视图
|
|
1828
|
+
// (view:false)却要顶层代码一直跑(提醒轮询/快捷键/常驻浮窗…)的插件用它。
|
|
1829
|
+
preload: m.preload === true,
|
|
1790
1830
|
// 插件对宿主 UI 的贡献(manifest "ui":slot 框架 + 整理意图,issue #146)。
|
|
1791
1831
|
// 权限:与 activate 的 can("ui") **同一口径**(严格模式 = 声明了 permissions
|
|
1792
1832
|
// 或 apiVersion>=2):严格模式下必须含 "ui" 族,否则整份忽略;旧全权格式放行。
|
|
@@ -1840,7 +1880,19 @@ export class PluginManager {
|
|
|
1840
1880
|
}
|
|
1841
1881
|
}
|
|
1842
1882
|
catch {
|
|
1843
|
-
|
|
1883
|
+
// 无 manifest.json / JSON 解析失败 —— 占位行展示(坏插件不跳过,
|
|
1884
|
+
// 设置面板清单标红 + 给原因;ensureLoaded 照例跳过激活)。
|
|
1885
|
+
const msg = pick(l, `manifest.json 缺失或解析失败(${name}/),不是有效插件 —— 修复或移走该目录后点“重新扫描”`, `manifest.json missing or unparsable (${name}/), not a valid plugin — fix or remove the directory, then hit Rescan`, "plugins.manifest.broken", { name });
|
|
1886
|
+
console.error(`[plugin:${name}] ${msg}`);
|
|
1887
|
+
const brokenDiags = this.diagnosticsOf(name);
|
|
1888
|
+
out.push({
|
|
1889
|
+
id: name,
|
|
1890
|
+
name,
|
|
1891
|
+
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
1892
|
+
error: msg,
|
|
1893
|
+
view: false,
|
|
1894
|
+
...(brokenDiags ? { diagnostics: brokenDiags } : {}),
|
|
1895
|
+
});
|
|
1844
1896
|
}
|
|
1845
1897
|
}
|
|
1846
1898
|
// 删掉的目录不同时清快照:门禁会把不存在的 id 当 403,正确的应该是 404。
|
|
@@ -2073,7 +2125,22 @@ export class PluginManager {
|
|
|
2073
2125
|
if (text.length > 8000)
|
|
2074
2126
|
return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
|
|
2075
2127
|
const accountId = String(req?.accountId ?? "default").slice(0, 64);
|
|
2076
|
-
|
|
2128
|
+
// issue #226:透传定时任务对齐的四件套(各按长度封顶,语义校验归宿主 chatFromPlugin)。
|
|
2129
|
+
const r = (req ?? {});
|
|
2130
|
+
const passthrough = { text, accountId };
|
|
2131
|
+
const cwd = String(r.cwd ?? "").trim();
|
|
2132
|
+
if (cwd)
|
|
2133
|
+
passthrough.cwd = cwd.slice(0, 1024);
|
|
2134
|
+
const conversationId = String(r.conversationId ?? "").trim();
|
|
2135
|
+
if (conversationId)
|
|
2136
|
+
passthrough.conversationId = conversationId.slice(0, 128);
|
|
2137
|
+
const model = String(r.model ?? "").trim();
|
|
2138
|
+
if (model)
|
|
2139
|
+
passthrough.model = model.slice(0, 256);
|
|
2140
|
+
const thinkingLevel = String(r.thinkingLevel ?? "").trim();
|
|
2141
|
+
if (thinkingLevel)
|
|
2142
|
+
passthrough.thinkingLevel = thinkingLevel.slice(0, 64);
|
|
2143
|
+
return self.chatProvider(info.id, passthrough);
|
|
2077
2144
|
};
|
|
2078
2145
|
const host = {
|
|
2079
2146
|
broadcast: (payload) => this.broadcast(info.id, payload),
|
|
@@ -2702,10 +2769,12 @@ export class PluginManager {
|
|
|
2702
2769
|
let timer;
|
|
2703
2770
|
let grace;
|
|
2704
2771
|
let cancelled = false;
|
|
2772
|
+
/** 下一次触发时刻的展示串;`null` = 一年内没有下一次(如 2 月 31 日)。 */
|
|
2705
2773
|
const nextText = () => {
|
|
2706
2774
|
if (parts) {
|
|
2707
2775
|
try {
|
|
2708
|
-
|
|
2776
|
+
const at = nextCronFire(parts, Date.now());
|
|
2777
|
+
return at === null ? null : new Date(at).toLocaleString();
|
|
2709
2778
|
}
|
|
2710
2779
|
catch {
|
|
2711
2780
|
return specText;
|
|
@@ -2713,6 +2782,10 @@ export class PluginManager {
|
|
|
2713
2782
|
}
|
|
2714
2783
|
return `每 ${Math.round(ms / 1000)}s`;
|
|
2715
2784
|
};
|
|
2785
|
+
const statusText = () => {
|
|
2786
|
+
const next = nextText();
|
|
2787
|
+
return next === null ? "不再触发(表达式在一年内不会命中)" : `下次 ${next}`;
|
|
2788
|
+
};
|
|
2716
2789
|
// 后台面板条目(持久任务独有):看得见下次时间,停止=删声明(不再复活)。
|
|
2717
2790
|
let bgRefresh;
|
|
2718
2791
|
let bgUnreg;
|
|
@@ -2723,7 +2796,7 @@ export class PluginManager {
|
|
|
2723
2796
|
id: taskId,
|
|
2724
2797
|
label: `⏰ ${label ?? sid}`,
|
|
2725
2798
|
since: Date.now(),
|
|
2726
|
-
status:
|
|
2799
|
+
status: statusText(),
|
|
2727
2800
|
stop: () => off(),
|
|
2728
2801
|
};
|
|
2729
2802
|
bgTaskTable.set(taskId, entry);
|
|
@@ -2739,7 +2812,7 @@ export class PluginManager {
|
|
|
2739
2812
|
bgRefresh = () => {
|
|
2740
2813
|
if (!bgTaskTable.has(taskId))
|
|
2741
2814
|
return;
|
|
2742
|
-
entry.status =
|
|
2815
|
+
entry.status = statusText();
|
|
2743
2816
|
fireBg();
|
|
2744
2817
|
};
|
|
2745
2818
|
bgUnreg = () => {
|
|
@@ -2773,14 +2846,30 @@ export class PluginManager {
|
|
|
2773
2846
|
}
|
|
2774
2847
|
bgRefresh?.();
|
|
2775
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
|
+
*/
|
|
2776
2857
|
const armCron = () => {
|
|
2777
2858
|
if (cancelled || !parts)
|
|
2778
2859
|
return;
|
|
2779
2860
|
const next = nextCronFire(parts, Date.now());
|
|
2861
|
+
if (next === null)
|
|
2862
|
+
return;
|
|
2780
2863
|
timer = setTimeout(() => {
|
|
2781
|
-
|
|
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();
|
|
2782
2871
|
armCron();
|
|
2783
|
-
},
|
|
2872
|
+
}, armDelay(next, Date.now()));
|
|
2784
2873
|
timer.unref?.();
|
|
2785
2874
|
};
|
|
2786
2875
|
if (parts)
|
|
@@ -2792,7 +2881,12 @@ export class PluginManager {
|
|
|
2792
2881
|
// 漏跑补跑:以上次触发(没跑过按创建时间)为锚,下一次已在过去=漏了。
|
|
2793
2882
|
if (persistent && catchUp === "once") {
|
|
2794
2883
|
const refTime = loadScheduleRecords(dir)[sid]?.lastRun ?? loadScheduleRecords(dir)[sid]?.createdAt ?? Date.now();
|
|
2795
|
-
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();
|
|
2796
2890
|
if (missed) {
|
|
2797
2891
|
// 15s 缓冲:刚启动时模型/网络可能还没就绪,补跑不等那 15 秒可能白跑。
|
|
2798
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
|
+
}
|
|
@@ -149,7 +149,7 @@ export function buildSkillsText(skills, lang = "en", fullText = false) {
|
|
|
149
149
|
return "";
|
|
150
150
|
const lines = [
|
|
151
151
|
pick(lang, "以下技能为特定任务提供专门的指令。", "The following skills provide specialized instructions for specific tasks.", "prompt.skills.intro.specialized"),
|
|
152
|
-
pick(lang, "当任务与某技能的描述相符时,用
|
|
152
|
+
pick(lang, "当任务与某技能的描述相符时,用 skill 工具按名称加载该技能全文。", "Use the skill tool to load a skill's full text by name when the task matches its description.", "prompt.skills.intro.use.skill"),
|
|
153
153
|
pick(lang, "当技能文件引用相对路径时,以技能目录(SKILL.md 的父目录 / 该路径的 dirname)为基准解析,并在工具命令中使用解析后的绝对路径。", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "prompt.skills.intro.resolve.path"),
|
|
154
154
|
"",
|
|
155
155
|
"<available_skills>",
|