mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
266 lines
6.5 KiB
TypeScript
266 lines
6.5 KiB
TypeScript
import { detectBlockingLayer } from "./layers";
|
|
import type { BlockingLayer, VomNode, VomOptions, VomResult, VomScene } from "./types";
|
|
|
|
const SKIP_ROLES = new Set(["generic", "none", "presentation", "inlinetextbox"]);
|
|
|
|
const INTERACTIVE_ROLES = new Set([
|
|
"button",
|
|
"checkbox",
|
|
"combobox",
|
|
"listbox",
|
|
"link",
|
|
"menuitem",
|
|
"menuitemcheckbox",
|
|
"menuitemradio",
|
|
"option",
|
|
"radio",
|
|
"scrollbar",
|
|
"searchbox",
|
|
"slider",
|
|
"spinbutton",
|
|
"switch",
|
|
"tab",
|
|
"textbox",
|
|
"treeitem",
|
|
]);
|
|
|
|
const STRUCTURAL_ROLES = new Set([
|
|
"alert",
|
|
"alertdialog",
|
|
"article",
|
|
"banner",
|
|
"cell",
|
|
"columnheader",
|
|
"complementary",
|
|
"contentinfo",
|
|
"definition",
|
|
"dialog",
|
|
"directory",
|
|
"document",
|
|
"feed",
|
|
"figure",
|
|
"form",
|
|
"grid",
|
|
"group",
|
|
"heading",
|
|
"iframe",
|
|
"img",
|
|
"list",
|
|
"listitem",
|
|
"main",
|
|
"navigation",
|
|
"paragraph",
|
|
"region",
|
|
"rootwebarea",
|
|
"row",
|
|
"rowgroup",
|
|
"rowheader",
|
|
"search",
|
|
"section",
|
|
"status",
|
|
"table",
|
|
"term",
|
|
"toolbar",
|
|
"tree",
|
|
"webarea",
|
|
]);
|
|
|
|
const SENSITIVE_MASK = "•••";
|
|
const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
|
|
const DEFAULT_MAX_TOKENS = Number.POSITIVE_INFINITY;
|
|
const MAX_VALUE_LENGTH = 200;
|
|
|
|
function estimateTokens(text: string): number {
|
|
return Math.ceil(text.length / 4);
|
|
}
|
|
|
|
function cleaned(value: string | undefined): string | undefined {
|
|
const trimmed = value?.replace(/\s+/g, " ").trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function normalizedRole(node: VomNode): string {
|
|
return node.role?.toLowerCase() ?? "";
|
|
}
|
|
|
|
function shouldRender(node: VomNode): boolean {
|
|
if (!node.role) return false;
|
|
|
|
const role = normalizedRole(node);
|
|
if (SKIP_ROLES.has(role)) return false;
|
|
if (INTERACTIVE_ROLES.has(role)) return true;
|
|
if (STRUCTURAL_ROLES.has(role)) return true;
|
|
|
|
return cleaned(node.name) !== undefined;
|
|
}
|
|
|
|
function shouldReference(node: VomNode): boolean {
|
|
return INTERACTIVE_ROLES.has(normalizedRole(node));
|
|
}
|
|
|
|
function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
|
|
const children = new Map<number | null, VomNode[]>();
|
|
const ids = new Set(nodes.map((node) => node.id));
|
|
|
|
for (const node of nodes) {
|
|
const parentId = node.parentId !== null && ids.has(node.parentId) ? node.parentId : null;
|
|
const siblings = children.get(parentId) ?? [];
|
|
siblings.push(node);
|
|
children.set(parentId, siblings);
|
|
}
|
|
|
|
return children;
|
|
}
|
|
|
|
function renderNodeLine(node: VomNode, depth: number, ref: string | undefined): string {
|
|
let line = `${" ".repeat(depth)}${ref ? `@${ref} ` : ""}${node.role}`;
|
|
|
|
const name = cleaned(node.name);
|
|
if (name) line += ` ${JSON.stringify(name)}`;
|
|
|
|
// For link nodes with an external href, annotate so the agent can
|
|
// distinguish external navigation from same-origin links.
|
|
if (node.role === "link" && node.href) {
|
|
line += ` [→ ${node.href}]`;
|
|
}
|
|
|
|
const value = node.sensitive ? SENSITIVE_MASK : cleaned(node.value)?.slice(0, MAX_VALUE_LENGTH);
|
|
if (value !== undefined) line += ` =${JSON.stringify(value)}`;
|
|
|
|
return line;
|
|
}
|
|
|
|
interface RenderState {
|
|
lines: string[];
|
|
refs: Array<{ ref: string; backendNodeId: number }>;
|
|
nextRef: number;
|
|
tokens: number;
|
|
maxDepth: number;
|
|
maxTokens: number;
|
|
truncated: boolean;
|
|
stopped: boolean;
|
|
}
|
|
|
|
function renderTree(
|
|
children: Map<number | null, VomNode[]>,
|
|
parentId: number | null,
|
|
depth: number,
|
|
state: RenderState,
|
|
): void {
|
|
if (state.stopped) return;
|
|
|
|
const nodes = children.get(parentId) ?? [];
|
|
for (const node of nodes) {
|
|
if (state.stopped) return;
|
|
|
|
if (!shouldRender(node)) {
|
|
renderTree(children, node.id, depth, state);
|
|
continue;
|
|
}
|
|
|
|
if (depth > state.maxDepth) {
|
|
state.truncated = true;
|
|
continue;
|
|
}
|
|
|
|
const ref = shouldReference(node) ? `e${state.nextRef}` : undefined;
|
|
const line = renderNodeLine(node, depth, ref);
|
|
const nextTokens = state.tokens + estimateTokens(line);
|
|
if (nextTokens > state.maxTokens) {
|
|
state.truncated = true;
|
|
state.stopped = true;
|
|
return;
|
|
}
|
|
|
|
state.lines.push(line);
|
|
state.tokens = nextTokens;
|
|
if (ref) {
|
|
state.refs.push({ ref, backendNodeId: node.id });
|
|
state.nextRef += 1;
|
|
}
|
|
|
|
renderTree(children, node.id, depth + 1, state);
|
|
}
|
|
}
|
|
|
|
function renderNodes(nodes: VomNode[], options: VomOptions, initialLines: string[]): RenderState {
|
|
const state: RenderState = {
|
|
lines: [...initialLines],
|
|
refs: [],
|
|
nextRef: 1,
|
|
tokens: estimateTokens(initialLines.join("\n")),
|
|
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
truncated: false,
|
|
stopped: false,
|
|
};
|
|
|
|
renderTree(buildChildren(nodes), null, 1, state);
|
|
|
|
return state;
|
|
}
|
|
|
|
function renderPageOcclusionLine(hiddenNodeCount: number): string {
|
|
return `L2 page … occluded by L1 (~${hiddenNodeCount} nodes, not actionable)`;
|
|
}
|
|
|
|
function collectDescendantsOfMembers(nodes: VomNode[], members: Set<number>): Set<number> {
|
|
const included = new Set(members);
|
|
let changed = true;
|
|
|
|
while (changed) {
|
|
changed = false;
|
|
for (const node of nodes) {
|
|
if (included.has(node.id)) continue;
|
|
if (node.parentId !== null && included.has(node.parentId)) {
|
|
included.add(node.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return included;
|
|
}
|
|
|
|
function countRenderable(nodes: VomNode[]): number {
|
|
return nodes.filter(shouldRender).length;
|
|
}
|
|
|
|
function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOptions): VomResult {
|
|
const included = collectDescendantsOfMembers(scene.nodes, layer.members);
|
|
const visibleNodes = scene.nodes.filter((node) => included.has(node.id));
|
|
const hiddenCount = countRenderable(scene.nodes.filter((node) => !included.has(node.id)));
|
|
const header = [
|
|
"@vom 1",
|
|
`@view ${scene.viewport.width}x${scene.viewport.height}`,
|
|
"@layers 2 focus=L1",
|
|
`L1 ${layer.kind} cover=${Math.round(layer.coverage * 100)}%`,
|
|
];
|
|
const state = renderNodes(visibleNodes, options, header);
|
|
state.lines.push(renderPageOcclusionLine(hiddenCount));
|
|
|
|
return {
|
|
text: state.lines.join("\n"),
|
|
refs: state.refs,
|
|
truncated: state.truncated,
|
|
};
|
|
}
|
|
|
|
export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult {
|
|
const layer = detectBlockingLayer(scene.nodes, scene.viewport);
|
|
if (layer) return renderDoubleLayer(scene, layer, options);
|
|
|
|
const state = renderNodes(scene.nodes, options, [
|
|
"@vom 1",
|
|
`@view ${scene.viewport.width}x${scene.viewport.height}`,
|
|
"@layers 1 focus=L1",
|
|
"L1 page",
|
|
]);
|
|
|
|
return {
|
|
text: state.lines.join("\n"),
|
|
refs: state.refs,
|
|
truncated: state.truncated,
|
|
};
|
|
}
|