pi-web-ui 0.86.2 → 0.87.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 (36) hide show
  1. package/CHANGELOG.md +59 -21
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  34. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  35. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  36. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
@@ -379,6 +379,34 @@ export class DshRuntime {
379
379
  await this.start();
380
380
  return this._request("skills/set-disabled", { skills });
381
381
  }
382
+ // -----------------------------------------------------------------------
383
+ // Agent 预设 RPC(dsh-web 四模式;roster 缺失 = legacy,list 报 unavailable)
384
+ // -----------------------------------------------------------------------
385
+ /** 列出预设名录(字段以运行时树为准;unavailable = 回落 legacy 单组合)。 */
386
+ async listPresets() {
387
+ await this.start();
388
+ return this._request("preset/list", {});
389
+ }
390
+ /** 登记创建期预设(新会话首个 prompt 前调用;创建时消费)。 */
391
+ async assignPreset(sessionId, preset) {
392
+ await this.start();
393
+ return this._request("preset/assign", { sessionId, preset });
394
+ }
395
+ /** 空白会话切换预设(首轮后锁;走 {ok:false,code,message} 信封,不抛 domain 错)。 */
396
+ async selectPreset(sessionId, preset) {
397
+ await this.start();
398
+ return this._request("preset/select", { sessionId, preset });
399
+ }
400
+ /** 读一个会话的权限预设(官方 permissions projection 同源:options + currentValue)。 */
401
+ async getPermission(sessionId) {
402
+ await this.start();
403
+ return this._request("permission/get", { sessionId });
404
+ }
405
+ /** 按会话热切换权限预设(写会话日志,无需重启;走 {ok:false,code,message} 信封)。 */
406
+ async setPermission(sessionId, preset) {
407
+ await this.start();
408
+ return this._request("permission/set", { sessionId, preset });
409
+ }
382
410
  /** 优雅关闭:shutdown 握手 → stdin EOF → SIGTERM → SIGKILL 阶梯。 */
383
411
  async close() {
384
412
  if (this.closed)
@@ -96,6 +96,34 @@ function zstdDecompressAll(buf) {
96
96
  }
97
97
  return out || buf.toString("utf8");
98
98
  }
99
+ /** 会话实际跑的预设:最后一条 agent-preset/selected 事件 > header 记录 > null。
100
+ * 重启/切换回放时用它恢复 conv 预设(服务内存已丢时唯一的真相)。纯函数。 */
101
+ /** 回放权限预设:permission/preset 事件最后一条(切换即追加,无 header 兜底)。 */
102
+ export function sessionLogPermission(log) {
103
+ let selected = null;
104
+ for (const ev of log.events) {
105
+ if (ev.type === "permission/preset") {
106
+ const id = ev.data?.preset;
107
+ if (typeof id === "string" && id)
108
+ selected = id;
109
+ }
110
+ }
111
+ return selected;
112
+ }
113
+ export function sessionLogPreset(log) {
114
+ let selected = null;
115
+ for (const ev of log.events) {
116
+ if (ev.type === "agent-preset/selected") {
117
+ const id = ev.data?.agentPreset;
118
+ if (typeof id === "string" && id)
119
+ selected = id;
120
+ }
121
+ }
122
+ if (selected)
123
+ return selected;
124
+ const h = log.header?.agentPreset;
125
+ return typeof h === "string" && h ? h : null;
126
+ }
99
127
  /** 读一个会话 JSONL 文件 → { header, events }。 */
