mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Merge pull request #165 from Tencent/perf/vom-algorithmic-optimizations
perf(vom): optimize rendering algorithms
This commit is contained in:
@@ -298,6 +298,34 @@ describe("renderVom single-layer page", () => {
|
||||
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]);
|
||||
});
|
||||
|
||||
it("renders through deeply nested transparent wrappers without exhausting the call stack", () => {
|
||||
const wrapperCount = 10_000;
|
||||
const nodes = [node({ id: 1, role: "RootWebArea", name: "Doc" })];
|
||||
for (let index = 0; index < wrapperCount; index += 1) {
|
||||
nodes.push(
|
||||
node({
|
||||
id: index + 2,
|
||||
parentId: index + 1,
|
||||
role: "generic",
|
||||
}),
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
node({
|
||||
id: wrapperCount + 2,
|
||||
parentId: wrapperCount + 1,
|
||||
role: "button",
|
||||
name: "Deep action",
|
||||
tag: "button",
|
||||
}),
|
||||
);
|
||||
|
||||
const out = renderVom(scene(nodes));
|
||||
|
||||
expect(out.text).toContain('@e1 button "Deep action"');
|
||||
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: wrapperCount + 2 }]);
|
||||
});
|
||||
|
||||
it("truncates when maxTokens is exceeded while keeping refs in sync with rendered text", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
@@ -785,6 +813,55 @@ describe("renderVom single-layer page", () => {
|
||||
expect(out.text).not.toContain("北京市小客车指标调控管理信息系统]");
|
||||
});
|
||||
|
||||
it("derives duplicate-label context from indexed DOM ancestry without semantic proximity", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "Doc", contextScopeId: "root" }),
|
||||
node({
|
||||
id: 3,
|
||||
parentId: 1,
|
||||
role: "button",
|
||||
name: "View",
|
||||
tag: "button",
|
||||
domParentId: 2,
|
||||
domAncestorIds: [2, 1],
|
||||
contextScopeId: "root",
|
||||
}),
|
||||
node({
|
||||
id: 5,
|
||||
parentId: 1,
|
||||
role: "button",
|
||||
name: "View",
|
||||
tag: "button",
|
||||
domParentId: 4,
|
||||
domAncestorIds: [4, 1],
|
||||
contextScopeId: "root",
|
||||
}),
|
||||
node({
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
role: "group",
|
||||
name: "Project Alpha",
|
||||
domParentId: 1,
|
||||
domAncestorIds: [1],
|
||||
contextScopeId: "root",
|
||||
}),
|
||||
node({
|
||||
id: 4,
|
||||
parentId: 1,
|
||||
role: "group",
|
||||
name: "Project Beta",
|
||||
domParentId: 1,
|
||||
domAncestorIds: [1],
|
||||
contextScopeId: "root",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(out.text).toContain('@e1 button "View" [ctx: Project Alpha]');
|
||||
expect(out.text).toContain('@e2 button "View" [ctx: Project Beta]');
|
||||
});
|
||||
|
||||
it("does not add low-value root context to duplicated brand links", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
@@ -833,6 +910,34 @@ describe("renderVom single-layer page", () => {
|
||||
});
|
||||
|
||||
describe("renderVom double-layer page", () => {
|
||||
it("collects blocking-layer descendants even when children precede parents in source order", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||
node({
|
||||
id: 3,
|
||||
parentId: 2,
|
||||
role: "button",
|
||||
name: "Close",
|
||||
tag: "button",
|
||||
paintOrder: 0,
|
||||
}),
|
||||
node({
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
role: "dialog",
|
||||
name: "Modal",
|
||||
rect: { x: 0, y: 0, w: 1280, h: 720 },
|
||||
paintOrder: 10,
|
||||
position: "fixed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(out.text).toContain('@e1 button "Close"');
|
||||
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 3 }]);
|
||||
});
|
||||
|
||||
it("renders a blocking modal first and folds the base page into an occluded summary", () => {
|
||||
const out = renderVom(
|
||||
scene([
|
||||
|
||||
+134
-66
@@ -421,6 +421,41 @@ function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
|
||||
return children;
|
||||
}
|
||||
|
||||
const CONTEXT_LABEL_ROLES = new Set([
|
||||
"heading",
|
||||
"article",
|
||||
"section",
|
||||
"group",
|
||||
"region",
|
||||
"row",
|
||||
"form",
|
||||
]);
|
||||
|
||||
interface DomContextIndex {
|
||||
nonBoundaryByScopeParent: Map<string | undefined, Map<number, VomNode[]>>;
|
||||
sourceOrderById: Map<number, number>;
|
||||
}
|
||||
|
||||
function buildDomContextIndex(nodes: VomNode[]): DomContextIndex {
|
||||
const nonBoundaryByScopeParent = new Map<string | undefined, Map<number, VomNode[]>>();
|
||||
const sourceOrderById = new Map<number, number>();
|
||||
for (const [sourceOrder, node] of nodes.entries()) {
|
||||
sourceOrderById.set(node.id, sourceOrder);
|
||||
if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node)) || isContextBoundary(node)) continue;
|
||||
const parentId = node.domParentId;
|
||||
if (parentId === undefined || parentId === null) continue;
|
||||
let byParent = nonBoundaryByScopeParent.get(node.contextScopeId);
|
||||
if (!byParent) {
|
||||
byParent = new Map();
|
||||
nonBoundaryByScopeParent.set(node.contextScopeId, byParent);
|
||||
}
|
||||
const siblings = byParent.get(parentId) ?? [];
|
||||
siblings.push(node);
|
||||
byParent.set(parentId, siblings);
|
||||
}
|
||||
return { nonBoundaryByScopeParent, sourceOrderById };
|
||||
}
|
||||
|
||||
function duplicateReferenceNames(nodes: VomNode[]): Set<string> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const node of nodes) {
|
||||
@@ -440,13 +475,7 @@ function needsHandleContext(nodeName: string, duplicateRefNames: Set<string>): b
|
||||
}
|
||||
|
||||
function contextLabel(node: VomNode, targetName: string): string | undefined {
|
||||
if (
|
||||
!["heading", "article", "section", "group", "region", "row", "form"].includes(
|
||||
normalizedRole(node),
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node))) return undefined;
|
||||
return weakContextText(
|
||||
cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText),
|
||||
targetName,
|
||||
@@ -607,19 +636,45 @@ function collectDomContext(
|
||||
): ContextCandidate[] {
|
||||
const ancestorIds = node.domAncestorIds ?? [];
|
||||
if (ancestorIds.length === 0) return [];
|
||||
const ancestors = new Set(ancestorIds);
|
||||
const contexts: ContextCandidate[] = [];
|
||||
const ancestorRank = new Map<number, number>();
|
||||
for (const [rank, ancestorId] of ancestorIds.entries()) {
|
||||
if (!ancestorRank.has(ancestorId)) ancestorRank.set(ancestorId, rank);
|
||||
}
|
||||
|
||||
for (const candidate of state.nodesById.values()) {
|
||||
const candidates = new Map<number, VomNode>();
|
||||
const nonBoundaryByParent = state.domContextIndex.nonBoundaryByScopeParent.get(
|
||||
node.contextScopeId,
|
||||
);
|
||||
for (const ancestorId of ancestorRank.keys()) {
|
||||
const boundary = state.nodesById.get(ancestorId);
|
||||
const boundaryParentId = boundary?.domParentId;
|
||||
if (
|
||||
boundary &&
|
||||
sharesContextScope(node, boundary) &&
|
||||
isContextBoundary(boundary) &&
|
||||
boundaryParentId !== undefined &&
|
||||
boundaryParentId !== null &&
|
||||
ancestorRank.has(boundaryParentId)
|
||||
) {
|
||||
candidates.set(boundary.id, boundary);
|
||||
}
|
||||
for (const candidate of nonBoundaryByParent?.get(ancestorId) ?? []) {
|
||||
candidates.set(candidate.id, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceOrderById = state.domContextIndex.sourceOrderById;
|
||||
const orderedCandidates = [...candidates.values()].sort(
|
||||
(a, b) => (sourceOrderById.get(a.id) ?? 0) - (sourceOrderById.get(b.id) ?? 0),
|
||||
);
|
||||
for (const candidate of orderedCandidates) {
|
||||
if (candidate.id === node.id) continue;
|
||||
if (!sharesContextScope(node, candidate)) continue;
|
||||
if (isContextBoundary(candidate) && !ancestorIds.includes(candidate.id)) continue;
|
||||
const parentId = candidate.domParentId;
|
||||
if (parentId === undefined || parentId === null || !ancestors.has(parentId)) continue;
|
||||
if (parentId === undefined || parentId === null) continue;
|
||||
const label = contextLabel(candidate, nodeName);
|
||||
if (!label) continue;
|
||||
const rank = ancestorIds.indexOf(parentId);
|
||||
contexts.push({ text: label, source: "dom", order: rank < 0 ? contexts.length : rank });
|
||||
contexts.push({ text: label, source: "dom", order: ancestorRank.get(parentId) ?? 0 });
|
||||
}
|
||||
|
||||
return contexts;
|
||||
@@ -734,6 +789,7 @@ interface RenderState {
|
||||
children: Map<number | null, VomNode[]>;
|
||||
parentMap: Map<number, number | null>;
|
||||
nodesById: Map<number, VomNode>;
|
||||
domContextIndex: DomContextIndex;
|
||||
duplicateRefNames: Set<string>;
|
||||
surfaceMap: Map<number, CondSurface>;
|
||||
scopeMap: Map<number, ActiveScopeBlock>;
|
||||
@@ -768,20 +824,25 @@ function emitActiveScopeBlock(scope: ActiveScopeBlock, depth: number, state: Ren
|
||||
}
|
||||
}
|
||||
|
||||
function renderTree(
|
||||
children: Map<number | null, VomNode[]>,
|
||||
parentId: number | null,
|
||||
function pushRenderChildren(
|
||||
stack: Array<{ node: VomNode; depth: number }>,
|
||||
children: readonly VomNode[],
|
||||
depth: number,
|
||||
state: RenderState,
|
||||
): void {
|
||||
if (state.stopped) return;
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
stack.push({ node: children[index], depth });
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = children.get(parentId) ?? [];
|
||||
for (const node of nodes) {
|
||||
if (state.stopped) return;
|
||||
function renderTree(children: Map<number | null, VomNode[]>, state: RenderState): void {
|
||||
const stack: Array<{ node: VomNode; depth: number }> = [];
|
||||
pushRenderChildren(stack, children.get(null) ?? [], 1);
|
||||
|
||||
while (stack.length > 0 && !state.stopped) {
|
||||
const { node, depth } = stack.pop() as { node: VomNode; depth: number };
|
||||
|
||||
if (!shouldRender(node)) {
|
||||
renderTree(children, node.id, depth, state);
|
||||
pushRenderChildren(stack, children.get(node.id) ?? [], depth);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -798,7 +859,7 @@ function renderTree(
|
||||
if (nextTokens > state.maxTokens) {
|
||||
state.truncated = true;
|
||||
state.stopped = true;
|
||||
return;
|
||||
break;
|
||||
}
|
||||
|
||||
state.lines.push(line);
|
||||
@@ -823,7 +884,7 @@ function renderTree(
|
||||
if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) {
|
||||
continue;
|
||||
}
|
||||
renderTree(children, node.id, depth + 1, state);
|
||||
pushRenderChildren(stack, children.get(node.id) ?? [], depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,12 +909,13 @@ function renderNodes(
|
||||
children,
|
||||
parentMap: buildParentMap(nodes),
|
||||
nodesById: new Map(nodes.map((node) => [node.id, node])),
|
||||
domContextIndex: buildDomContextIndex(nodes),
|
||||
duplicateRefNames: duplicateReferenceNames(nodes),
|
||||
surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])),
|
||||
scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])),
|
||||
};
|
||||
|
||||
renderTree(children, null, 1, state);
|
||||
renderTree(children, state);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -922,23 +984,33 @@ function paintLineageByFrame(
|
||||
return lineage;
|
||||
}
|
||||
|
||||
function cachedPaintLineageByFrame(
|
||||
node: VomNode,
|
||||
parentMap: Map<number, number | null>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
cache: Map<number, Map<string | undefined, VomNode>>,
|
||||
): Map<string | undefined, VomNode> {
|
||||
const cached = cache.get(node.id);
|
||||
if (cached) return cached;
|
||||
const lineage = paintLineageByFrame(node, parentMap, nodesById);
|
||||
cache.set(node.id, lineage);
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/** Resolve both nodes to the nearest document where their paint order is comparable. */
|
||||
function comparablePaintNodes(
|
||||
target: VomNode,
|
||||
blocker: VomNode,
|
||||
parentMap: Map<number, number | null>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
lineageCache: Map<number, Map<string | undefined, VomNode>>,
|
||||
): { target: VomNode; blocker: VomNode } | null {
|
||||
if (target.frameId === blocker.frameId) return { target, blocker };
|
||||
const targetLineage = paintLineageByFrame(target, parentMap, nodesById);
|
||||
let blockerInFrame: VomNode | undefined = blocker;
|
||||
let guard = 0;
|
||||
while (blockerInFrame && guard <= parentMap.size) {
|
||||
const targetInFrame = targetLineage.get(blockerInFrame.frameId);
|
||||
const targetLineage = cachedPaintLineageByFrame(target, parentMap, nodesById, lineageCache);
|
||||
const blockerLineage = cachedPaintLineageByFrame(blocker, parentMap, nodesById, lineageCache);
|
||||
for (const [frameId, blockerInFrame] of blockerLineage) {
|
||||
const targetInFrame = targetLineage.get(frameId);
|
||||
if (targetInFrame) return { target: targetInFrame, blocker: blockerInFrame };
|
||||
const parentId: number | null = parentMap.get(blockerInFrame.id) ?? null;
|
||||
blockerInFrame = parentId === null ? undefined : nodesById.get(parentId);
|
||||
guard += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -949,12 +1021,13 @@ function isBlockedByRegion(
|
||||
parentMap: Map<number, number | null>,
|
||||
nodesById: Map<number, VomNode>,
|
||||
viewportCoverage: number,
|
||||
lineageCache: Map<number, Map<string | undefined, VomNode>>,
|
||||
): boolean {
|
||||
if (target.id === blocker.id) return false;
|
||||
if (isAncestorOf(parentMap, blocker.id, target.id)) return false;
|
||||
if (isAncestorOf(parentMap, target.id, blocker.id)) return false;
|
||||
if (!target.rect || !blocker.rect) return false;
|
||||
const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById);
|
||||
const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById, lineageCache);
|
||||
if (!paintNodes) return false;
|
||||
|
||||
const points = interactionPoints(target.rect);
|
||||
@@ -979,17 +1052,22 @@ function isBlockedByRegion(
|
||||
return points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y));
|
||||
}
|
||||
|
||||
function collectDescendantsOfIds(nodes: VomNode[], roots: Set<number>): Set<number> {
|
||||
function collectDescendants(nodes: VomNode[], roots: Set<number>): Set<number> {
|
||||
const children = new Map<number, number[]>();
|
||||
for (const node of nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
const siblings = children.get(node.parentId) ?? [];
|
||||
siblings.push(node.id);
|
||||
children.set(node.parentId, siblings);
|
||||
}
|
||||
|
||||
const included = new Set(roots);
|
||||
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;
|
||||
}
|
||||
const queue = [...roots];
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
for (const childId of children.get(queue[index]) ?? []) {
|
||||
if (included.has(childId)) continue;
|
||||
included.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
return included;
|
||||
@@ -998,6 +1076,7 @@ function collectDescendantsOfIds(nodes: VomNode[], roots: Set<number>): Set<numb
|
||||
function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
|
||||
const parentMap = buildParentMap(nodes);
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const lineageCache = new Map<number, Map<string | undefined, VomNode>>();
|
||||
const candidates = nodes
|
||||
.filter(isPositionedRegionCandidate)
|
||||
.map((node) => {
|
||||
@@ -1014,40 +1093,29 @@ function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
|
||||
for (const target of nodes) {
|
||||
if (!isVomReferenceNode(target)) continue;
|
||||
const blocked = candidates.some((candidate) =>
|
||||
isBlockedByRegion(target, candidate.node, parentMap, nodesById, candidate.viewportCoverage),
|
||||
isBlockedByRegion(
|
||||
target,
|
||||
candidate.node,
|
||||
parentMap,
|
||||
nodesById,
|
||||
candidate.viewportCoverage,
|
||||
lineageCache,
|
||||
),
|
||||
);
|
||||
if (blocked) blockedRoots.add(target.id);
|
||||
}
|
||||
|
||||
if (blockedRoots.size === 0) return nodes;
|
||||
const blocked = collectDescendantsOfIds(nodes, blockedRoots);
|
||||
const blocked = collectDescendants(nodes, blockedRoots);
|
||||
return nodes.filter((node) => !blocked.has(node.id));
|
||||
}
|
||||
|
||||
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 included = collectDescendants(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 = [
|
||||
|
||||
Reference in New Issue
Block a user