@opentiny/next-sdk 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/AgentModelProvider.ts +78 -81
- package/agent/type.ts +6 -9
- package/agent/utils/getAISDKTools.ts +0 -1
- package/agent/utils/getBuiltinMcpTools.ts +7 -7
- package/core.ts +0 -3
- package/dist/SimulatorMask-BHVXyogh-iNUb4dyF.js +361 -0
- package/dist/agent/type.d.ts +4 -12
- package/dist/agent/utils/getBuiltinMcpTools.d.ts +3 -3
- package/dist/core.d.ts +0 -1
- package/dist/core.js +16 -17
- package/dist/index-CXK8CcKq.js +6076 -0
- package/dist/index.d.ts +0 -3
- package/dist/index.js +57 -4969
- package/dist/{initialize-builtin-WebMCP-HgObT902.js → initialize-builtin-WebMCP-rALfqS-o.js} +1166 -1056
- package/dist/page-tools/a11y/build.d.ts +11 -0
- package/dist/page-tools/a11y/constants.d.ts +9 -0
- package/dist/page-tools/a11y/search.d.ts +17 -0
- package/dist/page-tools/a11y/types.d.ts +104 -0
- package/dist/page-tools/a11y/utils.d.ts +34 -0
- package/dist/page-tools/a11y/vnode.d.ts +37 -0
- package/dist/page-tools/a11y-tree.d.ts +8 -99
- package/dist/page-tools/constants.d.ts +22 -0
- package/dist/page-tools/context.d.ts +28 -0
- package/dist/page-tools/handlers/browserState.d.ts +8 -0
- package/dist/page-tools/handlers/click.d.ts +8 -0
- package/dist/page-tools/handlers/executeJavascript.d.ts +8 -0
- package/dist/page-tools/handlers/fill.d.ts +8 -0
- package/dist/page-tools/handlers/scroll.d.ts +8 -0
- package/dist/page-tools/handlers/searchTree.d.ts +8 -0
- package/dist/page-tools/handlers/select.d.ts +8 -0
- package/dist/page-tools/page-agent-highlight/index.d.ts +9 -0
- package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +14 -0
- package/dist/page-tools/page-agent-mask/checkDarkMode.d.ts +5 -0
- package/dist/page-tools/page-agent-tool.d.ts +3 -8
- package/dist/page-tools/schema.d.ts +43 -0
- package/dist/page-tools/utils/dom.d.ts +6 -0
- package/dist/page-tools/utils/scroll.d.ts +15 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +744 -0
- package/dist/utils/builtinProxy.d.ts +1 -1
- package/index.ts +0 -5
- package/package.json +6 -17
- package/page-tools/a11y/build.ts +70 -0
- package/page-tools/a11y/constants.ts +110 -0
- package/page-tools/a11y/search.ts +129 -0
- package/page-tools/a11y/types.ts +108 -0
- package/page-tools/a11y/utils.ts +266 -0
- package/page-tools/a11y/vnode.ts +229 -0
- package/page-tools/a11y-tree.ts +8 -527
- package/page-tools/bridge.ts +1 -1
- package/page-tools/constants.ts +72 -0
- package/page-tools/context.ts +13 -0
- package/page-tools/handlers/browserState.ts +12 -0
- package/page-tools/handlers/click.ts +26 -0
- package/page-tools/handlers/executeJavascript.ts +22 -0
- package/page-tools/handlers/fill.ts +60 -0
- package/page-tools/handlers/scroll.ts +66 -0
- package/page-tools/handlers/searchTree.ts +30 -0
- package/page-tools/handlers/select.ts +34 -0
- package/page-tools/page-agent-highlight/index.ts +198 -0
- package/page-tools/page-agent-mask/SimulatorMask.module.css +14 -0
- package/page-tools/page-agent-mask/SimulatorMask.ts +279 -0
- package/page-tools/page-agent-mask/checkDarkMode.ts +181 -0
- package/page-tools/page-agent-mask/cursor-border.svg +3 -0
- package/page-tools/page-agent-mask/cursor-fill.svg +5 -0
- package/page-tools/page-agent-mask/cursor.module.css +70 -0
- package/page-tools/page-agent-prompt.md +34 -18
- package/page-tools/page-agent-tool.ts +104 -152
- package/page-tools/schema.ts +51 -0
- package/page-tools/utils/dom.ts +155 -0
- package/page-tools/utils/scroll.ts +58 -0
- package/runtime.ts +13 -0
- package/utils/builtinProxy.ts +45 -13
- package/vite.config.runtime.ts +22 -0
- package/vite.config.ts +52 -8
- package/McpSdk.ts +0 -14
- package/WebAgent.ts +0 -5
- package/WebMcp.ts +0 -26
- package/Zod.ts +0 -1
- package/dist/McpSdk.d.ts +0 -14
- package/dist/SimulatorMask-BHVXyogh-BFEGpD5S.js +0 -1048
- package/dist/SimulatorMask-BHVXyogh-CCYbrb84.js +0 -801
- package/dist/WebAgent.d.ts +0 -5
- package/dist/WebMcp.d.ts +0 -23
- package/dist/Zod.d.ts +0 -1
- package/dist/index.es.dev.js +0 -59017
- package/dist/index.es.js +0 -46795
- package/dist/index.umd.dev.js +0 -60355
- package/dist/index.umd.js +0 -1248
- package/dist/mcpsdk@1.25.3.dev.js +0 -22780
- package/dist/mcpsdk@1.25.3.es.dev.js +0 -22778
- package/dist/mcpsdk@1.25.3.es.js +0 -16960
- package/dist/mcpsdk@1.25.3.js +0 -48
- package/dist/transport/ExtensionClientTransport.d.ts +0 -24
- package/dist/transport/ExtensionContentServerTransport.d.ts +0 -39
- package/dist/transport/ExtensionPageServerTransport.d.ts +0 -36
- package/dist/transport/messages.d.ts +0 -9
- package/dist/vite.config.mcpSdk.d.ts +0 -2
- package/dist/vite.config.webAgent.d.ts +0 -2
- package/dist/vite.config.webMcp.d.ts +0 -2
- package/dist/vite.config.webMcpFull.d.ts +0 -2
- package/dist/vite.config.zod.d.ts +0 -2
- package/dist/webagent.dev.js +0 -49360
- package/dist/webagent.es.dev.js +0 -49071
- package/dist/webagent.es.js +0 -39219
- package/dist/webagent.js +0 -642
- package/dist/webmcp-full.dev.js +0 -31336
- package/dist/webmcp-full.es.dev.js +0 -30283
- package/dist/webmcp-full.es.js +0 -22889
- package/dist/webmcp-full.js +0 -645
- package/dist/webmcp.dev.js +0 -9572
- package/dist/webmcp.es.dev.js +0 -8518
- package/dist/webmcp.es.js +0 -6727
- package/dist/webmcp.js +0 -602
- package/dist/zod@3.25.76.dev.js +0 -4037
- package/dist/zod@3.25.76.es.dev.js +0 -4033
- package/dist/zod@3.25.76.es.js +0 -2945
- package/dist/zod@3.25.76.js +0 -1
- package/transport/ExtensionClientTransport.ts +0 -100
- package/transport/ExtensionContentServerTransport.ts +0 -162
- package/transport/ExtensionPageServerTransport.ts +0 -149
- package/transport/messages.ts +0 -63
- package/vite-build-tsc.ts +0 -63
- package/vite-env.d.ts +0 -10
- package/vite.config.mcpSdk.ts +0 -28
- package/vite.config.webAgent.ts +0 -19
- package/vite.config.webMcp.ts +0 -40
- package/vite.config.webMcpFull.ts +0 -19
- package/vite.config.zod.ts +0 -23
- /package/dist/{vite-build-tsc.d.ts → vite.config.runtime.d.ts} +0 -0
package/page-tools/a11y-tree.ts
CHANGED
|
@@ -1,532 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* a11y-tree.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 核心改进:
|
|
7
|
-
* 1. 两阶段处理:buildVNode(构建中间树)→ serializeVNode(剪枝序列化)
|
|
8
|
-
* 2. 统一剪枝规则:无 ref 且无 accessible name → 透明穿透(跳过本节点,保留子节点)
|
|
9
|
-
* - 兼顾操作场景(去掉 generic/list/listitem 噪音)
|
|
10
|
-
* - 兼顾内容理解场景(有 name 的 listitem 等节点保留)
|
|
11
|
-
* 3. 修复全局可变状态:refCounter 改为局部对象,消除并发调用隐患
|
|
12
|
-
*
|
|
13
|
-
* 依赖:
|
|
14
|
-
* - dom-accessibility-api: W3C AccName 规范 JS 实现(计算 accessible name)
|
|
15
|
-
* - tabbable: 可交互/可聚焦元素检测(工业级,处理所有边界情况)
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { computeAccessibleName } from 'dom-accessibility-api'
|
|
19
|
-
import { isFocusable } from 'tabbable'
|
|
20
|
-
|
|
21
|
-
// ─── 类型定义 ────────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/** ref 索引 → HTMLElement 映射,供 click/fill/select 操作使用 */
|
|
24
|
-
export type RefMap = Map<number, HTMLElement>
|
|
25
|
-
|
|
26
|
-
/** 内部中间态节点,与 DOM 解耦,便于剪枝和序列化 */
|
|
27
|
-
interface VNode {
|
|
28
|
-
role: string
|
|
29
|
-
/** W3C AccName 算法计算出的语义化名称 */
|
|
30
|
-
name: string
|
|
31
|
-
tokens: string[]
|
|
32
|
-
/** 只有交互节点才有 ref */
|
|
33
|
-
ref?: number
|
|
34
|
-
el: HTMLElement
|
|
35
|
-
children: VNode[]
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface A11yTreeOptions {
|
|
39
|
-
/**
|
|
40
|
-
* 是否启用剪枝:无 ref 且无 accessible name 的节点透明穿透
|
|
41
|
-
* 默认 true(推荐)
|
|
42
|
-
*/
|
|
43
|
-
pruneUnnamed?: boolean
|
|
44
|
-
/**
|
|
45
|
-
* 强制保留的角色列表,即使无 name 也不穿透(优先级最高)
|
|
46
|
-
* 例如:['table', 'row'] 用于保留表格结构
|
|
47
|
-
*/
|
|
48
|
-
preserveRoles?: string[]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface A11yTreeResult {
|
|
52
|
-
/** 语义化 YAML 文本(供 AI 阅读和 Diff 计算) */
|
|
53
|
-
yaml: string
|
|
54
|
-
/** ref 索引 → HTMLElement 映射(供后续操作使用) */
|
|
55
|
-
refMap: RefMap
|
|
56
|
-
/** 可交互元素总数 */
|
|
57
|
-
interactiveCount: number
|
|
58
|
-
/** 原始行数组(不含 yaml 代码块包裹),供搜索使用 */
|
|
59
|
-
lines: string[]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** 关键词搜索选项 */
|
|
63
|
-
export interface SearchA11yTreeOptions extends A11yTreeOptions {
|
|
64
|
-
/**
|
|
65
|
-
* 每个匹配行前后保留的上下文行数(类似 grep -C N)
|
|
66
|
-
* 默认 2
|
|
67
|
-
*/
|
|
68
|
-
contextLines?: number
|
|
69
|
-
/**
|
|
70
|
-
* 是否大小写不敏感,默认 true
|
|
71
|
-
*/
|
|
72
|
-
caseInsensitive?: boolean
|
|
73
|
-
/**
|
|
74
|
-
* 最大返回匹配分组数(防止命中过多撑爆上下文),默认 20
|
|
75
|
-
*/
|
|
76
|
-
maxMatches?: number
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** 单个匹配分组 */
|
|
80
|
-
export interface A11ySearchMatch {
|
|
81
|
-
/** 主命中行行号(1-based) */
|
|
82
|
-
lineNumber: number
|
|
83
|
-
/** 主命中行内容 */
|
|
84
|
-
line: string
|
|
85
|
-
/** 含上下文的行列表(带行号) */
|
|
86
|
-
context: Array<{ lineNumber: number; line: string }>
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** searchA11yTree 返回值 */
|
|
90
|
-
export interface SearchA11yTreeResult {
|
|
91
|
-
/** 格式化后可直接发给 LLM 的文本 */
|
|
92
|
-
text: string
|
|
93
|
-
/** 结构化匹配列表 */
|
|
94
|
-
matches: A11ySearchMatch[]
|
|
95
|
-
/** 无障碍树总行数 */
|
|
96
|
-
totalLines: number
|
|
97
|
-
/** 原始命中行数(去重前) */
|
|
98
|
-
matchCount: number
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ─── ARIA 隐式角色静态映射表(覆盖页面 95%+ 的常用标签)───────────────────────
|
|
102
|
-
|
|
103
|
-
const TAG_ROLE_MAP: Record<string, string> = {
|
|
104
|
-
a: 'link',
|
|
105
|
-
article: 'article',
|
|
106
|
-
aside: 'complementary',
|
|
107
|
-
button: 'button',
|
|
108
|
-
caption: 'caption',
|
|
109
|
-
cell: 'cell',
|
|
110
|
-
checkbox: 'checkbox',
|
|
111
|
-
code: 'code',
|
|
112
|
-
columnheader: 'columnheader',
|
|
113
|
-
combobox: 'combobox',
|
|
114
|
-
datalist: 'listbox',
|
|
115
|
-
dd: 'definition',
|
|
116
|
-
details: 'group',
|
|
117
|
-
dialog: 'dialog',
|
|
118
|
-
dt: 'term',
|
|
119
|
-
em: 'emphasis',
|
|
120
|
-
fieldset: 'group',
|
|
121
|
-
figure: 'figure',
|
|
122
|
-
footer: 'contentinfo',
|
|
123
|
-
form: 'form',
|
|
124
|
-
h1: 'heading',
|
|
125
|
-
h2: 'heading',
|
|
126
|
-
h3: 'heading',
|
|
127
|
-
h4: 'heading',
|
|
128
|
-
h5: 'heading',
|
|
129
|
-
h6: 'heading',
|
|
130
|
-
header: 'banner',
|
|
131
|
-
hr: 'separator',
|
|
132
|
-
img: 'img',
|
|
133
|
-
input: 'textbox', // 默认,具体 type 下面会覆盖
|
|
134
|
-
li: 'listitem',
|
|
135
|
-
link: 'link',
|
|
136
|
-
main: 'main',
|
|
137
|
-
mark: 'mark',
|
|
138
|
-
math: 'math',
|
|
139
|
-
menu: 'list',
|
|
140
|
-
menuitem: 'menuitem',
|
|
141
|
-
meter: 'meter',
|
|
142
|
-
nav: 'navigation',
|
|
143
|
-
ol: 'list',
|
|
144
|
-
option: 'option',
|
|
145
|
-
output: 'status',
|
|
146
|
-
p: 'paragraph',
|
|
147
|
-
progress: 'progressbar',
|
|
148
|
-
rowheader: 'rowheader',
|
|
149
|
-
search: 'search',
|
|
150
|
-
section: 'region',
|
|
151
|
-
select: 'listbox',
|
|
152
|
-
strong: 'strong',
|
|
153
|
-
summary: 'button',
|
|
154
|
-
table: 'table',
|
|
155
|
-
tbody: 'rowgroup',
|
|
156
|
-
td: 'cell',
|
|
157
|
-
tfoot: 'rowgroup',
|
|
158
|
-
th: 'columnheader',
|
|
159
|
-
thead: 'rowgroup',
|
|
160
|
-
time: 'time',
|
|
161
|
-
tr: 'row',
|
|
162
|
-
ul: 'list',
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// input[type=*] 的角色覆盖
|
|
166
|
-
const INPUT_TYPE_ROLE: Record<string, string> = {
|
|
167
|
-
button: 'button',
|
|
168
|
-
checkbox: 'checkbox',
|
|
169
|
-
color: 'textbox',
|
|
170
|
-
email: 'textbox',
|
|
171
|
-
file: 'textbox',
|
|
172
|
-
image: 'button',
|
|
173
|
-
number: 'spinbutton',
|
|
174
|
-
radio: 'radio',
|
|
175
|
-
range: 'slider',
|
|
176
|
-
reset: 'button',
|
|
177
|
-
search: 'searchbox',
|
|
178
|
-
submit: 'button',
|
|
179
|
-
tel: 'textbox',
|
|
180
|
-
text: 'textbox',
|
|
181
|
-
url: 'textbox',
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ─── 工具函数 ────────────────────────────────────────────────────────────────
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* 获取元素的 ARIA 角色
|
|
188
|
-
* 优先级:显式 role 属性 > 标签隐式角色 > 'generic'
|
|
189
|
-
*/
|
|
190
|
-
function inferRole(el: Element): string {
|
|
191
|
-
const explicit = el.getAttribute('role')
|
|
192
|
-
if (explicit && explicit !== 'presentation' && explicit !== 'none') {
|
|
193
|
-
return explicit
|
|
194
|
-
}
|
|
195
|
-
const tag = el.tagName.toLowerCase()
|
|
196
|
-
if (tag === 'input') {
|
|
197
|
-
const inputType = (el as HTMLInputElement).type?.toLowerCase() ?? 'text'
|
|
198
|
-
return INPUT_TYPE_ROLE[inputType] ?? 'textbox'
|
|
199
|
-
}
|
|
200
|
-
return TAG_ROLE_MAP[tag] ?? 'generic'
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* 收集节点的 ARIA 状态 token
|
|
205
|
-
* 格式:[checked] [selected] [disabled] [hasPopup] [cursor=pointer] [value="..."]
|
|
4
|
+
* 此文件为 A11y Tree 模块的汇总出口,
|
|
5
|
+
* 方便外部直接从 './a11y-tree' 引入相关类型及方法,而无需调整现有的引用路径。
|
|
206
6
|
*/
|
|
207
|
-
function getStateTokens(el: Element): string[] {
|
|
208
|
-
const tokens: string[] = []
|
|
209
|
-
const aria = (k: string) => el.getAttribute(k)
|
|
210
|
-
|
|
211
|
-
const checked = aria('aria-checked')
|
|
212
|
-
if (checked === 'true') tokens.push('checked')
|
|
213
|
-
else if (checked === 'false') tokens.push('unchecked')
|
|
214
|
-
|
|
215
|
-
if (aria('aria-selected') === 'true') tokens.push('selected')
|
|
216
|
-
|
|
217
|
-
const disabled = aria('aria-disabled') === 'true' || (el as HTMLInputElement).disabled
|
|
218
|
-
if (disabled) tokens.push('disabled')
|
|
219
|
-
|
|
220
|
-
const hasPopup = aria('aria-haspopup')
|
|
221
|
-
if (hasPopup && hasPopup !== 'false') tokens.push('hasPopup')
|
|
222
|
-
|
|
223
|
-
if (aria('aria-expanded') === 'true') tokens.push('expanded')
|
|
224
|
-
|
|
225
|
-
// heading level(h1-h6)
|
|
226
|
-
const headingMatch = el.tagName.match(/^H([1-6])$/)
|
|
227
|
-
if (headingMatch) tokens.push(`level=${headingMatch[1]}`)
|
|
228
|
-
const ariaLevel = aria('aria-level')
|
|
229
|
-
if (ariaLevel && !headingMatch) tokens.push(`level=${ariaLevel}`)
|
|
230
|
-
|
|
231
|
-
// cursor=pointer 表示"视觉上可点击"
|
|
232
|
-
try {
|
|
233
|
-
const style = window.getComputedStyle(el as HTMLElement)
|
|
234
|
-
if (style.cursor === 'pointer') tokens.push('cursor=pointer')
|
|
235
|
-
} catch {
|
|
236
|
-
// 某些元素 getComputedStyle 可能抛异常,忽略
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// 记录输入元素的值,以便在 fill/输入后在 A11y 树中显示并产生 Diff
|
|
240
|
-
const tag = el.tagName.toLowerCase()
|
|
241
|
-
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
242
|
-
const val = (el as HTMLInputElement).value
|
|
243
|
-
if (val !== undefined && val !== '') {
|
|
244
|
-
tokens.push(`value="${val}"`)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
const valuenow = el.getAttribute('aria-valuenow')
|
|
248
|
-
if (valuenow) {
|
|
249
|
-
tokens.push(`valuenow="${valuenow}"`)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return tokens
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** 判断元素是否应被跳过(不可见或在黑名单中) */
|
|
256
|
-
function isHidden(el: Element): boolean {
|
|
257
|
-
if (el.getAttribute('aria-hidden') === 'true') return true
|
|
258
|
-
if ((el as HTMLElement).hidden) return true
|
|
259
|
-
try {
|
|
260
|
-
const style = window.getComputedStyle(el as HTMLElement)
|
|
261
|
-
if (style.display === 'none' || style.visibility === 'hidden') return true
|
|
262
|
-
} catch {
|
|
263
|
-
// 忽略
|
|
264
|
-
}
|
|
265
|
-
return false
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ─── 阶段 1:构建 VNode 中间树 ────────────────────────────────────────────────
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* 递归将 DOM 元素转换为 VNode 中间表示
|
|
272
|
-
* @param el 当前 DOM 元素
|
|
273
|
-
* @param refCounter 引用计数器(使用对象引用避免全局可变状态)
|
|
274
|
-
* @param refMap ref 索引 → 元素映射
|
|
275
|
-
* @param blacklistSet 用户自定义黑名单
|
|
276
|
-
*/
|
|
277
|
-
function buildVNode(
|
|
278
|
-
el: Element,
|
|
279
|
-
refCounter: { value: number },
|
|
280
|
-
refMap: RefMap,
|
|
281
|
-
blacklistSet: Set<Element>,
|
|
282
|
-
whitelistSet: Set<Element>,
|
|
283
|
-
): VNode | null {
|
|
284
|
-
if (isHidden(el) || blacklistSet.has(el)) return null
|
|
285
|
-
|
|
286
|
-
const role = inferRole(el)
|
|
287
|
-
const tokens = getStateTokens(el)
|
|
288
|
-
const name = computeAccessibleName(el as HTMLElement)
|
|
289
|
-
const isTrulyInteractive = isFocusable(el as HTMLElement)
|
|
290
|
-
const isVisuallyClickable = tokens.includes('cursor=pointer')
|
|
291
|
-
const isWhitelisted = whitelistSet.has(el)
|
|
292
|
-
// generic 无 name 时,即使有 cursor=pointer 也不分配 ref:
|
|
293
|
-
// cursor 通常是 CSS 继承传播的,这类 div 本身无法被有意义地操作
|
|
294
|
-
const interactive = isTrulyInteractive || isWhitelisted || (isVisuallyClickable && (role !== 'generic' || name !== ''))
|
|
295
|
-
|
|
296
|
-
let ref: number | undefined
|
|
297
|
-
if (interactive) {
|
|
298
|
-
ref = refCounter.value
|
|
299
|
-
refMap.set(ref, el as HTMLElement)
|
|
300
|
-
refCounter.value++
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const children: VNode[] = []
|
|
304
|
-
for (const child of Array.from(el.children)) {
|
|
305
|
-
const childVNode = buildVNode(child, refCounter, refMap, blacklistSet, whitelistSet)
|
|
306
|
-
if (childVNode) children.push(childVNode)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
return { role, name, tokens, ref, el: el as HTMLElement, children }
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// ─── 阶段 2:剪枝序列化 ──────────────────────────────────────────────────────
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* 判断 VNode 子树是否包含任何有价值的节点(有 ref 或有 accessible name)
|
|
316
|
-
* 用于过滤空容器子树,避免输出无内容的嵌套层级
|
|
317
|
-
*/
|
|
318
|
-
function hasValue(vnode: VNode): boolean {
|
|
319
|
-
if (vnode.ref !== undefined || vnode.name.trim() !== '') return true
|
|
320
|
-
return vnode.children.some(hasValue)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* 判断节点是否需要透明穿透(跳过本节点但保留子节点)
|
|
325
|
-
*
|
|
326
|
-
* 统一规则:无 ref(非交互)且无 accessible name → 穿透
|
|
327
|
-
* 这样可以同时:
|
|
328
|
-
* - 去掉 generic/list/listitem 等纯布局噪音(无 name 时穿透)
|
|
329
|
-
* - 保留有 name 的 listitem(有 name 时保留,兼顾内容理解场景)
|
|
330
|
-
*/
|
|
331
|
-
function shouldPassThrough(vnode: VNode, opts: Required<A11yTreeOptions>): boolean {
|
|
332
|
-
if (!opts.pruneUnnamed) return false
|
|
333
|
-
// preserveRoles 中的角色强制保留
|
|
334
|
-
if (opts.preserveRoles.includes(vnode.role)) return false
|
|
335
|
-
// 有 ref(交互节点)→ 永远保留
|
|
336
|
-
if (vnode.ref !== undefined) return false
|
|
337
|
-
// 有 accessible name → 保留(兼顾内容理解场景)
|
|
338
|
-
if (vnode.name.trim() !== '') return false
|
|
339
|
-
// 无 ref 且无 name → 透明穿透
|
|
340
|
-
return true
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* 将 VNode 序列化为 YAML 行数组
|
|
345
|
-
* 穿透节点时,子节点在当前 depth 平铺输出(不增加缩进层级)
|
|
346
|
-
*/
|
|
347
|
-
function serializeVNode(
|
|
348
|
-
vnode: VNode,
|
|
349
|
-
depth: number,
|
|
350
|
-
opts: Required<A11yTreeOptions>,
|
|
351
|
-
): string[] {
|
|
352
|
-
if (shouldPassThrough(vnode, opts)) {
|
|
353
|
-
// 透明穿透:跳过本节点,子节点保持当前 depth(层级不增加)
|
|
354
|
-
// 同时过滤掉整棵子树都无价值的空容器,避免输出无意义的嵌套
|
|
355
|
-
return vnode.children
|
|
356
|
-
.filter(c => hasValue(c))
|
|
357
|
-
.flatMap(c => serializeVNode(c, depth, opts))
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const indent = ' '.repeat(depth)
|
|
361
|
-
const refStr = vnode.ref !== undefined ? ` #${vnode.ref}` : ''
|
|
362
|
-
const tokenStr = vnode.tokens.length > 0 ? ` [${vnode.tokens.join(' ')}]` : ''
|
|
363
|
-
const nameStr = vnode.name ? ` "${vnode.name}"` : ''
|
|
364
|
-
const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`
|
|
365
|
-
|
|
366
|
-
const childLines = vnode.children.flatMap(c => serializeVNode(c, depth + 1, opts))
|
|
367
|
-
return [line, ...childLines]
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ─── 主入口 ──────────────────────────────────────────────────────────────────
|
|
371
|
-
|
|
372
|
-
const DEFAULT_OPTIONS: Required<A11yTreeOptions> = {
|
|
373
|
-
pruneUnnamed: true,
|
|
374
|
-
preserveRoles: [],
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* 生成当前页面的语义化 ARIA YAML 树
|
|
379
|
-
*
|
|
380
|
-
* @param root 遍历起点,默认 document.body
|
|
381
|
-
* @param blacklist 需要跳过的元素(用户自定义黑名单)
|
|
382
|
-
* @param whitelist 需要识别为可交互的白名单元素列表
|
|
383
|
-
* @param options 过滤选项
|
|
384
|
-
*/
|
|
385
|
-
export function buildA11yTree(
|
|
386
|
-
root: Element = document.body,
|
|
387
|
-
blacklist: Element[] = [],
|
|
388
|
-
whitelist: Element[] = [],
|
|
389
|
-
options?: A11yTreeOptions,
|
|
390
|
-
): A11yTreeResult {
|
|
391
|
-
const opts: Required<A11yTreeOptions> = { ...DEFAULT_OPTIONS, ...options }
|
|
392
|
-
// 使用对象引用避免全局可变状态,消除并发调用隐患
|
|
393
|
-
const refCounter = { value: 0 }
|
|
394
|
-
const refMap: RefMap = new Map()
|
|
395
|
-
const blacklistSet = new Set(blacklist)
|
|
396
|
-
const whitelistSet = new Set(whitelist)
|
|
397
|
-
const lines: string[] = []
|
|
398
|
-
|
|
399
|
-
for (const child of Array.from(root.children)) {
|
|
400
|
-
const vnode = buildVNode(child, refCounter, refMap, blacklistSet, whitelistSet)
|
|
401
|
-
if (vnode) {
|
|
402
|
-
lines.push(...serializeVNode(vnode, 0, opts))
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const yaml = '```yaml\n' + lines.join('\n') + '\n```'
|
|
407
|
-
|
|
408
|
-
return {
|
|
409
|
-
yaml,
|
|
410
|
-
refMap,
|
|
411
|
-
interactiveCount: refMap.size,
|
|
412
|
-
lines,
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ─── 关键词搜索 ──────────────────────────────────────────────────────────────
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* 在无障碍树中按关键词搜索,返回带行号的匹配结果和上下文
|
|
420
|
-
*
|
|
421
|
-
* 支持的搜索维度(均对同一个 query 字符串做包含匹配):
|
|
422
|
-
* - role:如 `button`、`link`、`heading`
|
|
423
|
-
* - accessible name:节点的语义化名称(引号内文本)
|
|
424
|
-
* - state token:如 `checked`、`disabled`、`expanded`
|
|
425
|
-
* - ref 索引:如 `#5`
|
|
426
|
-
*
|
|
427
|
-
* @example
|
|
428
|
-
* searchA11yTree('button') // 找全部按钮
|
|
429
|
-
* searchA11yTree('提交') // 找名称含"提交"的节点
|
|
430
|
-
* searchA11yTree('#3') // 找 ref #3
|
|
431
|
-
*/
|
|
432
|
-
export function searchA11yTree(
|
|
433
|
-
query: string,
|
|
434
|
-
root: Element = document.body,
|
|
435
|
-
blacklist: Element[] = [],
|
|
436
|
-
whitelist: Element[] = [],
|
|
437
|
-
options?: SearchA11yTreeOptions,
|
|
438
|
-
): SearchA11yTreeResult {
|
|
439
|
-
const {
|
|
440
|
-
contextLines = 2,
|
|
441
|
-
caseInsensitive = true,
|
|
442
|
-
maxMatches = 20,
|
|
443
|
-
...treeOptions
|
|
444
|
-
} = options ?? {}
|
|
445
|
-
|
|
446
|
-
// 复用 buildA11yTree 生成完整树,直接取 lines 数组(不重复构建 DOM 遍历)
|
|
447
|
-
const { lines } = buildA11yTree(root, blacklist, whitelist, treeOptions)
|
|
448
|
-
|
|
449
|
-
const needle = caseInsensitive ? query.toLowerCase() : query
|
|
450
|
-
const totalLines = lines.length
|
|
451
|
-
|
|
452
|
-
const isRefQuery = /^#\d+$/.test(query)
|
|
453
|
-
const refRegex = isRefQuery ? new RegExp(`\\s${query}(?:\\s|[\\[]|$)`) : null
|
|
454
|
-
|
|
455
|
-
// 找出所有命中行的下标(0-based)
|
|
456
|
-
const hitIndices: number[] = []
|
|
457
|
-
for (let i = 0; i < lines.length; i++) {
|
|
458
|
-
let matched = false
|
|
459
|
-
if (refRegex) {
|
|
460
|
-
matched = refRegex.test(lines[i])
|
|
461
|
-
} else {
|
|
462
|
-
const haystack = caseInsensitive ? lines[i].toLowerCase() : lines[i]
|
|
463
|
-
matched = haystack.includes(needle)
|
|
464
|
-
}
|
|
465
|
-
if (matched) {
|
|
466
|
-
hitIndices.push(i)
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// 合并重叠的上下文区间,避免重复输出行
|
|
471
|
-
const mergedRanges: Array<{ start: number; end: number; hits: number[] }> = []
|
|
472
|
-
let isTruncated = false
|
|
473
|
-
for (const idx of hitIndices) {
|
|
474
|
-
const start = Math.max(0, idx - contextLines)
|
|
475
|
-
const end = Math.min(totalLines - 1, idx + contextLines)
|
|
476
|
-
const last = mergedRanges[mergedRanges.length - 1]
|
|
477
|
-
if (last && start <= last.end + 1) {
|
|
478
|
-
// 区间重叠或紧邻,合并
|
|
479
|
-
last.end = Math.max(last.end, end)
|
|
480
|
-
last.hits.push(idx)
|
|
481
|
-
} else {
|
|
482
|
-
if (mergedRanges.length >= maxMatches) {
|
|
483
|
-
isTruncated = true
|
|
484
|
-
break
|
|
485
|
-
}
|
|
486
|
-
mergedRanges.push({ start, end, hits: [idx] })
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// 构建结构化结果
|
|
491
|
-
const matches: A11ySearchMatch[] = mergedRanges.map((range) => ({
|
|
492
|
-
lineNumber: range.hits[0] + 1,
|
|
493
|
-
line: lines[range.hits[0]],
|
|
494
|
-
context: Array.from({ length: range.end - range.start + 1 }, (_, k) => ({
|
|
495
|
-
lineNumber: range.start + k + 1,
|
|
496
|
-
line: lines[range.start + k],
|
|
497
|
-
})),
|
|
498
|
-
}))
|
|
499
|
-
|
|
500
|
-
// 格式化为可读文本(模仿 grep -n -C 风格,便于 LLM 直接理解)
|
|
501
|
-
const textParts: string[] = [
|
|
502
|
-
`无障碍树搜索结果 — 关键词: "${query}" | 总行数: ${totalLines} | 命中: ${hitIndices.length} 行 | 返回分组: ${matches.length}`,
|
|
503
|
-
'',
|
|
504
|
-
]
|
|
505
|
-
|
|
506
|
-
if (matches.length === 0) {
|
|
507
|
-
textParts.push('(未找到匹配项)')
|
|
508
|
-
} else {
|
|
509
|
-
matches.forEach((match, m) => {
|
|
510
|
-
const range = mergedRanges[m]
|
|
511
|
-
textParts.push(`── 分组 ${m + 1}(第 ${range.start + 1}–${range.end + 1} 行)──`)
|
|
512
|
-
match.context.forEach(({ lineNumber, line }) => {
|
|
513
|
-
const isHit = range.hits.includes(lineNumber - 1)
|
|
514
|
-
const ln = String(lineNumber).padStart(4)
|
|
515
|
-
// 命中行用 >>> 标记,上下文行用普通行号前缀
|
|
516
|
-
textParts.push(isHit ? `>>>${ln} | ${line}` : ` ${ln} | ${line}`)
|
|
517
|
-
})
|
|
518
|
-
textParts.push('')
|
|
519
|
-
})
|
|
520
|
-
if (isTruncated) {
|
|
521
|
-
textParts.push(`⚠️ 命中过多,已截断至前 ${maxMatches} 个分组,建议缩小搜索范围`)
|
|
522
|
-
}
|
|
523
|
-
textParts.push(`提示:如需操作命中元素,使用其 #N 索引;如需查看完整树,请使用 browserState。`)
|
|
524
|
-
}
|
|
525
7
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
8
|
+
export * from './a11y/types'
|
|
9
|
+
export * from './a11y/constants'
|
|
10
|
+
export * from './a11y/utils'
|
|
11
|
+
export * from './a11y/vnode'
|
|
12
|
+
export * from './a11y/build'
|
|
13
|
+
export * from './a11y/search'
|
package/page-tools/bridge.ts
CHANGED
|
@@ -605,7 +605,7 @@ export function registerPageTool(options: RegisterPageToolByHandlersOptions): ()
|
|
|
605
605
|
|
|
606
606
|
/**
|
|
607
607
|
* 建立浏览器原生或 polyfill 与 next-sdk 页面工具桥接的联系。
|
|
608
|
-
* 若用户或第三方库直接操作
|
|
608
|
+
* 若用户或第三方库直接操作 document.modelContext 进行工具注册,
|
|
609
609
|
* 我们通过此函数进行拦截劫持,并同步到 next-sdk 的握手线路 (MSG_TOOL_REGISTERED / MSG_TOOL_UNREGISTERED),
|
|
610
610
|
* 从而保证无论浏览器是否原生支持,均能正常完成 WebMCP 握手交互。
|
|
611
611
|
*/
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** 校验错误默认选择器:ARIA 标准 + 主流 UI 框架 */
|
|
2
|
+
export const DEFAULT_ERROR_SELECTORS: string[] = [
|
|
3
|
+
// W3C ARIA 标准(最可靠,框架无关)
|
|
4
|
+
'[role="alert"]',
|
|
5
|
+
'[aria-invalid="true"]',
|
|
6
|
+
// Tiny3 / Lego(华为云)
|
|
7
|
+
'.ti3-unifyvalid-error',
|
|
8
|
+
'.ti3-error',
|
|
9
|
+
'.ti-error',
|
|
10
|
+
'.lego-text-error',
|
|
11
|
+
'.lego-error',
|
|
12
|
+
// Element UI / Element Plus
|
|
13
|
+
'.el-form-item__error',
|
|
14
|
+
// Ant Design
|
|
15
|
+
'.ant-form-item-explain-error',
|
|
16
|
+
// Bootstrap
|
|
17
|
+
'.is-invalid',
|
|
18
|
+
'.invalid-feedback',
|
|
19
|
+
// Angular
|
|
20
|
+
'.ng-invalid',
|
|
21
|
+
// 通用命名约定
|
|
22
|
+
'.error-msg',
|
|
23
|
+
'.error-message',
|
|
24
|
+
'.error-text',
|
|
25
|
+
'.field-error',
|
|
26
|
+
'.form-error',
|
|
27
|
+
'.is-error',
|
|
28
|
+
'.has-error',
|
|
29
|
+
'.validate-error',
|
|
30
|
+
'.valid-error'
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** 模态弹窗默认选择器:ARIA 标准 + 主流 UI 框架 */
|
|
34
|
+
export const DEFAULT_DIALOG_SELECTORS: string[] = [
|
|
35
|
+
// W3C ARIA 标准
|
|
36
|
+
'[role="dialog"]',
|
|
37
|
+
'[role="alertdialog"]',
|
|
38
|
+
// Tiny3 / Lego(华为云)
|
|
39
|
+
'[class*="ti3-modal"]',
|
|
40
|
+
'[class*="ti3-message-box"]',
|
|
41
|
+
// Element UI / Element Plus
|
|
42
|
+
'[class*="el-dialog"]',
|
|
43
|
+
'[class*="el-message-box"]',
|
|
44
|
+
// Ant Design
|
|
45
|
+
'[class*="ant-modal"]',
|
|
46
|
+
// Bootstrap
|
|
47
|
+
'[class*="modal-content"]',
|
|
48
|
+
// Vuetify
|
|
49
|
+
'[class*="v-dialog"]',
|
|
50
|
+
// Naive UI
|
|
51
|
+
'[class*="n-modal"]'
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
export interface PageAgentToolOptions {
|
|
55
|
+
/** 允许在无障碍树节点中额外暴露的 DOM 属性白名单 */
|
|
56
|
+
exposedAttributes?: string[]
|
|
57
|
+
/** 是否启用元素高亮 */
|
|
58
|
+
enableHighlight?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare global {
|
|
62
|
+
interface Window {
|
|
63
|
+
__webmcpcli_interactiveWhitelist?: Element[]
|
|
64
|
+
__webmcpcli_interactiveBlacklist?: Element[]
|
|
65
|
+
__webmcpcli_exposedAttributes?: string[]
|
|
66
|
+
__webmcpcli_beforeGetBrowserState?: (() => void) | null
|
|
67
|
+
/** 校验错误元素 CSS 选择器列表(覆盖默认,用于检测页面可见的校验错误) */
|
|
68
|
+
__webmcpcli_errorSelectors?: string[]
|
|
69
|
+
/** 模态弹窗元素 CSS 选择器列表(覆盖默认,用于检测阻塞交互的弹窗) */
|
|
70
|
+
__webmcpcli_dialogSelectors?: string[]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PageController } from '@page-agent/page-controller'
|
|
2
|
+
import type { PageStateCache } from './page-state-cache'
|
|
3
|
+
import type { RefMap } from './a11y-tree'
|
|
4
|
+
|
|
5
|
+
export interface ActionContext {
|
|
6
|
+
pageController: PageController
|
|
7
|
+
stateCache: PageStateCache
|
|
8
|
+
getRefMap: () => RefMap
|
|
9
|
+
setRefMap: (map: RefMap) => void
|
|
10
|
+
buildBrowserStateResponse: (mode?: 'full' | 'diff' | 'both') => Promise<{ content: Array<{ type: 'text'; text: string }> }>
|
|
11
|
+
refreshOnStaleRef: (action: string, index: number) => Promise<{ content: Array<{ type: 'text'; text: string }> }>
|
|
12
|
+
errContent: (msg: string) => Promise<{ content: Array<{ type: 'text'; text: string }> }>
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ActionContext } from '../context'
|
|
2
|
+
|
|
3
|
+
export async function handleBrowserState(args: any, ctx: ActionContext) {
|
|
4
|
+
if (window.__webmcpcli_beforeGetBrowserState) {
|
|
5
|
+
try {
|
|
6
|
+
window.__webmcpcli_beforeGetBrowserState()
|
|
7
|
+
} catch (e) {
|
|
8
|
+
console.warn('__webmcpcli_beforeGetBrowserState error:', e)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return ctx.buildBrowserStateResponse(args.responseMode ?? 'diff')
|
|
12
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { clickElement } from '@page-agent/page-controller'
|
|
2
|
+
import { waitForDomSettled } from '../utils/dom'
|
|
3
|
+
import type { ActionContext } from '../context'
|
|
4
|
+
|
|
5
|
+
export async function handleClick(args: any, ctx: ActionContext) {
|
|
6
|
+
const mode = args.responseMode ?? 'diff'
|
|
7
|
+
if (args.index === undefined) return ctx.errContent('点击结果: 缺少元素索引')
|
|
8
|
+
const el = ctx.getRefMap().get(args.index)
|
|
9
|
+
if (!el) return ctx.refreshOnStaleRef('点击', args.index)
|
|
10
|
+
|
|
11
|
+
// 若 ref 指向 shadow host,解析其内部真正的可点击元素
|
|
12
|
+
let targetEl = el as HTMLElement
|
|
13
|
+
if (el.shadowRoot && !(el instanceof HTMLButtonElement) && !(el instanceof HTMLAnchorElement)) {
|
|
14
|
+
const innerClickable = el.shadowRoot.querySelector(
|
|
15
|
+
'button, a, [role="button"], [role="link"]'
|
|
16
|
+
) as HTMLElement | null
|
|
17
|
+
if (innerClickable) {
|
|
18
|
+
targetEl = innerClickable
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
await clickElement(targetEl)
|
|
22
|
+
// 等待框架异步插入的校验错误/条件渲染内容稳定后再采集状态
|
|
23
|
+
await waitForDomSettled()
|
|
24
|
+
// 操作成功后自动返回 diff/both/full
|
|
25
|
+
return ctx.buildBrowserStateResponse(mode)
|
|
26
|
+
}
|