// 把一张 document 上「可操作的控件」列成一份给模型看的纯文本清单。 // // 与 operateInDocument 是同一套认知:输入项认 label / aria-label / placeholder, // 按钮认可见文字。两边必须同源,否则会出现「清单里报了、真去操作却找不到」的鬼打墙, // 所以命名与可见性判定都照抄执行器的规则。 // // 只报**控件名**,不报用户填进去的值、不报页面正文:模型要的是"能操作什么", // 不是页面内容;少带一分也少泄一分。下拉框例外——选项文字必须报, // 否则模型没法把「翻译」映射到 `translate` 这个选项。 const CLICKABLE_SELECTOR = 'button, input[type="button"], input[type="submit"], a, [role="button"]'; const UNFILLABLE_INPUT_TYPES = ['hidden', 'checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'image']; export interface DescribePageControlsOptions { /** 输入项/按钮各自最多列几个,缺省 40。 */ maxItems?: number; /** 单个下拉框最多列几个选项,缺省 20。 */ maxOptions?: number; /** 单个名字最多几个字,缺省 60。 */ maxNameLength?: number; } const DEFAULT_MAX_ITEMS = 40; const DEFAULT_MAX_OPTIONS = 20; const DEFAULT_MAX_NAME_LENGTH = 60; /** * 生成清单。没有任何可操作控件时回空串——空清单不该占提示词。 * 形如: * 输入项:用户名、备注(多行) * 下拉框「选择 API 函数」可选:kb-qa、summarize-alarm、translate * 按钮:刷新函数列表、调用 invoke */ export function describePageControls( doc: Document, options: DescribePageControlsOptions = {}, ): string { const maxItems = Math.max(1, options.maxItems ?? DEFAULT_MAX_ITEMS); const maxOptions = Math.max(1, options.maxOptions ?? DEFAULT_MAX_OPTIONS); const maxName = Math.max(4, options.maxNameLength ?? DEFAULT_MAX_NAME_LENGTH); const inputs: string[] = []; const selects: string[] = []; const seen = new Set(); for (const control of Array.from(doc.querySelectorAll('input, textarea, select'))) { if (seen.has(control) || !isFillable(doc, control) || !isVisible(control) || isDisabled(control)) { continue; } seen.add(control); const name = controlName(doc, control, maxName); if (!name) { continue; } const view = getView(doc); if (control instanceof view.HTMLSelectElement) { if (selects.length >= maxItems) { continue; } const optionNames = Array.from(control.options) .map((option) => clip(normalize(option.textContent ?? '') || option.value, maxName)) .filter(Boolean) .slice(0, maxOptions); selects.push( optionNames.length > 0 ? `下拉框「${name}」可选:${optionNames.join('、')}` : `下拉框「${name}」`, ); continue; } if (inputs.length >= maxItems) { continue; } // 刻意不加「(多行)」这类注解:实测模型会把注解当成控件名的一部分原样发回来 // (target=「input(JSON)(多行)」),反而匹配不上。清单里只放能直接用的名字。 inputs.push(name); } const buttons: string[] = []; const seenButtonText = new Set(); for (const element of Array.from(doc.querySelectorAll(CLICKABLE_SELECTOR))) { if (buttons.length >= maxItems || !isVisible(element) || isDisabled(element)) { continue; } const text = clip(clickableText(doc, element), maxName); if (!text || seenButtonText.has(text)) { continue; } seenButtonText.add(text); buttons.push(text); } const lines: string[] = []; if (inputs.length > 0) { lines.push(`输入项:${inputs.join('、')}`); } lines.push(...selects); if (buttons.length > 0) { lines.push(`按钮:${buttons.join('、')}`); } return lines.join('\n'); } /** * 页面可见文字节选。控件清单回答不了"这个框要什么格式"——页面上写着 * `in: { "text": ... }`、"金额需为整数"这类要求时,不给节选模型就会填错值。 * * 只取可见文字,跳过 script/style 与隐藏节点;不取输入框里的值(那是用户数据)。 * 超长从头截断:页面要求通常写在控件附近的上半屏,尾部多是列表与页脚。 */ export function describePageText(doc: Document, maxCharacters: number): string { const body = doc.body; if (!body) { return ''; } const parts: string[] = []; let total = 0; const walk = (node: Element): void => { if (total >= maxCharacters || !isVisible(node)) { return; } const tag = node.tagName.toLowerCase(); if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') { return; } if (node.children.length === 0) { const text = normalize(node.textContent ?? ''); if (text) { parts.push(text); total += text.length + 1; } return; } for (const child of Array.from(node.children)) { walk(child); } // 夹在子元素之间的裸文本节点(如

金额 必须 是整数

的首尾)也要带上。 for (const child of Array.from(node.childNodes)) { if (child.nodeType === 3) { const text = normalize(child.textContent ?? ''); if (text) { parts.push(text); total += text.length + 1; } } } }; walk(body); return parts.join('\n').slice(0, maxCharacters).trim(); } type FillableControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; /** 与执行器同序:label → aria-label → placeholder;取不到名字的控件不进清单(模型也没法指认)。 */ function controlName(doc: Document, control: Element, maxName: number): string { const forId = control.getAttribute('id'); if (forId) { for (const label of Array.from(doc.querySelectorAll('label'))) { if (label.getAttribute('for') === forId) { const text = normalize(label.textContent ?? ''); if (text) { return clip(text, maxName); } } } } const wrapping = control.closest('label'); const wrappingText = wrapping ? normalize(wrapping.textContent ?? '') : ''; if (wrappingText) { return clip(wrappingText, maxName); } const aria = normalize(control.getAttribute('aria-label') ?? ''); if (aria) { return clip(aria, maxName); } return clip(normalize(control.getAttribute('placeholder') ?? ''), maxName); } function isFillable(doc: Document, element: Element): element is FillableControl { const view = getView(doc); if (element instanceof view.HTMLInputElement) { return !UNFILLABLE_INPUT_TYPES.includes(element.type); } return element instanceof view.HTMLTextAreaElement || element instanceof view.HTMLSelectElement; } function clickableText(doc: Document, element: HTMLElement): string { const view = getView(doc); if (element instanceof view.HTMLInputElement) { return normalize(element.value); } return normalize(element.textContent ?? '') || normalize(element.getAttribute('aria-label') ?? ''); } /** 可见性只查确定性信号,与执行器一致(JSDOM 没有布局,offsetParent 不可用)。 */ function isVisible(element: Element): boolean { for (let node: Element | null = element; node; node = node.parentElement) { if ((node as HTMLElement).hidden || (node as HTMLElement).style?.display === 'none') { return false; } } return true; } function isDisabled(element: Element): boolean { return (element as HTMLElement & { disabled?: boolean }).disabled === true || element.getAttribute('aria-disabled') === 'true'; } function normalize(value: string): string { return value.replace(/\s+/g, ' ').trim(); } function clip(value: string, maxLength: number): string { return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; } function getView(doc: Document): Window & typeof globalThis { return (doc.defaultView ?? globalThis) as Window & typeof globalThis; }