@opentiny/next-sdk 0.3.3 → 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.
Files changed (134) hide show
  1. package/agent/AgentModelProvider.ts +78 -81
  2. package/agent/type.ts +6 -9
  3. package/agent/utils/getAISDKTools.ts +0 -1
  4. package/agent/utils/getBuiltinMcpTools.ts +7 -7
  5. package/core.ts +1 -3
  6. package/dist/SimulatorMask-BHVXyogh-iNUb4dyF.js +361 -0
  7. package/dist/agent/type.d.ts +4 -12
  8. package/dist/agent/utils/getBuiltinMcpTools.d.ts +3 -3
  9. package/dist/core.d.ts +1 -1
  10. package/dist/core.js +18 -18
  11. package/dist/index-CXK8CcKq.js +6076 -0
  12. package/dist/index.d.ts +2 -4
  13. package/dist/index.js +57 -2783
  14. package/dist/initialize-builtin-WebMCP-rALfqS-o.js +6389 -0
  15. package/dist/page-tools/a11y/build.d.ts +11 -0
  16. package/dist/page-tools/a11y/constants.d.ts +9 -0
  17. package/dist/page-tools/a11y/search.d.ts +17 -0
  18. package/dist/page-tools/a11y/types.d.ts +104 -0
  19. package/dist/page-tools/a11y/utils.d.ts +34 -0
  20. package/dist/page-tools/a11y/vnode.d.ts +37 -0
  21. package/dist/page-tools/a11y-tree.d.ts +12 -0
  22. package/dist/page-tools/bridge.d.ts +0 -6
  23. package/dist/page-tools/constants.d.ts +22 -0
  24. package/dist/page-tools/context.d.ts +28 -0
  25. package/dist/page-tools/handlers/browserState.d.ts +8 -0
  26. package/dist/page-tools/handlers/click.d.ts +8 -0
  27. package/dist/page-tools/handlers/executeJavascript.d.ts +8 -0
  28. package/dist/page-tools/handlers/fill.d.ts +8 -0
  29. package/dist/page-tools/handlers/scroll.d.ts +8 -0
  30. package/dist/page-tools/handlers/searchTree.d.ts +8 -0
  31. package/dist/page-tools/handlers/select.d.ts +8 -0
  32. package/dist/page-tools/initialize-builtin-WebMCP.d.ts +1 -0
  33. package/dist/page-tools/page-agent-highlight/index.d.ts +9 -0
  34. package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +14 -0
  35. package/dist/page-tools/page-agent-mask/checkDarkMode.d.ts +5 -0
  36. package/dist/page-tools/page-agent-tool.d.ts +4 -0
  37. package/dist/page-tools/page-state-cache.d.ts +36 -0
  38. package/dist/page-tools/schema.d.ts +43 -0
  39. package/dist/page-tools/utils/dom.d.ts +6 -0
  40. package/dist/page-tools/utils/scroll.d.ts +15 -0
  41. package/dist/runtime.d.ts +4 -0
  42. package/dist/runtime.js +744 -0
  43. package/dist/utils/builtinProxy.d.ts +1 -1
  44. package/index.ts +2 -19
  45. package/package.json +14 -18
  46. package/page-tools/a11y/build.ts +70 -0
  47. package/page-tools/a11y/constants.ts +110 -0
  48. package/page-tools/a11y/search.ts +129 -0
  49. package/page-tools/a11y/types.ts +108 -0
  50. package/page-tools/a11y/utils.ts +266 -0
  51. package/page-tools/a11y/vnode.ts +229 -0
  52. package/page-tools/a11y-tree.ts +13 -0
  53. package/page-tools/bridge.ts +49 -15
  54. package/page-tools/constants.ts +72 -0
  55. package/page-tools/context.ts +13 -0
  56. package/page-tools/handlers/browserState.ts +12 -0
  57. package/page-tools/handlers/click.ts +26 -0
  58. package/page-tools/handlers/executeJavascript.ts +22 -0
  59. package/page-tools/handlers/fill.ts +60 -0
  60. package/page-tools/handlers/scroll.ts +66 -0
  61. package/page-tools/handlers/searchTree.ts +30 -0
  62. package/page-tools/handlers/select.ts +34 -0
  63. package/page-tools/initialize-builtin-WebMCP.ts +20 -0
  64. package/page-tools/page-agent-highlight/index.ts +198 -0
  65. package/page-tools/page-agent-mask/SimulatorMask.module.css +14 -0
  66. package/page-tools/page-agent-mask/SimulatorMask.ts +279 -0
  67. package/page-tools/page-agent-mask/checkDarkMode.ts +181 -0
  68. package/page-tools/page-agent-mask/cursor-border.svg +3 -0
  69. package/page-tools/page-agent-mask/cursor-fill.svg +5 -0
  70. package/page-tools/page-agent-mask/cursor.module.css +70 -0
  71. package/page-tools/page-agent-prompt.md +139 -0
  72. package/page-tools/page-agent-tool.ts +175 -0
  73. package/page-tools/page-state-cache.ts +78 -0
  74. package/page-tools/schema.ts +51 -0
  75. package/page-tools/utils/dom.ts +155 -0
  76. package/page-tools/utils/scroll.ts +58 -0
  77. package/runtime.ts +13 -0
  78. package/utils/builtinProxy.ts +45 -13
  79. package/vite.config.runtime.ts +22 -0
  80. package/vite.config.ts +52 -8
  81. package/McpSdk.ts +0 -14
  82. package/WebAgent.ts +0 -5
  83. package/WebMcp.ts +0 -20
  84. package/Zod.ts +0 -1
  85. package/dist/AgentModelProvider-CwqOT28f.js +0 -4198
  86. package/dist/McpSdk.d.ts +0 -14
  87. package/dist/WebAgent.d.ts +0 -5
  88. package/dist/WebMcp.d.ts +0 -20
  89. package/dist/Zod.d.ts +0 -1
  90. package/dist/index.es.dev.js +0 -53427
  91. package/dist/index.es.js +0 -42147
  92. package/dist/index.umd.dev.js +0 -53820
  93. package/dist/index.umd.js +0 -814
  94. package/dist/mcpsdk@1.25.3.dev.js +0 -22621
  95. package/dist/mcpsdk@1.25.3.es.dev.js +0 -22619
  96. package/dist/mcpsdk@1.25.3.es.js +0 -16802
  97. package/dist/mcpsdk@1.25.3.js +0 -43
  98. package/dist/transport/ExtensionClientTransport.d.ts +0 -24
  99. package/dist/transport/ExtensionContentServerTransport.d.ts +0 -39
  100. package/dist/transport/ExtensionPageServerTransport.d.ts +0 -36
  101. package/dist/transport/messages.d.ts +0 -9
  102. package/dist/vite.config.mcpSdk.d.ts +0 -2
  103. package/dist/vite.config.webAgent.d.ts +0 -2
  104. package/dist/vite.config.webMcp.d.ts +0 -2
  105. package/dist/vite.config.webMcpFull.d.ts +0 -2
  106. package/dist/vite.config.zod.d.ts +0 -2
  107. package/dist/webagent.dev.js +0 -47597
  108. package/dist/webagent.es.dev.js +0 -47204
  109. package/dist/webagent.es.js +0 -37494
  110. package/dist/webagent.js +0 -634
  111. package/dist/webmcp-full.dev.js +0 -24430
  112. package/dist/webmcp-full.es.dev.js +0 -24426
  113. package/dist/webmcp-full.es.js +0 -18333
  114. package/dist/webmcp-full.js +0 -43
  115. package/dist/webmcp.dev.js +0 -1595
  116. package/dist/webmcp.es.dev.js +0 -1589
  117. package/dist/webmcp.es.js +0 -1406
  118. package/dist/webmcp.js +0 -1
  119. package/dist/zod@3.25.76.dev.js +0 -4037
  120. package/dist/zod@3.25.76.es.dev.js +0 -4033
  121. package/dist/zod@3.25.76.es.js +0 -2945
  122. package/dist/zod@3.25.76.js +0 -1
  123. package/transport/ExtensionClientTransport.ts +0 -100
  124. package/transport/ExtensionContentServerTransport.ts +0 -162
  125. package/transport/ExtensionPageServerTransport.ts +0 -149
  126. package/transport/messages.ts +0 -63
  127. package/vite-build-tsc.ts +0 -63
  128. package/vite-env.d.ts +0 -5
  129. package/vite.config.mcpSdk.ts +0 -28
  130. package/vite.config.webAgent.ts +0 -19
  131. package/vite.config.webMcp.ts +0 -40
  132. package/vite.config.webMcpFull.ts +0 -19
  133. package/vite.config.zod.ts +0 -23
  134. /package/dist/{vite-build-tsc.d.ts → vite.config.runtime.d.ts} +0 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * a11y/utils.ts
