// 对话请求随附的「当前页面上下文」契约:浏览器侧产、服务端注入模型。 // // 为什么需要它:工具在服务端执行,模型看不见用户正在看的那张页面,只能凭用户原话猜控件名 // (「翻译按钮」——页面上其实叫「调用 invoke」),猜错就全落空;而页面操作的执行结果又 // 不随工具返回值回流,模型也不知道自己错了,还会一路宣布成功。这两个洞都由本契约补: // - controls:当前页面可操作控件清单,让模型用页面上的原文名字; // - operations:最近几步页面操作的真实结果,让模型能自我纠正而不是嘴上成功。 // // 隐私与预算纪律:两者都由**宿主**决定要不要报(不接就没有),都过字节/条数帽, // 且只带控件名与结果短句——不带用户填的值、不带页面正文。 // // 本文件不许 import 服务端模块:浏览器侧与服务端共用。 import { AI_LIMITS, utf8ByteLength } from '../ai-shared/AiLimits'; export interface AiChatPageContext { /** 当前页面可操作控件清单(纯文本,已裁剪)。 */ controls?: string; /** * 页面可见文字节选。控件清单只回答"能操作什么",回答不了"这个框要什么格式"—— * 页面上写着 `in: { "text": ... }`、"金额需为整数"这类要求时,不给节选模型就会填错值。 */ text?: string; /** * 宿主自身的导航上下文(如侧栏可打开的菜单)。 * 不给的话模型只能照用户口语猜菜单名,猜出来的名字页面上根本不存在。 */ navigation?: string; /** 最近几步页面操作的结果,最新在后。 */ operations?: string[]; } /** 两端共用的规整:超帽截断而不是拒收——上下文是锦上添花,不该因为页面大就发不出对话。 */ export function normalizeAiChatPageContext(value: unknown): AiChatPageContext | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } const source = value as { controls?: unknown; text?: unknown; navigation?: unknown; operations?: unknown; }; const controls = typeof source.controls === 'string' ? clampBytes(source.controls.trim(), AI_LIMITS.chatPageControlsBytes) : ''; const text = typeof source.text === 'string' ? source.text.trim().slice(0, AI_LIMITS.chatPageTextCharacters) : ''; const navigation = typeof source.navigation === 'string' ? clampBytes(source.navigation.trim(), AI_LIMITS.chatPageControlsBytes) : ''; const operations = Array.isArray(source.operations) ? source.operations .filter((item): item is string => typeof item === 'string') .map((item) => item.trim().slice(0, AI_LIMITS.chatPageOperationCharacters)) .filter(Boolean) .slice(-AI_LIMITS.chatPageOperations) : []; if (!controls && !text && !navigation && operations.length === 0) { return undefined; } return { ...(controls ? { controls } : {}), ...(text ? { text } : {}), ...(navigation ? { navigation } : {}), ...(operations.length > 0 ? { operations } : {}), }; } /** * 拼成提示词里的一段。措辞刻意强调「用清单里的原文名字」与「上一步真实结果」, * 因为这两点正是模型自己猜不出来的部分。 */ export function describeAiChatPageContext(context: AiChatPageContext | undefined): string { if (!context) { return ''; } const sections: string[] = []; if (context.controls) { sections.push( '【当前页面可操作的控件】用户正在看的页面上只有下列控件;' + '要在页面上填写或点击时,控件名必须原样取自这份清单,不要自己编,也不要用用户的口语代称:\n' + context.controls, ); } if (context.navigation) { sections.push( '【可打开的页面/菜单】要打开别的页面时,名字必须取自这份清单(这是当前账号真实有权限的部分):\n' + context.navigation, ); } if (context.text) { sections.push( '【当前页面上的文字(节选)】用来理解页面对填写内容的要求(比如某个框要 JSON、' + '某个值要整数);控件名仍以上面的清单为准,不要从这段文字里编控件名:\n' + context.text, ); } if (context.operations && context.operations.length > 0) { sections.push( '【最近几步页面操作的真实结果】(按先后顺序,这是页面回来的事实,不是你的推测):\n' + context.operations.map((item) => `- ${item}`).join('\n') + '\n其中失败的步骤说明并没有做到,请据此纠正,不要再宣称它成功了。', ); } return sections.join('\n'); } function clampBytes(value: string, maxBytes: number): string { if (utf8ByteLength(value) <= maxBytes) { return value; } // 中文按 3 字节估,先粗切再逐字回退,避免切出半个字符。 let text = value.slice(0, Math.max(1, Math.floor(maxBytes / 3))); while (text.length > 0 && utf8ByteLength(text) > maxBytes) { text = text.slice(0, -1); } return text; }