100
128
  export function readSessionLog(file) {
101
129
  const raw = readFileSync(file);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * dsh-usage.ts — DSH 的 token 用量 → 底栏统计口径(纯函数,零依赖,可单测)。
3
+ *
4
+ * DSH `TokenUsage`(模型调用级,见 @deepseek-ai/dsh-llm)的三个**输入桶互斥**:
5
+ * inputTokens 未命中 prompt cache 的输入
6
+ * cacheReadTokens 命中 prompt cache 的输入(DeepSeek 从 prompt_tokens 里减出来)
7
+ * cacheWriteTokens 写入 / 刷新 cache 的输入
8
+ * outputTokens 本次输出
9
+ * 所以「最近一次请求的输入 = 三桶相加」,而底栏缓存命中率(web/src/cache-stats.ts 的
10
+ * cacheMetrics)正是按 miss/read/write 归一算的 — 两边口径一致。
11
+ *
12
+ * 两条投递路径共用这里的映射:
13
+ * - 老运行时(0.1.1-rc.2):usage 是持久 `assistant/chunk` 事件(逐 chunk 推)
14
+ * - 新运行时(0.1.1-rc.2 之后):usage 随持久 `assistant/message.usage` 落一次,另有
15
+ * `agent/assistant-stream` 直播帧(wrapper 转成 `assistant.stream` 通知)
16
+ * 字段形状两边一致,故收成一个纯函数(单测 tests/unit/dsh-usage.test.ts)。
17
+ */
18
+ /** 非负整数兜底:缺字段 / 非法值(NaN、负、Infinity)一律算 0。 */
19
+ function count(v) {
20
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.round(v) : 0;
21
+ }
22
+ /**
23
+ * 一次模型调用的 DSH usage → 四桶统计。
24
+ *
25
+ * @param usage - `assistant/chunk.usage` / `assistant/message.usage`(结构子集)。
26
+ * @returns 四桶;非对象(缺 usage)→ null,调用方据此保留旧统计不动。
27
+ */
28
+ export function normalizeDshUsage(usage) {
29
+ if (!usage || typeof usage !== "object" || Array.isArray(usage))
30
+ return null;
31
+ const u = usage;
32
+ return {
33
+ input: count(u.inputTokens),
34
+ output: count(u.outputTokens),
35
+ cacheRead: count(u.cacheReadTokens),
36
+ cacheWrite: count(u.cacheWriteTokens),
37
+ };
38
+ }
39
+ /** 最近一次请求的 prompt token 数(三个输入桶互斥 → 相加)。 */
40
+ export function dshPromptTokens(tokens) {
41
+ return tokens.input + tokens.cacheRead + tokens.cacheWrite;
42
+ }
43
+ /**
44
+ * 当前上下文占用 = 最近一次请求的 prompt + 该次输出(下一步请求会把它带上)。
45
+ * 还没拿到任何 usage(新对话 / 回放无 usage)→ tokens/percent 为 null,前端显示
46
+ * `—`(与 pi 引擎一致,而不是骗人的 `0 / 1.0M`)。
47
+ */
48
+ export function dshContextUsage(tokens, contextWindow) {
49
+ if (!tokens)
50
+ return { tokens: null, contextWindow, percent: null };
51
+ const used = dshPromptTokens(tokens) + tokens.output;
52
+ if (!(contextWindow > 0))
53
+ return { tokens: used, contextWindow, percent: null };
54
+ return { tokens: used, contextWindow, percent: Math.min(100, (used / contextWindow) * 100) };
55
+ }
56
+ /**
57
+ * 从会话日志(JSONL 回放 / 事件流)里取**最后一次** usage,供切回历史会话时底栏
58
+ * 立刻有上下文与缓存命中数字。
59
+ *
60
+ * 同时认两种形状:新运行时的 `assistant/message.usage`,老运行时的
61
+ * `assistant/chunk` 里的 `{type:"usage", usage}`。按日志顺序取最后一条(日志即 seq 序)。
62
+ */
63
+ export function lastUsageFromEvents(events) {
64
+ let found = null;
65
+ for (const ev of events) {
66
+ const data = ev.data ?? {};
67
+ if (ev.type === "assistant/message") {
68
+ const usage = normalizeDshUsage(data.usage);
69
+ if (usage)
70
+ found = usage;
71
+ }
72
+ else if (ev.type === "assistant/chunk") {
73
+ const chunk = data.chunk;
74
+ if (chunk?.type === "usage") {
75
+ const usage = normalizeDshUsage(chunk.usage);
76
+ if (usage)
77
+ found = usage;
78
+ }
79
+ }
80
+ }
81
+ return found;
82
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * preset-clones.ts — DSH Agent 预设的 file: 克隆生成。
3
+ *
4
+ * 背景(实测结论,见 tests/scratch/preset-spike-2026-09-15.md 探针2/5):
5
+ * shipped 预设(dsh-agent-presets 包内 standard/ptc/minimal/cordis)的 composition
6
+ * 用裸包名(`@deepseek-ai/dsh-persona` 等),挂载时按 agent scope 的 baseUrl 解析 ——
7
+ * launcher 式 boot 下 baseUrl = cordis.yml 所在目录,裸包名全部不可解析,四个预设
8
+ * 全带 broken,mount 直接失败。官方 CLI 靠 PluginPackages resolution 走安装锚点,
9
+ * pi-web-ui 没装那套。已验证可行的路:把 composition 拷一份,把裸包名改写成指向
10
+ * 运行时树的绝对 file: URL(clone),roster 用 clone 目录做 system 根并关掉
11
+ * includeShippedRoot(同名 id 早根优先,shipped 的 broken 条目会被 clone 遮蔽)。
12
+ *
13
+ * 本模块是纯 TS(vitest 可测):发现 shipped 目录 → 逐预设拷贝+改写 → 写
14
+ * preset-plane.patch.yml(disable 表 + roster,二者原子生效,缺一不可)。
15
+ * Node 侧(dsh-agent-service)在每次启动运行时前调用;launcher 只消费环境变量,
16
+ * 不做生成(职责分离;clone 缺失 = 回落 legacy 单组合,见 PI_WEB_DSH_PRESET_PATCH)。
17
+ */
18
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath, pathToFileURL } from "node:url";
21
+ /** roster 默认预设(与官方 dsh-web-app/cordis.patch.yml 一致)。 */
22
+ export const PRESET_DEFAULT_ID = "standard";
23
+ /** clone 输出目录名(<dataDir>/ 下)。 */
24
+ export const PRESET_CLONES_DIRNAME = "dsh-preset-clones";
25
+ /** preset-plane patch 文件名(<dataDir>/ 下,由本模块生成,launcher 按 env 加载)。 */
26
+ export const PRESET_PLANE_PATCH_FILENAME = "dsh-preset-plane.patch.yml";
27
+ // ---------------------------------------------------------------------------
28
+ // 运行时树发现(server/dsh/runtime/runtime-root.mjs 的 TS 镜像;改一处记得改另一处)
29
+ // ---------------------------------------------------------------------------
30
+ /** 候选 node_modules 根(顺序同 runtime-root.mjs:显式 > 本包 > execPath 邻近 > npm 全局)。 */
31
+ export function runtimeBaseCandidates() {
32
+ const out = [];
33
+ const explicit = process.env.PI_WEB_DSH_RUNTIME;
34
+ if (explicit)
35
+ out.push(resolve(explicit));
36
+ out.push(resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "node_modules")));
37
+ out.push(join(dirname(process.execPath), "node_modules"));
38
+ try {
39
+ const prefixes = [
40
+ process.env.NPM_CONFIG_PREFIX,
41
+ process.env.npm_config_prefix,
42
+ join(dirname(process.execPath), ".."),
43
+ ];
44
+ for (const prefix of prefixes) {
45
+ if (!prefix)
46
+ continue;
47
+ out.push(join(prefix, "lib", "node_modules"));
48
+ out.push(join(prefix, "node_modules"));
49
+ }
50
+ }
51
+ catch {
52
+ /* ignore */
53
+ }
54
+ return [...new Set(out)];
55
+ }
56
+ function runtimeBaseFor(root) {
57
+ const flat = join(root, "@deepseek-ai");
58
+ if (existsSync(join(flat, "dsh-base", "cordis.patch.yml")) &&
59
+ existsSync(join(flat, "dsh-app-boot", "lib", "index.js"))) {
60
+ return resolve(root);
61
+ }
62
+ const nested = join(root, "@deepseek-ai", "dsh", "node_modules", "@deepseek-ai");
63
+ if (existsSync(join(nested, "dsh-base", "cordis.patch.yml")) &&
64
+ existsSync(join(nested, "dsh-app-boot", "lib", "index.js"))) {
65
+ return resolve(join(root, "@deepseek-ai", "dsh", "node_modules"));
66
+ }
67
+ return null;
68
+ }
69
+ /** shipped 预设源目录(<runtimeBase>/@deepseek-ai/dsh-agent-presets/presets),找不到 = null。 */
70
+ export function findShippedPresetsDir() {
71
+ for (const root of runtimeBaseCandidates()) {
72
+ const base = runtimeBaseFor(root);
73
+ if (!base)
74
+ continue;
75
+ const dir = join(base, "@deepseek-ai", "dsh-agent-presets", "presets");
76
+ if (existsSync(join(dir, "standard", "agent.cordis.yml")))
77
+ return dir;
78
+ }
79
+ return null;
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // 裸包名改写(entry 文件解析 + 文本改写;改写是纯函数,可单测)
83
+ // ---------------------------------------------------------------------------
84
+ /**
85
+ * 把一个裸包名解析到运行时树内的入口文件 file: URL。
86
+ * discovery 的健康检查要求 file: 指向真实文件(目录 URL 恒判 broken),
87
+ * mount 侧 Loader 直接 import 该 URL(peer 依赖从运行时树向上解析)。
88
+ * @param scopeDir 运行时树里 `@deepseek-ai` 作用域的本地目录
89
+ * @param bare `@deepseek-ai/<pkg>` 或 `@deepseek-ai/<pkg>/<sub>`
90
+ * @returns 入口文件的 file: URL;解析不出返回 null(调用方保留原文,roster 报 broken)
91
+ */
92
+ export function resolvePackageEntryFileUrl(scopeDir, bare) {
93
+ const parts = bare.split("/");
94
+ if (parts.length < 2 || !parts[0].startsWith("@") || !parts[1])
95
+ return null;
96
+ const pkg = parts[1];
97
+ const sub = parts.length > 2 ? parts.slice(2).join("/") : undefined;
98
+ const pkgDir = join(scopeDir, pkg);
99
+ let manifest = {};
100
+ try {
101
+ manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ const pickDefault = (v) => {
107
+ if (typeof v === "string")
108
+ return v;
109
+ if (v && typeof v === "object" && typeof v.default === "string") {
110
+ return v.default;
111
+ }
112
+ return null;
113
+ };
114
+ const candidates = [];
115
+ const exportsMap = manifest.exports && typeof manifest.exports === "object" ? manifest.exports : null;
116
+ if (sub) {
117
+ const hit = exportsMap?.[`./${sub}`];
118
+ const mapped = pickDefault(hit);
119
+ if (mapped)
120
+ candidates.push(mapped);
121
+ candidates.push(join("lib", `${sub}.js`), join("lib", sub, "index.js"));
122
+ }
123
+ else {
124
+ const mapped = pickDefault(exportsMap?.["."]);
125
+ if (mapped)
126
+ candidates.push(mapped);
127
+ if (typeof manifest.main === "string" && manifest.main)
128
+ candidates.push(manifest.main);
129
+ candidates.push(join("lib", "index.js"));
130
+ }
131
+ for (const rel of candidates) {
132
+ const abs = resolve(pkgDir, rel);
133
+ if (existsSync(abs))
134
+ return pathToFileURL(abs).href;
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * 把 composition 文本里的裸包名改写成入口文件 file: URL。
140
+ * 只动 `name:` 行的 `@deepseek-ai/<pkg[\/sub]>`(单/双引号),`cordis:group`、
141
+ * `./相对路径`、`!!js` 表达式一律不动。resolve 回 null 的行保留原文。
142
+ */
143
+ export function rewriteBareNames(yamlText, resolve) {
144
+ const unresolved = new Set();
145
+ const text = yamlText.replace(/^([ \t]*name:[ \t]*)(['"])@deepseek-ai\/([^'"\s]+)\2([ \t]*(?:#.*)?)$/gim, (_m, prefix, quote, rest, suffix) => {
146
+ const url = resolve(`@deepseek-ai/${rest}`);
147
+ if (!url) {
148
+ unresolved.add(`@deepseek-ai/${rest}`);
149
+ return _m;
150
+ }
151
+ return `${prefix}${quote}${url}${quote}${suffix}`;
152
+ });
153
+ return { text, unresolved: [...unresolved] };
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // preset-plane patch(disable 表 + roster;纯函数,可单测)
157
+ // ---------------------------------------------------------------------------
158
+ /** 与官方 dsh-web-app/cordis.patch.yml 同表的 agent-plane disable 行(last-write-wins 整行替换)。 */
159
+ export const AGENT_PLANE_DISABLE_IDS = [
160
+ "tool-bash",
161
+ "tool-pwsh",
162
+ "tool-jobs",
163
+ "tool-fs",
164
+ "tool-fs-search",
165
+ "tool-str-replace-editor",
166
+ "skill-filesystem",
167
+ "tool-skill",
168
+ "command-goal",
169
+ "tool-goal",
170
+ "plan-mode",
171
+ "compaction-basic",
172
+ "command-compact",
173
+ "tool-result-pruner",
174
+ "tool-subagent-control",
175
+ "tool-subagent-list-agents",
176
+ "tool-subagent",
177
+ "tool-subagent-fork",
178
+ "workflow-worker-thread",
179
+ "tool-workflow",
180
+ "tool-ralph",
181
+ "agent-instructions",
182
+ "tool-todo",
183
+ "tool-web",
184
+ ];
185
+ /**
186
+ * 生成 preset-plane patch 文本:先 disable 全部 agent-plane 行(工具下沉到预设),
187
+ * 再 insert roster(clone 目录做唯一 system 根,关 shipped 根;user 根保留,
188
+ * `$DSH_HOME/.agent-presets` 的自建预设照常出现)。
189
+ * 注意:YAML 里 clonesDir 走 JSON 转义(Windows 反斜杠安全)。
190
+ */
191
+ export function buildPresetPlanePatch(clonesDir) {
192
+ const disables = AGENT_PLANE_DISABLE_IDS.map((id) => `- id: ${id}\n disabled: true`).join("\n");
193
+ return (`# 由 server/dsh/preset-clones.ts 生成(每次 DSH 运行时启动前重写),勿手改。\n` +
194
+ `# agent plane 下沉到预设:与官方 dsh-web-app/cordis.patch.yml 同表。\n` +
195
+ `${disables}\n\n` +
196
+ `# preset roster:clone 目录是唯一 system 根(同名 id 早根优先,shipped 原件被遮蔽);\n` +
197
+ `# user 根保留(自建预设照常上架;裸包名在 launcher boot 下不可解析是已知限制,见文档)。\n` +
198
+ `- insert:\n` +
199
+ ` - id: agent-presets\n` +
200
+ ` name: '@deepseek-ai/dsh-agent-presets'\n` +
201
+ ` config:\n` +
202
+ ` default: ${PRESET_DEFAULT_ID}\n` +
203
+ ` includeShippedRoot: false\n` +
204
+ ` roots:\n` +
205
+ ` - path: ${JSON.stringify(clonesDir)}\n` +
206
+ ` trust: system\n`);
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // 生成主入口(IO;幂等:先清后写)
210
+ // ---------------------------------------------------------------------------
211
+ /**
212
+ * 生成 clone + preset-plane patch。成功返回结果;shipped 目录找不到返回 null
213
+ * (调用方回落 legacy 单组合:不下发 patch,roster 不挂载)。
214
+ * @param dataDir pi-web-ui 数据目录(输出 <dataDir>/dsh-preset-clones/ + patch 文件)
215
+ * @param shippedDir 覆盖 shipped 源目录(单测 fixture 用;缺省自动发现)
216
+ */
217
+ export function generatePresetClones(dataDir, shippedDir) {
218
+ const src = shippedDir ?? findShippedPresetsDir();
219
+ if (!src)
220
+ return null;
221
+ let ids;
222
+ try {
223
+ ids = readdirSync(src, { withFileTypes: true })
224
+ .filter((e) => e.isDirectory() && /^[a-z0-9][a-z0-9-]*$/.test(e.name))
225
+ .map((e) => e.name)
226
+ .filter((id) => existsSync(join(src, id, "agent.cordis.yml")))
227
+ .sort();
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ if (!ids.includes(PRESET_DEFAULT_ID))
233
+ return null;
234
+ const clonesDir = join(dataDir, PRESET_CLONES_DIRNAME);
235
+ rmSync(clonesDir, { recursive: true, force: true });
236
+ mkdirSync(clonesDir, { recursive: true });
237
+ // 入口解析基:运行时树里的 `@deepseek-ai` 作用域
238
+ //(src = <base>/@deepseek-ai/dsh-agent-presets/presets,上两级即作用域)。
239
+ const scopeDir = join(src, "..", "..");
240
+ const warnings = [];
241
+ try {
242
+ for (const id of ids) {
243
+ const dst = join(clonesDir, id);
244
+ cpSync(join(src, id), dst, { recursive: true });
245
+ const compFile = join(dst, "agent.cordis.yml");
246
+ const raw = readFileSync(compFile, "utf8");
247
+ const { text, unresolved } = rewriteBareNames(raw, (bare) => resolvePackageEntryFileUrl(scopeDir, bare));
248
+ for (const u of unresolved)
249
+ warnings.push(`${id}: ${u}`);
250
+ writeFileSync(compFile, text);
251
+ }
252
+ }
253
+ catch {
254
+ rmSync(clonesDir, { recursive: true, force: true });
255
+ return null;
256
+ }
257
+ const patchFile = join(dataDir, PRESET_PLANE_PATCH_FILENAME);
258
+ writeFileSync(patchFile, buildPresetPlanePatch(clonesDir));
259
+ return { clonesDir, patchFile, presetIds: ids, warnings };
260
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * pi-web-ui custom prompt section (host plane).
3
+ *
4
+ * pi-web-ui 的自定义系统提示词在预设时代的载体:官方 preset 的 persona 行会
5
+ * shadow 掉部署人设(standard/ptc/cordis),minimal(complete:true)更压住一切,
6
+ * 所以沿用 system-prompt 行(DSH_PERSONA)已无法让用户的自定义提示词生效。
7
+ * 本行在 host plane 注册一个独立 section(deployment:persona-suffix 之后),
8
+ * standard/ptc/cordis 下随系统提示词下发,minimal 下被 complete 压住(官方语义)。
9
+ *
10
+ * 文本来自环境变量 PI_WEB_DSH_CUSTOM_PROMPT(Node 侧 dsh-agent-service 维护,
11
+ * 变化即重启运行时);为空时不注册任何 section。无 Config export(loader 允许,
12
+ * 与 dsh-tool-ask-user 同形)。
13
+ */
14
+
15
+ const name = "pi-webui-custom-prompt";
16
+ const inject = ["systemPrompt"];
17
+
18
+ function apply(ctx) {
19
+ const text = (process.env.PI_WEB_DSH_CUSTOM_PROMPT ?? "").trim();
20
+ if (!text) return;
21
+ const order = ctx.systemPrompt.getSectionOrder("DEPLOYMENT_PERSONA_SUFFIX") + 50;
22
+ ctx.effect(
23
+ () =>
24
+ ctx.systemPrompt.section({
25
+ name: "pi-webui:custom-prompt",
26
+ order,
27
+ text,
28
+ }),
29
+ "pi-webui-custom-prompt.section()",
30
+ );
31
+ }
32
+
33
+ export { apply, inject, name };