pi-web-ui 0.68.0 → 0.68.2

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 (35) hide show
  1. package/bin/pi-web-ui.mjs +1876 -1876
  2. package/dist/server/agent-service.js +450 -68
  3. package/dist/server/attachments.js +3 -3
  4. package/dist/server/client-state.js +4 -2
  5. package/dist/server/dsh/dsh-agent-service.js +15 -1
  6. package/dist/server/dsh/runtime/cordis.yml +1 -1
  7. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -645
  8. package/dist/server/dsh/runtime/launcher.mjs +164 -164
  9. package/dist/server/dsh/runtime/override.patch.yml +71 -71
  10. package/dist/server/dsh/runtime/runtime-root.mjs +92 -86
  11. package/dist/server/index.js +68 -2
  12. package/dist/server/locales.js +156 -0
  13. package/dist/server/plugins.js +63 -0
  14. package/dist/server/prompt-composer.js +34 -0
  15. package/dist/server/serialize.js +24 -0
  16. package/dist/server/settings-service.js +9 -0
  17. package/dist/server/update-check.js +49 -50
  18. package/dist/server/vision-bridge.js +9 -9
  19. package/extensions/webui.ts +190 -190
  20. package/package.json +6 -2
  21. package/plugins/catalog.json +9 -0
  22. package/web/dist/assets/TerminalPanel-BQ5NTB9Y.js +6 -0
  23. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
  24. package/web/dist/assets/index-C_I-6Zul.css +10 -0
  25. package/web/dist/assets/index-Ck5pa3XK.js +333 -0
  26. package/web/dist/assets/markdown-DOsihKaR.js +51 -0
  27. package/web/dist/assets/{react-C9ovnpIm.js → react-DIP6JKYk.js} +2 -2
  28. package/web/dist/assets/xterm-B96xOxS9.js +38 -0
  29. package/web/dist/index.html +4 -4
  30. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
  31. package/web/dist/assets/TerminalPanel-CTY_LN4U.js +0 -6
  32. package/web/dist/assets/index-Dc6t3GTo.css +0 -10
  33. package/web/dist/assets/index-fwF-jmiV.js +0 -332
  34. package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
  35. package/web/dist/assets/xterm-D1D2FVe3.js +0 -38