3
+ *
4
+ * 存放生成无障碍树时的通用工具函数:节点解析、状态获取、角色推断及纯文本兜底等。
5
+ */
6
+
7
+ import { isFocusable, isTabbable } from 'tabbable'
8
+ import { TAG_ROLE_MAP, INPUT_TYPE_ROLE, DEFAULT_ERROR_SELECTORS, DEFAULT_WARNING_SELECTORS } from './constants'
9
+
10
+ /**
11
+ * 获取元素的 ARIA 角色
12
+ * 优先级:显式 role 属性 > 标签隐式角色 > 'generic'
13
+ */
14
+ export function inferRole(el: Element): string {
15
+ const explicit = el.getAttribute('role')
16
+ if (explicit && explicit !== 'presentation' && explicit !== 'none') {
17
+ return explicit
18
+ }
19
+ const tag = el.tagName.toLowerCase()
20
+ if (tag === 'input') {
21
+ const inputType = (el as HTMLInputElement).type?.toLowerCase() ?? 'text'
22
+ return INPUT_TYPE_ROLE[inputType] ?? 'textbox'
23
+ }
24
+ return TAG_ROLE_MAP[tag] ?? 'generic'
25
+ }
26
+
27
+ /**
28
+ * 收集节点的 ARIA 状态 token
29
+ * 格式:[checked] [selected] [disabled] [hasPopup] [cursor=pointer] [value="..."]
30
+ */
31
+ export function getStateTokens(
32
+ el: Element,
33
+ exposedAttributes?: string[],
34
+ errorSelectors?: string | string[],
35
+ warningSelectors?: string | string[],
36
+ ): string[] {
37
+ const tokens: string[] = []
38
+ const aria = (k: string) => el.getAttribute(k)
39
+
40
+ // 选中状态:优先 aria-checked,其次原生 input.checked
41
+ // 对 <label for="X"> 检测关联 checkbox/radio 的选中状态,帮助 AI 识别复选框当前状态
42
+ const ariaChecked = aria('aria-checked')
43
+ if (ariaChecked === 'true') {
44
+ tokens.push('checked')
45
+ } else if (ariaChecked === 'false') {
46
+ tokens.push('unchecked')
47
+ } else {
48
+ const elTag = el.tagName.toLowerCase()
49
+ let nativeChecked: boolean | undefined
50
+ if (elTag === 'input' && ((el as HTMLInputElement).type === 'checkbox' || (el as HTMLInputElement).type === 'radio')) {
51
+ nativeChecked = (el as HTMLInputElement).checked
52
+ } else if (elTag === 'label' && el.hasAttribute('for')) {
53
+ const target = document.getElementById(el.getAttribute('for')!)
54
+ if (target instanceof HTMLInputElement && (target.type === 'checkbox' || target.type === 'radio')) {
55
+ nativeChecked = target.checked
56
+ }
57
+ }
58
+ if (nativeChecked !== undefined) {
59
+ tokens.push(nativeChecked ? 'checked' : 'unchecked')
60
+ }
61
+ }
62
+
63
+ if (aria('aria-selected') === 'true') tokens.push('selected')
64
+
65
+ const disabled = aria('aria-disabled') === 'true' || (el as HTMLInputElement).disabled
66
+ if (disabled) tokens.push('disabled')
67
+
68
+ const hasPopup = aria('aria-haspopup')
69
+ if (hasPopup && hasPopup !== 'false') tokens.push('hasPopup')
70
+
71
+ if (aria('aria-expanded') === 'true') tokens.push('expanded')
72
+
73
+ // heading level(h1-h6)
74
+ const headingMatch = el.tagName.match(/^H([1-6])$/)
75
+ if (headingMatch) tokens.push(`level=${headingMatch[1]}`)
76
+ const ariaLevel = aria('aria-level')
77
+ if (ariaLevel && !headingMatch) tokens.push(`level=${ariaLevel}`)
78
+
79
+ // cursor=pointer 表示"视觉上可点击"
80
+ try {
81
+ const style = window.getComputedStyle(el as HTMLElement)
82
+ if (style.cursor === 'pointer') tokens.push('cursor=pointer')
83
+ } catch {
84
+ // 某些元素 getComputedStyle 可能抛异常,忽略
85
+ }
86
+
87
+ // 记录输入元素的值,以便在 fill/输入后在 A11y 树中显示并产生 Diff
88
+ const tag = el.tagName.toLowerCase()
89
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
90
+ const val = (el as HTMLInputElement).value
91
+ if (val !== undefined && val !== '') {
92
+ tokens.push(`value="${val}"`)
93
+ }
94
+ }
95
+ const valuenow = el.getAttribute('aria-valuenow')
96
+ if (valuenow) {
97
+ tokens.push(`valuenow="${valuenow}"`)
98
+ }
99
+
100
+ // link 元素:检测 target=_blank,提示 Agent 该链接会在新标签页打开
101
+ if (el.tagName.toLowerCase() === 'a' && el.getAttribute('target') === '_blank') {
102
+ tokens.push('opens-new-tab')
103
+ }
104
+
105
+ // 检测 CSS 激活/选中状态类名(用于未使用标准 ARIA 的 Tab/选项组件,如华为云镜像选择)
106
+ // 若元素携带常见激活类名,输出 [active] token,帮助 Agent 判断当前选中项
107
+ const cls = typeof el.className === 'string' ? el.className : ''
108
+ const ACTIVE_CLASS_PATTERNS = [
109
+ 'is-active', 'isActive',
110
+ 'is-selected', 'isSelected',
111
+ 'is-current', 'isCurrent',
112
+ 'active-item', 'activeItem',
113
+ 'tab-active', 'tabActive',
114
+ // 仅当作为独立 class 词或有连字符前缀时匹配 "active",避免误匹配 "interactive" 等
115
+ /\bactive\b/,
116
+ /\bselected\b/,
117
+ /\bcurrent\b/,
118
+ ]
119
+ const hasActiveClass = ACTIVE_CLASS_PATTERNS.some(p =>
120
+ typeof p === 'string' ? cls.split(/\s+/).includes(p) : p.test(cls)
121
+ )
122
+ // 只对有 cursor=pointer 或明确角色的元素输出 active token,避免太多噪音
123
+ const roleForActive = el.getAttribute('role') || el.tagName.toLowerCase()
124
+ const roles = roleForActive.split(/\s+/)
125
+ const isTabLike = roles.some(r => ['button', 'option', 'a', 'li', 'generic'].includes(r) || r.startsWith('tab'))
126
+ if (hasActiveClass && isTabLike && !tokens.includes('checked') && !tokens.includes('selected')) {
127
+ tokens.push('active')
128
+ }
129
+
130
+ // 检测校验错误/警告状态(ARIA 标准 + 主流 UI 框架,可配置)
131
+ // 输出 [error] / [warning] token,让 AI 能区分校验错误与普通说明文字
132
+ // 使用 closest() 向上查找,确保嵌套在错误容器内的子元素也能获得 error 语义
133
+ const errorSelector = Array.isArray(errorSelectors)
134
+ ? errorSelectors.join(', ')
135
+ : (errorSelectors || DEFAULT_ERROR_SELECTORS.join(', '))
136
+ const warningSelector = Array.isArray(warningSelectors)
137
+ ? warningSelectors.join(', ')
138
+ : (warningSelectors || DEFAULT_WARNING_SELECTORS.join(', '))
139
+ let errorAncestor = null
140
+ if (errorSelector) {
141
+ try {
142
+ errorAncestor = el.closest(errorSelector)
143
+ } catch {
144
+ // ignore
145
+ }
146
+ }
147
+
148
+ if (errorAncestor) {
149
+ tokens.push('error')
150
+ } else {
151
+ let warningAncestor = null
152
+ if (warningSelector) {
153
+ try {
154
+ warningAncestor = el.closest(warningSelector)
155
+ } catch {
156
+ // ignore
157
+ }
158
+ }
159
+ if (warningAncestor) {
160
+ tokens.push('warning')
161
+ }
162
+ }
163
+
164
+ // 额外暴露的自定义属性白名单
165
+ if (exposedAttributes) {
166
+ for (const attr of exposedAttributes) {
167
+ const val = el.getAttribute(attr)
168
+ if (val !== null) {
169
+ tokens.push(`${attr}="${val}"`)
170
+ }
171
+ }
172
+ }
173
+
174
+ return tokens
175
+ }
176
+
177
+ /** 判断元素是否应被跳过(不可见或在黑名单中) */
178
+ export function isHidden(el: Element): boolean {
179
+ if (el.getAttribute('aria-hidden') === 'true') return true
180
+ if ((el as HTMLElement).hidden) return true
181
+ try {
182
+ const style = window.getComputedStyle(el as HTMLElement)
183
+ if (style.display === 'none' || style.visibility === 'hidden') return true
184
+ // opacity:0 是常见的视觉隐藏方式,元素对用户不可见
185
+ if (style.opacity === '0') return true
186
+ // 宽或高为 0 且有 overflow 裁剪时,子内容实际不可见(如折叠的侧边栏 width:0 !important)
187
+ // 若 overflow 为 visible,子内容仍然溢出可见,不能过滤
188
+ const isClipX = style.overflowX !== 'visible'
189
+ const isClipY = style.overflowY !== 'visible'
190
+ if (parseFloat(style.width) === 0 && isClipX) return true
191
+ if (parseFloat(style.height) === 0 && isClipY) return true
192
+ } catch {
193
+ // 忽略跨域 iframe 等无法访问 style 的场景
194
+ }
195
+ return false
196
+ }
197
+
198
+
199
+ /**
200
+ * 收集子孙节点的文本内容,用作无障碍名字的兜底。
201
+ * 当普通计算无法提取文本时,遍历后代并拼接可见文本。
202
+ */
203
+ export function collectDescendantText(el: Element): string {
204
+ let text = ''
205
+ const walk = (node: Node) => {
206
+ if (node.nodeType === Node.TEXT_NODE) {
207
+ text += (node.textContent ?? '') + ' '
208
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
209
+ const element = node as Element
210
+ if (isHidden(element)) return
211
+
212
+ // 如果遇到嵌套的列表项或其他交互/语义节点,停止向下遍历该子树,避免兜底文本重复吸收
213
+ if (element !== el) {
214
+ const tag = element.tagName.toLowerCase()
215
+ const role = inferRole(element)
216
+ const isInteractiveTag = ['button', 'a', 'input', 'select', 'textarea', 'li', 'option'].includes(tag)
217
+ const isInteractiveRole = ['button', 'link', 'checkbox', 'radio', 'textbox', 'listitem', 'option', 'combobox', 'listbox'].includes(role)
218
+ const isTrulyInteractive = isTabbable(element as HTMLElement)
219
+ let isCursorPointer = false
220
+ try {
221
+ isCursorPointer = window.getComputedStyle(element as HTMLElement).cursor === 'pointer'
222
+ } catch { /* ignore */ }
223
+ const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isCursorPointer)
224
+
225
+ if (isMeaningfullyInteractive || isInteractiveTag || isInteractiveRole) {
226
+ return
227
+ }
228
+ }
229
+
230
+ for (const child of Array.from(element.childNodes)) {
231
+ walk(child)
232
+ }
233
+ }
234
+ }
235
+ walk(el)
236
+ return text.trim().replace(/\s+/g, ' ')
237
+ }
238
+
239
+ /**
240
+ * 获取元素在 composed tree(组合树)中的有效子元素
241
+ *
242
+ * Shadow DOM 场景下,简单拼接 el.children + el.shadowRoot.children 不符合浏览器
243
+ * 实际渲染和无障碍树使用的组合树语义:slotted 节点应出现在 <slot> 的位置而非
244
+ * host 下,<slot> 本身不应作为噪音节点出现,未被任何 slot 接收的 light children
245
+ * 在组合树中不可见。
246
+ *
247
+ * 因此:有 shadowRoot 时遍历 shadow tree,遇到 <slot> 用 assignedElements 替换;
248
+ * 无 shadowRoot 时直接遍历 light DOM children。
249
+ */
250
+ export function getComposedChildren(el: Element): Element[] {
251
+ // <slot> 可嵌套在 shadow tree 的任意层级(如 <div><slot/></div>),递归时
252
+ // wrapper 节点无 shadowRoot 走 light 分支,因此两个分支都需解析 <slot>。
253
+ // light DOM 中不会出现 <slot>,检查无副作用。
254
+ const source = el.shadowRoot ? el.shadowRoot.children : el.children
255
+ const result: Element[] = []
256
+ for (const node of Array.from(source)) {
257
+ if (node instanceof HTMLSlotElement) {
258
+ const assigned = node.assignedElements({ flatten: true })
259
+ // 有分配节点时用 slotted 内容;否则用 slot 的 fallback content
260
+ result.push(...(assigned.length > 0 ? assigned : Array.from(node.children)))
261
+ } else {
262
+ result.push(node)
263
+ }
264
+ }
265
+ return result
266
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * a11y/vnode.ts
3
+ *
4
+ * 处理无障碍树节点 (VNode) 的构建、剪枝与序列化逻辑。
5
+ */
6
+
7
+ import { computeAccessibleName } from 'dom-accessibility-api'
8
+ import { isFocusable, isTabbable } from 'tabbable'
9
+ import type { VNode, RefMap, A11yTreeOptions } from './types'
10
+ import { isHidden, inferRole, getStateTokens, collectDescendantText, getComposedChildren } from './utils'
11
+
12
+ /**
13
+ * 递归将 DOM 元素转换为 VNode 中间表示
14
+ * @param el 当前 DOM 元素
15
+ * @param refCounter 引用计数器(使用对象引用避免全局可变状态)
16
+ * @param refMap ref 索引 → 元素映射
17
+ * @param blacklistSet 用户自定义黑名单
18
+ * @param whitelistSet 用户自定义白名单
19
+ * @param exposedAttributes 需要暴露的自定义属性
20
+ * @param errorSelectors 错误选择器
21
+ * @param warningSelectors 警告选择器
22
+ */
23
+ export function buildVNode(
24
+ el: Element,
25
+ refCounter: { value: number },
26
+ refMap: RefMap,
27
+ blacklistSet: Set<Element>,
28
+ whitelistSet: Set<Element>,
29
+ exposedAttributes?: string[],
30
+ errorSelectors?: string | string[],
31
+ warningSelectors?: string | string[],
32
+ /** 祖先节点是否已是可交互节点(已分配 ref)。为 true 时,纯 CSS 继承的 cursor=pointer 不再额外分配 ref */
33
+ ancestorIsInteractive = false,
34
+ ): VNode | null {
35
+ if (isHidden(el) || blacklistSet.has(el)) return null
36
+
37
+ const role = inferRole(el)
38
+ const tokens = getStateTokens(el, exposedAttributes, errorSelectors, warningSelectors)
39
+
40
+ let name = computeAccessibleName(el as HTMLElement)
41
+ const isTrulyInteractive = isTabbable(el as HTMLElement)
42
+ const isVisuallyClickable = tokens.includes('cursor=pointer')
43
+ // 包含白名单属性的节点也视为白名单节点(确保不被剪枝并分配 ref 操作索引)
44
+ const isWhitelisted = whitelistSet.has(el) || (exposedAttributes?.some(attr => el.hasAttribute(attr)) ?? false)
45
+ // <label for="..."> 原生可点击:浏览器将点击转发到关联的表单控件(checkbox/radio 等)
46
+ // Angular/React 自定义组件常隐藏原生 input,仅暴露 label 文本和自定义 skin
47
+ const isLabelFor = el.tagName.toLowerCase() === 'label' && el.hasAttribute('for')
48
+
49
+ // 兜底方案:如果 AccName 计算结果为空,但该节点具有明显的交互性或属于结构性列表项,
50
+ // 我们从其子树收集纯文本作为其 fallback name,以防 AI 丢失可读上下文。
51
+ // 注意:不要对 <select> / combobox / listbox 等下拉组件进行文本兜底,以防它们吸收所有子选项文本导致极其嘈杂。
52
+ if (!name.trim()) {
53
+ const tag = el.tagName.toLowerCase()
54
+ const isDropdown = tag === 'select' || role === 'combobox' || role === 'listbox'
55
+ if (!isDropdown) {
56
+ const isInteractiveTag = ['button', 'a', 'input', 'textarea', 'li', 'label'].includes(tag)
57
+ if (isTrulyInteractive || isWhitelisted || isVisuallyClickable || isInteractiveTag || role === 'listitem' || role === 'option') {
58
+ name = collectDescendantText(el)
59
+ }
60
+ }
61
+ }
62
+
63
+ // 最终兜底:非交互元素(如错误提示 ti-error-msg、状态信息)的纯文本捕获。
64
+ // 仅当元素没有有价值的子元素时触发,避免与子节点文本重复:
65
+ // - 叶子 span(只有文本节点)→ 捕获文本作为 name
66
+ // - 容器元素(有 element 子节点)→ 不触发,由子节点各自捕获
67
+ if (!name.trim()) {
68
+ const hasElementChildren = getComposedChildren(el).length > 0
69
+ if (!hasElementChildren) {
70
+ const text = collectDescendantText(el)
71
+ if (text) {
72
+ name = text
73
+ }
74
+ }
75
+ }
76
+
77
+ // generic 无 name 时,即使有 cursor=pointer 也不分配 ref:
78
+ // cursor 通常是 CSS 继承传播的,这类 div 本身无法被有意义地操作。
79
+ // 此外,当祖先节点已是可交互节点时,子节点仅凭 isVisuallyClickable(CSS 继承)
80
+ // 不再额外分配 ref,避免父节点 cursor:pointer 传染给所有子孙导致高亮泛滥。
81
+ //
82
+ // 例外:<a>/<button>/<input> 等语义性交互标签,无论祖先是否已有 ref,
83
+ // 始终强制分配 ref,因为它们在 HTML 语义上就是独立的操作单元。
84
+ const tagName = el.tagName.toLowerCase()
85
+ const isSemanticInteractiveTag =
86
+ (tagName === 'a' && el.hasAttribute('href')) ||
87
+ (['button', 'input', 'select', 'textarea'].includes(tagName) && !el.hasAttribute('disabled'))
88
+
89
+ // 虽然有些元素有 tabindex="0" (isTrulyInteractive),但如果它是 generic 且没有 cursor:pointer,
90
+ // 往往是开发者加的结构化 focus 容器(如 tp-card),而非真正的可交互按钮,我们在此过滤掉它们。
91
+ const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isVisuallyClickable)
92
+
93
+ const interactive =
94
+ isMeaningfullyInteractive ||
95
+ isWhitelisted ||
96
+ isLabelFor ||
97
+ isSemanticInteractiveTag ||
98
+ (!ancestorIsInteractive && isVisuallyClickable && (role !== 'generic' || name !== ''))
99
+
100
+ let ref: number | undefined
101
+ if (interactive) {
102
+ ref = refCounter.value
103
+ refMap.set(ref, el as HTMLElement)
104
+ refCounter.value++
105
+ }
106
+
107
+ const children: VNode[] = []
108
+ for (const child of getComposedChildren(el)) {
109
+ const childVNode = buildVNode(
110
+ child,
111
+ refCounter,
112
+ refMap,
113
+ blacklistSet,
114
+ whitelistSet,
115
+ exposedAttributes,
116
+ errorSelectors,
117
+ warningSelectors,
118
+ // 将当前节点的交互性向下传递,子节点据此决定是否抑制 cursor=pointer
119
+ interactive || ancestorIsInteractive,
120
+ )
121
+ if (childVNode) children.push(childVNode)
122
+ }
123
+
124
+ return { role, name, tokens, ref, el: el as HTMLElement, children }
125
+ }
126
+
127
+ /**
128
+ * 判断 VNode 子树是否包含任何有价值的节点(有 ref 或有 accessible name)
129
+ * 用于过滤空容器子树,避免输出无内容的嵌套层级
130
+ */
131
+ export function hasValue(vnode: VNode): boolean {
132
+ if (vnode.ref !== undefined || vnode.name.trim() !== '') return true
133
+ return vnode.children.some(hasValue)
134
+ }
135
+
136
+ /**
137
+ * 检查节点子树中是否存在任何有 ref 的可交互子节点。
138
+ * 用于判断父节点是否可以安全省略其子节点输出。
139
+ */
140
+ function hasInteractiveDescendant(vnode: VNode): boolean {
141
+ return vnode.children.some(c => c.ref !== undefined || hasInteractiveDescendant(c))
142
+ }
143
+
144
+ /**
145
+ * 找到子树中恰好唯一一个有 ref 的节点,若有多个则返回 null。
146
+ * 用于判断是否可将该子节点与父节点合并输出。
147
+ */
148
+ function findSingleRefDescendant(vnode: VNode): VNode | null {
149
+ const refs: VNode[] = []
150
+ const collect = (v: VNode) => {
151
+ if (v.ref !== undefined) refs.push(v)
152
+ if (refs.length > 1) return // 超过一个就提前退出
153
+ v.children.forEach(collect)
154
+ }
155
+ vnode.children.forEach(collect)
156
+ return refs.length === 1 ? refs[0] : null
157
+ }
158
+
159
+ /**
160
+ * 判断节点是否需要透明穿透(跳过本节点但保留子节点)
161
+ *
162
+ * 统一规则:无 ref(非交互)且无 accessible name → 穿透
163
+ * 这样可以同时:
164
+ * - 去掉 generic/list/listitem 等纯布局噪音(无 name 时穿透)
165
+ * - 保留有 name 的 listitem(有 name 时保留,兼顾内容理解场景)
166
+ */
167
+ export function shouldPassThrough(vnode: VNode, opts: Required<A11yTreeOptions>): boolean {
168
+ if (!opts.pruneUnnamed) return false
169
+ // preserveRoles 中的角色强制保留
170
+ if (opts.preserveRoles.includes(vnode.role)) return false
171
+ // 有 ref(交互节点)→ 永远保留
172
+ if (vnode.ref !== undefined) return false
173
+ // 有 accessible name → 保留(兼顾内容理解场景)
174
+ if (vnode.name.trim() !== '') return false
175
+ // 无 ref 且无 name → 透明穿透
176
+ return true
177
+ }
178
+
179
+ /**
180
+ * 将 VNode 序列化为 YAML 行数组
181
+ * 穿透节点时,子节点在当前 depth 平铺输出(不增加缩进层级)
182
+ */
183
+ export function serializeVNode(
184
+ vnode: VNode,
185
+ depth: number,
186
+ opts: Required<A11yTreeOptions>,
187
+ ): string[] {
188
+ if (shouldPassThrough(vnode, opts)) {
189
+ // 透明穿透:跳过本节点,子节点保持当前 depth(层级不增加)
190
+ // 同时过滤掉整棵子树都无价値的空容器,避免输出无意义的嵌套
191
+ return vnode.children
192
+ .filter(c => hasValue(c))
193
+ .flatMap(c => serializeVNode(c, depth, opts))
194
+ }
195
+
196
+ const indent = ' '.repeat(depth)
197
+ const refStr = vnode.ref !== undefined ? ` #${vnode.ref}` : ''
198
+ const safeTokens = vnode.tokens.map(t => t.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"'))
199
+ const tokenStr = safeTokens.length > 0 ? ` [${safeTokens.join(' ')}]` : ''
200
+
201
+ if (vnode.ref !== undefined) {
202
+ if (!hasInteractiveDescendant(vnode)) {
203
+ // 子树无任何 ref 节点 → 直接省略子节点
204
+ // 父节点的 name 已由 computeAccessibleName 汇总了子树文本,信息不会丢失
205
+ const safeName = vnode.name ? vnode.name.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"') : ''
206
+ const nameStr = safeName ? ` "${safeName}"` : ''
207
+ return [`${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`]
208
+ }
209
+
210
+ // 子树中唯一一个 ref 节点且是无子 ref 的 generic 时,将其 name 提升到父节点,省略子节点输出。
211
+ // 场景:`listitem #22 [active]` 内部只有 `generic #23 "总览"`,
212
+ // 点击意义相同时,就展示为一行 `- listitem #22 [active] "总览"`。
213
+ const singleChild = findSingleRefDescendant(vnode)
214
+ if (singleChild && singleChild.role === 'generic' && !hasInteractiveDescendant(singleChild)) {
215
+ const mergedName = (vnode.name.trim() || singleChild.name.trim())
216
+ .replace(/[\r\n]+/g, ' ')
217
+ .replace(/"/g, '\\"')
218
+ const nameStr = mergedName ? ` "${mergedName}"` : ''
219
+ return [`${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`]
220
+ }
221
+ }
222
+
223
+ const safeName = vnode.name ? vnode.name.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"') : ''
224
+ const nameStr = safeName ? ` "${safeName}"` : ''
225
+ const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`
226
+
227
+ const childLines = vnode.children.flatMap(c => serializeVNode(c, depth + 1, opts))
228
+ return [line, ...childLines]
229
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * a11y-tree.ts
3
+ *
4
+ * 此文件为 A11y Tree 模块的汇总出口,
5
+ * 方便外部直接从 './a11y-tree' 引入相关类型及方法,而无需调整现有的引用路径。
6
+ */
7
+
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'
@@ -605,33 +605,53 @@ export function registerPageTool(options: RegisterPageToolByHandlersOptions): ()
605
605
 
606
606
  /**
607
607
  * 建立浏览器原生或 polyfill 与 next-sdk 页面工具桥接的联系。
608
- * 若用户或第三方库直接操作 navigator.modelContext 进行工具注册,
608
+ * 若用户或第三方库直接操作 document.modelContext 进行工具注册,
609
609
  * 我们通过此函数进行拦截劫持,并同步到 next-sdk 的握手线路 (MSG_TOOL_REGISTERED / MSG_TOOL_UNREGISTERED),
610
610
  * 从而保证无论浏览器是否原生支持,均能正常完成 WebMCP 握手交互。
611
611
  */
612
+ // 已注册工具列表(由 setupModelContextBridge 维护)
613
+ // 供 content.ts 通过 window.__nextSdkRegisteredTools() 跨沙箱查询完整工具定义
614
+ const _registeredTools = new Map<string, any>()
615
+
612
616
  export function setupModelContextBridge() {
613
- if (typeof navigator === 'undefined') return
614
- const nav = navigator as any
615
- const nativeCtx = nav.modelContext
617
+ if (typeof document === 'undefined') return
618
+ const doc = document as any
619
+ const nativeCtx = doc.modelContext
616
620
 
617
621
  // 如果不存在或者已经被拦截过,则不处理
618
622
  if (!nativeCtx || nativeCtx.__isNextSdkBridgeSetup) return
619
623
 
624
+ // 挂载全局查询函数,供 content script(隔离沙箱)访问
625
+ // 注意:必须强制覆盖,不检查是否已存在。
626
+ // init-webmcp.js 的 fallback polyfill 会提前注册一个基于内联 Map 的版本,
627
+ // 而该版本无法感知 bridge 拦截后的注册行为,必须用 bridge 版覆盖。
628
+ if (isBrowser()) {
629
+ ;(window as any).__nextSdkRegisteredTools = () => Array.from(_registeredTools.values())
630
+ }
631
+
620
632
  const originalRegisterTool = nativeCtx.registerTool?.bind(nativeCtx)
621
633
  const originalUnregisterTool = nativeCtx.unregisterTool?.bind(nativeCtx)
622
634
 
623
635
  if (typeof originalRegisterTool === 'function') {
624
- nativeCtx.registerTool = (config: any) => {
636
+ nativeCtx.registerTool = (config: any, options?: any) => {
625
637
  const name = config.name
626
638
  const toolConfig = { ...config } // 拷贝一份,避免修改用户原始对象
627
639
 
628
- // 1. 识别路由配置核心件:原生格式下仅从 config.routeConfig 对象识别
640
+ // 1. 监听 AbortSignal 以同步卸载状态
641
+ if (options?.signal) {
642
+ options.signal.addEventListener('abort', () => {
643
+ _registeredTools.delete(name)
644
+ broadcastToolChange(MSG_TOOL_UNREGISTERED)
645
+ })
646
+ }
647
+
648
+ // 2. 识别路由配置核心件:原生格式下仅从 config.routeConfig 对象识别
629
649
  const routeConfig: RouteConfig | null =
630
650
  toolConfig.routeConfig && typeof toolConfig.routeConfig === 'object' && 'route' in toolConfig.routeConfig
631
651
  ? (toolConfig.routeConfig as RouteConfig)
632
652
  : null
633
653
 
634
- // 2. 如果存在路由配置,且当前不在该目标页面,则包装为自动跳转的 handler
654
+ // 3. 如果存在路由配置,且当前不在该目标页面,则包装为自动跳转的 handler
635
655
  if (routeConfig) {
636
656
  const normalizedRoute = normalizeRoute(routeConfig.route)
637
657
  const effectConfig = resolveRuntimeEffectConfig(name, toolConfig.title, routeConfig.invokeEffect)
@@ -643,24 +663,38 @@ export function setupModelContextBridge() {
643
663
  delete toolConfig.routeConfig
644
664
  }
645
665
 
646
- // 3. 执行底层的原生注册逻辑并广播同步
666
+ // 4. 维护工具定义,供 content.ts 握手查询
667
+ _registeredTools.set(name, {
668
+ name,
669
+ title: config.title,
670
+ description: config.description,
671
+ inputSchema: config.inputSchema
672
+ })
673
+
674
+ // 5. 执行底层的原生注册逻辑并广播同步
647
675
  try {
648
- originalRegisterTool(toolConfig)
676
+ originalRegisterTool(toolConfig, options)
649
677
  broadcastToolChange(MSG_TOOL_REGISTERED)
650
678
  } catch (err) {
679
+ // 如果注册失败,撤销记录
680
+ _registeredTools.delete(name)
651
681
  // 忽略重复注册错误
652
682
  }
653
683
  }
654
684
  }
655
685
 
656
- if (typeof originalUnregisterTool === 'function') {
657
- nativeCtx.unregisterTool = (name: string) => {
658
- try {
686
+ // 无论原生是否提供 unregisterTool,都在 bridge 侧保证此方法存在,从而向后兼容老旧的卸载写法
687
+ nativeCtx.unregisterTool = (name: string) => {
688
+ try {
689
+ if (typeof originalUnregisterTool === 'function') {
659
690
  originalUnregisterTool(name)
660
- broadcastToolChange(MSG_TOOL_UNREGISTERED)
661
- } catch (err) {
662
- // 忽略注销错误
663
691
  }
692
+ } catch (err) {
693
+ // 忽略原生注销错误
694
+ } finally {
695
+ // 从工具名集合中移除
696
+ _registeredTools.delete(name)
697
+ broadcastToolChange(MSG_TOOL_UNREGISTERED)
664
698
  }
665
699
  }
666
700