@opentiny/next-sdk 0.4.1 → 0.4.2

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 (64) hide show
  1. package/dist/{SimulatorMask-BHVXyogh-iNUb4dyF.js → SimulatorMask-BHVXyogh-CARX3Rff.js} +1 -1
  2. package/dist/core.js +1 -1
  3. package/dist/{index-CXK8CcKq.js → index-R_HIbfUX.js} +1698 -1170
  4. package/dist/index.d.ts +11 -0
  5. package/dist/index.js +69 -50
  6. package/dist/{initialize-builtin-WebMCP-rALfqS-o.js → initialize-builtin-WebMCP-JaoKwVlm.js} +355 -346
  7. package/dist/page-tools/a11y/build.d.ts +3 -4
  8. package/dist/page-tools/a11y/config.d.ts +96 -0
  9. package/dist/page-tools/a11y/constants.d.ts +2 -0
  10. package/dist/page-tools/a11y/search.d.ts +1 -1
  11. package/dist/page-tools/a11y/types.d.ts +13 -22
  12. package/dist/page-tools/a11y/utils.d.ts +32 -11
  13. package/dist/page-tools/a11y/vnode.d.ts +14 -11
  14. package/dist/page-tools/a11y-tree.d.ts +1 -0
  15. package/dist/page-tools/configs/console-cloud.d.ts +6 -0
  16. package/dist/page-tools/constants.d.ts +5 -17
  17. package/dist/page-tools/context.d.ts +12 -0
  18. package/dist/page-tools/handlers/searchTree.d.ts +2 -1
  19. package/dist/page-tools/page-agent-highlight/index.d.ts +12 -0
  20. package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +2 -0
  21. package/dist/page-tools/page-agent-tool-event.d.ts +26 -0
  22. package/dist/page-tools/schema.d.ts +1 -0
  23. package/dist/page-tools/tool-config.d.ts +50 -0
  24. package/dist/runtime.d.ts +4 -1
  25. package/dist/runtime.js +37 -49
  26. package/dist/vitest.config.d.ts +2 -0
  27. package/index.ts +35 -0
  28. package/package.json +21 -16
  29. package/page-tools/a11y/build.ts +38 -34
  30. package/page-tools/a11y/config.ts +465 -0
  31. package/page-tools/a11y/constants.ts +21 -0
  32. package/page-tools/a11y/search.ts +1 -3
  33. package/page-tools/a11y/types.ts +14 -17
  34. package/page-tools/a11y/utils.ts +129 -156
  35. package/page-tools/a11y/vnode.ts +277 -67
  36. package/page-tools/a11y-tree.ts +1 -0
  37. package/page-tools/bridge.ts +22 -2
  38. package/page-tools/configs/console-cloud.ts +172 -0
  39. package/page-tools/constants.ts +5 -65
  40. package/page-tools/context.ts +38 -1
  41. package/page-tools/handlers/click.ts +6 -2
  42. package/page-tools/handlers/fill.ts +10 -5
  43. package/page-tools/handlers/searchTree.ts +7 -10
  44. package/page-tools/handlers/select.ts +10 -5
  45. package/page-tools/page-agent-highlight/index.ts +66 -19
  46. package/page-tools/page-agent-mask/SimulatorMask.ts +42 -22
  47. package/page-tools/page-agent-mask/hauwei.svg +25 -0
  48. package/page-tools/page-agent-prompt.md +1 -1
  49. package/page-tools/page-agent-tool-event.ts +113 -0
  50. package/page-tools/page-agent-tool.ts +85 -53
  51. package/page-tools/schema.ts +1 -0
  52. package/page-tools/tool-config.ts +100 -0
  53. package/page-tools/utils/dom.ts +6 -3
  54. package/runtime.ts +37 -6
  55. package/test/page-tools/a11y/build.test.ts +638 -0
  56. package/test/page-tools/a11y/config.test.ts +370 -0
  57. package/test/page-tools/configs/console-cloud.test.ts +168 -0
  58. package/test/page-tools/page-agent-highlight.test.ts +110 -0
  59. package/test/page-tools/page-agent-tool-dispatch.test.ts +208 -0
  60. package/test/page-tools/page-agent-tool.test.ts +102 -0
  61. package/test/page-tools/tool-config.test.ts +112 -0
  62. package/test/page-tools/utils/dom.test.ts +122 -0
  63. package/vite.config.runtime.ts +1 -1
  64. package/vitest.config.ts +10 -0
@@ -5,20 +5,52 @@
5
5
  */
6
6
 