@@ -1,645 +1,645 @@
1
- /**
2
- * goal-rpc.mjs — pi-web-ui DSH runtime 的 goal RPC 扩展插件。
3
- *
4
- * 在官方 dsh-sdk-jsonrpc-server 之上包一层,为 stdio JSON-RPC 面增加 goal
5
- * 方法(goal/set goal/get goal/clear goal/resume goal/edit),直连 DSH 原生
6
- * goal 域(ctx.goals 服务,dsh-goal)。这样 pi-web-ui 前端的目标条驱动的是
7
- * DSH 自己的持久化目标状态机 + goal-round-driver 自动轮次,而不是另造一套
8
- * 审查会话 —— 审查语义 = DSH 原生(模型自判定 complete/blocked,轮次自动续)。
9
- *
10
- * 与官方插件的差异:inject 增加 "goals"(goal 域服务依赖);handleRequest
11
- * 增加 goal 分支;其余 stdio transport / shutdown 语义原样继承。
12
- *
13
- * 官方入口(做 base 类)从环境变量解析:PI_WEB_DSH_JSONRPC_ENTRY 由 dsh-client
14
- * 注入(指向项目 node_modules 的官方包);兜底从本文件位置向上找项目依赖。
15
- */
16
- import { resolve } from "node:path";
17
- import { dirname, join } from "node:path";
18
- import { fileURLToPath, pathToFileURL } from "node:url";
19
- import { JsonRpcLineTransport } from "@deepseek-ai/dsh-sdk-protocol";
20
- import { defineTool } from "@deepseek-ai/dsh-tools";
21
-
22
- const HERE = dirname(fileURLToPath(import.meta.url));
23
-
24
- /** 官方 jsonrpc-server 入口(env 优先,兜底项目 node_modules)。 */
25
- const officialEntry = process.env.PI_WEB_DSH_JSONRPC_ENTRY
26
- ? resolve(process.env.PI_WEB_DSH_JSONRPC_ENTRY)
27
- : join(
28
- resolve(HERE, "..", "..", "..", ".."),
29
- "node_modules",
30
- "@deepseek-ai",
31
- "dsh-sdk-jsonrpc-server",
32
- "lib",
33
- "index.js",
34
- );
35
-
36
- const official = await import(pathToFileURL(officialEntry).href);
37
- const { HarnessSdkJsonRpcServer, Config } = official;
38
-
39
- /** 提问桥超时(P0-6):PI_WEB_DSH_QUESTION_TIMEOUT_MS 可配置,默认 10 分钟。 */
40
- const QUESTION_TIMEOUT_MS = Number(process.env.PI_WEB_DSH_QUESTION_TIMEOUT_MS) || 10 * 60_000;
41
-
42
- /** 工具桥超时(#15):服务端跑插件实现的等待上限。PI_WEB_DSH_TOOL_TIMEOUT_MS 可配置,默认 10 分钟。 */
43
- const TOOL_TIMEOUT_MS = Number(process.env.PI_WEB_DSH_TOOL_TIMEOUT_MS) || 10 * 60_000;
44
-
45
- /** 过滤一条 skill-catalog 用户消息:按 disabled 集合剔除条目并重建文本。
46
- * 纯函数(可单测)。条目来自消息的 `source.entries`(`{name,description}`),
47
- * 文本里的 `<available_skills>…</available_skills>` 块按保留条目重建,其余不动。 */
48
- export function filterSkillCatalogMessage(message, disabled) {
49
- if (!message || message?.source?.kind !== "skill-catalog") return message;
50
- const entries = Array.isArray(message.source?.entries) ? message.source.entries : [];
51
- const kept = entries.filter((e) => e && !disabled.has(e.name));
52
- if (kept.length === entries.length) return message;
53
- const original =
54
- (Array.isArray(message.content) ? message.content.find((b) => b?.type === "text")?.text : undefined) ?? "";
55
- const text = rebuildCatalogText(original, kept);
56
- return {
57
- ...message,
58
- source: { ...message.source, entries: kept },
59
- content: [{ type: "text", text }],
60
- };
61
- }
62
-
63
- /** 重建 `<available_skills>` 块的文本(保留块外结构;无条目时给占位)。 */
64
- function rebuildCatalogText(original, entries) {
65
- const startTag = "<available_skills>";
66
- const endTag = "</available_skills>";
67
- const si = original.indexOf(startTag);
68
- const ei = original.indexOf(endTag);
69
- if (si < 0 || ei < 0) return original;
70
- const body = entries.length
71
- ? entries.map((e) => `- \`${e.name}\`: ${escapeTextSimple(e.description ?? "")}`).join("\n")
72
- : "(本会话无可启用技能)";
73
- return `${original.slice(0, si + startTag.length)}\n${body}\n${original.slice(ei)}`;
74
- }
75
-
76
- /** 最小 HTML 实体转义(避免 `<`/`&` 破坏 markdown/目录结构)。 */
77
- function escapeTextSimple(s) {
78
- return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
79
- }
80
-
81
- /** 把 pi-web-ui 插件工具的标准 JSON Schema `parameters`(`{type,properties,required[]}`)
82
- * 转成 DSH defineTool 的 per-property 参数映射 spec(`{key:{type,required?,description}}`)。
83
- * DSH 的 parameterSchemaSpecToJsonSchema 需要 property-map 形状(required 挂在每条属性上),
84
- * 而 pi 插件/TypeBox 用的是标准 JSON Schema(required 是对象数组)。纯函数。 */
85
- function piParamsToDshSpec(parameters) {
86
- if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return {};
87
- if (parameters.properties && typeof parameters.properties === "object" && !Array.isArray(parameters.properties)) {
88
- const required = Array.isArray(parameters.required) ? parameters.required : [];
89
- const propMap = {};
90
- for (const [key, def] of Object.entries(parameters.properties)) {
91
- if (!def || typeof def !== "object" || Array.isArray(def)) {
92
- propMap[key] = { type: "string" };
93
- continue;
94
- }
95
- const entry = {};
96
- for (const [k, v] of Object.entries(def)) {
97
- // 只保留 DSH schema 编译器认识的子集;default/format/title 等跳过(避免寄存器 reject)。
98
- if (["type", "description", "enum", "items", "anyOf", "oneOf"].includes(k)) entry[k] = v;
99
- }
100
- if (required.includes(key)) entry.required = true;
101
- propMap[key] = entry;
102
- }
103
- return propMap;
104
- }
105
- // 已是 DSH property-map 形状(每条属性自己带 type)——原样透传。
106
- return parameters;
107
- }
108
-
109
- /** goal view → RPC 载荷(紧凑 JSON;view 是扁平结构,无目标时 get 返回 undefined)。 */
110
- function viewPayload(view) {
111
- if (!view || view.id === undefined) return { goal: null, activation: null };
112
- return {
113
- goal: {
114
- id: view.id,
115
- revision: view.revision,
116
- objective: view.objective,
117
- phase: view.phase,
118
- maxGoalRounds: view.maxGoalRounds,
119
- ...(view.blockedReason === void 0 ? {} : { blockedReason: view.blockedReason }),
120
- roundsStarted: view.roundsStarted ?? 0,
121
- },
122
- activation: view.activation ?? null,
123
- };
124
- }
125
-
126
- /** 取会话的 live agent(不存在则按需创建,与 prompt 的隐式建会话一致)。 */
127
- function agentOf(server, sessionId) {
128
- return server.getOrCreateSession(sessionId).then((rec) => rec.handle.agent);
129
- }
130
-
131
- /**
132
- * 官方 SDK server 的 goal 扩展:goal/* 方法直连 ctx.goals 服务动词。
133
- */
134
- class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
135
- /**
136
- * 创建(或替换已完成的)目标并 arm —— round-driver 会在 agent idle 时
137
- * 自动续第一轮,不需要额外 prompt。
138
- */
139
- async goalSet(params) {
140
- if (typeof params?.sessionId !== "string") throw new TypeError("goal/set requires sessionId");
141
- if (typeof params?.objective !== "string" || !params.objective.trim()) {
142
- throw new TypeError("goal/set requires a non-empty objective");
143
- }
144
- const agent = await agentOf(this, params.sessionId);
145
- const request = { objective: params.objective.trim() };
146
- if (Number.isSafeInteger(params.maxGoalRounds) && params.maxGoalRounds > 0) {
147
- request.maxGoalRounds = params.maxGoalRounds;
148
- }
149
- return viewPayload(this.ctx.goals.create(agent, request));
150
- }
151
-
152
- /** 当前目标视图(goal 或 null)。 */
153
- async goalGet(params) {
154
- if (typeof params?.sessionId !== "string") throw new TypeError("goal/get requires sessionId");
155
- const agent = await agentOf(this, params.sessionId);
156
- return viewPayload(this.ctx.goals.get(agent));
157
- }
158
-
159
- /** 清除当前目标(保留 durable 墓碑与历史)。 */
160
- async goalClear(params) {
161
- if (typeof params?.sessionId !== "string") throw new TypeError("goal/clear requires sessionId");
162
- const agent = await agentOf(this, params.sessionId);
163
- const view = this.ctx.goals.get(agent);
164
- if (view === void 0 || view.id === void 0) return { cleared: false };
165
- this.ctx.goals.clear(agent, { id: view.id, revision: view.revision });
166
- return { cleared: true };
167
- }
168
-
169
- /** 恢复被 disarm 的目标(abort 重启运行时后轮次驱动停止,需要 resume 续)。 */
170
- async goalResume(params) {
171
- if (typeof params?.sessionId !== "string") throw new TypeError("goal/resume requires sessionId");
172
- const agent = await agentOf(this, params.sessionId);
173
- const view = this.ctx.goals.get(agent);
174
- if (view === void 0 || view.id === void 0) throw new Error("no current goal to resume");
175
- return viewPayload(this.ctx.goals.resume(agent, { id: view.id, revision: view.revision }));
176
- }
177
-
178
- /** 编辑目标(改客观文本或轮次上限,不改 phase)。 */
179
- async goalEdit(params) {
180
- if (typeof params?.sessionId !== "string") throw new TypeError("goal/edit requires sessionId");
181
- const agent = await agentOf(this, params.sessionId);
182
- const view = this.ctx.goals.get(agent);
183
- if (view === void 0 || view.id === void 0) throw new Error("no current goal to edit");
184
- const request = {};
185
- if (typeof params?.objective === "string" && params.objective.trim()) {
186
- request.objective = params.objective.trim();
187
- }
188
- if (Number.isSafeInteger(params?.maxGoalRounds) && params.maxGoalRounds > 0) {
189
- request.maxGoalRounds = params.maxGoalRounds;
190
- }
191
- if (request.objective === undefined && request.maxGoalRounds === undefined) {
192
- throw new TypeError("goal/edit requires objective and/or maxGoalRounds");
193
- }
194
- return viewPayload(this.ctx.goals.edit(agent, { id: view.id, revision: view.revision }, request));
195
- }
196
-
197
- /** 方法分发:goal/* 与 attachment/* 走上面/下面,其余交官方。 */
198
- async handleRequest(method, params) {
199
- switch (method) {
200
- case "goal/set":
201
- return this.goalSet(params);
202
- case "goal/get":
203
- return this.goalGet(params);
204
- case "goal/clear":
205
- return this.goalClear(params);
206
- case "goal/resume":
207
- return this.goalResume(params);
208
- case "goal/edit":
209
- return this.goalEdit(params);
210
- case "attachment/save":
211
- return this.attachmentSave(params);
212
- case "attachment/read":
213
- return this.attachmentRead(params);
214
- case "question/answer":
215
- return this.answerQuestion(params);
216
- case "model/list":
217
- return this.listModels(params);
218
- case "tools/sync":
219
- return this.syncTools(params);
220
- case "tools/list":
221
- return this.listTools();
222
- case "tools/call-result":
223
- return this.toolsCallResult(params);
224
- case "tools/invoke":
225
- return this.toolsInvoke(params);
226
- case "skills/list":
227
- return this.listSkills();
228
- case "skills/set-disabled":
229
- return this.setDisabledSkills(params);
230
- case "skills/register":
231
- return this.registerSkill(params);
232
- case "skills/get":
233
- return this.getSkill(params);
234
- default:
235
- return super.handleRequest(method, params);
236
- }
237
- }
238
-
239
- /** P2-17 模型目录动态化:查询 adapter(ctx.llm)的真实模型清单。
240
- * dsh-llm-deepseek 的 listModels 返回模型目录(含 inputModalities),
241
- * 失败时返回空数组(服务端回退本地表)。 */
242
- async listModels(params) {
243
- try {
244
- const providerId = params?.provider ?? "deepseek-official";
245
- const models = await this.ctx.llm.listModels?.(providerId);
246
- if (!Array.isArray(models)) return { models: [] };
247
- return {
248
- models: models.map((m) => ({
249
- id: m.id,
250
- ...(m.name ? { name: m.name } : {}),
251
- ...(Array.isArray(m.inputModalities)
252
- ? { inputModalities: m.inputModalities }
253
- : Array.isArray(m.input)
254
- ? { inputModalities: m.input }
255
- : {}),
256
- })),
257
- };
258
- } catch (err) {
259
- return { models: [], error: err?.message ?? String(err) };
260
- }
261
- }
262
-
263
- // -----------------------------------------------------------------------
264
- // 用户提问桥(交互式调研/确认):模型调 ask_user_question 工具 →
265
- // ctx.userQuestions.ask() 阻塞 → 本 provider 把问题发给浏览器 →
266
- // question/answer RPC 带回答案 → 工具结果恢复模型循环。
267
- // -----------------------------------------------------------------------
268
-
269
- /** 挂起的提问 + 排队(apply 注册 provider 时初始化)。一次只向浏览器展示一个。 */
270
- questionBridge = { pending: null, queue: [] };
271
-
272
- // -----------------------------------------------------------------------
273
- // 工具桥(#15 插件注入点):把 pi-web-ui 插件 AI 工具(registerAgentTool)
274
- // 桥成 DSH 原生工具。服务端 tools/sync 注册 defineTool(trampoline execute),
275
- // 模型调用时 trampoline 发 tools.call.request 通知 → 服务端跑插件实现 →
276
- // tools/call-result RPC 恢复工具结果。
277
- // -----------------------------------------------------------------------
278
-
279
- /** 挂起的桥接调用:callId → { resolve, reject }(超时/中止统一拒)。 */
280
- toolsPending = new Map();
281
- /** 已注册工具的卸装回调(工具列表变化时先卸再重注册,last-write-wins 覆盖)。 */
282
- toolUnregisters = [];
283
-
284
- /** 注册(或替换)一批插件工具为 DSH 原生工具。返回当前注册清单。 */
285
- async syncTools(params) {
286
- const tools = Array.isArray(params?.tools) ? params.tools : [];
287
- for (const off of this.toolUnregisters) {
288
- try {
289
- off();
290
- } catch {
291
- /* re-register 前的卸载不阻断 */
292
- }
293
- }
294
- this.toolUnregisters = [];
295
- const registered = [];
296
- for (const t of tools) {
297
- if (!t || typeof t.name !== "string" || typeof t.description !== "string") continue;
298
- const tool = defineTool({
299
- name: t.name,
300
- description: t.description,
301
- parameters: piParamsToDshSpec(t.parameters),
302
- output: {
303
- schema: { type: "string" },
304
- render: (_args, value) => [{ type: "text", text: String(value ?? "") }],
305
- },
306
- execute: (args, exec) => this.invokeBridgedTool(exec, t.name, args),
307
- });
308
- const off = this.ctx.tools.register(tool);
309
- this.toolUnregisters.push(off);
310
- registered.push(t.name);
311
- }
312
- return { registered, count: registered.length };
313
- }
314
-
315
- /** 列出运行时当前可见的工具 schema(供零 key 验证/调试)。 */
316
- async listTools() {
317
- const schemas = this.ctx.tools.schemas();
318
- return {
319
- tools: schemas.map((s) => ({
320
- name: s.name,
321
- description: s.description,
322
- parameters: s.parameters,
323
- })),
324
- };
325
- }
326
-
327
- /** 桥接工具的 execute:把调用发给服务端并 await 结果。 */
328
- invokeBridgedTool(exec, name, args) {
329
- const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
330
- const sessionId =
331
- exec?.agent?.session?.id && typeof exec.agent.session.id === "string" ? String(exec.agent.session.id) : undefined;
332
- return new Promise((resolve, reject) => {
333
- let done = false;
334
- const cleanup = () => {
335
- if (exec?.signal) exec.signal.removeEventListener("abort", onAbort);
336
- };
337
- const settle = (err, result) => {
338
- if (done) return;
339
- done = true;
340
- cleanup();
341
- clearTimeout(timer);
342
- if (err) reject(err);
343
- else resolve(result);
344
- };
345
- const onAbort = () => {
346
- this.toolsPending.delete(id);
347
- settle(new Error(`工具 ${name} 已中止`));
348
- };
349
- const timer = setTimeout(() => {
350
- this.toolsPending.delete(id);
351
- settle(new Error(`工具 ${name} 执行超时(等待服务端响应)`));
352
- }, TOOL_TIMEOUT_MS);
353
- timer.unref?.();
354
- this.toolsPending.set(id, {
355
- resolve: (result) => settle(null, result),
356
- reject: (err) => settle(err),
357
- });
358
- exec?.signal?.addEventListener("abort", onAbort, { once: true });
359
- this.transport.notify("tools.call.request", {
360
- id,
361
- name,
362
- args,
363
- ...(sessionId ? { sessionId } : {}),
364
- });
365
- });
366
- }
367
-
368
- /** 调试/probe 用:绕过模型直接触发一个已注册桥接工具的完整往返
369
- * (trampoline → tools.call.request → tools/call-result 恢复)。仅 PI_WEB_DSH_DEBUG=1 可用。 */
370
- async toolsInvoke(params) {
371
- if (process.env.PI_WEB_DSH_DEBUG !== "1") {
372
- throw new Error("tools/invoke 仅调试可用(服务端需设 PI_WEB_DSH_DEBUG=1)");
373
- }
374
- const name = params?.name;
375
- if (typeof name !== "string") return { ok: false, error: "tools/invoke requires name" };
376
- const tool = this.ctx.tools.get(name);
377
- if (!tool) return { ok: false, error: `tools/invoke: 工具未注册 ${name}` };
378
- const exec = { signal: new AbortController().signal };
379
- try {
380
- const value = await tool.execute(params?.args ?? {}, exec);
381
- return { ok: true, value };
382
- } catch (err) {
383
- return { ok: false, error: err?.message ?? String(err) };
384
- }
385
- }
386
-
387
- /** tools/call-result RPC:服务端跑完插件实现后回传结果,恢复桥接调用。 */
388
- async toolsCallResult(params) {
389
- const id = params?.id;
390
- if (typeof id !== "string") throw new TypeError("tools/call-result requires id");
391
- const pending = this.toolsPending.get(id);
392
- if (!pending) throw new Error(`tools/call-result 无匹配 id=${id}`);
393
- this.toolsPending.delete(id);
394
- if (params?.isError) pending.reject(new Error(params?.result ?? "工具执行失败"));
395
- else pending.resolve(params?.result ?? "");
396
- return { ok: true };
397
- }
398
-
399
- // -----------------------------------------------------------------------
400
- // 技能 RPC(#18 技能启停 UI):暴露 SkillRegistry list/get + 设置禁用集合
401
- // (晚 pre-step 钩子用它过滤 skill-catalog 消息)。
402
- // -----------------------------------------------------------------------
403
-
404
- /** 禁用的技能名集合(skills/set-disabled 更新)。 */
405
- disabledSkills = new Set();
406
-
407
- /** 列出运行时当前可见技能(SkillRegistry.list,零 key 校验/前端显示用)。 */
408
- async listSkills() {
409
- try {
410
- const skills = await this.ctx.skills.list();
411
- if (!Array.isArray(skills)) return { skills: [] };
412
- return {
413
- skills: skills.map((s) => ({
414
- name: s.name,
415
- description: s.description ?? "",
416
- ...(s.invocation ? { invocation: s.invocation } : {}),
417
- })),
418
- };
419
- } catch (err) {
420
- return { skills: [], error: err?.message ?? String(err) };
421
- }
422
- }
423
-
424
- /** 读取单个技能的完整定义(SkillRegistry.get)。 */
425
- async getSkill(params) {
426
- if (typeof params?.name !== "string") throw new TypeError("skills/get requires name");
427
- const skill = await this.ctx.skills.get(params.name);
428
- return { skill: skill ?? null };
429
- }
430
-
431
- /** 切换一个技能的可见性(服务端 set_settings.disabledSkills 变化时推送)。 */
432
- async setDisabledSkills(params) {
433
- const names = Array.isArray(params?.skills) ? params.skills : [];
434
- this.disabledSkills = new Set(names.map((n) => String(n)));
435
- return { disabled: [...this.disabledSkills] };
436
- }
437
-
438
- /** 调试/probe 用:注册一个运行时技能(SkillRegistry.register),仅 DEBUG 可用。 */
439
- async registerSkill(params) {
440
- if (process.env.PI_WEB_DSH_DEBUG !== "1") throw new Error("skills/register 仅调试可用");
441
- if (typeof params?.name !== "string" || !params.name.trim()) {
442
- throw new TypeError("skills/register requires name");
443
- }
444
- const reg = this.ctx.skills.register({
445
- name: params.name.trim(),
446
- description: typeof params?.description === "string" ? params.description : "",
447
- invocation: { modelInvocable: true, userInvocable: false },
448
- source: "runtime",
449
- content: typeof params?.content === "string" ? params.content : "(test skill)",
450
- });
451
- return { ok: true, unregister: typeof reg === "function" };
452
- }
453
-
454
- /** 把队首提问变为 pending(发通知给浏览器)。 */
455
- dispatchNextQuestion() {
456
- if (this.questionBridge.pending) return;
457
- const next = this.questionBridge.queue.shift();
458
- if (!next) return;
459
- this.transport.notify("question.pending", {
460
- id: next.qid,
461
- questions: next.questions,
462
- deadline: next.deadline,
463
- });
464
- this.questionBridge.pending = { qid: next.qid, resolve: next.resolve, reject: next.reject };
465
- }
466
-
467
- /** ctx.userQuestions provider:把问题桥到客户端并等待答案。
468
- * P2-20:并发 ask() 排队(深度 3),当前提问回答后自动发下一个。 */
469
- async askUser(request) {
470
- const questions = (request?.questions ?? []).map((q) => ({
471
- id: q.id,
472
- question: q.question,
473
- ...(q.detail ? { detail: q.detail } : {}),
474
- ...(q.header ? { header: q.header } : {}),
475
- ...(q.options ? { options: q.options } : {}),
476
- ...(q.multiSelect ? { multiSelect: q.multiSelect } : {}),
477
- }));
478
- if (questions.length === 0) throw new Error("ask_user_question requires at least one question");
479
- const qid = `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
480
- const deadline = Date.now() + QUESTION_TIMEOUT_MS;
481
- return new Promise((resolve, reject) => {
482
- const timer = setTimeout(() => {
483
- const queued = this.questionBridge.queue.findIndex((q) => q.qid === qid);
484
- if (queued >= 0) this.questionBridge.queue.splice(queued, 1);
485
- if (this.questionBridge.pending?.qid === qid) {
486
- this.questionBridge.pending = null;
487
- reject(new Error("提问超时(等待回答过久)"));
488
- }
489
- }, QUESTION_TIMEOUT_MS);
490
- timer.unref?.();
491
- const entry = {
492
- qid,
493
- questions,
494
- deadline,
495
- resolve: (answers) => {
496
- clearTimeout(timer);
497
- resolve(answers);
498
- },
499
- reject: (err) => {
500
- clearTimeout(timer);
501
- reject(err);
502
- },
503
- };
504
- if (this.questionBridge.pending || this.questionBridge.queue.length > 0) {
505
- // 已有提问在展示 → 排队;深度 3,满则拒绝(模型会收到工具报错继续)。
506
- if (this.questionBridge.queue.length >= 3) {
507
- clearTimeout(timer);
508
- reject(new Error("提问排队已满(最多 3 个),请先回答当前提问"));
509
- return;
510
- }
511
- this.questionBridge.queue.push(entry);
512
- } else {
513
- // 无 pending → 立即发通知并等待。
514
- this.transport.notify("question.pending", { id: qid, questions, deadline });
515
- this.questionBridge.pending = { qid, resolve: entry.resolve, reject: entry.reject };
516
- }
517
- });
518
- }
519
-
520
- /** question/answer RPC:前端提交答案(或取消)。回答后自动发队列里的下一个。 */
521
- async answerQuestion(params) {
522
- const pending = this.questionBridge.pending;
523
- if (!pending || pending.qid !== params?.id) {
524
- throw new Error(`question/answer 不匹配(id=${params?.id})`);
525
- }
526
- this.questionBridge.pending = null;
527
- if (params?.cancelled) {
528
- pending.reject(new Error("用户取消了提问"));
529
- } else {
530
- const answers = Array.isArray(params?.answers) ? params.answers : [];
531
- pending.resolve({ answers });
532
- }
533
- this.dispatchNextQuestion();
534
- return { ok: true };
535
- }
536
-
537
- // -----------------------------------------------------------------------
538
- // 附件 RPC(视觉桥):base64 图片 → durable ref;ref → base64 回读。
539
- // 模型请求侧的 image 块由 dsh-llm-deepseek adapter 自动解析 ref(file-id/inline)。
540
- // -----------------------------------------------------------------------
541
-
542
- /** 保存一张 base64 图片到附件存储(ctx.attachments,dsh-attachment-local 后端)。 */
543
- async attachmentSave(params) {
544
- const mediaType = params?.mediaType;
545
- if (typeof mediaType !== "string" || !/^image\/(png|jpeg|webp|gif)$/u.test(mediaType)) {
546
- throw new TypeError("attachment/save requires mediaType (image/png|jpeg|webp|gif)");
547
- }
548
- if (typeof params?.data !== "string" || !params.data) {
549
- throw new TypeError("attachment/save requires base64 data");
550
- }
551
- const bytes = new Uint8Array(Buffer.from(params.data, "base64"));
552
- if (bytes.length === 0) throw new TypeError("attachment/save: empty image data");
553
- const ref = await this.ctx.attachments.saveImage({
554
- data: bytes,
555
- mediaType,
556
- ...(typeof params?.name === "string" && params.name ? { name: params.name } : {}),
557
- });
558
- return { ref };
559
- }
560
-
561
- /** 按 ref 回读规范化后的图片字节(base64),供前端回放显示。 */
562
- async attachmentRead(params) {
563
- const ref = params?.ref;
564
- if (!ref || typeof ref?.attachmentId !== "string" || typeof ref?.mediaType !== "string") {
565
- throw new TypeError("attachment/read requires ref (attachmentId + mediaType)");
566
- }
567
- const stored = await this.ctx.attachments.readImage(ref);
568
- return {
569
- mediaType: stored.ref?.mediaType ?? ref.mediaType,
570
- data: Buffer.from(stored.data).toString("base64"),
571
- };
572
- }
573
- }
574
-
575
- /**
576
- * 插件 apply:transport 接线 / shutdown 语义与官方一致,server 换成本地扩展类。
577
- * 保持 named exports(无 default),Loader unwrapExports 需要 name/inject/Config/apply。
578
- */
579
- function apply(ctx, config) {
580
- const resolvedConfig = config;
581
- const rootFiber = ctx.root.fiber;
582
- /* v8 ignore next -- production stdio wiring */
583
- const input = config.input ?? process.stdin;
584
- /* v8 ignore next -- production stdio wiring */
585
- const output = config.output ?? process.stdout;
586
- /* v8 ignore next -- production exit wiring */
587
- const exit =
588
- config.exit ??
589
- ((code) => {
590
- process.exit(code);
591
- });
592
- const transport = new JsonRpcLineTransport(input, output);
593
- const server = new DshGoalJsonRpcServer(ctx, transport, {
594
- maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
595
- });
596
- // 注册用户提问 provider:模型 ask_user_question → 浏览器对话框 → 答案回传。
597
- // 单个 context 只允许一个 provider;dispose 时随 ctx.effect 清理。
598
- const unregisterQuestions = ctx.userQuestions.registerProvider({
599
- ask: (request) => server.askUser(request),
600
- });
601
- ctx.effect(() => unregisterQuestions, "user-questions.bridge");
602
- // 技能启停(#18):晚 agent/pre-step 钩子,在 dsh-tool-skill 注入技能目录后
603
- // 按 server.disabledSkills 过滤 catalog 消息(剔除禁用技能条目)。
604
- // 注册在 base 之后 → 本钩子后跑,能拿到已渲染的 catalog。
605
- // eslint-disable-next-line no-empty-pattern -- only next is needed
606
- ctx.on("agent/pre-step", async ({}, next) => {
607
- const decision = await next();
608
- if (server.disabledSkills.size && decision?.kind === "enter" && Array.isArray(decision.messages)) {
609
- if (decision.messages.some((m) => m?.source?.kind === "skill-catalog")) {
610
- decision.messages = decision.messages.map((m) => filterSkillCatalogMessage(m, server.disabledSkills));
611
- }
612
- }
613
- return decision;
614
- });
615
- let exitTask;
616
- const disposeAndExit = () => {
617
- exitTask ??= (async () => {
618
- await Promise.allSettled([Promise.resolve().then(() => transport.flush())]);
619
- await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())]);
620
- exit(0);
621
- })();
622
- return exitTask;
623
- };
624
- transport.onRequest(async (method, params) => {
625
- if (method === "initialize") await ctx.get("loader")?.await();
626
- const result = await server.handleRequest(method, params);
627
- if (method === "shutdown")
628
- setImmediate(() => {
629
- disposeAndExit();
630
- });
631
- return result;
632
- });
633
- ctx.effect(() => {
634
- transport.start();
635
- return async () => {
636
- await server.shutdown();
637
- transport.close();
638
- };
639
- }, "jsonrpc.serve");
640
- }
641
-
642
- const name = "sdk-jsonrpc-server";
643
- const inject = ["agents", "goals", "attachments", "userQuestions", "llm", "tools", "skills"];
644
-
645
- export { Config, apply, inject, name };
1
+ /**
2
+ * goal-rpc.mjs — pi-web-ui DSH runtime 的 goal RPC 扩展插件。
3
+ *
4
+ * 在官方 dsh-sdk-jsonrpc-server 之上包一层,为 stdio JSON-RPC 面增加 goal
5
+ * 方法(goal/set goal/get goal/clear goal/resume goal/edit),直连 DSH 原生
6
+ * goal 域(ctx.goals 服务,dsh-goal)。这样 pi-web-ui 前端的目标条驱动的是
7
+ * DSH 自己的持久化目标状态机 + goal-round-driver 自动轮次,而不是另造一套
8
+ * 审查会话 —— 审查语义 = DSH 原生(模型自判定 complete/blocked,轮次自动续)。
9
+ *
10
+ * 与官方插件的差异:inject 增加 "goals"(goal 域服务依赖);handleRequest
11
+ * 增加 goal 分支;其余 stdio transport / shutdown 语义原样继承。
12
+ *
13
+ * 官方入口(做 base 类)从环境变量解析:PI_WEB_DSH_JSONRPC_ENTRY 由 dsh-client
14
+ * 注入(指向项目 node_modules 的官方包);兜底从本文件位置向上找项目依赖。
15
+ */
16
+ import { resolve } from "node:path";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+ import { JsonRpcLineTransport } from "@deepseek-ai/dsh-sdk-protocol";
20
+ import { defineTool } from "@deepseek-ai/dsh-tools";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+
24
+ /** 官方 jsonrpc-server 入口(env 优先,兜底项目 node_modules)。 */
25
+ const officialEntry = process.env.PI_WEB_DSH_JSONRPC_ENTRY
26
+ ? resolve(process.env.PI_WEB_DSH_JSONRPC_ENTRY)
27
+ : join(
28
+ resolve(HERE, "..", "..", "..", ".."),
29
+ "node_modules",
30
+ "@deepseek-ai",
31
+ "dsh-sdk-jsonrpc-server",
32
+ "lib",
33
+ "index.js",
34
+ );
35
+
36
+ const official = await import(pathToFileURL(officialEntry).href);
37
+ const { HarnessSdkJsonRpcServer, Config } = official;
38
+
39
+ /** 提问桥超时(P0-6):PI_WEB_DSH_QUESTION_TIMEOUT_MS 可配置,默认 10 分钟。 */
40
+ const QUESTION_TIMEOUT_MS = Number(process.env.PI_WEB_DSH_QUESTION_TIMEOUT_MS) || 10 * 60_000;
41
+
42
+ /** 工具桥超时(#15):服务端跑插件实现的等待上限。PI_WEB_DSH_TOOL_TIMEOUT_MS 可配置,默认 10 分钟。 */
43
+ const TOOL_TIMEOUT_MS = Number(process.env.PI_WEB_DSH_TOOL_TIMEOUT_MS) || 10 * 60_000;
44
+
45
+ /** 过滤一条 skill-catalog 用户消息:按 disabled 集合剔除条目并重建文本。
46
+ * 纯函数(可单测)。条目来自消息的 `source.entries`(`{name,description}`),
47
+ * 文本里的 `<available_skills>…</available_skills>` 块按保留条目重建,其余不动。 */
48
+ export function filterSkillCatalogMessage(message, disabled) {
49
+ if (!message || message?.source?.kind !== "skill-catalog") return message;
50
+ const entries = Array.isArray(message.source?.entries) ? message.source.entries : [];
51
+ const kept = entries.filter((e) => e && !disabled.has(e.name));
52
+ if (kept.length === entries.length) return message;
53
+ const original =
54
+ (Array.isArray(message.content) ? message.content.find((b) => b?.type === "text")?.text : undefined) ?? "";
55
+ const text = rebuildCatalogText(original, kept);
56
+ return {
57
+ ...message,
58
+ source: { ...message.source, entries: kept },
59
+ content: [{ type: "text", text }],
60
+ };
61
+ }
62
+
63
+ /** 重建 `<available_skills>` 块的文本(保留块外结构;无条目时给占位)。 */
64
+ function rebuildCatalogText(original, entries) {
65
+ const startTag = "<available_skills>";
66
+ const endTag = "</available_skills>";
67
+ const si = original.indexOf(startTag);
68
+ const ei = original.indexOf(endTag);
69
+ if (si < 0 || ei < 0) return original;
70
+ const body = entries.length
71
+ ? entries.map((e) => `- \`${e.name}\`: ${escapeTextSimple(e.description ?? "")}`).join("\n")
72
+ : "(本会话无可启用技能)";
73
+ return `${original.slice(0, si + startTag.length)}\n${body}\n${original.slice(ei)}`;
74
+ }
75
+
76
+ /** 最小 HTML 实体转义(避免 `<`/`&` 破坏 markdown/目录结构)。 */
77
+ function escapeTextSimple(s) {
78
+ return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
79
+ }
80
+
81
+ /** 把 pi-web-ui 插件工具的标准 JSON Schema `parameters`(`{type,properties,required[]}`)
82
+ * 转成 DSH defineTool 的 per-property 参数映射 spec(`{key:{type,required?,description}}`)。
83
+ * DSH 的 parameterSchemaSpecToJsonSchema 需要 property-map 形状(required 挂在每条属性上),
84
+ * 而 pi 插件/TypeBox 用的是标准 JSON Schema(required 是对象数组)。纯函数。 */
85
+ function piParamsToDshSpec(parameters) {
86
+ if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return {};
87
+ if (parameters.properties && typeof parameters.properties === "object" && !Array.isArray(parameters.properties)) {
88
+ const required = Array.isArray(parameters.required) ? parameters.required : [];
89
+ const propMap = {};
90
+ for (const [key, def] of Object.entries(parameters.properties)) {
91
+ if (!def || typeof def !== "object" || Array.isArray(def)) {
92
+ propMap[key] = { type: "string" };
93
+ continue;
94
+ }
95
+ const entry = {};
96
+ for (const [k, v] of Object.entries(def)) {
97
+ // 只保留 DSH schema 编译器认识的子集;default/format/title 等跳过(避免寄存器 reject)。
98
+ if (["type", "description", "enum", "items", "anyOf", "oneOf"].includes(k)) entry[k] = v;
99
+ }
100
+ if (required.includes(key)) entry.required = true;
101
+ propMap[key] = entry;
102
+ }
103
+ return propMap;
104
+ }
105
+ // 已是 DSH property-map 形状(每条属性自己带 type)——原样透传。
106
+ return parameters;
107
+ }
108
+
109
+ /** goal view → RPC 载荷(紧凑 JSON;view 是扁平结构,无目标时 get 返回 undefined)。 */
110
+ function viewPayload(view) {
111
+ if (!view || view.id === undefined) return { goal: null, activation: null };
112
+ return {
113
+ goal: {
114
+ id: view.id,
115
+ revision: view.revision,
116
+ objective: view.objective,
117
+ phase: view.phase,
118
+ maxGoalRounds: view.maxGoalRounds,
119
+ ...(view.blockedReason === void 0 ? {} : { blockedReason: view.blockedReason }),
120
+ roundsStarted: view.roundsStarted ?? 0,
121
+ },
122
+ activation: view.activation ?? null,
123
+ };
124
+ }
125
+
126
+ /** 取会话的 live agent(不存在则按需创建,与 prompt 的隐式建会话一致)。 */
127
+ function agentOf(server, sessionId) {
128
+ return server.getOrCreateSession(sessionId).then((rec) => rec.handle.agent);
129
+ }
130
+
131
+ /**
132
+ * 官方 SDK server 的 goal 扩展:goal/* 方法直连 ctx.goals 服务动词。
133
+ */
134
+ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
135
+ /**
136
+ * 创建(或替换已完成的)目标并 arm —— round-driver 会在 agent idle 时
137
+ * 自动续第一轮,不需要额外 prompt。
138
+ */
139
+ async goalSet(params) {
140
+ if (typeof params?.sessionId !== "string") throw new TypeError("goal/set requires sessionId");
141
+ if (typeof params?.objective !== "string" || !params.objective.trim()) {
142
+ throw new TypeError("goal/set requires a non-empty objective");
143
+ }
144
+ const agent = await agentOf(this, params.sessionId);
145
+ const request = { objective: params.objective.trim() };
146
+ if (Number.isSafeInteger(params.maxGoalRounds) && params.maxGoalRounds > 0) {
147
+ request.maxGoalRounds = params.maxGoalRounds;
148
+ }
149
+ return viewPayload(this.ctx.goals.create(agent, request));
150
+ }
151
+
152
+ /** 当前目标视图(goal 或 null)。 */
153
+ async goalGet(params) {
154
+ if (typeof params?.sessionId !== "string") throw new TypeError("goal/get requires sessionId");
155
+ const agent = await agentOf(this, params.sessionId);
156
+ return viewPayload(this.ctx.goals.get(agent));
157
+ }
158
+
159
+ /** 清除当前目标(保留 durable 墓碑与历史)。 */
160
+ async goalClear(params) {
161
+ if (typeof params?.sessionId !== "string") throw new TypeError("goal/clear requires sessionId");
162
+ const agent = await agentOf(this, params.sessionId);
163
+ const view = this.ctx.goals.get(agent);
164
+ if (view === void 0 || view.id === void 0) return { cleared: false };
165
+ this.ctx.goals.clear(agent, { id: view.id, revision: view.revision });
166
+ return { cleared: true };
167
+ }
168
+
169
+ /** 恢复被 disarm 的目标(abort 重启运行时后轮次驱动停止,需要 resume 续)。 */
170
+ async goalResume(params) {
171
+ if (typeof params?.sessionId !== "string") throw new TypeError("goal/resume requires sessionId");
172
+ const agent = await agentOf(this, params.sessionId);
173
+ const view = this.ctx.goals.get(agent);
174
+ if (view === void 0 || view.id === void 0) throw new Error("no current goal to resume");
175
+ return viewPayload(this.ctx.goals.resume(agent, { id: view.id, revision: view.revision }));
176
+ }
177
+
178
+ /** 编辑目标(改客观文本或轮次上限,不改 phase)。 */
179
+ async goalEdit(params) {
180
+ if (typeof params?.sessionId !== "string") throw new TypeError("goal/edit requires sessionId");
181
+ const agent = await agentOf(this, params.sessionId);
182
+ const view = this.ctx.goals.get(agent);
183
+ if (view === void 0 || view.id === void 0) throw new Error("no current goal to edit");
184
+ const request = {};
185
+ if (typeof params?.objective === "string" && params.objective.trim()) {
186
+ request.objective = params.objective.trim();
187
+ }
188
+ if (Number.isSafeInteger(params?.maxGoalRounds) && params.maxGoalRounds > 0) {
189
+ request.maxGoalRounds = params.maxGoalRounds;
190
+ }
191
+ if (request.objective === undefined && request.maxGoalRounds === undefined) {
192
+ throw new TypeError("goal/edit requires objective and/or maxGoalRounds");
193
+ }
194
+ return viewPayload(this.ctx.goals.edit(agent, { id: view.id, revision: view.revision }, request));
195
+ }
196
+
197
+ /** 方法分发:goal/* 与 attachment/* 走上面/下面,其余交官方。 */
198
+ async handleRequest(method, params) {
199
+ switch (method) {
200
+ case "goal/set":
201
+ return this.goalSet(params);
202
+ case "goal/get":
203
+ return this.goalGet(params);
204
+ case "goal/clear":
205
+ return this.goalClear(params);
206
+ case "goal/resume":
207
+ return this.goalResume(params);
208
+ case "goal/edit":
209
+ return this.goalEdit(params);
210
+ case "attachment/save":
211
+ return this.attachmentSave(params);
212
+ case "attachment/read":
213
+ return this.attachmentRead(params);
214
+ case "question/answer":
215
+ return this.answerQuestion(params);
216
+ case "model/list":
217
+ return this.listModels(params);
218
+ case "tools/sync":
219
+ return this.syncTools(params);
220
+ case "tools/list":
221
+ return this.listTools();
222
+ case "tools/call-result":
223
+ return this.toolsCallResult(params);
224
+ case "tools/invoke":
225
+ return this.toolsInvoke(params);
226
+ case "skills/list":
227
+ return this.listSkills();
228
+ case "skills/set-disabled":
229
+ return this.setDisabledSkills(params);
230
+ case "skills/register":
231
+ return this.registerSkill(params);
232
+ case "skills/get":
233
+ return this.getSkill(params);
234
+ default:
235
+ return super.handleRequest(method, params);
236
+ }
237
+ }
238
+
239
+ /** P2-17 模型目录动态化:查询 adapter(ctx.llm)的真实模型清单。
240
+ * dsh-llm-deepseek 的 listModels 返回模型目录(含 inputModalities),
241
+ * 失败时返回空数组(服务端回退本地表)。 */
242
+ async listModels(params) {
243
+ try {
244
+ const providerId = params?.provider ?? "deepseek-official";
245
+ const models = await this.ctx.llm.listModels?.(providerId);
246
+ if (!Array.isArray(models)) return { models: [] };
247
+ return {
248
+ models: models.map((m) => ({
249
+ id: m.id,
250
+ ...(m.name ? { name: m.name } : {}),
251
+ ...(Array.isArray(m.inputModalities)
252
+ ? { inputModalities: m.inputModalities }
253
+ : Array.isArray(m.input)
254
+ ? { inputModalities: m.input }
255
+ : {}),
256
+ })),
257
+ };
258
+ } catch (err) {
259
+ return { models: [], error: err?.message ?? String(err) };
260
+ }
261
+ }
262
+
263
+ // -----------------------------------------------------------------------
264
+ // 用户提问桥(交互式调研/确认):模型调 ask_user_question 工具 →
265
+ // ctx.userQuestions.ask() 阻塞 → 本 provider 把问题发给浏览器 →
266
+ // question/answer RPC 带回答案 → 工具结果恢复模型循环。
267
+ // -----------------------------------------------------------------------
268
+
269
+ /** 挂起的提问 + 排队(apply 注册 provider 时初始化)。一次只向浏览器展示一个。 */
270
+ questionBridge = { pending: null, queue: [] };
271
+
272
+ // -----------------------------------------------------------------------
273
+ // 工具桥(#15 插件注入点):把 pi-web-ui 插件 AI 工具(registerAgentTool)
274
+ // 桥成 DSH 原生工具。服务端 tools/sync 注册 defineTool(trampoline execute),
275
+ // 模型调用时 trampoline 发 tools.call.request 通知 → 服务端跑插件实现 →
276
+ // tools/call-result RPC 恢复工具结果。
277
+ // -----------------------------------------------------------------------
278
+
279
+ /** 挂起的桥接调用:callId → { resolve, reject }(超时/中止统一拒)。 */
280
+ toolsPending = new Map();
281
+ /** 已注册工具的卸装回调(工具列表变化时先卸再重注册,last-write-wins 覆盖)。 */
282
+ toolUnregisters = [];
283
+
284
+ /** 注册(或替换)一批插件工具为 DSH 原生工具。返回当前注册清单。 */
285
+ async syncTools(params) {
286
+ const tools = Array.isArray(params?.tools) ? params.tools : [];
287
+ for (const off of this.toolUnregisters) {
288
+ try {
289
+ off();
290
+ } catch {
291
+ /* re-register 前的卸载不阻断 */
292
+ }
293
+ }
294
+ this.toolUnregisters = [];
295
+ const registered = [];
296
+ for (const t of tools) {
297
+ if (!t || typeof t.name !== "string" || typeof t.description !== "string") continue;
298
+ const tool = defineTool({
299
+ name: t.name,
300
+ description: t.description,
301
+ parameters: piParamsToDshSpec(t.parameters),
302
+ output: {
303
+ schema: { type: "string" },
304
+ render: (_args, value) => [{ type: "text", text: String(value ?? "") }],
305
+ },
306
+ execute: (args, exec) => this.invokeBridgedTool(exec, t.name, args),
307
+ });
308
+ const off = this.ctx.tools.register(tool);
309
+ this.toolUnregisters.push(off);
310
+ registered.push(t.name);
311
+ }
312
+ return { registered, count: registered.length };
313
+ }
314
+
315
+ /** 列出运行时当前可见的工具 schema(供零 key 验证/调试)。 */
316
+ async listTools() {
317
+ const schemas = this.ctx.tools.schemas();
318
+ return {
319
+ tools: schemas.map((s) => ({
320
+ name: s.name,
321
+ description: s.description,
322
+ parameters: s.parameters,
323
+ })),
324
+ };
325
+ }
326
+
327
+ /** 桥接工具的 execute:把调用发给服务端并 await 结果。 */
328
+ invokeBridgedTool(exec, name, args) {
329
+ const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
330
+ const sessionId =
331
+ exec?.agent?.session?.id && typeof exec.agent.session.id === "string" ? String(exec.agent.session.id) : undefined;
332
+ return new Promise((resolve, reject) => {
333
+ let done = false;
334
+ const cleanup = () => {
335
+ if (exec?.signal) exec.signal.removeEventListener("abort", onAbort);
336
+ };
337
+ const settle = (err, result) => {
338
+ if (done) return;
339
+ done = true;
340
+ cleanup();
341
+ clearTimeout(timer);
342
+ if (err) reject(err);
343
+ else resolve(result);
344
+ };
345
+ const onAbort = () => {
346
+ this.toolsPending.delete(id);
347
+ settle(new Error(`工具 ${name} 已中止`));
348
+ };
349
+ const timer = setTimeout(() => {
350
+ this.toolsPending.delete(id);
351
+ settle(new Error(`工具 ${name} 执行超时(等待服务端响应)`));
352
+ }, TOOL_TIMEOUT_MS);
353
+ timer.unref?.();
354
+ this.toolsPending.set(id, {
355
+ resolve: (result) => settle(null, result),
356
+ reject: (err) => settle(err),
357
+ });
358
+ exec?.signal?.addEventListener("abort", onAbort, { once: true });
359
+ this.transport.notify("tools.call.request", {
360
+ id,
361
+ name,
362
+ args,
363
+ ...(sessionId ? { sessionId } : {}),
364
+ });
365
+ });
366
+ }
367
+
368
+ /** 调试/probe 用:绕过模型直接触发一个已注册桥接工具的完整往返
369
+ * (trampoline → tools.call.request → tools/call-result 恢复)。仅 PI_WEB_DSH_DEBUG=1 可用。 */
370
+ async toolsInvoke(params) {
371
+ if (process.env.PI_WEB_DSH_DEBUG !== "1") {
372
+ throw new Error("tools/invoke 仅调试可用(服务端需设 PI_WEB_DSH_DEBUG=1)");
373
+ }
374
+ const name = params?.name;
375
+ if (typeof name !== "string") return { ok: false, error: "tools/invoke requires name" };
376
+ const tool = this.ctx.tools.get(name);
377
+ if (!tool) return { ok: false, error: `tools/invoke: 工具未注册 ${name}` };
378
+ const exec = { signal: new AbortController().signal };
379
+ try {
380
+ const value = await tool.execute(params?.args ?? {}, exec);
381
+ return { ok: true, value };
382
+ } catch (err) {
383
+ return { ok: false, error: err?.message ?? String(err) };
384
+ }
385
+ }
386
+
387
+ /** tools/call-result RPC:服务端跑完插件实现后回传结果,恢复桥接调用。 */
388
+ async toolsCallResult(params) {
389
+ const id = params?.id;
390
+ if (typeof id !== "string") throw new TypeError("tools/call-result requires id");
391
+ const pending = this.toolsPending.get(id);
392
+ if (!pending) throw new Error(`tools/call-result 无匹配 id=${id}`);
393
+ this.toolsPending.delete(id);
394
+ if (params?.isError) pending.reject(new Error(params?.result ?? "工具执行失败"));
395
+ else pending.resolve(params?.result ?? "");
396
+ return { ok: true };
397
+ }
398
+
399
+ // -----------------------------------------------------------------------
400
+ // 技能 RPC(#18 技能启停 UI):暴露 SkillRegistry list/get + 设置禁用集合
401
+ // (晚 pre-step 钩子用它过滤 skill-catalog 消息)。
402
+ // -----------------------------------------------------------------------
403
+
404
+ /** 禁用的技能名集合(skills/set-disabled 更新)。 */
405
+ disabledSkills = new Set();
406
+
407
+ /** 列出运行时当前可见技能(SkillRegistry.list,零 key 校验/前端显示用)。 */
408
+ async listSkills() {
409
+ try {
410
+ const skills = await this.ctx.skills.list();
411
+ if (!Array.isArray(skills)) return { skills: [] };
412
+ return {
413
+ skills: skills.map((s) => ({
414
+ name: s.name,
415
+ description: s.description ?? "",
416
+ ...(s.invocation ? { invocation: s.invocation } : {}),
417
+ })),
418
+ };
419
+ } catch (err) {
420
+ return { skills: [], error: err?.message ?? String(err) };
421
+ }
422
+ }
423
+
424
+ /** 读取单个技能的完整定义(SkillRegistry.get)。 */
425
+ async getSkill(params) {
426
+ if (typeof params?.name !== "string") throw new TypeError("skills/get requires name");
427
+ const skill = await this.ctx.skills.get(params.name);
428
+ return { skill: skill ?? null };
429
+ }
430
+
431
+ /** 切换一个技能的可见性(服务端 set_settings.disabledSkills 变化时推送)。 */
432
+ async setDisabledSkills(params) {
433
+ const names = Array.isArray(params?.skills) ? params.skills : [];
434
+ this.disabledSkills = new Set(names.map((n) => String(n)));
435
+ return { disabled: [...this.disabledSkills] };
436
+ }
437
+
438
+ /** 调试/probe 用:注册一个运行时技能(SkillRegistry.register),仅 DEBUG 可用。 */
439
+ async registerSkill(params) {
440
+ if (process.env.PI_WEB_DSH_DEBUG !== "1") throw new Error("skills/register 仅调试可用");
441
+ if (typeof params?.name !== "string" || !params.name.trim()) {
442
+ throw new TypeError("skills/register requires name");
443
+ }
444
+ const reg = this.ctx.skills.register({
445
+ name: params.name.trim(),
446
+ description: typeof params?.description === "string" ? params.description : "",
447
+ invocation: { modelInvocable: true, userInvocable: false },
448
+ source: "runtime",
449
+ content: typeof params?.content === "string" ? params.content : "(test skill)",
450
+ });
451
+ return { ok: true, unregister: typeof reg === "function" };
452
+ }
453
+
454
+ /** 把队首提问变为 pending(发通知给浏览器)。 */
455
+ dispatchNextQuestion() {
456
+ if (this.questionBridge.pending) return;
457
+ const next = this.questionBridge.queue.shift();
458
+ if (!next) return;
459
+ this.transport.notify("question.pending", {
460
+ id: next.qid,
461
+ questions: next.questions,
462
+ deadline: next.deadline,
463
+ });
464
+ this.questionBridge.pending = { qid: next.qid, resolve: next.resolve, reject: next.reject };
465
+ }
466
+
467
+ /** ctx.userQuestions provider:把问题桥到客户端并等待答案。
468
+ * P2-20:并发 ask() 排队(深度 3),当前提问回答后自动发下一个。 */
469
+ async askUser(request) {
470
+ const questions = (request?.questions ?? []).map((q) => ({
471
+ id: q.id,
472
+ question: q.question,
473
+ ...(q.detail ? { detail: q.detail } : {}),
474
+ ...(q.header ? { header: q.header } : {}),
475
+ ...(q.options ? { options: q.options } : {}),
476
+ ...(q.multiSelect ? { multiSelect: q.multiSelect } : {}),
477
+ }));
478
+ if (questions.length === 0) throw new Error("ask_user_question requires at least one question");
479
+ const qid = `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
480
+ const deadline = Date.now() + QUESTION_TIMEOUT_MS;
481
+ return new Promise((resolve, reject) => {
482
+ const timer = setTimeout(() => {
483
+ const queued = this.questionBridge.queue.findIndex((q) => q.qid === qid);
484
+ if (queued >= 0) this.questionBridge.queue.splice(queued, 1);
485
+ if (this.questionBridge.pending?.qid === qid) {
486
+ this.questionBridge.pending = null;
487
+ reject(new Error("提问超时(等待回答过久)"));
488
+ }
489
+ }, QUESTION_TIMEOUT_MS);
490
+ timer.unref?.();
491
+ const entry = {
492
+ qid,
493
+ questions,
494
+ deadline,
495
+ resolve: (answers) => {
496
+ clearTimeout(timer);
497
+ resolve(answers);
498
+ },
499
+ reject: (err) => {
500
+ clearTimeout(timer);
501
+ reject(err);
502
+ },
503
+ };
504
+ if (this.questionBridge.pending || this.questionBridge.queue.length > 0) {
505
+ // 已有提问在展示 → 排队;深度 3,满则拒绝(模型会收到工具报错继续)。
506
+ if (this.questionBridge.queue.length >= 3) {
507
+ clearTimeout(timer);
508
+ reject(new Error("提问排队已满(最多 3 个),请先回答当前提问"));
509
+ return;
510
+ }
511
+ this.questionBridge.queue.push(entry);
512
+ } else {
513
+ // 无 pending → 立即发通知并等待。
514
+ this.transport.notify("question.pending", { id: qid, questions, deadline });
515
+ this.questionBridge.pending = { qid, resolve: entry.resolve, reject: entry.reject };
516
+ }
517
+ });
518
+ }
519
+
520
+ /** question/answer RPC:前端提交答案(或取消)。回答后自动发队列里的下一个。 */
521
+ async answerQuestion(params) {
522
+ const pending = this.questionBridge.pending;
523
+ if (!pending || pending.qid !== params?.id) {
524
+ throw new Error(`question/answer 不匹配(id=${params?.id})`);
525
+ }
526
+ this.questionBridge.pending = null;
527
+ if (params?.cancelled) {
528
+ pending.reject(new Error("用户取消了提问"));
529
+ } else {
530
+ const answers = Array.isArray(params?.answers) ? params.answers : [];
531
+ pending.resolve({ answers });
532
+ }
533
+ this.dispatchNextQuestion();
534
+ return { ok: true };
535
+ }
536
+
537
+ // -----------------------------------------------------------------------
538
+ // 附件 RPC(视觉桥):base64 图片 → durable ref;ref → base64 回读。
539
+ // 模型请求侧的 image 块由 dsh-llm-deepseek adapter 自动解析 ref(file-id/inline)。
540
+ // -----------------------------------------------------------------------
541
+
542
+ /** 保存一张 base64 图片到附件存储(ctx.attachments,dsh-attachment-local 后端)。 */
543
+ async attachmentSave(params) {
544
+ const mediaType = params?.mediaType;
545
+ if (typeof mediaType !== "string" || !/^image\/(png|jpeg|webp|gif)$/u.test(mediaType)) {
546
+ throw new TypeError("attachment/save requires mediaType (image/png|jpeg|webp|gif)");
547
+ }
548
+ if (typeof params?.data !== "string" || !params.data) {
549
+ throw new TypeError("attachment/save requires base64 data");
550
+ }
551
+ const bytes = new Uint8Array(Buffer.from(params.data, "base64"));
552
+ if (bytes.length === 0) throw new TypeError("attachment/save: empty image data");
553
+ const ref = await this.ctx.attachments.saveImage({
554
+ data: bytes,
555
+ mediaType,
556
+ ...(typeof params?.name === "string" && params.name ? { name: params.name } : {}),
557
+ });
558
+ return { ref };
559
+ }
560
+
561
+ /** 按 ref 回读规范化后的图片字节(base64),供前端回放显示。 */
562
+ async attachmentRead(params) {
563
+ const ref = params?.ref;
564
+ if (!ref || typeof ref?.attachmentId !== "string" || typeof ref?.mediaType !== "string") {
565
+ throw new TypeError("attachment/read requires ref (attachmentId + mediaType)");
566
+ }
567
+ const stored = await this.ctx.attachments.readImage(ref);
568
+ return {
569
+ mediaType: stored.ref?.mediaType ?? ref.mediaType,
570
+ data: Buffer.from(stored.data).toString("base64"),
571
+ };
572
+ }
573
+ }
574
+
575
+ /**
576
+ * 插件 apply:transport 接线 / shutdown 语义与官方一致,server 换成本地扩展类。
577
+ * 保持 named exports(无 default),Loader unwrapExports 需要 name/inject/Config/apply。
578
+ */
579
+ function apply(ctx, config) {
580
+ const resolvedConfig = config;
581
+ const rootFiber = ctx.root.fiber;
582
+ /* v8 ignore next -- production stdio wiring */
583
+ const input = config.input ?? process.stdin;
584
+ /* v8 ignore next -- production stdio wiring */
585
+ const output = config.output ?? process.stdout;
586
+ /* v8 ignore next -- production exit wiring */
587
+ const exit =
588
+ config.exit ??
589
+ ((code) => {
590
+ process.exit(code);
591
+ });
592
+ const transport = new JsonRpcLineTransport(input, output);
593
+ const server = new DshGoalJsonRpcServer(ctx, transport, {
594
+ maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
595
+ });
596
+ // 注册用户提问 provider:模型 ask_user_question → 浏览器对话框 → 答案回传。
597
+ // 单个 context 只允许一个 provider;dispose 时随 ctx.effect 清理。
598
+ const unregisterQuestions = ctx.userQuestions.registerProvider({
599
+ ask: (request) => server.askUser(request),
600
+ });
601
+ ctx.effect(() => unregisterQuestions, "user-questions.bridge");
602
+ // 技能启停(#18):晚 agent/pre-step 钩子,在 dsh-tool-skill 注入技能目录后
603
+ // 按 server.disabledSkills 过滤 catalog 消息(剔除禁用技能条目)。
604
+ // 注册在 base 之后 → 本钩子后跑,能拿到已渲染的 catalog。
605
+ // eslint-disable-next-line no-empty-pattern -- only next is needed
606
+ ctx.on("agent/pre-step", async ({}, next) => {
607
+ const decision = await next();
608
+ if (server.disabledSkills.size && decision?.kind === "enter" && Array.isArray(decision.messages)) {
609
+ if (decision.messages.some((m) => m?.source?.kind === "skill-catalog")) {
610
+ decision.messages = decision.messages.map((m) => filterSkillCatalogMessage(m, server.disabledSkills));
611
+ }
612
+ }
613
+ return decision;
614
+ });
615
+ let exitTask;
616
+ const disposeAndExit = () => {
617
+ exitTask ??= (async () => {
618
+ await Promise.allSettled([Promise.resolve().then(() => transport.flush())]);
619
+ await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())]);
620
+ exit(0);
621
+ })();
622
+ return exitTask;
623
+ };
624
+ transport.onRequest(async (method, params) => {
625
+ if (method === "initialize") await ctx.get("loader")?.await();
626
+ const result = await server.handleRequest(method, params);
627
+ if (method === "shutdown")
628
+ setImmediate(() => {
629
+ disposeAndExit();
630
+ });
631
+ return result;
632
+ });
633
+ ctx.effect(() => {
634
+ transport.start();
635
+ return async () => {
636
+ await server.shutdown();
637
+ transport.close();
638
+ };
639
+ }, "jsonrpc.serve");
640
+ }
641
+
642
+ const name = "sdk-jsonrpc-server";
643
+ const inject = ["agents", "goals", "attachments", "userQuestions", "llm", "tools", "skills"];
644
+
645
+ export { Config, apply, inject, name };