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.
- package/CHANGELOG.md +167 -2
- package/README.md +112 -97
- package/README.zh-CN.md +86 -66
- package/dist/server/agent-service.js +385 -13
- package/dist/server/attachments.js +36 -0
- package/dist/server/index.js +5 -0
- package/dist/server/mcp-bridge.js +132 -23
- package/dist/server/plugins.js +23 -2
- package/dist/server/subagent-templates.js +30 -2
- package/dist/server/subagents.js +25 -15
- package/dist/server/tool-manager.js +7 -1
- package/dist/server/webui-context.js +28 -4
- package/package.json +4 -2
- package/plugins/catalog.json +9 -0
- package/themes/cyberpunk.css +1 -0
- package/themes/dazzle.css +1 -0
- package/themes/md-preview.css +1 -0
- package/themes/mist.css +1 -0
- package/themes/paper.css +1 -0
- package/themes/sakura.css +1 -0
- package/themes/white.css +1 -0
- package/web/dist/assets/{TerminalPanel-CeruoZjh.js → TerminalPanel-BF99sld0.js} +1 -1
- package/web/dist/assets/index-B1XPfcM0.css +10 -0
- package/web/dist/assets/index-D__1RagK.js +348 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BduNm7_u.css +0 -10
- package/web/dist/assets/index-DtBJSe33.js +0 -347
|
@@ -37,13 +37,14 @@ import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, nor
|
|
|
37
37
|
import { bilingual, pick, resolveServerLang } from "./i18n.js";
|
|
38
38
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
39
39
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
40
|
-
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
41
|
-
import { WebUIContext
|
|
40
|
+
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
41
|
+
import { WebUIContext } from "./webui-context.js";
|
|
42
42
|
import { decodeText } from "./text-sniff.js";
|
|
43
43
|
import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
44
44
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
45
45
|
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
46
|
-
import { buildAttachmentMessages } from "./attachments.js";
|
|
46
|
+
import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
|
|
47
|
+
import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
|
|
47
48
|
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
48
49
|
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
49
50
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
@@ -297,6 +298,221 @@ ownerId) {
|
|
|
297
298
|
},
|
|
298
299
|
};
|
|
299
300
|
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// 浏览器页面工具(标准 pi 引擎的 browser_page customTool)
|
|
303
|
+
//
|
|
304
|
+
// 模型调 browser_page → 服务端发 page_request 给浏览器 → 前端转 page-picker
|
|
305
|
+
// 扩展 → 扩展操作目标页面 → 前端回 page_response → 工具结果回到模型。
|
|
306
|
+
//
|
|
307
|
+
// op 的语义(read/click/type/…)属于**扩展侧**,服务端只透传,不解读也不校验
|
|
308
|
+
// ——所以参数说明写在 tool description 里让模型知道怎么用,不在这里分支处理。
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
/** timeoutMs 默认值。对面是扩展不是人,超时必须自己兜住。 */
|
|
311
|
+
const PAGE_CALL_DEFAULT_TIMEOUT_MS = 30_000;
|
|
312
|
+
/** 夹取区间:太小会误杀慢页面(拿不到结果还白跑一趟),太大就把模型拖到
|
|
313
|
+
* 工具看门狗(20 分钟)附近了。 */
|
|
314
|
+
const PAGE_CALL_MIN_TIMEOUT_MS = 1_000;
|
|
315
|
+
const PAGE_CALL_MAX_TIMEOUT_MS = 120_000;
|
|
316
|
+
/** timeoutMs 归一:非有限数字/缺省 → 默认;其余夹在 [1000, 120000]。
|
|
317
|
+
* 工具入口与页桥(pageCall)共用,防手写脏值绕过 schema。 */
|
|
318
|
+
export function normalizePageCallTimeoutMs(v) {
|
|
319
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : PAGE_CALL_DEFAULT_TIMEOUT_MS;
|
|
320
|
+
return Math.min(PAGE_CALL_MAX_TIMEOUT_MS, Math.max(PAGE_CALL_MIN_TIMEOUT_MS, n));
|
|
321
|
+
}
|
|
322
|
+
/** 除 op/target/timeoutMs 外的扁平参数名(保持扩展侧原名:what/selector/…)。
|
|
323
|
+
* 列表即 schema 里的可选字段——改 schema 忘改这里,单测会炸(见
|
|
324
|
+
* tests/unit/browser-page-tool.test.ts)。 */
|
|
325
|
+
const BROWSER_PAGE_ARG_KEYS = ["what", "selector", "text", "url", "code", "all", "index", "maxEdge"];
|
|
326
|
+
/** 只收模型**确实传了**的参数:undefined 不入包,否则扩展拿到一堆
|
|
327
|
+
* `"selector": undefined` 会覆盖自己的默认值。 */
|
|
328
|
+
export function collectBrowserPageArgs(params) {
|
|
329
|
+
const args = {};
|
|
330
|
+
for (const k of BROWSER_PAGE_ARG_KEYS) {
|
|
331
|
+
if (params[k] !== undefined)
|
|
332
|
+
args[k] = params[k];
|
|
333
|
+
}
|
|
334
|
+
return args;
|
|
335
|
+
}
|
|
336
|
+
/** 页面调用结果 → 给模型的文本:字符串原样(read 的正文就是这样,别再加引号),
|
|
337
|
+
* 其余 JSON 缩进;空结果给一句说明,免得模型以为工具没输出。 */
|
|
338
|
+
export function formatPageCallResult(result) {
|
|
339
|
+
if (typeof result === "string")
|
|
340
|
+
return result.length > 0 ? result : "(empty)";
|
|
341
|
+
if (result === undefined || result === null)
|
|
342
|
+
return "(no result)";
|
|
343
|
+
try {
|
|
344
|
+
return JSON.stringify(result, null, 2) ?? String(result);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// 循环引用/含大整数等不可序列化结果:别让格式化把工具调用炸掉。
|
|
348
|
+
return String(result);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** 失败文本:带上 op 与原因,再补一句**可执行的**下一步(模型只有知道该让
|
|
352
|
+
* 用户干什么,才不会再盲目重试同一个调用)。 */
|
|
353
|
+
export function formatBrowserPageError(op, error) {
|
|
354
|
+
return [
|
|
355
|
+
`browser_page "${op}" failed: ${error}`,
|
|
356
|
+
`browser_page "${op}" 失败:${error}`,
|
|
357
|
+
'Next: make sure a pi-web-ui page is open with the page-picker extension enabled and paired, then try op:"pages" to see which pages are available. If the target page is not allowed yet, ask the user to allow it in the extension.',
|
|
358
|
+
'下一步:确认 pi-web-ui 页面已打开、page-picker 扩展已启用并与该页面配对,再用 op:"pages" 看有哪些可操作页面;若目标页面尚未授权,请让用户先在扩展里授权。',
|
|
359
|
+
].join("\n");
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* 标准 pi 引擎的 browser_page 工具:模型调用时把请求桥到用户浏览器里的
|
|
363
|
+
* pi-web-ui 页面(page_request/page_response 协议),由 page-picker 扩展真正
|
|
364
|
+
* 操作用户授权的页面。
|
|
365
|
+
*
|
|
366
|
+
* 与 ask_user_question 同样以 customTool 注册(标准 SDK 没有这个工具;DSH 引擎
|
|
367
|
+
* 走自己的运行时,也不经此)。写法严格比照 makeAskUserQuestionTool。
|
|
368
|
+
*
|
|
369
|
+
* pageCall 签名同样带 {aborted} 快照而非完整 AbortSignal(customTool 的 execute
|
|
370
|
+
* 信号服务于整个 agent 生命周期,这里只要「已中止即失败」的最小语义)。
|
|
371
|
+
*/
|
|
372
|
+
export function makeBrowserPageTool(clientSession,
|
|
373
|
+
/** 本 runtime 所属会话(语义与 ask_user_question 的 ownerId 一致)。 */
|
|
374
|
+
ownerId) {
|
|
375
|
+
return {
|
|
376
|
+
name: BROWSER_PAGE_TOOL_NAME,
|
|
377
|
+
label: "Browser page",
|
|
378
|
+
description: [
|
|
379
|
+
'Read or act on a page in the USER\'S OWN browser through the pi-web-ui page-picker extension (the extension talks to this page; the server only forwards the request). Only pages the user has explicitly allowed/paired in that extension can be touched. Call it with op:"pages" first to see which pages are currently available, and use it ONLY when the user asked you to read or operate a web page — never click/type on their pages on your own initiative.',
|
|
380
|
+
"ops (forwarded to the extension as-is, the server does not interpret them):",
|
|
381
|
+
" pages — no args; lists the pages you may act on",
|
|
382
|
+
' read — { what?: "text" | "html" | "title" | "url" | "query", selector?, all? }',
|
|
383
|
+
" click — { selector, index? }",
|
|
384
|
+
" type — { selector, text, clear?, submit? } (submit: true presses Enter)",
|
|
385
|
+
" scroll — { selector?, to?: { x, y }, by?: { x, y } }",
|
|
386
|
+
" goto — { url }",
|
|
387
|
+
" wait — { selector?, text?, timeoutMs? } waits for the element/text to appear; that timeoutMs is the op's own",
|
|
388
|
+
" eval — { code } runs JS inside the page (extension-side switch, off by default)",
|
|
389
|
+
"Op options that are not fields of this tool (e.g. read's `limit`) fall back to the extension's defaults. `target` selects the page by origin when more than one is allowed; `timeoutMs` is how long the SERVER waits for the browser (1000-120000, default 30000) before failing the call.",
|
|
390
|
+
].join("\n"),
|
|
391
|
+
promptSnippet: bilingual("read or operate a page in the user's browser (page-picker extension; allowed pages only)", "读取/操作用户浏览器里已授权的页面(page-picker 扩展,仅限已授权页面)"),
|
|
392
|
+
promptGuidelines: [
|
|
393
|
+
bilingual("Only use browser_page when the user asked you to read or act on a page in their browser; never click or type on their pages on your own initiative", "只在用户明确要求读取/操作浏览器页面时才用 browser_page;不要自作主张去点用户的页面"),
|
|
394
|
+
bilingual('Start with op:"pages" to see which pages are available; the target page must already be allowed in the page-picker extension — when it fails, tell the user what to enable instead of retrying blindly', '先用 op:"pages" 看有哪些可操作页面;目标页面必须已在 page-picker 扩展里授权——失败时把需要开什么告诉用户,不要盲目重试'),
|
|
395
|
+
],
|
|
396
|
+
parameters: Type.Object({
|
|
397
|
+
op: Type.String({
|
|
398
|
+
description: "Action name (extension-side): pages | read | click | type | scroll | goto | wait | eval | shot — see the tool description for each op and its options.",
|
|
399
|
+
}),
|
|
400
|
+
target: Type.Optional(Type.String({
|
|
401
|
+
description: "Target page origin (e.g. https://example.com). Only needed when several pages are allowed.",
|
|
402
|
+
})),
|
|
403
|
+
what: Type.Optional(Type.String({ description: 'For op:read — "text" | "html" | "title" | "url" | "query" (default: text).' })),
|
|
404
|
+
selector: Type.Optional(Type.String({ description: "CSS selector, for op:read / click / type / scroll / wait." })),
|
|
405
|
+
text: Type.Optional(Type.String({ description: "For op:type — the text to enter; for op:wait — the text to wait for." })),
|
|
406
|
+
url: Type.Optional(Type.String({ description: "For op:goto — the absolute URL to navigate to." })),
|
|
407
|
+
code: Type.Optional(Type.String({
|
|
408
|
+
description: "For op:eval — JavaScript to run inside the page (extension-side switch, disabled by default).",
|
|
409
|
+
})),
|
|
410
|
+
all: Type.Optional(Type.Boolean({ description: "For op:read — return every match instead of only the first one." })),
|
|
411
|
+
index: Type.Optional(Type.Number({ description: "For op:click — which match to click (default: 0)." })),
|
|
412
|
+
maxEdge: Type.Optional(Type.Number({
|
|
413
|
+
description: "For op:shot — max size of the longer side in px (320-1568, default 1280). Bigger = more tokens.",
|
|
414
|
+
})),
|
|
415
|
+
timeoutMs: Type.Optional(Type.Number({
|
|
416
|
+
description: "How long the server waits for the browser before failing (1000-120000 ms, default 30000).",
|
|
417
|
+
})),
|
|
418
|
+
}),
|
|
419
|
+
execute: async (_id, params, signal) => {
|
|
420
|
+
const p = (params ?? {});
|
|
421
|
+
const op = typeof p.op === "string" ? p.op.trim() : "";
|
|
422
|
+
if (!op) {
|
|
423
|
+
throw new Error('browser_page requires a non-empty `op` (e.g. "pages", "read", "click").\nbrowser_page 需要非空的 op(如 pages/read/click)。');
|
|
424
|
+
}
|
|
425
|
+
const resolved = await clientSession.pageCall({
|
|
426
|
+
op,
|
|
427
|
+
args: collectBrowserPageArgs(p),
|
|
428
|
+
target: typeof p.target === "string" && p.target.length > 0 ? p.target : undefined,
|
|
429
|
+
timeoutMs: normalizePageCallTimeoutMs(p.timeoutMs),
|
|
430
|
+
}, {
|
|
431
|
+
aborted: signal?.aborted,
|
|
432
|
+
}, ownerId);
|
|
433
|
+
if (!resolved.ok) {
|
|
434
|
+
// 抛 Error 而不是回一段失败文本:模型需要看到「工具失败」才会改策略。
|
|
435
|
+
throw new Error(formatBrowserPageError(op, resolved.error));
|
|
436
|
+
}
|
|
437
|
+
const shot = extractShotImage(resolved.result);
|
|
438
|
+
if (!shot) {
|
|
439
|
+
// 工具结果:read 的正文原样给模型,结构化结果 JSON 缩进;details 留 UI/轨迹。
|
|
440
|
+
return {
|
|
441
|
+
content: [{ type: "text", text: formatPageCallResult(resolved.result) }],
|
|
442
|
+
details: { op, args: collectBrowserPageArgs(p), target: p.target, result: resolved.result },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// 截图:**主模型能看图就直接把图给回去**(当轮就能看到,不用等下一轮);
|
|
446
|
+
// 看不到图(纯文本模型)就交给视觉桥转写成文字证据 —— 与用户粘贴图片走同一套
|
|
447
|
+
// 选择逻辑与提示词,设置里开着就自动生效,模型侧不需要任何额外配置。
|
|
448
|
+
const where = `${p.target ?? "the page"}${shot.selector ? ` (element ${shot.selector})` : ""}`;
|
|
449
|
+
const caption = [
|
|
450
|
+
`Screenshot of ${where} — ${shot.width ?? "?"}×${shot.height ?? "?"} px.`,
|
|
451
|
+
`页面截图:${where} — ${shot.width ?? "?"}×${shot.height ?? "?"} px。`,
|
|
452
|
+
].join("\n");
|
|
453
|
+
const details = {
|
|
454
|
+
op,
|
|
455
|
+
args: collectBrowserPageArgs(p),
|
|
456
|
+
target: p.target,
|
|
457
|
+
result: { ...resolved.result, image: "[image]" },
|
|
458
|
+
};
|
|
459
|
+
if (clientSession.canSeeImages?.() === true) {
|
|
460
|
+
return {
|
|
461
|
+
content: [
|
|
462
|
+
{ type: "text", text: caption },
|
|
463
|
+
{ type: "image", data: shot.data, mimeType: shot.mimeType },
|
|
464
|
+
],
|
|
465
|
+
details,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const bridged = await clientSession.transcribeToolImage?.(shot, signal);
|
|
469
|
+
const note = bridged?.text
|
|
470
|
+
? `
|
|
471
|
+
|
|
472
|
+
<vision-bridge>
|
|
473
|
+
${bridged.text}
|
|
474
|
+
</vision-bridge>`
|
|
475
|
+
: [
|
|
476
|
+
`
|
|
477
|
+
|
|
478
|
+
(当前模型看不到图片:${bridged?.reason ?? "视觉桥不可用"} —— 可让用户改用支持识图的模型,或在模型配置里加一个支持图片的模型)`,
|
|
479
|
+
`(The current model cannot see images: ${bridged?.reason ?? "vision bridge unavailable"})`,
|
|
480
|
+
].join("\n");
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: "text", text: caption + note }],
|
|
483
|
+
details,
|
|
484
|
+
};
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* 从扩展的截图结果里取出图片。
|
|
490
|
+
*
|
|
491
|
+
* 扩展回的是 `{ image: { dataUrl, mimeType, width, height }, selector?, rect?, viewport? }`;
|
|
492
|
+
* dataUrl 带 `data:image/jpeg;base64,` 前缀,而模型 API 要的是**纯 base64** —— 剥前缀这一步
|
|
493
|
+
* 很容易忘(忘了就是「图片解析失败」)。
|
|
494
|
+
*/
|
|
495
|
+
export function extractShotImage(result) {
|
|
496
|
+
if (!result || typeof result !== "object")
|
|
497
|
+
return undefined;
|
|
498
|
+
const image = result.image;
|
|
499
|
+
if (!image || typeof image !== "object")
|
|
500
|
+
return undefined;
|
|
501
|
+
const src = image;
|
|
502
|
+
if (typeof src.dataUrl !== "string")
|
|
503
|
+
return undefined;
|
|
504
|
+
const match = /^data:([^;,]+);base64,(.+)$/s.exec(src.dataUrl);
|
|
505
|
+
if (!match)
|
|
506
|
+
return undefined;
|
|
507
|
+
const selector = result.selector;
|
|
508
|
+
return {
|
|
509
|
+
data: match[2],
|
|
510
|
+
mimeType: typeof src.mimeType === "string" && src.mimeType ? src.mimeType : match[1],
|
|
511
|
+
...(typeof src.width === "number" ? { width: src.width } : {}),
|
|
512
|
+
...(typeof src.height === "number" ? { height: src.height } : {}),
|
|
513
|
+
...(typeof selector === "string" && selector ? { selector } : {}),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
300
516
|
/**
|
|
301
517
|
* 插件结构化工具 → SDK ToolDefinition。
|
|
302
518
|
* execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
|
|
@@ -745,18 +961,15 @@ export class ClientSession {
|
|
|
745
961
|
this.convs.set(conv.id, conv);
|
|
746
962
|
// 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
|
|
747
963
|
this.applyRetryOverrides();
|
|
748
|
-
// 扩展绑定(rpc
|
|
749
|
-
//
|
|
750
|
-
// 代理上抛 "Theme not initialized",每个扩展一条 error toast。)
|
|
964
|
+
// 扩展绑定(rpc 模式);用 headless 的 Web UI context:
|
|
965
|
+
// 扩展绑定时不会因缺方法崩,UI 输出也不下发(不会与主对话的 widget/status 冲突)。
|
|
751
966
|
try {
|
|
752
967
|
await conv.session.bindExtensions({
|
|
753
968
|
mode: "rpc",
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
notify: () => { },
|
|
759
|
-
},
|
|
969
|
+
// 子代理的扩展照常拿到完整 ExtensionUIContext(扩展调用新增方法不会因
|
|
970
|
+
// 局部 mock 缺失而崩),但它是 headless 的:UI 输出全部丢弃、弹窗按取消返回,
|
|
971
|
+
// 因此既不与主对话的 widget/status 串台,也不会让扩展卡在永远无人应答的弹窗上。
|
|
972
|
+
uiContext: WebUIContext.headless(),
|
|
760
973
|
onError: (err) => this.emit({ type: "notice", level: "error", text: err.error, textEn: err.error }),
|
|
761
974
|
});
|
|
762
975
|
}
|
|
@@ -802,6 +1015,26 @@ export class ClientSession {
|
|
|
802
1015
|
});
|
|
803
1016
|
}
|
|
804
1017
|
}
|
|
1018
|
+
// 思考强度:模板指定则固定用它,否则跟随主对话当前强度(与「跟随主对话模型」
|
|
1019
|
+
// 同一取数源:this.session,即共享 ModelRuntime 的当前活动会话)。所以子代理默认
|
|
1020
|
+
// 与主对话一致,而不是默默回到 SDK 默认档位。放在换模型之后:setModel 会按模型
|
|
1021
|
+
// 能力重算强度,我们先让它算完再覆盖。不传 persist:只影响这个子代理会话,不动
|
|
1022
|
+
// 全局默认强度;模型不支持的档位由 SDK 自动收敛(reasoning:false 的模型只能是 off)。
|
|
1023
|
+
const thinkingLevel = apply?.thinkingLevel?.trim() || this.session.thinkingLevel;
|
|
1024
|
+
if (thinkingLevel) {
|
|
1025
|
+
try {
|
|
1026
|
+
conv.session.setThinkingLevel(thinkingLevel);
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
// 强度不合法/会话未就绪都不阻断运行(沿用当前档位)。
|
|
1030
|
+
this.emit({
|
|
1031
|
+
type: "notice",
|
|
1032
|
+
level: "warning",
|
|
1033
|
+
text: `子代理思考强度设置失败(将按当前档位运行):${thinkingLevel}(${err.message})`,
|
|
1034
|
+
textEn: `Failed to set subagent thinking level, keeping the current one: ${thinkingLevel} (${err.message})`,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
805
1038
|
// 触发回合(后台执行;失败转识为通知)。
|
|
806
1039
|
void conv.session.sendUserMessage(prompt).catch((err) => {
|
|
807
1040
|
this.emit({
|
|
@@ -1071,7 +1304,13 @@ export class ClientSession {
|
|
|
1071
1304
|
listTemplates: () => this.subagentTemplates
|
|
1072
1305
|
.list()
|
|
1073
1306
|
.filter((t) => t.enabled)
|
|
1074
|
-
.map((t) => ({
|
|
1307
|
+
.map((t) => ({
|
|
1308
|
+
name: t.name,
|
|
1309
|
+
description: t.description,
|
|
1310
|
+
descriptionEn: t.descriptionEn,
|
|
1311
|
+
model: t.model,
|
|
1312
|
+
thinkingLevel: t.thinkingLevel,
|
|
1313
|
+
})),
|
|
1075
1314
|
isTemplateUsable: (name) => {
|
|
1076
1315
|
const t = this.subagentTemplates.get(name);
|
|
1077
1316
|
return !!t && t.enabled;
|
|
@@ -1138,6 +1377,20 @@ export class ClientSession {
|
|
|
1138
1377
|
/** 待答提问(id → 载荷 + resolve)。一次正常只有一个(agent 阻塞在工具执行);
|
|
1139
1378
|
* conversationId 记录谁问的:看门狗豁免、快照恢复都靠它。 */
|
|
1140
1379
|
pendingQuestions = new Map();
|
|
1380
|
+
// -----------------------------------------------------------------------
|
|
1381
|
+
// 浏览器页面桥(标准 pi 引擎的 browser_page customTool):模型调工具 → 发
|
|
1382
|
+
// page_request 给浏览器 → 前端转 page-picker 扩展 → page_response 回到这里
|
|
1383
|
+
// resolve 工具结果。
|
|
1384
|
+
//
|
|
1385
|
+
// 与用户提问桥的关键差别:对面是**程序**(扩展)而不是人,所以必须有超时——
|
|
1386
|
+
// 前端没开/扩展没装时不会有人来答,无限等只会把模型卡死;也因此它**不进**
|
|
1387
|
+
// 看门狗豁免(见 tool_execution_start 的注释),就是一件普通工具。
|
|
1388
|
+
// -----------------------------------------------------------------------
|
|
1389
|
+
pageSeq = 0;
|
|
1390
|
+
/** 待回页面请求(id → resolve 与计时器)。同上,一次正常只有一个
|
|
1391
|
+
* (agent 阻塞在工具执行);conversationId 仅存档用于诊断(页请求不进快照,
|
|
1392
|
+
* 协议 page_request 也没有这个字段)。 */
|
|
1393
|
+
pendingPageCalls = new Map();
|
|
1141
1394
|
constructor(clientId, cwd, agentDir, stateStore) {
|
|
1142
1395
|
this.clientId = clientId;
|
|
1143
1396
|
this.cwd = cwd;
|
|
@@ -1453,6 +1706,10 @@ export class ClientSession {
|
|
|
1453
1706
|
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
1454
1707
|
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
1455
1708
|
makeAskUserQuestionTool(this, ownerId),
|
|
1709
|
+
// 浏览器页面工具:模型调用 → page_request 给浏览器 → page-picker 扩展
|
|
1710
|
+
// 操作用户授权的页面 → page_response 回来。ownerId 语义同上(本 runtime
|
|
1711
|
+
// 所属会话,不是派发瞬间的 active)。
|
|
1712
|
+
makeBrowserPageTool(this, ownerId),
|
|
1456
1713
|
],
|
|
1457
1714
|
});
|
|
1458
1715
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
@@ -2445,6 +2702,119 @@ export class ClientSession {
|
|
|
2445
2702
|
}
|
|
2446
2703
|
this.pendingQuestions.clear();
|
|
2447
2704
|
}
|
|
2705
|
+
// -----------------------------------------------------------------------
|
|
2706
|
+
// 浏览器页面桥(标准 pi 引擎 browser_page customTool)
|
|
2707
|
+
// -----------------------------------------------------------------------
|
|
2708
|
+
/** 标准引擎模型调 browser_page:发 page_request 给浏览器并等 page_response。
|
|
2709
|
+
*
|
|
2710
|
+
* 与 askUser 的不同点都在超时上:对面是扩展不是人,没人回答时必须自己收场
|
|
2711
|
+
* (否则就是挂死的工具)。因此 timeoutMs 到点即按失败 resolve,并且:
|
|
2712
|
+
* - sig.aborted / disposed → 立即失败(会话已中止,发出去也没意义);
|
|
2713
|
+
* - 没有前端在线 → 立即给出**可执行**的错误(page_request 不进快照,浏览器
|
|
2714
|
+
* 刷新也不会补发,硬等一个超时对模型毫无信息量)。 */
|
|
2715
|
+
/** 当前对话模型能不能直接看图 —— 决定截图是「给图」还是「走视觉桥」。 */
|
|
2716
|
+
canSeeImages() {
|
|
2717
|
+
return this.session?.model?.input?.includes("image") === true;
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* 把**工具里的截图**交给视觉桥转写(主模型看不到图时)。
|
|
2721
|
+
*
|
|
2722
|
+
* 与用户粘贴图片走同一套选择逻辑(设置里指定的视觉模型 → 自动探测)与同一套提示词,
|
|
2723
|
+
* 所以「视觉桥开着就自动生效」对工具截图同样成立 —— 这里只是多了一个入口,
|
|
2724
|
+
* 不是另立一套判定。
|
|
2725
|
+
*
|
|
2726
|
+
* 失败**不抛**:返回 `{reason}`,由工具把它写进结果文本(模型至少知道「图没看到,为什么」)。
|
|
2727
|
+
*/
|
|
2728
|
+
async transcribeToolImage(image, signal) {
|
|
2729
|
+
const settings = this.settingsSvc.current;
|
|
2730
|
+
if (settings.visionBridgeEnabled === false) {
|
|
2731
|
+
return { reason: "视觉桥已在设置里关闭(设置 → 视觉桥)" };
|
|
2732
|
+
}
|
|
2733
|
+
const runtime = this.session?.modelRuntime;
|
|
2734
|
+
if (!runtime)
|
|
2735
|
+
return { reason: "拿不到模型运行时" };
|
|
2736
|
+
const lang = this.getLang?.() ?? "en";
|
|
2737
|
+
let chosen = findVisionModels(runtime)[0] ?? null;
|
|
2738
|
+
const pref = settings.visionBridgeModel;
|
|
2739
|
+
if (pref) {
|
|
2740
|
+
const spec = parseModelSpec(pref);
|
|
2741
|
+
if (spec) {
|
|
2742
|
+
const pm = runtime.getModel(spec.provider, spec.id);
|
|
2743
|
+
if (pm?.input?.includes("image")) {
|
|
2744
|
+
chosen = { provider: spec.provider, id: spec.id, label: `${pm.name ?? pm.id} (${spec.provider})` };
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
if (!chosen)
|
|
2749
|
+
return { reason: "没有可用的视觉模型(在模型配置里加一个支持图片的模型即可)" };
|
|
2750
|
+
const model = runtime.getModel(chosen.provider, chosen.id);
|
|
2751
|
+
if (!model)
|
|
2752
|
+
return { reason: "视觉模型已不可用" };
|
|
2753
|
+
try {
|
|
2754
|
+
const text = await transcribeImages(runtime, [{ data: image.data, mimeType: image.mimeType, name: "page-shot.jpg" }], {
|
|
2755
|
+
model,
|
|
2756
|
+
...(signal ? { signal } : {}),
|
|
2757
|
+
lang,
|
|
2758
|
+
systemPrompt: buildVisionBridgePrompt(settings.visionBridgePromptMode, settings.visionBridgePrompt, lang),
|
|
2759
|
+
});
|
|
2760
|
+
return text.trim() ? { text } : { reason: "视觉桥返回了空转写" };
|
|
2761
|
+
}
|
|
2762
|
+
catch (err) {
|
|
2763
|
+
return { reason: `视觉桥转写失败:${err instanceof Error ? err.message : String(err)}` };
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
pageCall(req, sig, conversationId) {
|
|
2767
|
+
return new Promise((resolve) => {
|
|
2768
|
+
if (sig?.aborted || this.disposed) {
|
|
2769
|
+
resolve({ ok: false, error: "页面调用已中止(browser_page aborted)。" });
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
if (this.sinks.size === 0) {
|
|
2773
|
+
resolve({
|
|
2774
|
+
ok: false,
|
|
2775
|
+
error: "没有已连接的 pi-web-ui 页面(no browser connected)。请打开 pi-web-ui 页面,并确认 page-picker 扩展已启用且已与该页面配对。",
|
|
2776
|
+
});
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
const id = `p-${++this.pageSeq}`;
|
|
2780
|
+
// 夹取与工具入口同一套规则(防手写脏值/其它调用方绕过 schema)。
|
|
2781
|
+
const timeoutMs = normalizePageCallTimeoutMs(req.timeoutMs);
|
|
2782
|
+
const timer = setTimeout(() => {
|
|
2783
|
+
// 到点:先删再 resolve——晚到的 page_response 在 resolvePageCall 里找
|
|
2784
|
+
// 找不到 id,会静默忽略(见那里的注释)。
|
|
2785
|
+
if (this.pendingPageCalls.delete(id)) {
|
|
2786
|
+
resolve({
|
|
2787
|
+
ok: false,
|
|
2788
|
+
error: `${Math.round(timeoutMs / 1000)} 秒内没有收到浏览器响应(timeout ${timeoutMs}ms)。请确认 pi-web-ui 页面已打开且 page-picker 扩展已启用。`,
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
}, timeoutMs);
|
|
2792
|
+
// 先登记再发:同步回包(同进程假客户端)也不能漏掉。
|
|
2793
|
+
this.pendingPageCalls.set(id, { resolve, timer, conversationId });
|
|
2794
|
+
this.emit({ type: "page_request", id, op: req.op, args: req.args, target: req.target, timeoutMs });
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
/** 前端回页面调用结果(index.ts 的 page_response → cs.resolvePageCall)。
|
|
2798
|
+
* 找不到 id 就静默忽略:那是正常竞态(超时后才迟到、页面刷新后重发、旧链接
|
|
2799
|
+
* 残留),不是错误,也没人能处理。 */
|
|
2800
|
+
resolvePageCall(id, ok, result, error) {
|
|
2801
|
+
const pending = this.pendingPageCalls.get(id);
|
|
2802
|
+
if (!pending)
|
|
2803
|
+
return;
|
|
2804
|
+
this.pendingPageCalls.delete(id);
|
|
2805
|
+
clearTimeout(pending.timer);
|
|
2806
|
+
pending.resolve(ok
|
|
2807
|
+
? { ok: true, result }
|
|
2808
|
+
: { ok: false, error: error?.trim() || "浏览器操作失败(no error message from the page)" });
|
|
2809
|
+
}
|
|
2810
|
+
/** 关闭所有挂起页面调用(dispose 时):以失败解析,避免模型/工具挂死。 */
|
|
2811
|
+
cancelPendingPageCalls() {
|
|
2812
|
+
for (const [, p] of this.pendingPageCalls) {
|
|
2813
|
+
clearTimeout(p.timer);
|
|
2814
|
+
p.resolve({ ok: false, error: "会话已关闭,挂起中的页面调用被取消(conversation closed)。" });
|
|
2815
|
+
}
|
|
2816
|
+
this.pendingPageCalls.clear();
|
|
2817
|
+
}
|
|
2448
2818
|
/**
|
|
2449
2819
|
* Whether the pi agent has at least one usable model. ModelRuntime's
|
|
2450
2820
|
* available snapshot already accounts for models.json, auth.json, env-var
|
|
@@ -5062,6 +5432,8 @@ export class ClientSession {
|
|
|
5062
5432
|
this.webUi.dispose();
|
|
5063
5433
|
// 关闭所有挂起的用户提问(dispose 时以「取消」解析,避免模型挂死)。
|
|
5064
5434
|
this.cancelPendingQuestions();
|
|
5435
|
+
// 同理关闭挂起的页面调用(以失败解析:对面是扩展,没有答可等)。
|
|
5436
|
+
this.cancelPendingPageCalls();
|
|
5065
5437
|
this.bg.stop();
|
|
5066
5438
|
for (const conv of this.convs.values()) {
|
|
5067
5439
|
this.clearAllToolWatchdogs(conv);
|
|
@@ -14,6 +14,10 @@ export function parseModelSpec(spec) {
|
|
|
14
14
|
return null;
|
|
15
15
|
return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), spec };
|
|
16
16
|
}
|
|
17
|
+
/** XML attribute escaping — page titles can contain quotes/brackets. */
|
|
18
|
+
function attr(value) {
|
|
19
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
20
|
+
}
|
|
17
21
|
export async function buildAttachmentMessages(ctx, attachments) {
|
|
18
22
|
if (!attachments || attachments.length === 0)
|
|
19
23
|
return [];
|
|
@@ -243,6 +247,38 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
243
247
|
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
244
248
|
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
245
249
|
for (const [idx, att] of attachments.entries()) {
|
|
250
|
+
// Granted web page (page-picker extension): `path` is the page origin,
|
|
251
|
+
// NOT a workspace path — never stat/read it. The model gets the exact
|
|
252
|
+
// browser_page target plus the fact that this page is already granted,
|
|
253
|
+
// so it doesn't have to guess an origin out of the prose.
|
|
254
|
+
if (att.mode === "page") {
|
|
255
|
+
const url = att.path;
|
|
256
|
+
let target = url;
|
|
257
|
+
try {
|
|
258
|
+
// The extension matches pages by origin — keep the hint in the same
|
|
259
|
+
// shape as `browser_page`'s `target` (sub-paths are not part of it).
|
|
260
|
+
target = new URL(url).origin;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Not a full URL (hand-written string) → pass it through as-is and
|
|
264
|
+
// let the extension decide.
|
|
265
|
+
}
|
|
266
|
+
const pageTitle = att.name ?? url;
|
|
267
|
+
out.push({
|
|
268
|
+
message: {
|
|
269
|
+
customType: "file",
|
|
270
|
+
content: [
|
|
271
|
+
{
|
|
272
|
+
type: "text",
|
|
273
|
+
text: `\n<browser-page url="${attr(url)}" title="${attr(pageTitle)}">\nThe user attached this web page; it is already granted to the AI through the browser extension. Use the browser_page tool with target="${attr(target)}" to read or act on it — do not fetch it over the network.\n</browser-page>`,
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
display: true,
|
|
277
|
+
details: { name: pageTitle, path: url, mode: "page" },
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
246
282
|
// Raw pasted/dropped/uploaded image — no workspace path involved (the
|
|
247
283
|
// browser downscales client-side; this guard only prevents abuse).
|
|
248
284
|
if (att.imageData) {
|
package/dist/server/index.js
CHANGED
|
@@ -1142,6 +1142,11 @@ wss.on("connection", (ws) => {
|
|
|
1142
1142
|
case "question_answer":
|
|
1143
1143
|
void cs.answerQuestion?.(msg.id, msg.answers, msg.cancelled);
|
|
1144
1144
|
break;
|
|
1145
|
+
case "page_response":
|
|
1146
|
+
// 浏览器(page-picker 扩展经前端)对 browser_page 的回包:恢复挂起的
|
|
1147
|
+
// pageCall;id 不匹配(超时后迟到/页面刷新)由 resolvePageCall 静默忽略。
|
|
1148
|
+
cs.resolvePageCall?.(msg.id, msg.ok, msg.result, msg.error);
|
|
1149
|
+
break;
|
|
1145
1150
|
case "save_preset":
|
|
1146
1151
|
void cs.savePreset(msg.name);
|
|
1147
1152
|
break;
|