7
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'
8
+ import { isTabbable } from 'tabbable'
9
+ import type { VNode, RefMap, A11yTreeShapeOptions } from './types'
10
+ import type { ResolvedA11yConfig } from './config'
11
+ import { resolveA11yInfo } from './config'
12
+ import {
13
+ isHidden,
14
+ isNonContentElement,
15
+ collectDescendantText,
16
+ collectTitleLabel,
17
+ getComposedChildren,
18
+ hasOwnPointerCursor,
19
+ } from './utils'
20
+
21
+ /**
22
+ * 语义上即为可操作控件的 ARIA 角色。
23
+ * jsdom 下部分带 tabindex 的自定义控件 isTabbable 可能为 false,
24
+ * 若不强制分配 ref,Static-Lift 会把它们误判为静态分支并吸收进父 name。
25
+ */
26
+ const INTERACTIVE_ROLES = new Set([
27
+ 'button',
28
+ 'link',
29
+ 'textbox',
30
+ 'searchbox',
31
+ 'combobox',
32
+ 'listbox',
33
+ 'checkbox',
34
+ 'radio',
35
+ 'switch',
36
+ 'slider',
37
+ 'spinbutton',
38
+ 'menuitem',
39
+ 'menuitemcheckbox',
40
+ 'menuitemradio',
41
+ 'tab',
42
+ 'treeitem',
43
+ ])
11
44
 
12
45
  /**
13
46
  * 递归将 DOM 元素转换为 VNode 中间表示
14
47
  * @param el 当前 DOM 元素
15
48
  * @param refCounter 引用计数器(使用对象引用避免全局可变状态)
16
49
  * @param refMap ref 索引 → 元素映射
17
- * @param blacklistSet 用户自定义黑名单
18
- * @param whitelistSet 用户自定义白名单
19
- * @param exposedAttributes 需要暴露的自定义属性
20
- * @param errorSelectors 错误选择器
21
- * @param warningSelectors 警告选择器
50
+ * @param blacklistSet 已解析的黑名单元素集合
51
+ * @param whitelistSet 已解析的白名单元素集合
52
+ * @param config 已与默认值合并的统一无障碍配置
53
+ * @param hoverPointerSet 只在 :hover/:focus/:active 下声明 cursor:pointer 的元素集合(静止态读不到手势,靠样式表扫描兜底)
22
54
  */
