Skip to content

Commit 46e0667

Browse files
committed
fix(controller): cap indexed options per dropdown container
A select/dropdown with hundreds of options (common in Element UI / Avue forms) puts every option into the simplified DOM, so the LLM request can exceed the server limit (HTTP 413). Index at most 20 options per dropdown container and fold the rest into a hint (e.g. "... 80 more option(s) not shown ..."), keeping the payload bounded while telling the LLM that more options exist.
1 parent 8fffd2f commit 46e0667

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { beforeEach, describe, expect, it } from 'vitest'
2+
3+
import { flatTreeToString } from '../index'
4+
import domTree from './index.js'
5+
6+
function setupSizes() {
7+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
8+
configurable: true,
9+
get() {
10+
return 100
11+
},
12+
})
13+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
14+
configurable: true,
15+
get() {
16+
return 30
17+
},
18+
})
19+
}
20+
21+
function buildDropdown(html: string) {
22+
return domTree({
23+
doHighlightElements: false,
24+
viewportExpansion: -1,
25+
interactiveBlacklist: [],
26+
interactiveWhitelist: [],
27+
}) as any
28+
}
29+
30+
describe('dropdown option cap (#348)', () => {
31+
beforeEach(() => {
32+
setupSizes()
33+
document.body.innerHTML = ''
34+
})
35+
36+
it('indexes at most 20 options per dropdown container', () => {
37+
const options = Array.from(
38+
{ length: 25 },
39+
(_, i) => `<li class="el-select-dropdown__item" style="cursor:pointer">选项 ${i}</li>`
40+
)
41+
document.body.innerHTML = `
42+
<div class="el-select-dropdown">
43+
<ul class="el-select-dropdown__list">${options.join('')}</ul>
44+
</div>
45+
`
46+
const tree = buildDropdown(document.body.innerHTML)
47+
const liNodes = Object.values(tree.map).filter((n: any) => n.tagName === 'li')
48+
const indexed = liNodes.filter((n: any) => typeof n.highlightIndex === 'number')
49+
expect(indexed.length).toBe(20)
50+
})
51+
52+
it('records dropped options on the container', () => {
53+
const options = Array.from(
54+
{ length: 25 },
55+
(_, i) => `<li class="el-select-dropdown__item" style="cursor:pointer">选项 ${i}</li>`
56+
)
57+
document.body.innerHTML = `
58+
<div class="el-select-dropdown">
59+
<ul class="el-select-dropdown__list">${options.join('')}</ul>
60+
</div>
61+
`
62+
const tree = buildDropdown(document.body.innerHTML)
63+
const container = Object.values(tree.map).find(
64+
(n: any) => n.tagName === 'div' && n.extra?.droppedOptions === 5
65+
)
66+
expect(container).toBeDefined()
67+
})
68+
69+
it('renders a folded-options hint in the simplified HTML', () => {
70+
const options = Array.from(
71+
{ length: 25 },
72+
(_, i) => `<li class="el-select-dropdown__item" style="cursor:pointer">选项 ${i}</li>`
73+
)
74+
document.body.innerHTML = `
75+
<div class="el-select-dropdown">
76+
<ul class="el-select-dropdown__list">${options.join('')}</ul>
77+
</div>
78+
`
79+
const tree = buildDropdown(document.body.innerHTML)
80+
const html = flatTreeToString(tree)
81+
expect(html).toContain('5 more option(s) not shown')
82+
// only the first 20 options carry indexes
83+
expect(html).toContain('[19]<li >选项 19')
84+
expect(html).not.toContain('选项 24')
85+
})
86+
87+
it('does not cap dropdowns with few options', () => {
88+
const options = Array.from(
89+
{ length: 6 },
90+
(_, i) => `<li class="el-select-dropdown__item" style="cursor:pointer">选项 ${i}</li>`
91+
)
92+
document.body.innerHTML = `
93+
<div class="el-select-dropdown">
94+
<ul class="el-select-dropdown__list">${options.join('')}</ul>
95+
</div>
96+
`
97+
const tree = buildDropdown(document.body.innerHTML)
98+
const liNodes = Object.values(tree.map).filter((n: any) => n.tagName === 'li')
99+
expect(liNodes.filter((n: any) => typeof n.highlightIndex === 'number').length).toBe(6)
100+
const html = flatTreeToString(tree)
101+
expect(html).not.toContain('not shown')
102+
})
103+
})

‎packages/page-controller/src/dom/dom_tree/index.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,21 @@ export default (
720720
return Boolean(element.closest(DROPDOWN_CONTAINER_SELECTOR))
721721
}
722722

723+
/**
724+
* @edit cap dropdown options per container (#348)
725+
* A select/dropdown with hundreds of options would blow up the LLM payload;
726+
* index at most MAX_DROPDOWN_OPTIONS_PER_CONTAINER options and fold the rest
727+
* into a hint on the container.
728+
*/
729+
const MAX_DROPDOWN_OPTIONS_PER_CONTAINER = 20
730+
const dropdownOptionCounts = new WeakMap() // container element -> indexed option count
731+
732+
function getDropdownOptionContainer(element) {
733+
if (!element || element.nodeType !== Node.ELEMENT_NODE) return null
734+
if (!isDropdownOptionElement(element)) return null
735+
return element.closest(DROPDOWN_CONTAINER_SELECTOR)
736+
}
737+
723738
/**
724739
* Checks if an element is interactive.
725740
*
@@ -1677,6 +1692,24 @@ export default (
16771692
if (nodeData.isVisible) {
16781693
nodeData.isTopElement = isTopElement(node)
16791694

1695+
/**
1696+
* @edit cap dropdown options per container (#348)
1697+
* A dropdown with hundreds of options would blow up the LLM payload;
1698+
* index at most MAX_DROPDOWN_OPTIONS_PER_CONTAINER options per container
1699+
* and fold the excess into a hint on the container.
1700+
*/
1701+
if (isDropdownOptionElement(node)) {
1702+
const container = getDropdownOptionContainer(node)
1703+
const used = dropdownOptionCounts.get(container) || 0
1704+
if (used >= MAX_DROPDOWN_OPTIONS_PER_CONTAINER) {
1705+
addExtraData(container, {
1706+
droppedOptions: (extraData.get(container)?.droppedOptions || 0) + 1,
1707+
})
1708+
return null // Skip excess options entirely (text included)
1709+
}
1710+
dropdownOptionCounts.set(container, used + 1)
1711+
}
1712+
16801713
// Special handling for ARIA menu containers - check interactivity even if not top element
16811714
const role = node.getAttribute('role')
16821715
const isMenuContainer = role === 'menu' || role === 'menubar' || role === 'listbox'

‎packages/page-controller/src/dom/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,14 @@ export function flatTreeToString(
432432
processNode(child, nextDepth, result)
433433
}
434434

435+
/**
436+
* @edit dropdown options are capped per container (#348);
437+
* render a hint so the LLM knows more options exist.
438+
*/
439+
if (node.extra?.droppedOptions) {
440+
result.push(`${depthStr}... ${node.extra.droppedOptions} more option(s) not shown ...`)
441+
}
442+
435443
if (emitSemantic) {
436444
// empty tag should be removed
437445
if (result.length === mark + 1) {

0 commit comments

Comments
 (0)