pi-web-ui 0.80.2 → 0.83.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.
@@ -22,6 +22,8 @@ let rpcSeq = 0;
22
22
  /**
23
23
  * 单个 MCP 服务器的客户端:管理子进程、请求/响应按 id 关联、握手与工具调用。
24
24
  * 线程模型:无需并发控制(MCP 允许乱序 + 我们按请求 id 匹配响应)。
25
+ * 自愈:子进程意外退出(崩溃/被杀)后不永久失效 —— 下一次工具调用会惰性重启并
26
+ * 重新握手、重新拉取工具列表;显式 close() 之后才永久停用。
25
27
  */
26
28
  export class McpClient {
27
29
  spec;
@@ -34,17 +36,22 @@ export class McpClient {
34
36
  /** 已握手的工具列表(tools/list 结果缓存)。 */
35
37
  tools = [];
36
38
  shuttingDown = false;
39
+ /** 进行中的启动/重启(并发调用共享同一次重连)。 */
40
+ starting = null;
41
+ /** 已启动次数(含自愈重启;诊断/测试用)。 */
42
+ startedCount = 0;
37
43
  constructor(name, spec, log) {
38
44
  this.spec = spec;
39
45
  this.name = name;
40
46
  this.log = log ?? (() => { });
41
47
  }
42
- /** 启动子进程 + 握手 + 拉取工具列表。 */
43
- async start(_timeoutMs = 8000) {
48
+ /** 启动子进程 + 握手 + 拉取工具列表。可安全重入:子进程退出后再次调用即全新启动。 */
49
+ async start(timeoutMs = 8000) {
44
50
  if (this.child)
45
51
  return;
46
52
  const { command, args = [], cwd, env } = this.spec;
47
53
  this.log(`[mcp:${this.name}] starting: ${command} ${args.join(" ")}`);
54
+ this.buffer = "";
48
55
  const child = spawn(command, args, {
49
56
  cwd: cwd ?? undefined,
50
57
  env: { ...process.env, ...env },
@@ -52,36 +59,103 @@ export class McpClient {
52
59
  windowsHide: true,
53
60
  });
54
61
  this.child = child;
62
+ // 进程退出后向 stdin 写请求会触发 EPIPE —— 静默忽略(send 也会判 child 存活)。
63
+ child.stdin?.on("error", () => { });
55
64
  child.stderr.on("data", (d) => this.log(`[mcp:${this.name}] stderr:`, d.toString().trimEnd()));
56
65
  child.on("error", (err) => this.rejectAll(new Error(`[mcp:${this.name}] spawn error: ${err.message}`)));
57
66
  child.on("exit", (code, sig) => {
58
67
  this.child = null;
59
- if (!this.shuttingDown)
68
+ this.buffer = "";
69
+ if (!this.shuttingDown) {
70
+ this.log(`[mcp:${this.name}] 进程退出 (${sig ?? code}),下次调用将自动重启`);
60
71
  this.rejectAll(new Error(`[mcp:${this.name}] 进程退出 (${sig ?? code})`));
72
+ }
61
73
  });
62
74
  child.stdout.setEncoding("utf8");
63
75
  child.stdout.on("data", (chunk) => this.onData(chunk));
64
- // 握手
65
- const handshake = await this.request("initialize", {
66
- protocolVersion: this.spec.protocolVersion ?? PROTOCOL_VERSION,
67
- capabilities: {},
68
- clientInfo: { name: "pi-web-ui", version: "0.41.0" },
76
+ this.startedCount++;
77
+ try {
78
+ // 握手
79
+ const handshake = await this.request("initialize", {
80
+ protocolVersion: this.spec.protocolVersion ?? PROTOCOL_VERSION,
81
+ capabilities: {},
82
+ clientInfo: { name: "pi-web-ui", version: "0.41.0" },
83
+ }, timeoutMs);
84
+ const version = handshake?.protocolVersion ?? this.spec.protocolVersion ?? PROTOCOL_VERSION;
85
+ // 通知 initialized(无 id 的 notification)
86
+ this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
87
+ // 仍以协商协议版本调用 tools(多数服务器对新版本容忍,这里用协商结果)
88
+ void version;
89
+ const listed = ((await this.request("tools/list", {}, timeoutMs)) ?? {});
90
+ this.tools = Array.isArray(listed.tools) ? listed.tools : [];
91
+ this.log(`[mcp:${this.name}] ready, ${this.tools.length} tools`);
92
+ // 若重启过程中被显式 close(),趁机回收刚启动的子进程,不留孤儿。
93
+ if (this.shuttingDown) {
94
+ try {
95
+ child.kill();
96
+ }
97
+ catch {
98
+ /* 已退出 */
99
+ }
100
+ if (this.child === child)
101
+ this.child = null;
102
+ }
103
+ }
104
+ catch (err) {
105
+ // 启动/握手失败:回收本次子进程,避免泄漏;调用方可安全重试(自愈会再试)。
106
+ try {
107
+ child.kill();
108
+ }
109
+ catch {
110
+ /* 已退出 */
111
+ }
112
+ if (this.child === child)
113
+ this.child = null;
114
+ throw err;
115
+ }
116
+ }
117
+ /** 已启动次数(含自愈重启;诊断/测试用)。 */
118
+ get startCount() {
119
+ return this.startedCount;
120
+ }
121
+ /**
122
+ * 保活:子进程还活着就直接返回;已意外退出则惰性重启(并发调用共享同一次重连)。
123
+ * 显式 close() 后抛错,绝不复活。
124
+ */
125
+ async ensureStarted(timeoutMs) {
126
+ if (this.shuttingDown)
127
+ throw new Error(`[mcp:${this.name}] 客户端已关闭,不会重启`);
128
+ // 先认「进行中的重连」再认 child:start() 是同步把 child 落位的,握手却还没完 ——
129
+ // 此时若按 child 判存活就直接返回,并发的第二个调用会抢在 initialize 应答前发出
130
+ // tools/call(严格实现会回「未初始化」)。共享同一个 promise 才能真正串行化。
131
+ if (this.starting)
132
+ return this.starting;
133
+ if (this.child)
134
+ return;
135
+ this.starting = this.start(timeoutMs).finally(() => {
136
+ this.starting = null;
69
137
  });
70
- const version = handshake?.protocolVersion ?? this.spec.protocolVersion ?? PROTOCOL_VERSION;
71
- // 通知 initialized(无 id 的 notification)
72
- this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
73
- // 仍以协商协议版本调用 tools(多数服务器对新版本容忍,这里用协商结果)
74
- void version;
75
- const listed = ((await this.request("tools/list", {})) ?? {});
76
- this.tools = Array.isArray(listed.tools) ? listed.tools : [];
77
- this.log(`[mcp:${this.name}] ready, ${this.tools.length} tools`);
138
+ await this.starting;
78
139
  }
79
140
  /** 已发现工具。 */
80
141
  getTools() {
81
142
  return this.tools.map((t) => ({ ...t }));
82
143
  }
83
- /** 调用一个工具,返回结果文本(多 content 拼接为 JSON 字符串保真)。 */
144
+ /**
145
+ * 调用一个工具,返回其结果。
146
+ * 纯文本块拼接成字符串(老形状,向后兼容);出现非文本块(image/resource/audio 等)时按序透传或退化提示,不再静默丢弃。
147
+ */
84
148
  async call(name, args, timeoutMs = 60000) {
149
+ if (this.shuttingDown)
150
+ throw new Error(`[mcp:${this.name}] 客户端已关闭,不会重启`);
151
+ try {
152
+ // 自愈:子进程已退出(非主动关闭)→ 先惰性重启再发;重启失败给出明确错误而不是挂 60s 超时。
153
+ await this.ensureStarted(8000);
154
+ }
155
+ catch (err) {
156
+ const detail = err instanceof Error ? err.message : String(err);
157
+ throw new Error(`[mcp:${this.name}] 服务器进程已退出且自动重启失败:${detail}`);
158
+ }
85
159
  const res = (await this.request("tools/call", { name, arguments: args }, timeoutMs));
86
160
  if (res?.isError) {
87
161
  const msg = (res.content ?? [])
@@ -90,14 +164,49 @@ export class McpClient {
90
164
  .trim() || "MCP 工具错误";
91
165
  throw new Error(msg);
92
166
  }
93
- // 结构化结果优先,其次文本内容。
167
+ // 结构化结果优先,其次内容块。
94
168
  if (res?.structuredContent !== undefined)
95
169
  return res.structuredContent;
96
- const text = (res.content ?? [])
97
- .map((c) => c.text ?? "")
98
- .filter((x) => x)
99
- .join("\n");
100
- return { content: text, isError: !!res.isError };
170
+ const blocks = [];
171
+ let hasNonText = false;
172
+ for (const c of res.content ?? []) {
173
+ if (c.type === "image" && typeof c.data === "string" && c.data) {
174
+ // MCP image 块字段(type/data/mimeType)与 SDK ImageContent 完全一致,原样透传;
175
+ // 超大图由 SDK 的 normalizeToolResultImages 统一缩放(afterToolCall 钩子,默认 autoResize)。
176
+ blocks.push({ type: "image", data: c.data, mimeType: c.mimeType?.trim() || "image/png" });
177
+ hasNonText = true;
178
+ continue;
179
+ }
180
+ if (c.type && c.type !== "text") {
181
+ const r = typeof c.resource === "object" && c.resource !== null ? c.resource : {};
182
+ // MCP 的 EmbeddedResource 有两种承载:TextResourceContents(resource.text)与
183
+ // BlobResourceContents(resource.blob)。文本型带真实正文(filesystem 类 MCP 的
184
+ // read_text_file 就走这条),当文本透传 —— 退化成「已跳过」等于把文件内容吞掉。
185
+ if (typeof r.text === "string" && r.text) {
186
+ blocks.push({ type: "text", text: r.text });
187
+ continue;
188
+ }
189
+ // resource(blob)/audio 等块在 SDK 内容联合里没有载体(只有 text|image|thinking|toolCall),
190
+ // 退化为文本提示,让模型至少知道工具返回了什么,而不是看到一个空串。
191
+ const mime = (c.mimeType ?? r.mimeType ?? "").trim();
192
+ const blob = typeof r.blob === "string" && r.blob ? r.blob : c.data;
193
+ const size = typeof blob === "string" && blob ? `,约 ${Math.max(1, Math.round((blob.length * 3) / 4))} 字节` : "";
194
+ blocks.push({
195
+ type: "text",
196
+ text: `[MCP 工具返回了非文本内容块(${mime || c.type || "未知类型"}${size}),当前会话无法内联,已跳过。]`,
197
+ });
198
+ hasNonText = true;
199
+ continue;
200
+ }
201
+ const text = c.text ?? "";
202
+ if (text)
203
+ blocks.push({ type: "text", text });
204
+ }
205
+ if (!hasNonText) {
206
+ // 纯文本结果保持旧形状(拼接字符串),不破坏既有调用方。
207
+ return { content: blocks.map((b) => (b.type === "text" ? b.text : "")).join("\n"), isError: !!res.isError };
208
+ }
209
+ return { content: blocks, isError: !!res.isError };
101
210
  }
102
211
  /** 关闭:kill 子进程,拒绝所有在途请求。 */
103
212
  close() {
@@ -402,7 +402,17 @@ export class PluginManager {
402
402
  return;
403
403
  }
404
404
  try {
405
- handler(req, res);
405
+ // 异步 handler(`async (req, res) => …`)的 rejection 不会被这里的 try 接住,
406
+ // 会变成 unhandledRejection 直接杀掉整个服务(插件读文件失败、host.fs 越界
407
+ // 拒绝、上游超时…都会走到这条路上)——用 Promise.resolve().catch 兜住,
408
+ // 与同步抛错同样转 500。
409
+ void Promise.resolve(handler(req, res)).catch((err) => {
410
+ console.error(`[plugin:${pluginId}] http ${method} ${path} failed:`, err);
411
+ if (!res.headersSent)
412
+ res.status(500).end("internal error");
413
+ else
414
+ res.end();
415
+ });
406
416
  }
407
417
  catch (err) {
408
418
  console.error(`[plugin:${pluginId}] http ${method} ${path} failed:`, err);
@@ -646,7 +656,13 @@ export class PluginManager {
646
656
  }
647
657
  return found.map((f) => this.loaded.get(f.id)?.info ?? f);
648
658
  }
649
- /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。 */
659
+ /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。
660
+ *
661
+ * 注意这里必须把 id 从 attempted 里摘掉:目录一时不在(`pi-web-ui install --force`
662
+ * 先 rm 再 cp,扫描正好撞上窗口期)只是「暂时看成卸载」,目录回来后还要能重新激活;
663
+ * 留在 attempted 里 = 本进程内永远不再激活,插件的 HTTP 路由 / AI 工具全没了,
664
+ * 前端只会看到「代理请求失败 404 <url>」(插件的 /proxy 路由不存在),且 CLI 承诺的
665
+ * 「刷新浏览器即可加载」失效,必须重启服务才能恢复。 */
650
666
  deactivateEntry(id, p) {
651
667
  try {
652
668
  p.deactivate?.();
@@ -665,6 +681,11 @@ export class PluginManager {
665
681
  }
666
682
  this.loaded.delete(id);
667
683
  this.messageHandlers.delete(id);
684
+ this.attempted.delete(id);
685
+ // 重新激活时会 import 磁盘上的 index.mjs:Node 的 ESM 缓存按 URL(含 ?e=)
686
+ // 命中,epoch 不变就会拿到旧模块(更新插件后还是旧代码)——所以这里也 +1,
687
+ // 顺带让浏览器端 ?e= 变化、重拉插件的 client bundle。
688
+ this.epochCounter += 1;
668
689
  console.log(`[plugin:${id}] removed`);
669
690
  }
670
691
  /** 关机时反激活全部插件。 */
@@ -2,9 +2,12 @@
2
2
  * subagent-templates.ts — 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。
3
3
  *
4
4
  * 模板 = 派生子代理时套用的预设:角色系统提示词(replace/append 同主设置语义)+
5
- * 技能白名单 + 扩展白名单 + 可选模型。白名单空数组 = 该维度不限定,子代理跟随主会话设置。
5
+ * 技能白名单 + 扩展白名单 + 可选模型 + 可选思考强度。白名单空数组 = 该维度不限定,
6
+ * 子代理跟随主会话设置。
6
7
  * `model` 为 "provider/id"(与 subagent_spawn 的 model 参数、设置面板子代理默认模型
7
8
  * 同格式);空字符串 = 跟随主对话当前模型。
9
+ * `thinkingLevel` 为 SDK 的思考强度(off…max,见 THINKING_LEVELS);空字符串 = 跟随
10
+ * 主对话当前思考强度(与 model 的「跟随主对话」同语义)。
8
11
  * 带 `enabled: false` 的模板停用:设置面板仍可见、可重新启用,但 AI 工具
9
12
  * (subagent_templates / subagent_spawn)查询不到它、也不能选择它——「关闭 =
10
13
  * 对 AI 不可见」。
@@ -18,6 +21,13 @@
18
21
  */
19
22
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
23
  import { dirname } from "node:path";
24
+ /**
25
+ * 思考强度取值:与 SDK 的 THINKING_LEVEL_OPTIONS(前端 ModelThinking 的 THINKING_VALUES)
26
+ * 一致。SDK 未从包根导出该常量,这里照抄一份作输入校验(写错的值一律当未配置)。
27
+ * 注意模型能力收敛(reasoning / thinkingLevelMap)由 SDK 的 setThinkingLevel 负责:
28
+ * 非推理模型只会得到 "off",这里不做也不该做模型相关的判断。
29
+ */
30
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
21
31
  /** 按语言选模板简介:zh 用 description;en 优先 descriptionEn、缺失回落 description。 */
22
32
  export function pickTemplateDescription(t, lang) {
23
33
  if (lang === "zh")
@@ -39,7 +49,8 @@ const NAME_MAX = 60;
39
49
  * 白名单留空 = 技能/扩展跟随主会话设置,开箱即用。
40
50
  */
41
51
  export const DEFAULT_TEMPLATES = [
42
- // 全部内置模板默认 model 为空字符串 = 跟随主对话模型(面板改模板时可指定专属模型)。
52
+ // 全部内置模板默认 model / thinkingLevel 为空字符串 = 跟随主对话当前模型与思考强度
53
+ // (面板改模板时可指定专属模型与强度)。
43
54
  {
44
55
  name: "review",
45
56
  description: "代码 / 计划 / 方案 / PR 审查:有证据的 P0-P2 发现与合并结论",
@@ -86,6 +97,7 @@ export const DEFAULT_TEMPLATES = [
86
97
  enabledSkills: [],
87
98
  enabledExtensions: [],
88
99
  model: "",
100
+ thinkingLevel: "",
89
101
  enabled: true,
90
102
  },
91
103
  {
@@ -130,6 +142,7 @@ export const DEFAULT_TEMPLATES = [
130
142
  enabledSkills: [],
131
143
  enabledExtensions: [],
132
144
  model: "",
145
+ thinkingLevel: "",
133
146
  enabled: true,
134
147
  },
135
148
  {
@@ -170,6 +183,7 @@ export const DEFAULT_TEMPLATES = [
170
183
  enabledSkills: [],
171
184
  enabledExtensions: [],
172
185
  model: "",
186
+ thinkingLevel: "",
173
187
  enabled: true,
174
188
  },
175
189
  {
@@ -214,6 +228,7 @@ export const DEFAULT_TEMPLATES = [
214
228
  enabledSkills: [],
215
229
  enabledExtensions: [],
216
230
  model: "",
231
+ thinkingLevel: "",
217
232
  enabled: true,
218
233
  },
219
234
  {
@@ -240,6 +255,7 @@ export const DEFAULT_TEMPLATES = [
240
255
  enabledSkills: [],
241
256
  enabledExtensions: [],
242
257
  model: "",
258
+ thinkingLevel: "",
243
259
  enabled: true,
244
260
  },
245
261
  {
@@ -260,6 +276,7 @@ export const DEFAULT_TEMPLATES = [
260
276
  enabledSkills: [],
261
277
  enabledExtensions: [],
262
278
  model: "",
279
+ thinkingLevel: "",
263
280
  enabled: true,
264
281
  },
265
282
  // ---- oh-my-pi specialist 系列(移植自 oh-my-pi 内置 agents + persona 包装模板,
@@ -292,6 +309,7 @@ export const DEFAULT_TEMPLATES = [
292
309
  enabledSkills: [],
293
310
  enabledExtensions: [],
294
311
  model: "",
312
+ thinkingLevel: "",
295
313
  enabled: true,
296
314
  },
297
315
  {
@@ -316,6 +334,7 @@ export const DEFAULT_TEMPLATES = [
316
334
  enabledSkills: [],
317
335
  enabledExtensions: [],
318
336
  model: "",
337
+ thinkingLevel: "",
319
338
  enabled: true,
320
339
  },
321
340
  {
@@ -348,6 +367,7 @@ export const DEFAULT_TEMPLATES = [
348
367
  enabledSkills: [],
349
368
  enabledExtensions: [],
350
369
  model: "",
370
+ thinkingLevel: "",
351
371
  enabled: true,
352
372
  },
353
373
  {
@@ -378,6 +398,7 @@ export const DEFAULT_TEMPLATES = [
378
398
  enabledSkills: [],
379
399
  enabledExtensions: [],
380
400
  model: "",
401
+ thinkingLevel: "",
381
402
  enabled: true,
382
403
  },
383
404
  {
@@ -408,6 +429,7 @@ export const DEFAULT_TEMPLATES = [
408
429
  enabledSkills: [],
409
430
  enabledExtensions: [],
410
431
  model: "",
432
+ thinkingLevel: "",
411
433
  enabled: true,
412
434
  },
413
435
  {
@@ -432,6 +454,7 @@ export const DEFAULT_TEMPLATES = [
432
454
  enabledSkills: [],
433
455
  enabledExtensions: [],
434
456
  model: "",
457
+ thinkingLevel: "",
435
458
  enabled: true,
436
459
  },
437
460
  {
@@ -454,6 +477,7 @@ export const DEFAULT_TEMPLATES = [
454
477
  enabledSkills: [],
455
478
  enabledExtensions: [],
456
479
  model: "",
480
+ thinkingLevel: "",
457
481
  enabled: true,
458
482
  },
459
483
  ];
@@ -480,6 +504,10 @@ function normalize(raw) {
480
504
  : [],
481
505
  // 空字符串 = 跟随主对话;其余剥掉首尾空白,超长当脏数据丢弃。
482
506
  model: typeof o.model === "string" ? o.model.trim() : "",
507
+ // 只认 THINKING_LEVELS 里的档位;老文件没有该字段 / 值写错 → 空 = 跟随主对话。
508
+ thinkingLevel: typeof o.thinkingLevel === "string" && THINKING_LEVELS.includes(o.thinkingLevel.trim())
509
+ ? o.thinkingLevel.trim()
510
+ : "",
483
511
  enabled: o.enabled !== false,
484
512
  };
485
513
  }
@@ -92,15 +92,16 @@ export function makeSubagentTools(host, lang, selfConvId) {
92
92
  "subagent_get_result for results, subagent_steer to redirect mid-run, subagent_stop to stop. " +
93
93
  "Good for: long-running exploration, parallel research, delegating independent subtasks. Optional template " +
94
94
  "param: use a subagent template configured in the settings panel " +
95
- "(role system prompt + skills/extensions whitelist + optional model); optional model param: explicitly set " +
95
+ "(role system prompt + skills/extensions whitelist + optional model + optional thinking level); optional model " +
96
+ "param: explicitly set " +
96
97
  'the subagent model (provider/id, e.g. "anthropic/claude-opus-4-5"), which overrides the template and panel ' +
97
- "default; omit both = follow the main conversation's model.", "在后台启动一个独立的子代理对话,用一个明确的指令去完成一项可独立交付的工作(调研/实现/审查等)。" +
98
+ "default; omit both = follow the main conversation's model and thinking level.", "在后台启动一个独立的子代理对话,用一个明确的指令去完成一项可独立交付的工作(调研/实现/审查等)。" +
98
99
  "子代理会出现在左栏「运行的对话」列表(带子代理标识),用户可点开查看、补充、中止。主 agent 可并行派发多个:" +
99
100
  "用 subagent_wait_all 一次性等全部完成(不用轮询)、subagent_list 查看运行态、subagent_get_result 取结果、" +
100
101
  "subagent_steer 中途改向、subagent_stop 停止。" +
101
102
  "适合:长耗时探索、并行调研、独立子任务委派。可选 template 参数:使用设置面板配置的子代理模板" +
102
- "(角色系统提示词 + 技能/扩展白名单 + 可选模型);可选 model 参数:显式指定子代理模型(provider/id 格式," +
103
- '如 "anthropic/claude-opus-4-5"),优先级高于模板与设置面板的默认模型;都不传 = 跟随主对话当前模型。'),
103
+ "(角色系统提示词 + 技能/扩展白名单 + 可选模型 + 可选思考强度);可选 model 参数:显式指定子代理模型(provider/id 格式," +
104
+ '如 "anthropic/claude-opus-4-5"),优先级高于模板与设置面板的默认模型;都不传 = 跟随主对话当前模型与思考强度。'),
104
105
  promptSnippet: "spawn an independent background subagent for a deliverable task (parallel work)",
105
106
  parameters: Type.Object({
106
107
  prompt: Type.String({
@@ -112,8 +113,8 @@ export function makeSubagentTools(host, lang, selfConvId) {
112
113
  template: Type.Optional(Type.String({
113
114
  description: bilingual("Optional: subagent template name (a preset configured under Settings → Subagent Templates, see the " +
114
115
  "subagent_templates tool). Template = role system prompt + skills/extensions whitelist + optional " +
115
- "model; omit = run with the main session defaults.", "可选:子代理模板名(设置面板「子代理模板」配置的预设,见 subagent_templates 工具)。" +
116
- "模板 = 角色系统提示词 + 技能/扩展白名单 + 可选模型;不传 = 不使用模板,按主会话默认配置运行。"),
116
+ "model + optional thinking level; omit = run with the main session defaults.", "可选:子代理模板名(设置面板「子代理模板」配置的预设,见 subagent_templates 工具)。" +
117
+ "模板 = 角色系统提示词 + 技能/扩展白名单 + 可选模型 + 可选思考强度;不传 = 不使用模板,按主会话默认配置运行。"),
117
118
  })),
118
119
  model: Type.Optional(Type.String({
119
120
  description: bilingual('Optional: subagent model "provider/id" (e.g. "anthropic/claude-opus-4-5") for this run; overrides the ' +
@@ -311,8 +312,8 @@ export function makeSubagentTools(host, lang, selfConvId) {
311
312
  name: "subagent_templates",
312
313
  label: "List subagent templates",
313
314
  description: bilingual("List the configurable subagent templates (role system prompt + skills/extensions whitelist + optional model " +
314
- "presets) for the subagent_spawn template param. Disabled templates never appear here. " +
315
- "Empty list = no templates configured; subagents run with defaults.", "列出设置面板「子代理模板」配置的可用模板(角色系统提示词 + 技能/扩展白名单 + 可选模型 的组合预设)," +
315
+ "and thinking level presets) for the subagent_spawn template param. Disabled templates never appear here. " +
316
+ "Empty list = no templates configured; subagents run with defaults.", "列出设置面板「子代理模板」配置的可用模板(角色系统提示词 + 技能/扩展白名单 + 可选模型与思考强度 的组合预设)," +
316
317
  "供 subagent_spawn 的 template 参数选用。已停用的模板不会出现在这里。list 为空 = 未配置模板,子代理按默认配置运行。"),
317
318
  promptSnippet: "list configurable subagent templates (role prompt + skills/extensions whitelist presets)",
318
319
  parameters: Type.Object({}),
@@ -324,14 +325,23 @@ export function makeSubagentTools(host, lang, selfConvId) {
324
325
  }
325
326
  const lines = list.map((t) => {
326
327
  const desc = tLang === "zh" ? t.description : t.descriptionEn || t.description;
328
+ // 模型与思考强度分开报:思考强度是模板固定值(空 = 跟主对话当前强度)。
329
+ const modelPart = tLang === "zh"
330
+ ? t.model
331
+ ? `模型:${t.model}`
332
+ : "跟随主对话模型"
333
+ : t.model
334
+ ? `model: ${t.model}`
335
+ : "follows the main conversation model";
336
+ const thinkingPart = tLang === "zh"
337
+ ? t.thinkingLevel
338
+ ? `思考强度:${t.thinkingLevel}`
339
+ : "跟随主对话思考强度"
340
+ : t.thinkingLevel
341
+ ? `thinking: ${t.thinkingLevel}`
342
+ : "follows the main conversation thinking level";
327
343
  return ((tLang === "zh" ? `- ${t.name}${desc ? `:${desc}` : ""}` : `- ${t.name}${desc ? `: ${desc}` : ""}`) +
328
- (tLang === "zh"
329
- ? t.model
330
- ? `(模型:${t.model})`
331
- : "(跟随主对话模型)"
332
- : t.model
333
- ? ` (model: ${t.model})`
334
- : " (follows the main conversation model)"));
344
+ (tLang === "zh" ? `(${modelPart},${thinkingPart})` : ` (${modelPart}, ${thinkingPart})`));
335
345
  });
336
346
  const firstTemplateName = list[0]?.name;
337
347
  const templateLines = lines.join("\n");
@@ -44,9 +44,12 @@ export const ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
44
44
  /** 任务列表只读查询工具(定义见 agent-service.ts makeMarkersListTool;只服务 todo)。
45
45
  * 曾用名 markers_list(名过其实,已迁移,见 normalizeDisabledAgentTools)。 */
46
46
  export const MARKERS_LIST_TOOL_NAME = "todo_list";
47
+ /** 浏览器页面操作工具(定义见 agent-service.ts makeBrowserPageTool):模型经
48
+ * page-picker 浏览器扩展读/操作用户已授权的页面。 */
49
+ export const BROWSER_PAGE_TOOL_NAME = "browser_page";
47
50
  /** 旧工具名(持久化迁移用;新代码一律用 MARKERS_LIST_TOOL_NAME)。 */
48
51
  export const LEGACY_MARKERS_LIST_TOOL_NAME = "markers_list";
49
- /** 可开关的 Agent 工具总目录(共 18 个;bash 本体与 SDK 内置 edit/read
52
+ /** 可开关的 Agent 工具总目录(共 19 个;bash 本体与 SDK 内置 edit/read
50
53
  * 不进目录——关了 agent 就残了,不给关)。 */
51
54
  export const AGENT_TOOL_CATALOG = [
52
55
  ...TERMINAL_TOOL_NAMES.map((name) => ({
@@ -65,6 +68,9 @@ export const AGENT_TOOL_CATALOG = [
65
68
  { name: DELEGATE_TASK_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
66
69
  { name: ASK_USER_QUESTION_TOOL_NAME, group: "other", defaultOn: true, dshVisible: true },
67
70
  { name: MARKERS_LIST_TOOL_NAME, group: "other", defaultOn: true, dshVisible: true },
71
+ // 默认开但 dshVisible=false:DSH 引擎没有页面桥(page_request 由 pi 引擎的
72
+ // customTool 发出),列在那里只会让用户关一个不存在的工具。
73
+ { name: BROWSER_PAGE_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
68
74
  ];
69
75
  const KNOWN_NAMES = new Set(AGENT_TOOL_CATALOG.map((t) => t.name));
70
76
  /** 是否为本表登记的可开关工具(未知名一律 false,不抛错)。 */
@@ -33,16 +33,34 @@ const mockTui = new Proxy({
33
33
  });
34
34
  /**
35
35
  * Implements the subset of ExtensionUIContext that makes sense for a web UI.
36
- * TUI-only affordances (select/confirm/input dialogs, terminal input, custom
37
- * footer) are inert: dialogs resolve to cancellation instead of blocking.
36
+ * widgets/statuses/notices/dialogs are bridged to the browser (a dialog nobody
37
+ * answers resolves to cancellation); TUI-only affordances (terminal input,
38
+ * footer/header, custom components, editor hooks) are inert.
39
+ *
40
+ * 两种实例:挂浏览器的(构造时给真 emitter)与 headless 的(`WebUIContext.headless()`,
41
+ * 子代理会话用——见该方法的说明)。
38
42
  */
39
43
  export class WebUIContext {
40
44
  theme = mockThemeProxy;
41
45
  widgets = new Map();
42
46
  lastLines = new Map();
43
47
  emit;
44
- constructor(emit) {
45
- this.emit = emit;
48
+ /** headless 实例没有浏览器面板:输出全部丢弃、弹窗直接按取消返回。 */
49
+ headless;
50
+ constructor(emit, options = {}) {
51
+ this.headless = options.headless === true;
52
+ this.emit = this.headless ? () => { } : emit;
53
+ }
54
+ /** 子代理会话用的上下文:方法面与挂浏览器的那个完全一致(扩展调用任何
55
+ * ExtensionUIContext 方法都不会因方法缺失而崩),但没有面板接收输出,因此
56
+ *
57
+ * - widgets/status/notice 一律丢弃(不会与主对话的 widget/status 串台);
58
+ * - widget 组件工厂不调用(没人 dispose 的组件会一直挂着);
59
+ * - select/confirm/input 直接按「取消」resolve —— 关键:这里没有浏览器应答,
60
+ * 照常挂 Promise 会让扩展永久 await(只有 20 分钟的工具看门狗兜底)。
61
+ */
62
+ static headless() {
63
+ return new WebUIContext(() => { }, { headless: true });
46
64
  }
47
65
  // -- widgets -------------------------------------------------------------
48
66
  /** Register a widget whose lines come from a plain getter, re-evaluated on
@@ -57,6 +75,9 @@ export class WebUIContext {
57
75
  /** Matches ExtensionUIContext's overloaded setWidget exactly. */
58
76
  setWidget = (key, content, options) => {
59
77
  void options;
78
+ // headless:没有面板可渲染,连组件工厂都不调用。
79
+ if (this.headless)
80
+ return;
60
81
  if (content === undefined) {
61
82
  this.widgets.delete(key);
62
83
  this.lastLines.delete(key);
@@ -163,6 +184,9 @@ export class WebUIContext {
163
184
  confirm = (title, message) => this.openDialog("confirm", title, [message]);
164
185
  input = (title, placeholder) => this.openDialog("input", title, [placeholder ?? ""]);
165
186
  openDialog(kind, title, args) {
187
+ // headless:没人能应答,立刻按「取消」结束(与 cancelPendingDialogs 同值)。
188
+ if (this.headless)
189
+ return Promise.resolve(null);
166
190
  return new Promise((resolve) => {
167
191
  const id = ++this.dialogSeq;
168
192
  this.pendingDialogs.set(id, resolve);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.80.2",
3
+ "version": "0.83.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -59,8 +59,10 @@
59
59
  "build:server": "tsc -p tsconfig.server.json",
60
60
  "build:dsh-runtime": "node scripts/copy-dsh-runtime.mjs",
61
61
  "build:mermaid-vendor": "node scripts/build-mermaid-vendor.mjs",
62
+ "build:extension": "node plugins/page-picker/extension/build.mjs",
63
+ "pack:extension": "node plugins/page-picker/extension/pack.mjs",
62
64
  "build:runtrace-vendor": "node scripts/build-runtrace-vendor.mjs",
63
- "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit && tsc -p desktop/tsconfig.json --noEmit",
65
+ "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit && tsc -p desktop/tsconfig.json --noEmit && tsc -p plugins/page-picker/extension/tsconfig.json --noEmit",
64
66
  "test": "vitest run",
65
67
  "test:unit": "vitest run",
66
68
  "test:smoke": "node tests/run-smoke.mjs",
@@ -52,5 +52,14 @@
52
52
  "descriptionEn": "Legado reader (text book sources): search / explore / info / TOC / content with Android-compatible book source JSON, plus source import and health checking. Ships its own CORS+GBK proxy, local store, and AI tools to diagnose and repair book sources.",
53
53
  "source": "xing-shuyin/pi-web-ui/plugins/legado-web",
54
54
  "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/legado-web"
55
+ },
56
+ {
57
+ "id": "image-toolkit",
58
+ "name": "image-toolkit",
59
+ "icon": "🖼",
60
+ "description": "图片处理工作台:压缩(可按目标体积二分逼近)、裁剪、缩放、旋转/翻转、格式转换(PNG/JPEG/WebP/AVIF)、批量导出 ZIP、水印、滤镜调色、图片信息与 EXIF,可直接读写工作区图片;另给 AI 配了 4 个工具,让 agent 自己压缩/裁剪/缩放/加水印工作区里的图。",
61
+ "descriptionEn": "Image workbench: compress (with target-size binary search), crop, resize, rotate/flip, format conversion (PNG/JPEG/WebP/AVIF), batch ZIP export, watermark, filters, image info + EXIF, and direct workspace read/write. Ships 4 AI tools so the agent can compress/crop/resize/watermark workspace images itself.",
62
+ "source": "xing-shuyin/pi-web-ui/plugins/image-toolkit",
63
+ "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/image-toolkit"
55
64
  }
56
65
  ]
@@ -73,6 +73,7 @@
73
73
  --search-active-fg: #1a1d26;
74
74
  --switch-knob: #ffffff;
75
75
  --bg-elev3: rgba(255, 255, 255, 0.06);
76
+ --sunken-bg: rgba(0, 0, 0, 0.15);
76
77
  --glow-015: rgba(255, 255, 255, 0.015);
77
78
  --glow-025: rgba(255, 255, 255, 0.025);
78
79
  --glow-03: rgba(255, 255, 255, 0.03);
package/themes/dazzle.css CHANGED
@@ -73,6 +73,7 @@
73
73
  --search-active-fg: #1a1d26;
74
74
  --switch-knob: #ffffff;
75
75
  --bg-elev3: rgba(255, 255, 255, 0.06);
76
+ --sunken-bg: rgba(0, 0, 0, 0.15);
76
77
  --glow-015: rgba(255, 255, 255, 0.015);
77
78
  --glow-025: rgba(255, 255, 255, 0.025);
78
79
  --glow-03: rgba(255, 255, 255, 0.03);
@@ -73,6 +73,7 @@
73
73
  --search-active-fg: #1a1d26;
74
74
  --switch-knob: #ffffff;
75
75
  --bg-elev3: rgba(255, 255, 255, 0.06);
76
+ --sunken-bg: rgba(0, 0, 0, 0.15);
76
77
  --glow-015: rgba(255, 255, 255, 0.015);
77
78
  --glow-025: rgba(255, 255, 255, 0.025);
78
79
  --glow-03: rgba(255, 255, 255, 0.03);
package/themes/mist.css CHANGED
@@ -73,6 +73,7 @@
73
73
  --search-active-fg: #1a1d26;
74
74
  --switch-knob: #ffffff;
75
75
  --bg-elev3: rgba(30, 58, 95, 0.05);
76
+ --sunken-bg: rgba(30, 58, 95, 0.05);
76
77
  --glow-015: rgba(30, 58, 95, 0.02);
77
78
  --glow-025: rgba(30, 58, 95, 0.02);
78
79
  --glow-03: rgba(30, 58, 95, 0.02);
package/themes/paper.css CHANGED
@@ -73,6 +73,7 @@
73
73
  --search-active-fg: #1a1d26;
74
74
  --switch-knob: #ffffff;
75
75
  --bg-elev3: rgba(120, 90, 30, 0.06);
76
+ --sunken-bg: rgba(120, 90, 30, 0.05);
76
77
  --glow-015: rgba(120, 90, 30, 0.02);
77
78
  --glow-025: rgba(120, 90, 30, 0.02);
78
79
  --glow-03: rgba(120, 90, 30, 0.02);