23
55
  export function buildVNode(
24
56
  el: Element,
@@ -26,22 +58,26 @@ export function buildVNode(
26
58
  refMap: RefMap,
27
59
  blacklistSet: Set<Element>,
28
60
  whitelistSet: Set<Element>,
29
- exposedAttributes?: string[],
30
- errorSelectors?: string | string[],
31
- warningSelectors?: string | string[],
32
- /** 祖先节点是否已是可交互节点(已分配 ref)。为 true 时,纯 CSS 继承的 cursor=pointer 不再额外分配 ref */
33
- ancestorIsInteractive = false,
61
+ config: ResolvedA11yConfig,
62
+ hoverPointerSet: Set<Element> = new Set(),
34
63
  ): VNode | null {
35
- if (isHidden(el) || blacklistSet.has(el)) return null
64
+ // style/script 等非内容节点:不进树(避免 @font-face base64 等被当成 name)
65
+ if (isNonContentElement(el) || isHidden(el) || blacklistSet.has(el)) return null
66
+
67
+ const { role, tokens } = resolveA11yInfo(el, config)
36
68
 
37
- const role = inferRole(el)
38
- const tokens = getStateTokens(el, exposedAttributes, errorSelectors, warningSelectors)
39
-
40
69
  let name = computeAccessibleName(el as HTMLElement)
41
70
  const isTrulyInteractive = isTabbable(el as HTMLElement)
71
+ // computed cursor 为 pointer(含继承)——仅用于「无文本时是否值得兜底收集名字」等宽松场景
42
72
  const isVisuallyClickable = tokens.includes('cursor=pointer')
43
- // 包含白名单属性的节点也视为白名单节点(确保不被剪枝并分配 ref 操作索引)
44
- const isWhitelisted = whitelistSet.has(el) || (exposedAttributes?.some(attr => el.hasAttribute(attr)) ?? false)
73
+ // 真正的"可点击边界元素"判定(用于是否分配 ref):
74
+ // 1) 元素自身在静止态声明了 cursor:pointer(父级不是 pointer),排除 CSS 继承传染;
75
+ // 2) 或元素命中了 :hover/:focus/:active 下声明 cursor:pointer 的规则(hoverPointerSet)——
76
+ // 大量卡片只在 hover 时才显示手势,静止态 getComputedStyle 读到 auto,需靠样式表扫描兜底。
77
+ const hasClickableCursor = hasOwnPointerCursor(el) || hoverPointerSet.has(el)
78
+ // 白名单:仅由显式 whitelist 配置驱动,与 exposedAttributes(属性 token 输出)解耦,
79
+ // 避免把 data-qa-id 等追踪属性误当成「强制可交互」信号污染无障碍树。
80
+ const isWhitelisted = whitelistSet.has(el)
45
81
  // <label for="..."> 原生可点击:浏览器将点击转发到关联的表单控件(checkbox/radio 等)
46
82
  // Angular/React 自定义组件常隐藏原生 input,仅暴露 label 文本和自定义 skin
47
83
  const isLabelFor = el.tagName.toLowerCase() === 'label' && el.hasAttribute('for')
@@ -54,12 +90,27 @@ export function buildVNode(
54
90
  const isDropdown = tag === 'select' || role === 'combobox' || role === 'listbox'
55
91
  if (!isDropdown) {
56
92
  const isInteractiveTag = ['button', 'a', 'input', 'textarea', 'li', 'label'].includes(tag)
57
- if (isTrulyInteractive || isWhitelisted || isVisuallyClickable || isInteractiveTag || role === 'listitem' || role === 'option') {
58
- name = collectDescendantText(el)
93
+ if (isTrulyInteractive || isWhitelisted || isVisuallyClickable || hasClickableCursor || isInteractiveTag || role === 'listitem' || role === 'option') {
94
+ name = collectDescendantText(el, config)
59
95
  }
60
96
  }
61
97
  }
62
98
 
99
+ // 图标按钮常见:可点击容器本身无文本,title/aria 挂在子节点上(如控制台服务列表按钮)
100
+ if (!name.trim() && (isTrulyInteractive || isWhitelisted || isVisuallyClickable || hasClickableCursor || role === 'button')) {
101
+ name = collectTitleLabel(el)
102
+ }
103
+
104
+ // 结构性交互角色(tab/menuitem 等)的标签文字常落在可聚焦子节点内,
105
+ // collectDescendantText 会因“遇交互子节点即停”而拿不到 name;
106
+ // 此处用「跳过 style/script」的文本收集,禁止 textContent 把内联 CSS/base64 吸进来
107
+ if (
108
+ !name.trim() &&
109
+ ['tab', 'menuitem', 'option', 'treeitem', 'button'].includes(role)
110
+ ) {
111
+ name = collectVisiblePlainText(el)
112
+ }
113
+
63
114
  // 最终兜底:非交互元素(如错误提示 ti-error-msg、状态信息)的纯文本捕获。
64
115
  // 仅当元素没有有价值的子元素时触发,避免与子节点文本重复:
65
116
  // - 叶子 span(只有文本节点)→ 捕获文本作为 name
@@ -67,35 +118,43 @@ export function buildVNode(
67
118
  if (!name.trim()) {
68
119
  const hasElementChildren = getComposedChildren(el).length > 0
69
120
  if (!hasElementChildren) {
70
- const text = collectDescendantText(el)
121
+ const text = collectDescendantText(el, config)
71
122
  if (text) {
72
123
  name = text
73
124
  }
74
125
  }
75
126
  }
76
127
 
77
- // generic name 时,即使有 cursor=pointer 也不分配 ref:
78
- // cursor 通常是 CSS 继承传播的,这类 div 本身无法被有意义地操作。
79
- // 此外,当祖先节点已是可交互节点时,子节点仅凭 isVisuallyClickable(CSS 继承)
80
- // 不再额外分配 ref,避免父节点 cursor:pointer 传染给所有子孙导致高亮泛滥。
128
+ // cursor:pointer 是业界公认的"视觉可点击"通用信号(browser-use DOM 提取器均以此为核心兜底判据)。
129
+ // 但 CSS 的 cursor 会向子孙继承,若直接以 computed cursor 判定,可点击容器(<a>、卡片等)会把
130
+ // ref "传染"给所有子孙,导致高亮泛滥。因此改用 hasClickableCursor(自身声明手势 + hover 态手势)来
131
+ // 定位真正的可点击边界元素:这样既能覆盖 <div class="card-wrapper" (click)=...> 这类无语义、无
132
+ // tabindex 的自定义可点击卡片(包括只在 hover 时显示手势的卡片),又能自然排除仅靠继承拿到 pointer
133
+ // 的内部子孙,无需再依赖祖先传播标记。
81
134
  //
82
- // 例外:<a>/<button>/<input> 等语义性交互标签,无论祖先是否已有 ref
83
- // 始终强制分配 ref,因为它们在 HTML 语义上就是独立的操作单元。
135
+ // 例外:<a>/<button>/<input> 等语义性交互标签始终强制分配 ref,因为它们在 HTML 语义上
136
+ // 就是独立的操作单元,即使外层容器同样可点击也应各自暴露。
84
137
  const tagName = el.tagName.toLowerCase()
85
138
  const isSemanticInteractiveTag =
86
139
  (tagName === 'a' && el.hasAttribute('href')) ||
87
140
  (['button', 'input', 'select', 'textarea'].includes(tagName) && !el.hasAttribute('disabled'))
88
141
 
89
- // 虽然有些元素有 tabindex="0" (isTrulyInteractive),但如果它是 generic 且没有 cursor:pointer,
142
+ // 虽然有些元素有 tabindex="0" (isTrulyInteractive),但如果它是 generic 且没有可点击手势,
90
143
  // 往往是开发者加的结构化 focus 容器(如 tp-card),而非真正的可交互按钮,我们在此过滤掉它们。
91
- const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isVisuallyClickable)
144
+ const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !hasClickableCursor)
145
+
146
+ const isDisabled = tokens.includes('disabled')
92
147
 
93
148
  const interactive =
94
- isMeaningfullyInteractive ||
149
+ (!isDisabled && isMeaningfullyInteractive) ||
95
150
  isWhitelisted ||
96
151
  isLabelFor ||
97
152
  isSemanticInteractiveTag ||
98
- (!ancestorIsInteractive && isVisuallyClickable && (role !== 'generic' || name !== ''))
153
+ (!isDisabled && INTERACTIVE_ROLES.has(role)) ||
154
+ // hasClickableCursor 已排除 CSS 继承(hasOwnPointerCursor)并覆盖 hover 态手势,
155
+ // 无需再要求 role≠generic 或 name≠''——否则无文本的图标按钮(tp-icon.common-icon 等)
156
+ // 虽是真正的可点击边界,仍会因 generic+空名被漏判。
157
+ (!isDisabled && hasClickableCursor)
99
158
 
100
159
  let ref: number | undefined
101
160
  if (interactive) {
@@ -112,16 +171,107 @@ export function buildVNode(
112
171
  refMap,
113
172
  blacklistSet,
114
173
  whitelistSet,
115
- exposedAttributes,
116
- errorSelectors,
117
- warningSelectors,
118
- // 将当前节点的交互性向下传递,子节点据此决定是否抑制 cursor=pointer
119
- interactive || ancestorIsInteractive,
174
+ config,
175
+ hoverPointerSet,
120
176
  )
121
177
  if (childVNode) children.push(childVNode)
122
178
  }
123
179
 
124
- return { role, name, tokens, ref, el: el as HTMLElement, children }
180
+ const vnode: VNode = { role, name, tokens, ref, el: el as HTMLElement, children }
181
+
182
+ // 有可交互子孙时:清空 AccName 内容汇总,仅保留自身声明名。
183
+ // 静态子树文案的上提(Static-Lift)改由 serializeVNode 负责,避免与交互文案混进父 name。
184
+ if (hasInteractiveDescendant(vnode)) {
185
+ vnode.name = getOwnDeclaredName(el as HTMLElement)
186
+ }
187
+
188
+ return vnode
189
+ }
190
+
191
+ /**
192
+ * 仅取元素自身声明的可访问名,不含内容汇总(name from contents)。
193
+ */
194
+ function getOwnDeclaredName(el: HTMLElement): string {
195
+ if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) {
196
+ return computeAccessibleName(el).trim()
197
+ }
198
+ return ''
199
+ }
200
+
201
+ /** 子节点自身无 ref,且子树无可交互子孙 → 纯静态分支(可上提文案) */
202
+ function isStaticBranch(vnode: VNode): boolean {
203
+ return vnode.ref === undefined && !hasInteractiveDescendant(vnode)
204
+ }
205
+
206
+ /**
207
+ * 收集元素可见纯文本,跳过 style/script 等非内容子树。
208
+ * 禁止使用 el.textContent:它会包含内联 @font-face base64,导致 token 爆炸。
209
+ */
210
+ function collectVisiblePlainText(el: Element): string {
211
+ return collectDescendantText(el).replace(/\s+/g, ' ').trim()
212
+ }
213
+
214
+ /**
215
+ * 收集纯静态分支的展示文案(保字用)。
216
+ * 优先 name,其次递归子节点,最后可见纯文本兜底(不含 style/script)。
217
+ */
218
+ function getStaticDisplayText(vnode: VNode): string {
219
+ const childJoined =
220
+ vnode.children.length > 0
221
+ ? vnode.children
222
+ .map(getStaticDisplayText)
223
+ .filter(Boolean)
224
+ .join(' ')
225
+ .replace(/\s+/g, ' ')
226
+ .trim()
227
+ : ''
228
+ const plain = vnode.children.length === 0 ? collectVisiblePlainText(vnode.el) : ''
229
+ const named = vnode.name.trim()
230
+
231
+ const parts: string[] = []
232
+ if (named && !isNoiseAccessibleName(named)) parts.push(named)
233
+ if (childJoined && !isNoiseAccessibleName(childJoined)) {
234
+ const normNamed = named.replace(/\s+/g, ' ').trim()
235
+ const normChild = childJoined.replace(/\s+/g, ' ').trim()
236
+ // 声明名与子孙文案不同则合并保留,避免仅有 aria-label 时丢掉静态说明
237
+ if (!normNamed || (normChild !== normNamed && !normNamed.includes(normChild))) {
238
+ parts.push(childJoined)
239
+ }
240
+ }
241
+ if (parts.length === 0 && plain && !isNoiseAccessibleName(plain)) parts.push(plain)
242
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
243
+ }
244
+
245
+ /**
246
+ * 名称若像 CSS/字体资源噪声(常见于误吸收的 <style> 内容),视为无效,避免输出到 YAML。
247
+ */
248
+ function isNoiseAccessibleName(name: string): boolean {
249
+ const n = name.trim()
250
+ if (!n) return false
251
+ if (/@font-face\b/i.test(n)) return true
252
+ if (/data:(?:font|application|image)\//i.test(n)) return true
253
+ if (/base64,[A-Za-z0-9+/=]{80,}/.test(n)) return true
254
+ return false
255
+ }
256
+
257
+ /**
258
+ * 静态子节点可否省略:仅当其文案已由父 outputName 完整承载(去重且不丢字)。
259
+ * - 父使用自身声明名:仅当子文案与声明名完全相同才可省略
260
+ * - 父使用上提拼接名:子文案作为片段已包含在父名中才可省略
261
+ */
262
+ function canOmitStaticChild(
263
+ child: VNode,
264
+ parentOutputName: string,
265
+ parentUsesOwnDeclaredName: boolean,
266
+ ): boolean {
267
+ const text = getStaticDisplayText(child)
268
+ if (!text || !parentOutputName.trim()) return false
269
+ const parentNorm = parentOutputName.replace(/\s+/g, ' ').trim()
270
+ const textNorm = text.replace(/\s+/g, ' ').trim()
271
+ if (parentUsesOwnDeclaredName) {
272
+ return textNorm === parentNorm
273
+ }
274
+ return parentNorm.includes(textNorm)
125
275
  }
126
276
 
127
277
  /**
@@ -164,7 +314,7 @@ function findSingleRefDescendant(vnode: VNode): VNode | null {
164
314
  * - 去掉 generic/list/listitem 等纯布局噪音(无 name 时穿透)
165
315
  * - 保留有 name 的 listitem(有 name 时保留,兼顾内容理解场景)
166
316
  */
167
- export function shouldPassThrough(vnode: VNode, opts: Required<A11yTreeOptions>): boolean {
317
+ export function shouldPassThrough(vnode: VNode, opts: A11yTreeShapeOptions): boolean {
168
318
  if (!opts.pruneUnnamed) return false
169
319
  // preserveRoles 中的角色强制保留
170
320
  if (opts.preserveRoles.includes(vnode.role)) return false
@@ -176,54 +326,114 @@ export function shouldPassThrough(vnode: VNode, opts: Required<A11yTreeOptions>)
176
326
  return true
177
327
  }
178
328
 
329
+ /**
330
+ * 将节点 name 转义为 YAML 引号内文本;空名返回空串(调用方决定是否拼引号)。
331
+ */
332
+ function formatNameAttr(name: string): string {
333
+ const safe = name.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"').trim()
334
+ return safe ? ` "${safe}"` : ''
335
+ }
336
+
179
337
  /**
180
338
  * 将 VNode 序列化为 YAML 行数组
181
339
  * 穿透节点时,子节点在当前 depth 平铺输出(不增加缩进层级)
340
+ *
341
+ * 文本折叠规则(Static-Lift + Interactive-Keep,保字优先):
342
+ * 1. 子树全静态 → 省略子节点,文字保留在父节点
343
+ * 2. 混合子树 → 静态文案上提到父 name(成功才省略静态子节点);交互分支递归保留;
344
+ * 禁止把交互文案并入父 name;上提失败则静态子节点独立输出,绝不丢字
182
345
  */
183
346
  export function serializeVNode(
184
347
  vnode: VNode,
185
348
  depth: number,
186
- opts: Required<A11yTreeOptions>,
349
+ opts: A11yTreeShapeOptions,
187
350
  ): 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
-
351
+ const hasInteractive = hasInteractiveDescendant(vnode)
352
+ const forceKeepStructure = opts.preserveRoles.includes(vnode.role)
196
353
  const indent = ' '.repeat(depth)
197
354
  const refStr = vnode.ref !== undefined ? ` #${vnode.ref}` : ''
198
355
  const safeTokens = vnode.tokens.map(t => t.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"'))
199
356
  const tokenStr = safeTokens.length > 0 ? ` [${safeTokens.join(' ')}]` : ''
200
357
 
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}`]
358
+ // 规则 1:子树无可交互节点,且无需强制保留结构 折叠为一行(文字不丢)
359
+ if (!hasInteractive && !forceKeepStructure) {
360
+ if (shouldPassThrough(vnode, opts)) {
361
+ return vnode.children
362
+ .filter(c => hasValue(c))
363
+ .flatMap(c => serializeVNode(c, depth, opts))
208
364
  }
365
+ let displayName = vnode.name.trim()
366
+ if (!displayName && vnode.children.length > 0) {
367
+ displayName = collectVisiblePlainText(vnode.el)
368
+ }
369
+ if (isNoiseAccessibleName(displayName)) displayName = ''
370
+ return [`${indent}- ${vnode.role}${refStr}${tokenStr}${formatNameAttr(displayName)}`]
371
+ }
372
+
373
+ // preserveRoles 且无交互:保留结构,父级去掉 content name,子节点照常输出
374
+ if (!hasInteractive && forceKeepStructure) {
375
+ const outputName = getOwnDeclaredName(vnode.el)
376
+ const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${formatNameAttr(outputName)}`
377
+ const childLines = vnode.children.flatMap(c => serializeVNode(c, depth + 1, opts))
378
+ return [line, ...childLines]
379
+ }
380
+
381
+ // ── 混合子树:Static-Lift + Interactive-Keep ──
382
+ const ownDeclaredName = getOwnDeclaredName(vnode.el)
383
+ const parentUsesOwnDeclaredName = ownDeclaredName !== ''
384
+ const staticChildren = vnode.children.filter(isStaticBranch)
385
+ const liftedStaticName = staticChildren
386
+ .map(getStaticDisplayText)
387
+ .filter(Boolean)
388
+ .join(' ')
389
+ .replace(/\s+/g, ' ')
390
+ .trim()
391
+
392
+ // 父 name:声明名优先;否则上提静态子树文案(绝不吸收交互文案)
393
+ let outputName = parentUsesOwnDeclaredName ? ownDeclaredName : liftedStaticName
394
+ if (isNoiseAccessibleName(outputName)) outputName = ''
395
+ // vnode.name(声明名)若被污染同样丢弃
396
+ if (!parentUsesOwnDeclaredName && isNoiseAccessibleName(vnode.name)) {
397
+ outputName = liftedStaticName && !isNoiseAccessibleName(liftedStaticName) ? liftedStaticName : ''
398
+ }
399
+
400
+ const willEmitParent =
401
+ vnode.ref !== undefined ||
402
+ outputName !== '' ||
403
+ forceKeepStructure ||
404
+ !opts.pruneUnnamed
405
+
406
+ // 父节点不会输出时必须穿透,且不得省略静态子节点(保字)
407
+ if (!willEmitParent) {
408
+ return vnode.children
409
+ .filter(c => hasValue(c))
410
+ .flatMap(c => serializeVNode(c, depth, opts))
411
+ }
209
412
 
413
+ if (vnode.ref !== undefined) {
210
414
  // 子树中唯一一个 ref 节点且是无子 ref 的 generic 时,将其 name 提升到父节点,省略子节点输出。
211
- // 场景:`listitem #22 [active]` 内部只有 `generic #23 "总览"`,
212
- // 点击意义相同时,就展示为一行 `- listitem #22 [active] "总览"`。
213
415
  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}`]
416
+ if (
417
+ singleChild &&
418
+ singleChild.role === 'generic' &&
419
+ !hasInteractiveDescendant(singleChild) &&
420
+ staticChildren.length === 0
421
+ ) {
422
+ const mergedName = outputName.trim() || singleChild.name.trim()
423
+ // 合并输出时使用子节点 ref,避免 refMap 中的 #N 在 YAML 里丢失
424
+ const mergedRefStr =
425
+ singleChild.ref !== undefined ? ` #${singleChild.ref}` : refStr
426
+ return [`${indent}- ${vnode.role}${mergedRefStr}${tokenStr}${formatNameAttr(mergedName)}`]
220
427
  }
