// page-operate 工具的浏览器侧事件桥(§2.9.1 工具结果事件桥的内置实现)。 // // 工具在服务端只发指令,这里才真正对页面动手。宿主要填的空只有两个: // 1. resolvePageDocument():哪张 document 是"用户当前正在看的页面"—— // 整页宿主直接回 document;标签页/iframe 形态的宿主回当前激活那张; // 2. onFailure():失败了怎么让用户看见。 // 其余(认领事件、入参再规整、控件匹配、重试节奏、串行、失败即停、危险操作闸) // 都在包里,宿主不必各写一遍。 // // 为什么 onFailure 是必填:模型那头的工具已经成功返回、挂件也已显示"已在页面上执行操作", // 而执行结果不随工具返回值回流。页面这边再不出声,用户看到的就是一句与事实不符的成功提示。 // // 两条给后台系统兜底的纪律(实测教训,见下方各自注释): // - **串行 + 失败即停**:模型一轮里会连发"选下拉→填值→点提交"三条指令,它看不到 // 前一步成没成。不串行会出现"还没填完就点了提交";不失败即停会出现"填失败了照样提交", // 等于拿空数据/旧数据提交业务表单。 // - **危险操作闸**:助手能点任何按钮,包括「删除」「批量停用」。命中危险词时不直接点, // 交给宿主确认(没接确认钩子就直接拒绝并让用户自己点最后一下)。 // // 纪律与既有挂件一致:同一 document 只挂一次;回调不许抛;历史会话不重放 // (广播侧按 toolCallId 去重且只发直播流)。 import { AI_LIMITS } from '../../ai-shared/AiLimits'; import type { AiChatPageContext } from '../AiChatPageContext'; import { ASSISTANT_TOOL_RESULT_EVENT, type AssistantToolResultDetail, } from '../widget/mountAssistantWidget'; import { PAGE_OPERATE_EFFECT, PAGE_OPERATE_TOOL, normalizePageOperateEffect, type PageOperateEffect, } from './PageOperateContract'; import { describePageControls, describePageText } from './describePageControls'; import { isRetryableDomOperateResult, operateInDocument, type DomOperateResult, } from './operateInDocument'; export interface PageOperateRetryOptions { /** 尝试次数,缺省 5。 */ attempts?: number; /** 两次之间的间隔毫秒,缺省 400。 */ delayMs?: number; } /** * 缺省危险词:点下去多半不可逆、或直接改动业务数据的动作。 * 宁可误判成危险(多问用户一句),也不要漏判(悄悄替用户删了东西)。 */ export const DEFAULT_RISKY_KEYWORDS: readonly string[] = [ '删除', '移除', '清空', '清除', '重置', '注销', '作废', '撤销', '回滚', '停用', '禁用', '下线', '解绑', '解除', '发布', '上线', '审批', '通过', '驳回', '拒绝', '批量', '全部', 'delete', 'remove', 'reset', 'disable', 'publish', 'approve', 'reject', ]; export interface PageOperateRiskContext { action: PageOperateEffect['action']; /** 模型给的控件名。 */ target: string; /** 命中的危险词,用于向用户解释为什么要确认。 */ keyword: string; } export interface PageOperateEffectContext { /** * 返回"用户当前正在看的那张页面"的 document;暂时拿不到就回 null, * 桥会按重试节奏再问几次(刚打开的页面可能还在加载)。 */ resolvePageDocument(): Document | null; /** 操作失败时告知用户,理由已是面向用户的中文短句。 */ onFailure(reason: string, result: Extract): void; /** 成功回执,可选:宿主想提示"已填写 X"时接。 */ onSuccess?(detail: string, result: Extract): void; /** 重试节奏,缺省 5 次 × 400ms。 */ retry?: PageOperateRetryOptions; /** * 页面文字节选的字符上限;**填 0 表示不报页面文字**,只报控件名与操作结果。 * 留这个开关是因为节选会把页面上的文字发给模型上游:演示页无所谓, * 装着真实客户数据的后台可能不想外发,这是宿主的决定,不是包能替它拍板的。 */ pageTextCharacters?: number; /** * 危险操作确认。命中危险词的点击会先问这里:回 true 才点。 * **不接就一律拒绝**——助手替用户点「删除」是不可逆的,默认必须是保守的那一侧。 * 宿主接了就弹自己的确认框(原生 confirm / 业务弹窗都行)。 */ confirmRiskyOperation?(risk: PageOperateRiskContext): boolean | Promise; /** 覆盖危险词表;省略用 DEFAULT_RISKY_KEYWORDS。传空数组即关闭这道闸(不建议)。 */ riskyKeywords?: readonly string[]; } export interface PageOperateEffectHandle { destroy(): void; /** * 当前页面的可操作控件清单 + 最近几步操作结果,直接喂给挂件的 `pageContext`。 * 模型看不见页面,不给它这份清单它只能猜控件名;不给它执行结果它会一路宣称成功。 * * 取走即视为"新一轮对话开始":操作记录清空(每条只报给模型一次),失败即停的闸复位。 */ readPageContext(): AiChatPageContext | null; /** * 记一条本轮的页面效果结果,和 operatePage 的结果走同一条回流通道。 * 宿主自己声明的页面效果类工具(如"打开菜单")用它把真实结果带回模型, * 否则那些工具照样会"嘴上成功"。 */ recordOperation(line: string): void; /** 宿主可注入侧栏/导航清单等自身上下文,随下一轮请求带给模型。 */ setNavigation(navigation: string): void; } /** * Resolve the document currently owned by the browser page. * * Domain classes intentionally do not touch DOM globals. Keeping this tiny * adapter in the client entry lets a Host pass a document resolver to the * page-operation bridge without importing `document` into its domain layer. */ export function resolveCurrentPageDocument(): Document | null { return typeof document === 'undefined' ? null : document; } const DEFAULT_ATTEMPTS = 5; const DEFAULT_DELAY_MS = 400; /** 隔这么久才来的指令按新一轮处理(见 lastInstructionAt 注释)。 */ const HALT_IDLE_RESET_MS = 15_000; /** 同一 document 只挂一次;重复挂载返回 null,与助手挂件同语义。 */ const mountedDocuments = new WeakSet(); /** 浏览器侧便捷入口:事件挂在承载挂件的那张 document 上。 */ export function mountPageOperateEffectOnPage( context: PageOperateEffectContext, ): PageOperateEffectHandle | null { const doc = globalThis.document; return doc ? mountPageOperateEffect(doc, context) : null; } export function mountPageOperateEffect( doc: Document, context: PageOperateEffectContext, ): PageOperateEffectHandle | null { if (mountedDocuments.has(doc)) { return null; } mountedDocuments.add(doc); // 只留最近几条:老结果对下一轮没参考价值,还白占上下文。 const operations: string[] = []; let navigation = ''; /** 本轮是否已经有一步失败了——失败之后的指令一律不再落到页面上。 */ let haltedReason = ''; /** * 上一条指令到达的时刻。闸正常由 readPageContext()(换轮)复位,但宿主可能只接了 * 事件桥、没把 pageContext 接到挂件上——那样一次失败会把闸永久焊死。 * 模型同一轮的指令是连发的(相隔毫秒级),用户下一句最快也要数秒, * 所以"隔了很久才来的指令"按新一轮处理,给这种宿主兜底。 */ let lastInstructionAt = 0; /** 串行队列:模型连发的指令必须按到达顺序一条条做完。 */ let queue: Promise = Promise.resolve(); const record = (line: string): void => { operations.push(line); if (operations.length > AI_LIMITS.chatPageOperations) { operations.splice(0, operations.length - AI_LIMITS.chatPageOperations); } debugLog('operation', line); }; const onToolResult = (event: Event): void => { const effect = readEffect(event); if (!effect) { return; } debugLog('instruction', effect); const now = Date.now(); if (haltedReason && now - lastInstructionAt > HALT_IDLE_RESET_MS) { haltedReason = ''; } lastInstructionAt = now; // 事件回调里不许抛:一个效果失败不该掀掉整条监听链。 queue = queue .then(() => runOperation(effect, context, record, { isHalted: () => haltedReason, halt: (reason) => { haltedReason = reason; }, })) .catch(() => undefined); }; doc.addEventListener(ASSISTANT_TOOL_RESULT_EVENT, onToolResult); return { destroy(): void { doc.removeEventListener(ASSISTANT_TOOL_RESULT_EVENT, onToolResult); mountedDocuments.delete(doc); }, recordOperation(line: string): void { const text = typeof line === 'string' ? line.trim() : ''; if (text) { record(text.slice(0, AI_LIMITS.chatPageOperationCharacters)); } }, setNavigation(value: string): void { navigation = typeof value === 'string' ? value.trim() : ''; }, readPageContext(): AiChatPageContext | null { let controls = ''; let text = ''; const textCharacters = Math.max( 0, Math.min(context.pageTextCharacters ?? AI_LIMITS.chatPageTextCharacters, AI_LIMITS.chatPageTextCharacters), ); try { const pageDoc = context.resolvePageDocument(); if (pageDoc) { controls = describePageControls(pageDoc); text = textCharacters > 0 ? describePageText(pageDoc, textCharacters) : ''; } } catch { // 宿主解析当前页面时炸了不该拖垮整条对话——没清单就没清单。 controls = ''; text = ''; } // 取走即换轮:结果每条只报一次(重复报会让模型反复道歉),失败闸也在这里复位。 const reported = operations.splice(0, operations.length); haltedReason = ''; if (!controls && !text && !navigation && reported.length === 0) { return null; } const context_: AiChatPageContext = { ...(controls ? { controls } : {}), ...(text ? { text } : {}), ...(navigation ? { navigation } : {}), ...(reported.length > 0 ? { operations: reported } : {}), }; debugLog('page-context', context_); return context_; }, }; } /** * 执行一步操作,并按需重试。 * 只对"页面/控件还没出现"重试;歧义、不可用这类确定性失败立刻回执,重试只是白等。 */ export async function runPageOperation( effect: PageOperateEffect, resolvePageDocument: () => Document | null, retry: PageOperateRetryOptions = {}, ): Promise { const attempts = Math.max(1, retry.attempts ?? DEFAULT_ATTEMPTS); const delayMs = Math.max(0, retry.delayMs ?? DEFAULT_DELAY_MS); let last: DomOperateResult = { ok: false, code: 'not-found', reason: '当前没有可操作的页面', }; for (let round = 0; round < attempts; round += 1) { if (round > 0) { await sleep(delayMs); } const pageDoc = resolvePageDocument(); if (!pageDoc) { last = { ok: false, code: 'not-found', reason: '当前没有打开的页面,无法执行页面操作' }; continue; } last = operateInDocument(pageDoc, effect); if (last.ok || !isRetryableDomOperateResult(last)) { return last; } } return last; } /** 命中危险词就回它,便于向用户解释"为什么要问你"。 */ export function matchRiskyKeyword( target: string, keywords: readonly string[] = DEFAULT_RISKY_KEYWORDS, ): string { const text = target.toLowerCase(); for (const keyword of keywords) { if (keyword && text.includes(keyword.toLowerCase())) { return keyword; } } return ''; } interface HaltGate { isHalted(): string; halt(reason: string): void; } async function runOperation( effect: PageOperateEffect, context: PageOperateEffectContext, record: (line: string) => void, gate: HaltGate, ): Promise { const attempt = describeAttempt(effect); // 失败即停:上一步没做到,后面的指令多半建立在"它做到了"的假设上 // (填失败了还点提交 = 拿旧数据提交),一律不再落到页面上。 const halted = gate.isHalted(); if (halted) { const reason = `${attempt}:已跳过(上一步「${halted}」没有做到,本轮后续操作已停止)`; record(reason); context.onFailure(reason, { ok: false, code: 'not-found', reason }); return; } // 危险操作闸:点「删除」这类按钮之前必须有人拍板,默认拍板人是用户不是模型。 if (effect.action === 'click') { const keyword = matchRiskyKeyword(effect.target, context.riskyKeywords); if (keyword) { const approved = await confirmRisky(context, { action: effect.action, target: effect.target, keyword, }); if (!approved) { const reason = `${attempt}:已拦下(含「${keyword}」的操作需要你自己点确认,助手不代劳)`; record(reason); gate.halt(`${effect.target}`); context.onFailure(reason, { ok: false, code: 'disabled', reason }); return; } } } const result = await runPageOperation(effect, () => context.resolvePageDocument(), context.retry); // 记一行事实带回下一轮:模型只知道"指令已下发",不知道有没有做到。 record(`${attempt}:${result.ok ? `成功(${result.detail})` : `失败(${result.reason})`}`); if (result.ok) { context.onSuccess?.(result.detail, result); return; } gate.halt(effect.target || attempt); context.onFailure(result.reason, result); } async function confirmRisky( context: PageOperateEffectContext, risk: PageOperateRiskContext, ): Promise { if (!context.confirmRiskyOperation) { // 宿主没接确认钩子:不代点。默认必须站在保守那一侧。 return false; } try { return await context.confirmRiskyOperation(risk) === true; } catch { return false; } } function describeAttempt(effect: PageOperateEffect): string { if (effect.action === 'fill') { return `填写「${effect.target}」`; } if (effect.action === 'click') { return `点击「${effect.target}」`; } return `操作「${effect.target}」`; } /** * 只认自己的 tool + effect,其余工具结果一律忽略。 * action 非法不在这里拦——照样往下走,让执行器给用户一句"不支持的操作",而不是静默吞掉。 */ function readEffect(event: Event): PageOperateEffect | null { const detail = (event as CustomEvent).detail; if (!detail || detail.tool !== PAGE_OPERATE_TOOL) { return null; } const output = detail.output; if (!output || typeof output !== 'object' || Array.isArray(output)) { return null; } const payload = output as Record; if (payload.effect !== PAGE_OPERATE_EFFECT) { return null; } // 字段是模型产文本,浏览器侧再规整一次:只进匹配器与提示文案,不进 innerHTML。 return normalizePageOperateEffect(payload); } /** * 调试开关:`localStorage['soma-ai:debug'] = '1'` 后把每条指令、每条结果、 * 每次上报的页面上下文打到 console。接入方自查"助手为什么没动"时不必再自己写脚本抓事件。 */ function debugLog(kind: string, payload: unknown): void { try { if (globalThis.localStorage?.getItem('soma-ai:debug') !== '1') { return; } } catch { return; } console.info(`[soma-ai][页面操作] ${kind}`, payload); } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); }