pi-web-ui 0.95.0 → 0.96.1
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 +63 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +331 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-installer.js +30 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +718 -0
- package/themes/zhupi.css +771 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
- package/web/dist/assets/index-B7Zp23Vc.css +1 -0
- package/web/dist/assets/index-ByfoaDwp.js +366 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
package/dist/server/read-tool.js
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* 与 SDK ls 完全一致);其余情况(文件、图片、路径不存在、读取报错)原样
|
|
13
13
|
* 转发基底,行为与内置完全一致。
|
|
14
14
|
*
|
|
15
|
+
* 基底也可能是**第三方扩展注册的 read**:`customTools` 恒胜、会把它顶掉(见
|
|
16
|
+
* tool-overrides.ts),所以那种情况下改走 `withReadDirSupport()` —— 把扩展的实现
|
|
17
|
+
* 整个当基底叠目录能力,它的 schema/描述/prompt 指引/渲染原样保留(扩展独有的参数
|
|
18
|
+
* 照旧可用),只有目录分支归 pi-web-ui。
|
|
19
|
+
*
|
|
15
20
|
* 开关:`readDirEnabled`(设置面板「工具」页,默认开)。**行为开关**不是
|
|
16
21
|
* ActiveSet 开关(read 本体不可关,关了 agent 就残了),因此不进
|
|
17
22
|
* tool-manager 的 AGENT_TOOL_CATALOG;每次调用实时读设置,改动即时生效。
|
|
18
23
|
*
|
|
19
|
-
*
|
|
24
|
+
* 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
|
|
20
25
|
* 返回文本(目录头)按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
|
|
21
26
|
*
|
|
22
27
|
* DSH 引擎无 customTool 注册面(工具来自 shipped preset),本覆盖只服务 pi 引擎。
|
|
@@ -26,7 +31,7 @@ import { homedir } from "node:os";
|
|
|
26
31
|
import { isAbsolute, join, resolve as nodeResolve } from "node:path";
|
|
27
32
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
|
|
28
33
|
import { Type } from "typebox";
|
|
29
|
-
import {
|
|
34
|
+
import { pick } from "./i18n.js";
|
|
30
35
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
31
36
|
/**
|
|
32
37
|
* 目录判定用的路径归一:~ / @ 前缀、Unicode 空格、相对 → 绝对(对齐 SDK
|
|
@@ -56,16 +61,16 @@ export async function isDirectoryPath(absolutePath) {
|
|
|
56
61
|
/** 覆盖定义的参数 schema:内置的 path/offset/limit + file_path 别名。 */
|
|
57
62
|
const readDirSchema = Type.Object({
|
|
58
63
|
path: Type.String({
|
|
59
|
-
description:
|
|
64
|
+
description: "Path to the file (or directory) to read (relative or absolute)",
|
|
60
65
|
}),
|
|
61
66
|
file_path: Type.Optional(Type.String({
|
|
62
|
-
description:
|
|
67
|
+
description: "Alias of `path` — some clients/models emit file_path; if both are given, `path` wins",
|
|
63
68
|
})),
|
|
64
69
|
offset: Type.Optional(Type.Number({
|
|
65
|
-
description:
|
|
70
|
+
description: "Line number to start reading from (1-indexed)",
|
|
66
71
|
})),
|
|
67
72
|
limit: Type.Optional(Type.Number({
|
|
68
|
-
description:
|
|
73
|
+
description: "Maximum number of lines to read (for a directory path: maximum number of entries)",
|
|
69
74
|
})),
|
|
70
75
|
}, {});
|
|
71
76
|
/**
|
|
@@ -81,9 +86,59 @@ export function prepareReadArguments(raw) {
|
|
|
81
86
|
// 两者都没给时这里仍缺 path(静态类型是谎,运行时交给 schema 校验报错)。
|
|
82
87
|
return args;
|
|
83
88
|
}
|
|
89
|
+
/** Directory support note appended to any read definition (definitions are English-only). */
|
|
90
|
+
const DIR_DESCRIPTION_NOTE = "Also accepts a directory path: its entries are then 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.";
|
|
91
|
+
const DIR_GUIDELINE = "Use read on a directory to list its entries — no need to shell out to `ls`";
|
|
92
|
+
/**
|
|
93
|
+
* 两条路(内置基底 / 扩展基底)共用的执行体:先判「路径是不是目录」—— 是就复用 SDK 的
|
|
94
|
+
* ls 列条目(排序/`/` 后缀/截断提示口径一致),否则把请求转发给基底实现。
|
|
95
|
+
*
|
|
96
|
+
* `normalizePath`:只给了 `file_path` 别名时补出 `path` 再转发(SDK 内置实现需要);
|
|
97
|
+
* 扩展基底传 false —— 它自带 `prepareArguments`,参数原样交给它,免得我们这边把扩展
|
|
98
|
+
* 独有的字段(如 better-edit 的 `windows`)吃掉。
|
|
99
|
+
*/
|
|
100
|
+
async function dirAwareExecute(base, ls, fallbackCwd, dirEnabled, getLang, normalizePath, toolCallId, params, signal, onUpdate, ctx) {
|
|
101
|
+
const input = (params ?? {});
|
|
102
|
+
// 兜底(不依赖 prepareArguments 一定跑过):path 缺省/空时用 file_path。
|
|
103
|
+
const rawPath = typeof input.path === "string" && input.path.trim() ? input.path : input.file_path;
|
|
104
|
+
const path = typeof rawPath === "string" ? rawPath : "";
|
|
105
|
+
if (path && dirEnabled()) {
|
|
106
|
+
const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
|
|
107
|
+
if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
|
|
108
|
+
const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
|
|
109
|
+
// 列目录本体完全复用 SDK 的 ls。
|
|
110
|
+
const listed = (await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx));
|
|
111
|
+
const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
|
|
112
|
+
// 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
|
|
113
|
+
// details 不需要 ls 的字段。
|
|
114
|
+
const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
|
|
115
|
+
return { content, details: undefined };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return base.execute(toolCallId, (normalizePath ? { ...input, path } : params), signal, onUpdate, ctx);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 在**任意** read 实现(SDK 内置,或第三方扩展 `registerTool` 注册的同名工具)之上叠加
|
|
122
|
+
* 「路径是目录时列出条目」。基底的 name/label/描述/参数 schema/prepareArguments/render*
|
|
123
|
+
* 全部原样保留,只补一句目录说明与一条目录指引 —— 于是扩展的锚协议、独有参数、渲染都不丢。
|
|
124
|
+
*/
|
|
125
|
+
export function withReadDirSupport(base, fallbackCwd, options = {}) {
|
|
126
|
+
const dirEnabled = options.dirEnabled ?? (() => true);
|
|
127
|
+
const getLang = options.getLang ?? (() => "en");
|
|
128
|
+
const ls = createLsToolDefinition(fallbackCwd);
|
|
129
|
+
return defineTool({
|
|
130
|
+
...base,
|
|
131
|
+
description: `${base.description} ${DIR_DESCRIPTION_NOTE}`,
|
|
132
|
+
promptGuidelines: [...(base.promptGuidelines ?? []), DIR_GUIDELINE],
|
|
133
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
134
|
+
return dirAwareExecute(base, ls, fallbackCwd, dirEnabled, getLang, false, toolCallId, params, signal, onUpdate, ctx);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
84
138
|
/**
|
|
85
|
-
* 生成「read
|
|
86
|
-
* (会话工作区)。
|
|
139
|
+
* 生成「read 读目录」覆盖定义(**没有**扩展同名工具时的完整实现:内置基底 + 英文描述 +
|
|
140
|
+
* `file_path` 别名)。cwd 仅供创建时固定;执行时优先 ctx.cwd(会话工作区)。
|
|
141
|
+
* 有扩展同名工具时改用 `withReadDirSupport` 组合它的实现(见 tool-overrides.ts)。
|
|
87
142
|
*/
|
|
88
143
|
export function makeReadDirTool(fallbackCwd, options = {}) {
|
|
89
144
|
const base = createReadToolDefinition(fallbackCwd);
|
|
@@ -92,34 +147,18 @@ export function makeReadDirTool(fallbackCwd, options = {}) {
|
|
|
92
147
|
const getLang = options.getLang ?? (() => "en");
|
|
93
148
|
return defineTool({
|
|
94
149
|
...base,
|
|
95
|
-
description:
|
|
96
|
-
|
|
150
|
+
description: `${base.description} Also accepts \`file_path\` as an alias of \`path\`. ` +
|
|
151
|
+
"If the path is a directory, its entries are listed instead (one per line, directories suffixed with '/'); `limit` caps the entries and `offset` is ignored.",
|
|
152
|
+
promptSnippet: "Read file contents (a directory path lists its entries)",
|
|
97
153
|
promptGuidelines: [
|
|
98
154
|
...(base.promptGuidelines ?? []),
|
|
99
|
-
|
|
155
|
+
"Use read on a directory to list its entries — no need to shell out to `ls`",
|
|
100
156
|
],
|
|
101
157
|
parameters: readDirSchema,
|
|
102
158
|
prepareArguments: prepareReadArguments,
|
|
103
159
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
104
|
-
|
|
105
|
-
|
|
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);
|
|
160
|
+
// 目录分支与转发都在共用执行体里(normalizePath=true:只给了 file_path 时补出 path)。
|
|
161
|
+
return dirAwareExecute(base, ls, fallbackCwd, dirEnabled, getLang, true, toolCallId, params, signal, onUpdate, ctx);
|
|
123
162
|
},
|
|
124
163
|
});
|
|
125
164
|
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resolve-global-sdk —
|
|
2
|
+
* resolve-global-sdk — pi SDK 副本选择钩子(issue #260;#321 起默认启用)。
|
|
3
3
|
*
|
|
4
4
|
* 背景:pi-web-ui 依赖 `@earendil-works/pi-coding-agent`,而 npm 全局安装会把依赖
|
|
5
5
|
* **嵌在** `<npm root -g>/pi-web-ui/node_modules/`(不 hoist,实测),Node 又「嵌套优先于
|
|
6
6
|
* 祖先」—— 于是用户 `npm i -g @earendil-works/pi-coding-agent@latest` 改的是全局那份,
|
|
7
7
|
* 服务加载的仍是自带那份,表现为「升了 0.86.1,横幅和 /api/health 还显示 0.85.1」。
|
|
8
|
+
* 这个割裂在 #321 里反复咬人(界面此前完全看不见),所以解析顺序默认反转:
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
+
* 缺省 / global / auto → 机器上有**更新**的 pi 副本(祖先链,如全局 pi CLI)就用它,
|
|
11
|
+
* 多份取版本最高的;没有或都更旧 → 自带副本兜底。
|
|
12
|
+
* bundled / 0 / off /
|
|
13
|
+
* false / no → 强制自带副本(可复现、CI 覆盖的那份;报 bug 请用它复现)。
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (缺省 / 其它值) → 什么都不做,用自带副本。
|
|
15
|
+
* 为什么「绝不降级」:祖先副本必须严格比自带的新才采用(同版本继续用自带那份,
|
|
16
|
+
* 少一次重定向);绝不为了「用全局」而跑更旧的版本。
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* 为什么默认跟随(#321):用户升级全局 pi 后界面「显示没更新」,每个版本都有人踩,
|
|
19
|
+
* 每次都要人肉排查。可复现性的代价用两条路补回来:界面更新面板与 /api/health 的
|
|
20
|
+
* `piSdkCopies` 都如实报出实际加载的那份;`PI_WEB_SDK=bundled` 随时显式换回自带。
|
|
21
|
+
* 「全局 SDK 比宿主新」的兼容风险是被接受的:pi 与 pi-web-ui 同步演进,跟随全局
|
|
22
|
+
* 正是用户升级 pi 的本意。
|
|
20
23
|
*
|
|
21
24
|
* 安全约定:整个注册流程包在 try/catch 里,任何异常都退回默认解析 —— 这个模块**绝不允许**
|
|
22
25
|
* 让服务起不来。它必须在任何 SDK 静态 import **之前**被加载(`--import <本文件>`,
|
|
@@ -26,18 +29,28 @@ import { registerHooks } from "node:module";
|
|
|
26
29
|
import { pathToFileURL } from "node:url";
|
|
27
30
|
import { compareVersions, sdkCopies } from "./sdk-origin.js";
|
|
28
31
|
const PKG = "@earendil-works/pi-coding-agent";
|
|
32
|
+
/** 显式强制自带副本的取值(旧默认;可复现、CI 覆盖的那份)。 */
|
|
33
|
+
const BUNDLED_MODES = new Set(["bundled", "0", "off", "false", "no"]);
|
|
29
34
|
/**
|
|
30
35
|
* 该用哪一份对外解析(纯函数,便于单测)。
|
|
31
|
-
* `copies` 按 Node 的解析顺序([0] =
|
|
36
|
+
* `copies` 按 Node 的解析顺序([0] = 自带的、无钩子时实际会被用到的),
|
|
37
|
+
* 返回 null = 保持默认(用自带副本兜底)。
|
|
32
38
|
*/
|
|
33
39
|
export function pickGlobalSdk(copies, mode) {
|
|
34
|
-
if ((mode ?? "
|
|
40
|
+
if (BUNDLED_MODES.has((mode ?? "global").trim().toLowerCase()))
|
|
35
41
|
return null;
|
|
36
42
|
const bundled = copies[0];
|
|
37
|
-
//
|
|
38
|
-
|
|
43
|
+
// 只考虑祖先链上**严格更新**的副本,多份取版本最高的(旧的/同版本的都不折腾)。
|
|
44
|
+
let best = null;
|
|
45
|
+
for (const copy of copies.slice(1)) {
|
|
46
|
+
if ((!bundled || compareVersions(copy.version, bundled.version) > 0) &&
|
|
47
|
+
(!best || compareVersions(copy.version, best.version) > 0)) {
|
|
48
|
+
best = copy;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return best;
|
|
39
52
|
}
|
|
40
|
-
/** 注册钩子;返回实际选中的副本(null =
|
|
53
|
+
/** 注册钩子;返回实际选中的副本(null = 用自带副本)。 */
|
|
41
54
|
export function registerGlobalSdkPreference(mode = process.env.PI_WEB_SDK, fromFile = import.meta.url) {
|
|
42
55
|
try {
|
|
43
56
|
const copies = sdkCopies(fromFile);
|
|
@@ -68,5 +81,6 @@ export function registerGlobalSdkPreference(mode = process.env.PI_WEB_SDK, fromF
|
|
|
68
81
|
// ---- 副作用:被 `--import` 加载时立刻生效 --------------------------------
|
|
69
82
|
const used = registerGlobalSdkPreference();
|
|
70
83
|
if (used) {
|
|
71
|
-
console.log(`[pi-web-ui]
|
|
84
|
+
console.log(`[pi-web-ui] following a newer pi SDK on this machine: ${used.path} (v${used.version}; ` +
|
|
85
|
+
"set PI_WEB_SDK=bundled to pin the bundled copy)");
|
|
72
86
|
}
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
// 到期执行走 index.ts 的 executor:目标对话还在 → steer 语义唤醒它(不切用户
|
|
14
14
|
// 当前对话);不在了(关闭/重启)→ 回落原有无头执行;单次任务触发后自动删除。
|
|
15
15
|
//
|
|
16
|
-
//
|
|
16
|
+
// 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
|
|
17
17
|
// 返回文本按 lang 取 pick(lang, zh, en, key, vars),缺表回落英文内联。
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
|
-
import {
|
|
21
|
+
import { pick } from "./i18n.js";
|
|
22
22
|
import { SCHEDULE_CANCEL_TOOL_NAME, SCHEDULE_LIST_TOOL_NAME, SCHEDULE_TASK_TOOL_NAME } from "./tool-manager.js";
|
|
23
23
|
import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, } from "./scheduler-tasks.js";
|
|
24
24
|
import { parseCronSpec } from "./plugin-schedule.js";
|
|
@@ -107,17 +107,11 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
107
107
|
const taskTool = defineTool({
|
|
108
108
|
name: SCHEDULE_TASK_TOOL_NAME,
|
|
109
109
|
label: "Schedule a wake-up in this conversation",
|
|
110
|
-
description:
|
|
111
|
-
"Use
|
|
112
|
-
'
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"Manage with schedule_list / schedule_cancel; the user can also cancel from the background-tasks panel.", "在**当前对话**里创建一个定时唤醒:到指定 cron 时间或延迟后,调度器自动把你的 prompt 投回本对话,你继续执行并向用户汇报。" +
|
|
116
|
-
"用户要求定时巡检、延时提醒、定时汇总时用它 —— 不要用 sleep 死循环假装答应(休眠的脚本推不回任何消息)。" +
|
|
117
|
-
"时间写法收 5 字段 cron(“0 * * * *”=每小时)或相对延迟(“in 30m”“in 1h”“30m”);最短间隔 60s。" +
|
|
118
|
-
"默认单次(recurring=false 触发后自动删除);传 recurring=true 做周期任务。" +
|
|
119
|
-
"唤醒绑定发起对话与其落盘会话文件:上下文压缩或重启后自动重绑到同一会话;原对话不在时先回落到同项目的活跃对话(并明确提示迁移),同项目无存活对话才无头执行,报告留在调度面板历史里。" +
|
|
120
|
-
"用 schedule_list / schedule_cancel 管理;用户也可从后台任务面板取消。"),
|
|
110
|
+
description: "Create a scheduled wake-up in the CURRENT conversation: at the cron time or after the delay, your prompt is delivered back into this conversation so you continue and report. " +
|
|
111
|
+
"Use for periodic checks, delayed reminders, scheduled summaries — never sleep loops (they cannot push messages back). " +
|
|
112
|
+
'schedule: 5-field cron ("0 * * * *" hourly) or delay ("in 30m"); minimum 60s. One-shot by default; recurring=true repeats. ' +
|
|
113
|
+
"Re-binds to the same session after compaction/restart; if the conversation is gone it falls back to the project's active conversation (visibly reported), headless only when none exists. " +
|
|
114
|
+
"Manage with schedule_list / schedule_cancel.",
|
|
121
115
|
promptSnippet: "schedule a wake-up in this conversation (cron or delay), auto-report on fire",
|
|
122
116
|
parameters: Type.Object({
|
|
123
117
|
schedule: Type.String({
|
|
@@ -210,7 +204,9 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
210
204
|
const listTool = defineTool({
|
|
211
205
|
name: SCHEDULE_LIST_TOOL_NAME,
|
|
212
206
|
label: "List scheduled tasks",
|
|
213
|
-
description:
|
|
207
|
+
description: "List all built-in scheduled tasks (all projects): " +
|
|
208
|
+
"id, name, cron/interval, on/paused, one-shot, target conversation, next fire, last run. " +
|
|
209
|
+
"Use it to inspect before cancelling, or to answer the user about what is scheduled.",
|
|
214
210
|
promptSnippet: "list scheduled wake-up tasks",
|
|
215
211
|
parameters: Type.Object({}),
|
|
216
212
|
execute: async () => {
|
|
@@ -235,7 +231,8 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
235
231
|
const cancelTool = defineTool({
|
|
236
232
|
name: SCHEDULE_CANCEL_TOOL_NAME,
|
|
237
233
|
label: "Cancel a scheduled task",
|
|
238
|
-
description:
|
|
234
|
+
description: "Delete a scheduled task by id (see schedule_list). " +
|
|
235
|
+
"Deleting stops all future fires; use it when the user says the schedule is no longer needed.",
|
|
239
236
|
promptSnippet: "cancel a scheduled task by id",
|
|
240
237
|
parameters: Type.Object({
|
|
241
238
|
id: Type.String({ description: "Task id from schedule_list." }),
|
|
@@ -52,13 +52,29 @@ export function compareVersions(a, b) {
|
|
|
52
52
|
return 0;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* 本进程实际加载的是否为**随包自带**的那份(未被钩子重定向到祖先链副本)。
|
|
56
|
+
* 判据:自带副本是解析顺序上的第一份,且钩子只在祖先副本**严格更新**时才重定向
|
|
57
|
+
* (同版本继续用自带),所以 running == copies[0] ⟺ 自带在用。copies 为空
|
|
58
|
+
* (如部分桌面/容器布局)时按自带算 —— 那本来就是唯一能加载的份。
|
|
59
|
+
* UI 用它决定是否亮「安装全局引擎并切换」入口(issue #321)。
|
|
60
|
+
*/
|
|
61
|
+
export function isBundledInUse(copies, effectiveVersion) {
|
|
62
|
+
return copies.length === 0 || effectiveVersion === copies[0].version;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 启动横幅要打印的一行(不含前缀),以及是否需要提示「本进程在用的不是最新的那份」。
|
|
56
66
|
* `effective` 为空时(没找到副本)返回空串,调用方照旧只打印版本号。
|
|
67
|
+
*
|
|
68
|
+
* #321 起默认会自动跟随更新的那份(resolve-global-sdk),所以这条提示出现即意味着:
|
|
69
|
+
* 进程启动时没跟上(升级发生在启动后 / 旧构建没注入钩子),或用户显式
|
|
70
|
+
* PI_WEB_SDK=bundled 钉死了自带副本 —— 文案把两种出路都写明。
|
|
57
71
|
*/
|
|
58
72
|
export function sdkOriginNote(copies, effectiveVersion) {
|
|
59
73
|
const shadowed = copies.slice(1);
|
|
60
74
|
const newer = shadowed.filter((c) => compareVersions(c.version, effectiveVersion) > 0);
|
|
61
75
|
if (newer.length === 0)
|
|
62
76
|
return null;
|
|
63
|
-
return `a newer pi SDK is installed
|
|
77
|
+
return (`a newer pi SDK is installed on this machine (${newer.map((c) => `v${c.version}`).join(", ")}) ` +
|
|
78
|
+
`but this process runs v${effectiveVersion} — restart to follow it (default), ` +
|
|
79
|
+
`or set PI_WEB_SDK=bundled to pin the bundled copy.`);
|
|
64
80
|
}
|
package/dist/server/serialize.js
CHANGED
|
@@ -65,7 +65,11 @@ function serializeAssistantContent(content) {
|
|
|
65
65
|
return { type: "text", text, truncated };
|
|
66
66
|
}
|
|
67
67
|
if (b.type === "thinking") {
|
|
68
|
-
|
|
68
|
+
// thinking 也走 TEXT_CAP:思维链没有长度保证(长任务能刷出远超正文的
|
|
69
|
+
// 体量),不截断会把快照推送撑爆。UiThinkingBlock 没有 truncated 字段
|
|
70
|
+
// (protocol 不动),截断语义靠 truncate 自带的 "… [truncated]" 尾标。
|
|
71
|
+
const { text } = truncate(b.thinking, TEXT_CAP);
|
|
72
|
+
return { type: "thinking", thinking: text };
|
|
69
73
|
}
|
|
70
74
|
if (b.type === "toolCall") {
|
|
71
75
|
if (b.arguments === undefined) {
|
|
@@ -107,18 +111,98 @@ export function stripTransientRetryErrors(messages, retryActive) {
|
|
|
107
111
|
}
|
|
108
112
|
return end === messages.length ? messages : messages.slice(0, end);
|
|
109
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Single source for rendered message ids. Both serializeMessage (下发) and
|
|
116
|
+
* resolveMessageEntry (解析) must derive ids through this function — recomputing
|
|
117
|
+
* the format anywhere else is how the two sides drifted apart and fork/rollback
|
|
118
|
+
* on assistant bubbles stopped resolving (issue #381).
|
|
119
|
+
*/
|
|
120
|
+
export function uiMessageId(m, seq) {
|
|
121
|
+
switch (m.role) {
|
|
122
|
+
case "user":
|
|
123
|
+
return `u-${m.timestamp}-${seq}`;
|
|
124
|
+
case "assistant":
|
|
125
|
+
return `a-${m.timestamp}-${seq}`;
|
|
126
|
+
case "toolResult":
|
|
127
|
+
return `t-${m.toolCallId}`;
|
|
128
|
+
case "bashExecution":
|
|
129
|
+
return `b-${m.timestamp}-${seq}`;
|
|
130
|
+
case "custom":
|
|
131
|
+
return `c-${m.timestamp}-${seq}`;
|
|
132
|
+
case "branchSummary":
|
|
133
|
+
return `bs-${m.timestamp}-${seq}`;
|
|
134
|
+
case "compactionSummary":
|
|
135
|
+
return `cs-${m.timestamp}-${seq}`;
|
|
136
|
+
default:
|
|
137
|
+
return `x-${seq}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Find the session entry a rendered message id points at, by re-deriving each
|
|
142
|
+
* entry's rendered id through uiMessageId() — the same function serializeMessage
|
|
143
|
+
* used to hand ids to the browser. `seqOf` supplies the per-message seq; the
|
|
144
|
+
* caller injects its counter there (agent-service passes uiMessageKey().n, which
|
|
145
|
+
* is exactly the counter serialization drew from — issue #381).
|
|
146
|
+
*/
|
|
147
|
+
export function findEntryByUiId(entries, messageId, seqOf) {
|
|
148
|
+
const userSeqByTs = new Map();
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (entry.id === messageId)
|
|
151
|
+
return entry;
|
|
152
|
+
if (entry.type === "message" && entry.message) {
|
|
153
|
+
const m = entry.message;
|
|
154
|
+
let seq;
|
|
155
|
+
if (m.role === "user") {
|
|
156
|
+
// User ids count messages sharing a timestamp — mirrors the
|
|
157
|
+
// special-case in serializeCachedFor() (see its comment).
|
|
158
|
+
const ts = m.timestamp ?? 0;
|
|
159
|
+
seq = (userSeqByTs.get(ts) ?? 0) + 1;
|
|
160
|
+
userSeqByTs.set(ts, seq);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
seq = seqOf(m);
|
|
164
|
+
}
|
|
165
|
+
if (uiMessageId(m, seq) === messageId)
|
|
166
|
+
return entry;
|
|
167
|
+
}
|
|
168
|
+
else if (entry.type === "custom_message" && entry.display !== false) {
|
|
169
|
+
// Custom messages carry content/timestamp on the entry itself (no
|
|
170
|
+
// message object); rebuild the shape uiMessageId()/seqOf() key on —
|
|
171
|
+
// the same conversion createCustomMessage() uses on reload.
|
|
172
|
+
const m = {
|
|
173
|
+
role: "custom",
|
|
174
|
+
content: entry.content,
|
|
175
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : 0,
|
|
176
|
+
};
|
|
177
|
+
if (uiMessageId(m, seqOf(m)) === messageId)
|
|
178
|
+
return entry;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
110
183
|
export function serializeMessage(m, seq) {
|
|
184
|
+
// SDK 的 system 消息是 prompt sections 的内部差量
|
|
185
|
+
// (content 空串 + sections 结构化内存)、compaction 的
|
|
186
|
+
// systemMessage 等——从来不面向用户。不过滤的话
|
|
187
|
+
// 会掉进 default 分支被序列化成 content: []
|
|
188
|
+
// 的空气泡、前端顶着 system 标题白显示一条
|
|
189
|
+
// (对话结束后底部冒出的空 SYSTEM 气泡就是它)。
|
|
190
|
+
// LLM 上下文不受影响——这里只决定浏览器看到什么。
|
|
191
|
+
if (m.role === "system") {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const id = uiMessageId(m, seq);
|
|
111
195
|
switch (m.role) {
|
|
112
196
|
case "user":
|
|
113
197
|
return {
|
|
114
|
-
id
|
|
198
|
+
id,
|
|
115
199
|
role: "user",
|
|
116
200
|
content: serializeUserContent(m.content),
|
|
117
201
|
timestamp: m.timestamp,
|
|
118
202
|
};
|
|
119
203
|
case "assistant":
|
|
120
204
|
return {
|
|
121
|
-
id
|
|
205
|
+
id,
|
|
122
206
|
role: "assistant",
|
|
123
207
|
content: serializeAssistantContent(m.content),
|
|
124
208
|
timestamp: m.timestamp,
|
|
@@ -152,7 +236,7 @@ export function serializeMessage(m, seq) {
|
|
|
152
236
|
const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
|
|
153
237
|
const content = raw || images.length === 0 ? [{ type: "text", text, truncated }, ...images] : [...images];
|
|
154
238
|
const msg = {
|
|
155
|
-
id
|
|
239
|
+
id,
|
|
156
240
|
role: "toolResult",
|
|
157
241
|
content,
|
|
158
242
|
toolCallId: m.toolCallId,
|
|
@@ -177,7 +261,7 @@ export function serializeMessage(m, seq) {
|
|
|
177
261
|
case "bashExecution": {
|
|
178
262
|
const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
|
|
179
263
|
return {
|
|
180
|
-
id
|
|
264
|
+
id,
|
|
181
265
|
role: "bashExecution",
|
|
182
266
|
content: [
|
|
183
267
|
{
|
|
@@ -199,19 +283,32 @@ export function serializeMessage(m, seq) {
|
|
|
199
283
|
return null;
|
|
200
284
|
}
|
|
201
285
|
const content = serializeUserContent(m.content);
|
|
202
|
-
|
|
203
|
-
id
|
|
286
|
+
const msg = {
|
|
287
|
+
id,
|
|
204
288
|
role: "custom",
|
|
205
289
|
content,
|
|
206
290
|
customType: m.customType,
|
|
207
|
-
details: m.details,
|
|
208
291
|
timestamp: m.timestamp,
|
|
209
292
|
};
|
|
293
|
+
// custom details 与 toolResult 的 details 同一闸门(TOOL_DETAILS_CAP):
|
|
294
|
+
// details 随每 60ms 一发的快照推送,扩展塞进来的大对象不能无节制;
|
|
295
|
+
// 超限/序列化失败整丢(截断后的 JSON 不可解析,前端还得写容错)。
|
|
296
|
+
const rawDetails = m.details;
|
|
297
|
+
if (rawDetails !== undefined) {
|
|
298
|
+
try {
|
|
299
|
+
if (JSON.stringify(rawDetails).length <= TOOL_DETAILS_CAP)
|
|
300
|
+
msg.details = rawDetails;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return msg;
|
|
210
307
|
}
|
|
211
308
|
case "branchSummary": {
|
|
212
309
|
const { text, truncated } = truncate(m.summary, TEXT_CAP);
|
|
213
310
|
return {
|
|
214
|
-
id
|
|
311
|
+
id,
|
|
215
312
|
role: "branchSummary",
|
|
216
313
|
content: [{ type: "text", text, truncated }],
|
|
217
314
|
timestamp: m.timestamp,
|
|
@@ -220,26 +317,16 @@ export function serializeMessage(m, seq) {
|
|
|
220
317
|
case "compactionSummary": {
|
|
221
318
|
const { text, truncated } = truncate(m.summary, TEXT_CAP);
|
|
222
319
|
return {
|
|
223
|
-
id
|
|
320
|
+
id,
|
|
224
321
|
role: "compactionSummary",
|
|
225
322
|
content: [{ type: "text", text, truncated }],
|
|
226
323
|
timestamp: m.timestamp,
|
|
227
324
|
tokensBefore: m.tokensBefore,
|
|
228
325
|
};
|
|
229
326
|
}
|
|
230
|
-
case "system": {
|
|
231
|
-
// SDK 的 system 消息是 prompt sections 的内部差量
|
|
232
|
-
// (content 空串 + sections 结构化内存)、compaction 的
|
|
233
|
-
// systemMessage 等——从来不面向用户。不过滤的话
|
|
234
|
-
// 会掉进 default 分支被序列化成 content: []
|
|
235
|
-
// 的空气泡、前端顶着 system 标题白显示一条
|
|
236
|
-
// (对话结束后底部冒出的空 SYSTEM 气泡就是它)。
|
|
237
|
-
// LLM 上下文不受影响——这里只决定浏览器看到什么。
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
327
|
default:
|
|
241
328
|
return {
|
|
242
|
-
id
|
|
329
|
+
id,
|
|
243
330
|
role: String(m.role ?? "unknown"),
|
|
244
331
|
content: [],
|
|
245
332
|
timestamp: m.timestamp,
|
|
@@ -569,11 +569,13 @@ export class SettingsService {
|
|
|
569
569
|
if (partial.quickPhrasesEnabled !== undefined) {
|
|
570
570
|
this.settings.quickPhrasesEnabled = partial.quickPhrasesEnabled;
|
|
571
571
|
}
|
|
572
|
-
this.host.stateStore.saveSettings(this.host.clientId, this.settings);
|
|
573
|
-
this.push();
|
|
574
572
|
// 统一工具开关 live 生效(ActiveSet 加减;失败静默,下次创建/reload 重放)。
|
|
573
|
+
// 必须在 this.push() 前生效,保证 promptSnapshot 收集到的是最新的活跃工具集与完整提示词!
|
|
575
574
|
if (toolGatingChanged)
|
|
576
575
|
this.host.applyToolGating();
|
|
576
|
+
this.host.stateStore.saveSettings(this.host.clientId, this.settings);
|
|
577
|
+
this.push();
|
|
578
|
+
this.host.flushSnapshot();
|
|
577
579
|
if (needsReload)
|
|
578
580
|
await this.applyRuntime();
|
|
579
581
|
}
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
// 查不到被禁用的技能;
|
|
15
15
|
// - 与 SDK 同名工具撞名时 customTools 按 name 覆盖(bash 本体是先例)。
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
|
|
18
18
|
// 返回文本按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
import { statSync, readFileSync } from "node:fs";
|
|
21
21
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import { Type } from "typebox";
|
|
23
|
-
import {
|
|
23
|
+
import { pick } from "./i18n.js";
|
|
24
24
|
import { SKILL_TOOL_NAME } from "./tool-manager.js";
|
|
25
25
|
import { decodeText } from "./text-sniff.js";
|
|
26
26
|
/** 单文件正文上限(与 agent-service fillSkillContents 同口径:8KB)。 */
|
|
@@ -61,12 +61,10 @@ export function makeSkillTool(host, lang) {
|
|
|
61
61
|
return defineTool({
|
|
62
62
|
name: SKILL_TOOL_NAME,
|
|
63
63
|
label: "Load a skill",
|
|
64
|
-
description:
|
|
64
|
+
description: "Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
|
|
65
65
|
"Prefer this over reading the skill file with the read tool — no path guessing needed. " +
|
|
66
|
-
"Call without a name to list the current catalog.",
|
|
67
|
-
|
|
68
|
-
"不给名则返回当前名录。"),
|
|
69
|
-
promptSnippet: bilingual("load a skill's full text by name (skill tool, preferred over read)", "按名加载技能全文(skill 工具,优先于 read)"),
|
|
66
|
+
"Call without a name to list the current catalog.",
|
|
67
|
+
promptSnippet: "load a skill's full text by name (skill tool, preferred over read)",
|
|
70
68
|
parameters: Type.Object({
|
|
71
69
|
name: Type.Optional(Type.String({
|
|
72
70
|
description: "Exact skill name from the catalog. Omit to list the current catalog.",
|
|
@@ -42,6 +42,53 @@ export function pickTemplatePrompt(t, lang) {
|
|
|
42
42
|
}
|
|
43
43
|
/** 名字去空白折叠后非空且 ≤ 60 字符(工具参数可读,允许中文)。 */
|
|
44
44
|
const NAME_MAX = 60;
|
|
45
|
+
/**
|
|
46
|
+
* 模板体积上限(保存期拒绝,不静默截断):模板会整体进入设置面板快照并被
|
|
47
|
+
* subagent_templates 工具列给 AI,systemPrompt 还会拼进子代理上下文,无上限
|
|
48
|
+
* 的单条模板能把快照推送与子代理首响都拖爆。对齐常见上下文预算的保守值。
|
|
49
|
+
*/
|
|
50
|
+
export const TEMPLATE_LIMITS = {
|
|
51
|
+
/** 模板系统提示词上限(systemPrompt / systemPromptEn 各自计)。 */
|
|
52
|
+
systemPrompt: 32_768,
|
|
53
|
+
/** 模板简介上限(description / descriptionEn 各自计)。 */
|
|
54
|
+
description: 2_000,
|
|
55
|
+
/** 技能/扩展白名单各自最多条目数。 */
|
|
56
|
+
whitelistEntries: 64,
|
|
57
|
+
/** 白名单单条字符上限。 */
|
|
58
|
+
whitelistEntryLength: 200,
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* 保存期体积校验:超限返回明确错误文本(让设置面板能原样提示),null = 通过。
|
|
62
|
+
* 只拦新保存,不拦读盘老数据(向后兼容,与 replace 空提示词的拦截口径一致);
|
|
63
|
+
* 拒绝而非截断——静默截断会让用户以为保存的是完整内容。
|
|
64
|
+
*/
|
|
65
|
+
export function validateTemplateLimits(t) {
|
|
66
|
+
if (t.systemPrompt.length > TEMPLATE_LIMITS.systemPrompt) {
|
|
67
|
+
return `模板 "${t.name}" 的系统提示词超长(${t.systemPrompt.length} > ${TEMPLATE_LIMITS.systemPrompt} 字符),请精简后再保存`;
|
|
68
|
+
}
|
|
69
|
+
if ((t.systemPromptEn ?? "").length > TEMPLATE_LIMITS.systemPrompt) {
|
|
70
|
+
return `模板 "${t.name}" 的英文系统提示词超长(${(t.systemPromptEn ?? "").length} > ${TEMPLATE_LIMITS.systemPrompt} 字符),请精简后再保存`;
|
|
71
|
+
}
|
|
72
|
+
if (t.description.length > TEMPLATE_LIMITS.description) {
|
|
73
|
+
return `模板 "${t.name}" 的简介超长(${t.description.length} > ${TEMPLATE_LIMITS.description} 字符),请精简后再保存`;
|
|
74
|
+
}
|
|
75
|
+
if ((t.descriptionEn ?? "").length > TEMPLATE_LIMITS.description) {
|
|
76
|
+
return `模板 "${t.name}" 的英文简介超长(${(t.descriptionEn ?? "").length} > ${TEMPLATE_LIMITS.description} 字符),请精简后再保存`;
|
|
77
|
+
}
|
|
78
|
+
for (const [label, list] of [
|
|
79
|
+
["技能白名单", t.enabledSkills],
|
|
80
|
+
["扩展白名单", t.enabledExtensions],
|
|
81
|
+
]) {
|
|
82
|
+
if (list.length > TEMPLATE_LIMITS.whitelistEntries) {
|
|
83
|
+
return `模板 "${t.name}" 的${label}条目过多(${list.length} > ${TEMPLATE_LIMITS.whitelistEntries} 条)`;
|
|
84
|
+
}
|
|
85
|
+
const over = list.find((entry) => entry.length > TEMPLATE_LIMITS.whitelistEntryLength);
|
|
86
|
+
if (over) {
|
|
87
|
+
return `模板 "${t.name}" 的${label}有超长条目(${over.length} > ${TEMPLATE_LIMITS.whitelistEntryLength} 字符)`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
45
92
|
/**
|
|
46
93
|
* 内置默认模板(第一次运行时种子进列表;用户改动后以 <dataDir> 文件为准)。
|
|
47
94
|
* 文案改编自 pi-subagents 社区项目(tintinweb / nicobailon)的角色提示词,
|
|
@@ -629,6 +676,10 @@ export class SubagentTemplatesStore {
|
|
|
629
676
|
if (t.promptMode === "replace" && !t.systemPrompt.trim() && !(t.systemPromptEn ?? "").trim()) {
|
|
630
677
|
return `模板 "${t.name}" 用了 replace 模式但系统提示词为空:replace 会整体替换子代理 persona,空提示词等于模板没生效。请填写提示词(中文或英文至少一个),只想限定技能/扩展白名单请用 append 模式`;
|
|
631
678
|
}
|
|
679
|
+
// 体积上限同样只在保存期拦:超限明确报错,绝不静默截断。
|
|
680
|
+
const limitError = validateTemplateLimits(t);
|
|
681
|
+
if (limitError)
|
|
682
|
+
return limitError;
|
|
632
683
|
const list = this.load();
|
|
633
684
|
const i = list.findIndex((x) => x.name === t.name);
|
|
634
685
|
if (i >= 0)
|