221
428
  }
222
429
 
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}`
430
+ // 仅省略「文案已由父 outputName 承载」的静态子节点;其余(含全部交互分支)按原序输出
431
+ const childrenToEmit = vnode.children.filter(c => {
432
+ if (!isStaticBranch(c)) return true
433
+ return !canOmitStaticChild(c, outputName, parentUsesOwnDeclaredName)
434
+ })
226
435
 
227
- const childLines = vnode.children.flatMap(c => serializeVNode(c, depth + 1, opts))
436
+ const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${formatNameAttr(outputName)}`
437
+ const childLines = childrenToEmit.flatMap(c => serializeVNode(c, depth + 1, opts))
228
438
  return [line, ...childLines]
229
439
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  export * from './a11y/types'
9
9
  export * from './a11y/constants'
10
+ export * from './a11y/config'
10
11
  export * from './a11y/utils'
11
12
  export * from './a11y/vnode'
12
13
  export * from './a11y/build'
@@ -613,14 +613,34 @@ export function registerPageTool(options: RegisterPageToolByHandlersOptions): ()
613
613
  // 供 content.ts 通过 window.__nextSdkRegisteredTools() 跨沙箱查询完整工具定义
614
614
  const _registeredTools = new Map<string, any>()
615
615
 
616
+ /** 浏览器全局对象上可能挂载的 modelContext 接口 */
617
+ interface ModelContextHost {
618
+ modelContext?: BridgeableModelContext
619
+ }
620
+
621
+ /** 可被 bridge 拦截的原生 modelContext 接口 */
622
+ interface BridgeableModelContext {
623
+ registerTool?(config: unknown, options?: unknown): unknown
624
+ unregisterTool?(name: string): unknown
625
+ __isNextSdkBridgeSetup?: boolean
626
+ }
627
+
616
628
  export function setupModelContextBridge() {
617
629
  if (typeof document === 'undefined') return
618
- const doc = document as any
619
- const nativeCtx = doc.modelContext
630
+ const doc = document as unknown as ModelContextHost
631
+ const nav = typeof navigator !== 'undefined' ? (navigator as unknown as ModelContextHost) : undefined
632
+
633
+ // 兼容 document.modelContext 与 navigator.modelContext(不同浏览器/polyfill 挂载位置不同)
634
+ const nativeCtx = doc.modelContext || nav?.modelContext
620
635
 
621
636
  // 如果不存在或者已经被拦截过,则不处理
622
637
  if (!nativeCtx || nativeCtx.__isNextSdkBridgeSetup) return
623
638
 
639
+ // 若 modelContext 仅存在于 navigator 上,同步挂载到 document 以保持向后兼容
640
+ if (!doc.modelContext && nav?.modelContext) {
641
+ doc.modelContext = nav.modelContext
642
+ }
643
+
624
644
  // 挂载全局查询函数,供 content script(隔离沙箱)访问
625
645
  // 注意:必须强制覆盖,不检查是否已存在。
626
646
  // init-webmcp.js 的 fallback polyfill 会提前注册一个基于内联 Map 的版本,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * 云控制台(consoleCloud)专用 PageAgentToolOptions。
3
+ *
4
+ * 控制台基于 Tiny3(ti3-*)+ Angular,大量自定义组件缺少 role / aria-*:
5
+ * - Tab:`.ti3-tab-li` 无 role=tab,选中靠 `.ti3-tab-active`
6
+ * - 下拉:`.ti3-select-dominator-container` / `.selected-label` 无 combobox 语义
7
+ * - 图标按钮:仅有 `cf-uba` / 子节点 `title`,无 aria-label
8
+ * - 服务列表侧栏、区域选择等用 class 表达选中态
9
+ *
10
+ * 书写约定:优先 selector;仅当需要计算样式等复杂判断时再用 match。
11
+ * (roles 的 selector 为自身 matches;states 的 selector 为 closest)
12
+ */
13
+
14
+ import { defineA11yConfig } from '../a11y/config'
15
+ import type { PageAgentToolOptions } from '../tool-config'
16
+
17
+ /** 云控制台 page-agent-tool 预设配置 */
18
+ export const consoleCloudPageAgentToolOptions: PageAgentToolOptions = {
19
+ enableHighlight: false,
20
+ a11yConfig: defineA11yConfig({
21
+ roles: [
22
+ // Tiny3 Tabs:真正可聚焦/可点击的是内部 .ti3-tabs-text(tabindex=0)
23
+ { role: 'tablist', selector: 'ul.ti3-tabs' },
24
+ { role: 'tab', selector: '.ti3-tabs-text' },
25
+ { role: 'tabpanel', selector: ['ti-tab.ti3-tab-pane', '.ti3-tab-pane'] },
26
+
27
+ // 自定义下拉 / 区域选择触发器
28
+ {
29
+ role: 'combobox',
30
+ selector: [
31
+ '.ti3-select-dominator-container',
32
+ '.selected-label',
33
+ '.sort-select',
34
+ '.service-select',
35
+ '.region-select',
36
+ '[cf-uba="searchableRegion..open"]'
37
+ ]
38
+ },
39
+
40
+ // 图标型 / 无语义按钮
41
+ {
42
+ role: 'button',
43
+ selector: [
44
+ '.modules-service-list-menu-service-icon-container',
45
+ '.ti3-btn-only-icon-noborder',
46
+ '.ti3-select-dominator-dropdown-btn',
47
+ '.modules-right-sidebar-icon-item-wrapper',
48
+ '.modules-right-sidebar-hide-state-plugin',
49
+ '.components-service-list-container-service-list-input-service-close',
50
+ '.modules-searchable-region-region-panel-close',
51
+ '.modules-searchable-region-region-panel-search-btn',
52
+ '[cf-uba="rightSidebar..hideBtn"]',
53
+ // Tiny3 图标组件:帮助中心固定/全屏/关闭等(ti-icon / tp-icon 无原生 button 语义)
54
+ '.ti-global-help-panel-header-icon',
55
+ 'ti-icon[name]',
56
+ 'tp-icon[name]',
57
+ // 无 name 的箭头/操作图标(如 latest news 区 common-icon 展开箭头)
58
+ 'tp-icon.common-icon',
59
+ 'ti-icon.common-icon',
60
+ 'ti-icon.ti3-icon-full-screen',
61
+ 'ti-icon.ti3-icon-close',
62
+ 'tp-icon.ti3-icon-full-screen',
63
+ 'tp-icon.ti3-icon-close'
64
+ ]
65
+ },
66
+
67
+ // 服务列表侧栏分类 / 顶栏菜单项
68
+ {
69
+ role: 'menuitem',
70
+ selector: [
71
+ '.components-service-list-left-box-sidebar-visit-panel > li',
72
+ '.modules-menus-menu-item',
73
+ '.components-menu-wrapper-menu-text'
74
+ ]
75
+ },
76
+
77
+ // 区域筛选项 / 下拉选项
78
+ {
79
+ role: 'option',
80
+ selector: [
81
+ '.region-selector-item',
82
+ '.modules-searchable-region-project-item-region-item',
83
+ '.modules-searchable-region-region-panel-recent-region-item',
84
+ '.ti3-multiselect-box-cell'
85
+ ]
86
+ }
87
+ ],
88
+ states: {
89
+ // 选中态:挂在容器 class 上,用 selector + closest 即可命中内部交互节点
90
+ selected: [
91
+ { selector: 'li.ti3-tab-li.ti3-tab-active' },
92
+ // Tiny3 按钮组(计费模式等):ti3-active 在 .ti3-btn-item-container 上,不在 button 上
93
+ { selector: '.ti3-btn-item-container.ti3-active' },
94
+ { selector: '.components-service-list-left-box-active' },
95
+ {
96
+ selector: [
97
+ '.modules-searchable-region-project-item-region-item-selected',
98
+ '.components-search-content-search-select-search-select-selected'
99
+ ]
100
+ }
101
+ ],
102
+ hasPopup: [
103
+ {
104
+ selector: [
105
+ '.ti3-select-dominator-container',
106
+ '.selected-label',
107
+ '.sort-select',
108
+ '.service-select',
109
+ '.region-select',
110
+ '[cf-uba="searchableRegion..open"]',
111
+ '[cf-uba="serviceList..open"]',
112
+ '[cf-uba="userinfo"]'
113
+ ]
114
+ }
115
+ ],
116
+ disabled: [{ selector: '.ti3-disabled' }],
117
+ // 展开态:需结合计算样式,选择器表达不了「可见」
118
+ expanded: [
119
+ {
120
+ match: (el) => {
121
+ try {
122
+ if (
123
+ !el.matches(
124
+ '.modules-layout-module-sidebar-panel, .modules-searchable-region-region-panel, .ti3-dropdown-container'
125
+ )
126
+ ) {
127
+ return false
128
+ }
129
+ const style = window.getComputedStyle(el as HTMLElement)
130
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+ }
136
+ ]
137
+ },
138
+ // exposedAttributes 输出属性 token;带 name 的 ti-icon/tp-icon 可交互性由 roles/whitelist 中 ti-icon[name] 等规则判定。
139
+ exposedAttributes: ['cf-uba', 'data-qa-id', 'name'],
140
+ whitelist: [
141
+ '.modules-service-list-menu-service-icon-container',
142
+ '.ti3-tabs-text',
143
+ '[cf-uba="cloudShell"]',
144
+ '[cf-uba="messageBox"]',
145
+ '[cf-uba="helpFeedback"]',
146
+ '[cf-uba="globalMessage"]',
147
+ '[cf-uba="rightSidebar..hideBtn"]',
148
+ // 帮助中心等区域的 ti-icon / tp-icon 图标按钮
149
+ '.ti-global-help-panel-header-icon',
150
+ 'ti-icon[name]',
151
+ 'tp-icon[name]',
152
+ 'tp-icon.common-icon',
153
+ 'ti-icon.common-icon',
154
+ 'ti-icon.ti3-icon-full-screen'
155
+ ],
156
+ blacklist: ['noscript', 'pan-gu', '#J_header'],
157
+ dialogSelectors: [
158
+ '[class*="ti3-modal"]',
159
+ '[class*="ti3-message-box"]',
160
+ '[class*="drawer"]',
161
+ '.modules-layout-module-sidebar-panel'
162
+ ]
163
+ })
164
+ }
165
+
166
+ /** 判断当前页面是否应使用云控制台(consoleCloud)预设 */
167
+ export function isConsoleCloudHost(
168
+ hostname: string = typeof location !== 'undefined' ? location.hostname : ''
169
+ ): boolean {
170
+ // 要求 console 前为域名边界(开头或 `.`),避免误匹配 xconsole.huaweicloud.com
171
+ return /(^|\.)console\.huaweicloud\.com$/i.test(hostname)
172
+ }