diff --git a/electron/electron.js b/electron/electron.js index 9af28de75..058ab71a7 100644 --- a/electron/electron.js +++ b/electron/electron.js @@ -43,6 +43,7 @@ const { startMCPServer, stopMCPServer } = require("./lib/mcp"); const {onRenderer, renderer} = require("./lib/renderer"); const {onExport} = require("./lib/pdf-export"); const {allowedCalls, isWin} = require("./lib/other"); +const {registerPageInput} = require("./lib/page-input"); const webTabManager = require("./lib/web-tab-manager"); const faviconCache = require("./lib/favicon-cache"); @@ -624,6 +625,9 @@ app.on("will-quit", () => { globalShortcut.unregisterAll(); }) +// AI 助手页面操作 · CDP 可信输入(渲染端经 sendAsync('pageInput', ...) 调用) +registerPageInput(); + /** * 设置菜单语言包 * @param args {path} diff --git a/electron/lib/page-input.js b/electron/lib/page-input.js new file mode 100644 index 000000000..dacbfe51c --- /dev/null +++ b/electron/lib/page-input.js @@ -0,0 +1,57 @@ +// AI 助手页面操作 · CDP 可信输入(主进程) +// +// 渲染端经 window.electron.sendAsync('pageInput', {op, ...}) 调用(preload 已有的 +// invoke 通道);这里用 webContents.debugger 走 CDP Input 域,产生 isTrusted=true 的 +// 可信输入,等价 Playwright 真正可靠的那一半,但第一方、在用户会话内,且同源 iframe 可达。 +// +// 目标 webContents = event.sender(发起的渲染进程;同源 iframe 在其内,视口坐标可达)。 +// 任一步骤失败抛错 → 渲染端 electron-backend 捕获后回退 Web 后端。 + +const { ipcMain } = require('electron') + +const attached = new WeakSet() + +function ensureAttached(wc) { + if (attached.has(wc)) return + if (!wc.debugger.isAttached()) { + wc.debugger.attach('1.3') // 抛错则上抛,渲染端回退 + } + attached.add(wc) + wc.once('destroyed', () => attached.delete(wc)) +} + +async function dispatchClick(wc, x, y) { + const base = { x, y, button: 'left', buttons: 1, clickCount: 1 } + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }) + await wc.debugger.sendCommand('Input.dispatchMouseEvent', Object.assign({ type: 'mousePressed' }, base)) + await wc.debugger.sendCommand('Input.dispatchMouseEvent', Object.assign({ type: 'mouseReleased' }, base)) +} + +async function handle(event, args) { + const wc = event.sender + const op = args && args.op + ensureAttached(wc) + switch (op) { + case 'insertText': + // 覆盖当前选区(渲染端已 selectAll) + await wc.debugger.sendCommand('Input.insertText', { text: String(args.text == null ? '' : args.text) }) + return { ok: true } + case 'click': + await dispatchClick(wc, Math.round(args.x), Math.round(args.y)) + return { ok: true } + case 'key': { + const key = String(args.key || '') + await wc.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', key }) + await wc.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', key }) + return { ok: true } + } + default: + throw new Error('unknown_op: ' + op) + } +} + +function registerPageInput() { + ipcMain.handle('pageInput', handle) +} + +module.exports = { registerPageInput } diff --git a/resources/assets/js/components/AIAssistant/action-executor.js b/resources/assets/js/components/AIAssistant/action-executor.js index 37db9e2f7..db4833937 100644 --- a/resources/assets/js/components/AIAssistant/action-executor.js +++ b/resources/assets/js/components/AIAssistant/action-executor.js @@ -9,8 +9,8 @@ * 本模块只负责前端导航和 UI 操作。 */ -import { findElementByRef } from './page-context-collector'; import { resolveActiveContext } from './active-context'; +import { selectBackend } from './input-backends'; /** * 创建操作执行器 @@ -219,8 +219,9 @@ class ActionExecutor { /** * 设置当前的 refMap 与活动上下文(由 operation-module 在获取上下文后调用) */ - setRefMap(refMap, context = null) { - this.currentRefMap = refMap; + setRefMap(refElements, context = null) { + // refElements: Map(描述层产出,直接持有元素);容错旧的 plain object + this.currentRefElements = refElements instanceof Map ? refElements : null; this.currentContext = context; } @@ -254,65 +255,17 @@ class ActionExecutor { const doc = this.resolveContextDoc(); const element = this.findElement(elementUid, doc); if (!element) { - throw new Error(`找不到元素: ${elementUid}`); + throw new Error(`element_not_found: 找不到元素 ${elementUid}(可能页面已变更,请重新获取页面上下文)`); } - // 元素所在 window(主文档或微应用 iframe),事件需用它的构造器才被框架信任 + // 元素所在 window(主文档或微应用 iframe),合成事件需用它的构造器才被框架信任 const win = element.ownerDocument.defaultView || window; - switch (action) { - case 'click': - element.click(); - return { success: true, action: 'click', element: elementUid }; + // 选择输入后端:Electron CDP 可信输入优先,否则页面内合成事件(地板) + if (!this.backend) this.backend = selectBackend(); - case 'type': - if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.contentEditable === 'true') { - element.focus(); - if (element.contentEditable === 'true') { - element.textContent = value || ''; - } else { - element.value = value || ''; - } - element.dispatchEvent(new win.Event('input', { bubbles: true })); - element.dispatchEvent(new win.Event('change', { bubbles: true })); - return { success: true, action: 'type', value, element: elementUid }; - } - throw new Error('元素不支持输入操作'); - - case 'select': - if (element.tagName === 'SELECT') { - element.value = value; - element.dispatchEvent(new win.Event('change', { bubbles: true })); - return { success: true, action: 'select', value, element: elementUid }; - } - // iView Select 组件 - 先点击打开下拉 - element.click(); - await this.delay(200); - const options = element.ownerDocument.querySelectorAll('.ivu-select-dropdown-list .ivu-select-item'); - for (const option of options) { - if (option.textContent.trim().includes(value)) { - option.click(); - return { success: true, action: 'select', value, element: elementUid }; - } - } - throw new Error(`找不到选项: ${value}`); - - case 'focus': - element.focus(); - return { success: true, action: 'focus', element: elementUid }; - - case 'scroll': - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - return { success: true, action: 'scroll', element: elementUid }; - - case 'hover': - element.dispatchEvent(new win.MouseEvent('mouseenter', { bubbles: true })); - element.dispatchEvent(new win.MouseEvent('mouseover', { bubbles: true })); - return { success: true, action: 'hover', element: elementUid }; - - default: - throw new Error(`不支持的元素操作: ${action}`); - } + const result = await this.backend.perform(element, action, value, { doc, win, elementUid }); + return Object.assign({ element: elementUid }, result); } /** @@ -329,13 +282,14 @@ class ActionExecutor { ref = identifier; } - // 如果是 ref 格式,使用 refMap 查找 - if (ref && this.currentRefMap) { - const element = findElementByRef(ref, this.currentRefMap, doc); - if (element) return element; + // ref 格式:从描述层产出的 ref→Element 实时 Map 直接取(最准) + if (ref && this.currentRefElements) { + const element = this.currentRefElements.get(ref); + if (element && element.isConnected) return element; + if (element && !element.isConnected) return null; // 失联(DOM 已变更) } - // 尝试作为 CSS 选择器 + // 尝试作为 CSS 选择器(兜底) try { const element = doc.querySelector(identifier); if (element) return element; diff --git a/resources/assets/js/components/AIAssistant/aria/NOTICE.txt b/resources/assets/js/components/AIAssistant/aria/NOTICE.txt new file mode 100644 index 000000000..74c49640c --- /dev/null +++ b/resources/assets/js/components/AIAssistant/aria/NOTICE.txt @@ -0,0 +1,19 @@ +aria-bundle.js is a vendored, esbuild-bundled subset of Playwright's injected +ariaSnapshot code. + + Playwright + Copyright (c) Microsoft Corporation + Licensed under the Apache License, Version 2.0 + https://github.com/microsoft/playwright/blob/main/LICENSE + +Included source (from packages/injected/src and packages/isomorphic): + ariaSnapshot.ts, roleUtils.ts, domUtils.ts, and @isomorphic helpers + (stringUtils, yaml, cssTokenizer, ariaSnapshot). + +Regenerate: + 1. git clone github.com/microsoft/playwright (pin the commit in aria-bundle.js banner) + 2. esbuild esm-entry.ts --bundle --format=esm --platform=browser \ + --tsconfig=spike-tsconfig.json --outfile=aria-bundle.js + esm-entry.ts and spike-tsconfig.json in this folder are the build inputs. + +This file is provided to comply with the Apache-2.0 NOTICE/attribution requirement. diff --git a/resources/assets/js/components/AIAssistant/aria/aria-bundle.js b/resources/assets/js/components/AIAssistant/aria/aria-bundle.js new file mode 100644 index 000000000..9b5d6dcea --- /dev/null +++ b/resources/assets/js/components/AIAssistant/aria/aria-bundle.js @@ -0,0 +1,2563 @@ +/* + * Vendored subset of Playwright injected ariaSnapshot (Apache-2.0). + * Source: github.com/microsoft/playwright @ aa3c7b5 (packages/injected/src/{ariaSnapshot,roleUtils,domUtils}.ts + packages/isomorphic/*). + * Built via esbuild from esm-entry.ts. Do not edit by hand; regenerate to update. + * See dootask/docs/ai-page-operation-unified-executor.md. + */ + +// packages/isomorphic/ariaSnapshot.ts +function ariaNodesEqual(a, b) { + if (a.role !== b.role || a.name !== b.name) + return false; + if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) + return false; + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every((k) => a.props[k] === b.props[k]); +} +function hasPointerCursor(ariaNode) { + return ariaNode.box.cursor === "pointer"; +} +function ariaPropsEqual(a, b) { + return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.invalid === b.invalid && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; +} + +// packages/isomorphic/stringUtils.ts +var normalizedWhitespaceCache; +function normalizeWhiteSpace(text) { + let result = normalizedWhitespaceCache?.get(text); + if (result === void 0) { + result = text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " "); + normalizedWhitespaceCache?.set(text, result); + } + return result; +} +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function longestCommonSubstring(s1, s2) { + const n = s1.length; + const m = s2.length; + let maxLen = 0; + let endingIndex = 0; + const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s1[i - 1] === s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + if (dp[i][j] > maxLen) { + maxLen = dp[i][j]; + endingIndex = i; + } + } + } + } + return s1.slice(endingIndex - maxLen, endingIndex); +} +var ansiRegex = new RegExp("([\\u001B\\u009B][[\\]()#?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{0,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", "g"); + +// packages/isomorphic/yaml.ts +function yamlEscapeKeyIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) + return str; + return `'` + str.replace(/'/g, `''`) + `'`; +} +function yamlEscapeValueIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) + return str; + return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, (c) => { + switch (c) { + case "\\": + return "\\\\"; + case '"': + return '\\"'; + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case " ": + return "\\t"; + default: + const code = c.charCodeAt(0); + return "\\x" + code.toString(16).padStart(2, "0"); + } + }) + '"'; +} +function yamlStringNeedsQuotes(str) { + if (str.length === 0) + return true; + if (/^\s|\s$/.test(str)) + return true; + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str)) + return true; + if (/^-/.test(str)) + return true; + if (/[\n:](\s|$)/.test(str)) + return true; + if (/\s#/.test(str)) + return true; + if (/[\n\r]/.test(str)) + return true; + if (/^[&*\],?!>|@"'#%]/.test(str)) + return true; + if (/[{}`]/.test(str)) + return true; + if (/^\[/.test(str)) + return true; + if (!isNaN(Number(str)) || ["y", "n", "yes", "no", "true", "false", "on", "off", "null"].includes(str.toLowerCase())) + return true; + return false; +} + +// packages/injected/src/domUtils.ts +var globalOptions = {}; +function parentElementOrShadowHost(element) { + if (element.parentElement) + return element.parentElement; + if (!element.parentNode) + return; + if (element.parentNode.nodeType === 11 && element.parentNode.host) + return element.parentNode.host; +} +function enclosingShadowRootOrDocument(element) { + let node = element; + while (node.parentNode) + node = node.parentNode; + if (node.nodeType === 11 || node.nodeType === 9) + return node; +} +function enclosingShadowHost(element) { + while (element.parentElement) + element = element.parentElement; + return parentElementOrShadowHost(element); +} +function closestCrossShadow(element, css, scope) { + while (element) { + const closest = element.closest(css); + if (scope && closest !== scope && closest?.contains(scope)) + return; + if (closest) + return closest; + element = enclosingShadowHost(element); + } +} +function getElementComputedStyle(element, pseudo) { + const cache = pseudo === "::before" ? cacheStyleBefore : pseudo === "::after" ? cacheStyleAfter : cacheStyle; + if (cache && cache.has(element)) + return cache.get(element); + const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : void 0; + cache?.set(element, style); + return style; +} +function isElementStyleVisibilityVisible(element, style) { + style = style ?? getElementComputedStyle(element); + if (!style) + return true; + if (Element.prototype.checkVisibility && globalOptions.browserNameForWorkarounds !== "webkit") { + if (!element.checkVisibility()) + return false; + } else { + const detailsOrSummary = element.closest("details,summary"); + if (detailsOrSummary !== element && detailsOrSummary?.nodeName === "DETAILS" && !detailsOrSummary.open) + return false; + } + if (style.visibility !== "visible") + return false; + return true; +} +function computeBox(element) { + const style = getElementComputedStyle(element); + if (!style) + return { visible: true, inline: false }; + const cursor = style.cursor; + if (style.display === "contents") { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && isElementVisible(child)) + return { visible: true, inline: false, cursor }; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return { visible: true, inline: true, cursor }; + } + return { visible: false, inline: false, cursor }; + } + if (!isElementStyleVisibilityVisible(element, style)) + return { cursor, visible: false, inline: false }; + const rect = element.getBoundingClientRect(); + return { cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === "inline" }; +} +function isElementVisible(element) { + return computeBox(element).visible; +} +function isVisibleTextNode(node) { + const range = node.ownerDocument.createRange(); + range.selectNode(node); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} +function elementSafeTagName(element) { + const tagName = element.tagName; + if (typeof tagName === "string") + return tagName.toUpperCase(); + if (element instanceof HTMLFormElement) + return "FORM"; + return element.tagName.toUpperCase(); +} +var cacheStyle; +var cacheStyleBefore; +var cacheStyleAfter; +var cachesCounter = 0; +function beginDOMCaches() { + ++cachesCounter; + cacheStyle ??= /* @__PURE__ */ new Map(); + cacheStyleBefore ??= /* @__PURE__ */ new Map(); + cacheStyleAfter ??= /* @__PURE__ */ new Map(); +} +function endDOMCaches() { + if (!--cachesCounter) { + cacheStyle = void 0; + cacheStyleBefore = void 0; + cacheStyleAfter = void 0; + } +} + +// packages/isomorphic/cssTokenizer.ts +var between = function(num, first, last) { + return num >= first && num <= last; +}; +function digit(code) { + return between(code, 48, 57); +} +function hexdigit(code) { + return digit(code) || between(code, 65, 70) || between(code, 97, 102); +} +function uppercaseletter(code) { + return between(code, 65, 90); +} +function lowercaseletter(code) { + return between(code, 97, 122); +} +function letter(code) { + return uppercaseletter(code) || lowercaseletter(code); +} +function nonascii(code) { + return code >= 128; +} +function namestartchar(code) { + return letter(code) || nonascii(code) || code === 95; +} +function namechar(code) { + return namestartchar(code) || digit(code) || code === 45; +} +function nonprintable(code) { + return between(code, 0, 8) || code === 11 || between(code, 14, 31) || code === 127; +} +function newline(code) { + return code === 10; +} +function whitespace(code) { + return newline(code) || code === 9 || code === 32; +} +var maximumallowedcodepoint = 1114111; +var InvalidCharacterError = class extends Error { + constructor(message) { + super(message); + this.name = "InvalidCharacterError"; + } +}; +function preprocess(str) { + const codepoints = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code === 13 && str.charCodeAt(i + 1) === 10) { + code = 10; + i++; + } + if (code === 13 || code === 12) + code = 10; + if (code === 0) + code = 65533; + if (between(code, 55296, 56319) && between(str.charCodeAt(i + 1), 56320, 57343)) { + const lead = code - 55296; + const trail = str.charCodeAt(i + 1) - 56320; + code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; + i++; + } + codepoints.push(code); + } + return codepoints; +} +function stringFromCode(code) { + if (code <= 65535) + return String.fromCharCode(code); + code -= Math.pow(2, 16); + const lead = Math.floor(code / Math.pow(2, 10)) + 55296; + const trail = code % Math.pow(2, 10) + 56320; + return String.fromCharCode(lead) + String.fromCharCode(trail); +} +function tokenize(str1) { + const str = preprocess(str1); + let i = -1; + const tokens = []; + let code; + let line = 0; + let column = 0; + let lastLineLength = 0; + const incrLineno = function() { + line += 1; + lastLineLength = column; + column = 0; + }; + const locStart = { line, column }; + const codepoint = function(i2) { + if (i2 >= str.length) + return -1; + return str[i2]; + }; + const next = function(num) { + if (num === void 0) + num = 1; + if (num > 3) + throw "Spec Error: no more than three codepoints of lookahead."; + return codepoint(i + num); + }; + const consume = function(num) { + if (num === void 0) + num = 1; + i += num; + code = codepoint(i); + if (newline(code)) + incrLineno(); + else + column += num; + return true; + }; + const reconsume = function() { + i -= 1; + if (newline(code)) { + line -= 1; + column = lastLineLength; + } else { + column -= 1; + } + locStart.line = line; + locStart.column = column; + return true; + }; + const eof = function(codepoint2) { + if (codepoint2 === void 0) + codepoint2 = code; + return codepoint2 === -1; + }; + const donothing = function() { + }; + const parseerror = function() { + }; + const consumeAToken = function() { + consumeComments(); + consume(); + if (whitespace(code)) { + while (whitespace(next())) + consume(); + return new WhitespaceToken(); + } else if (code === 34) { + return consumeAStringToken(); + } else if (code === 35) { + if (namechar(next()) || areAValidEscape(next(1), next(2))) { + const token = new HashToken(""); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + token.type = "id"; + token.value = consumeAName(); + return token; + } else { + return new DelimToken(code); + } + } else if (code === 36) { + if (next() === 61) { + consume(); + return new SuffixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 39) { + return consumeAStringToken(); + } else if (code === 40) { + return new OpenParenToken(); + } else if (code === 41) { + return new CloseParenToken(); + } else if (code === 42) { + if (next() === 61) { + consume(); + return new SubstringMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 43) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 44) { + return new CommaToken(); + } else if (code === 45) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else if (next(1) === 45 && next(2) === 62) { + consume(2); + return new CDCToken(); + } else if (startsWithAnIdentifier()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + return new DelimToken(code); + } + } else if (code === 46) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 58) { + return new ColonToken(); + } else if (code === 59) { + return new SemicolonToken(); + } else if (code === 60) { + if (next(1) === 33 && next(2) === 45 && next(3) === 45) { + consume(3); + return new CDOToken(); + } else { + return new DelimToken(code); + } + } else if (code === 64) { + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + return new AtKeywordToken(consumeAName()); + else + return new DelimToken(code); + } else if (code === 91) { + return new OpenSquareToken(); + } else if (code === 92) { + if (startsWithAValidEscape()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + parseerror(); + return new DelimToken(code); + } + } else if (code === 93) { + return new CloseSquareToken(); + } else if (code === 94) { + if (next() === 61) { + consume(); + return new PrefixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 123) { + return new OpenCurlyToken(); + } else if (code === 124) { + if (next() === 61) { + consume(); + return new DashMatchToken(); + } else if (next() === 124) { + consume(); + return new ColumnToken(); + } else { + return new DelimToken(code); + } + } else if (code === 125) { + return new CloseCurlyToken(); + } else if (code === 126) { + if (next() === 61) { + consume(); + return new IncludeMatchToken(); + } else { + return new DelimToken(code); + } + } else if (digit(code)) { + reconsume(); + return consumeANumericToken(); + } else if (namestartchar(code)) { + reconsume(); + return consumeAnIdentlikeToken(); + } else if (eof()) { + return new EOFToken(); + } else { + return new DelimToken(code); + } + }; + const consumeComments = function() { + while (next(1) === 47 && next(2) === 42) { + consume(2); + while (true) { + consume(); + if (code === 42 && next() === 47) { + consume(); + break; + } else if (eof()) { + parseerror(); + return; + } + } + } + }; + const consumeANumericToken = function() { + const num = consumeANumber(); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) { + const token = new DimensionToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + token.unit = consumeAName(); + return token; + } else if (next() === 37) { + consume(); + const token = new PercentageToken(); + token.value = num.value; + token.repr = num.repr; + return token; + } else { + const token = new NumberToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + return token; + } + }; + const consumeAnIdentlikeToken = function() { + const str2 = consumeAName(); + if (str2.toLowerCase() === "url" && next() === 40) { + consume(); + while (whitespace(next(1)) && whitespace(next(2))) + consume(); + if (next() === 34 || next() === 39) + return new FunctionToken(str2); + else if (whitespace(next()) && (next(2) === 34 || next(2) === 39)) + return new FunctionToken(str2); + else + return consumeAURLToken(); + } else if (next() === 40) { + consume(); + return new FunctionToken(str2); + } else { + return new IdentToken(str2); + } + }; + const consumeAStringToken = function(endingCodePoint) { + if (endingCodePoint === void 0) + endingCodePoint = code; + let string = ""; + while (consume()) { + if (code === endingCodePoint || eof()) { + return new StringToken(string); + } else if (newline(code)) { + parseerror(); + reconsume(); + return new BadStringToken(); + } else if (code === 92) { + if (eof(next())) + donothing(); + else if (newline(next())) + consume(); + else + string += stringFromCode(consumeEscape()); + } else { + string += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + const consumeAURLToken = function() { + const token = new URLToken(""); + while (whitespace(next())) + consume(); + if (eof(next())) + return token; + while (consume()) { + if (code === 41 || eof()) { + return token; + } else if (whitespace(code)) { + while (whitespace(next())) + consume(); + if (next() === 41 || eof(next())) { + consume(); + return token; + } else { + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else if (code === 34 || code === 39 || code === 40 || nonprintable(code)) { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } else if (code === 92) { + if (startsWithAValidEscape()) { + token.value += stringFromCode(consumeEscape()); + } else { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else { + token.value += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + const consumeEscape = function() { + consume(); + if (hexdigit(code)) { + const digits = [code]; + for (let total = 0; total < 5; total++) { + if (hexdigit(next())) { + consume(); + digits.push(code); + } else { + break; + } + } + if (whitespace(next())) + consume(); + let value = parseInt(digits.map(function(x) { + return String.fromCharCode(x); + }).join(""), 16); + if (value > maximumallowedcodepoint) + value = 65533; + return value; + } else if (eof()) { + return 65533; + } else { + return code; + } + }; + const areAValidEscape = function(c1, c2) { + if (c1 !== 92) + return false; + if (newline(c2)) + return false; + return true; + }; + const startsWithAValidEscape = function() { + return areAValidEscape(code, next()); + }; + const wouldStartAnIdentifier = function(c1, c2, c3) { + if (c1 === 45) + return namestartchar(c2) || c2 === 45 || areAValidEscape(c2, c3); + else if (namestartchar(c1)) + return true; + else if (c1 === 92) + return areAValidEscape(c1, c2); + else + return false; + }; + const startsWithAnIdentifier = function() { + return wouldStartAnIdentifier(code, next(1), next(2)); + }; + const wouldStartANumber = function(c1, c2, c3) { + if (c1 === 43 || c1 === 45) { + if (digit(c2)) + return true; + if (c2 === 46 && digit(c3)) + return true; + return false; + } else if (c1 === 46) { + if (digit(c2)) + return true; + return false; + } else if (digit(c1)) { + return true; + } else { + return false; + } + }; + const startsWithANumber = function() { + return wouldStartANumber(code, next(1), next(2)); + }; + const consumeAName = function() { + let result = ""; + while (consume()) { + if (namechar(code)) { + result += stringFromCode(code); + } else if (startsWithAValidEscape()) { + result += stringFromCode(consumeEscape()); + } else { + reconsume(); + return result; + } + } + throw new Error("Internal parse error"); + }; + const consumeANumber = function() { + let repr = ""; + let type = "integer"; + if (next() === 43 || next() === 45) { + consume(); + repr += stringFromCode(code); + } + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + if (next(1) === 46 && digit(next(2))) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const c1 = next(1); + const c2 = next(2); + const c3 = next(3); + if ((c1 === 69 || c1 === 101) && digit(c2)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } else if ((c1 === 69 || c1 === 101) && (c2 === 43 || c2 === 45) && digit(c3)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const value = convertAStringToANumber(repr); + return { type, value, repr }; + }; + const convertAStringToANumber = function(string) { + return +string; + }; + const consumeTheRemnantsOfABadURL = function() { + while (consume()) { + if (code === 41 || eof()) { + return; + } else if (startsWithAValidEscape()) { + consumeEscape(); + donothing(); + } else { + donothing(); + } + } + }; + let iterationCount = 0; + while (!eof(next())) { + tokens.push(consumeAToken()); + iterationCount++; + if (iterationCount > str.length * 2) + throw new Error("I'm infinite-looping!"); + } + return tokens; +} +var CSSParserToken = class { + tokenType = ""; + value; + toJSON() { + return { token: this.tokenType }; + } + toString() { + return this.tokenType; + } + toSource() { + return "" + this; + } +}; +var BadStringToken = class extends CSSParserToken { + tokenType = "BADSTRING"; +}; +var BadURLToken = class extends CSSParserToken { + tokenType = "BADURL"; +}; +var WhitespaceToken = class extends CSSParserToken { + tokenType = "WHITESPACE"; + toString() { + return "WS"; + } + toSource() { + return " "; + } +}; +var CDOToken = class extends CSSParserToken { + tokenType = "CDO"; + toSource() { + return ""; + } +}; +var ColonToken = class extends CSSParserToken { + tokenType = ":"; +}; +var SemicolonToken = class extends CSSParserToken { + tokenType = ";"; +}; +var CommaToken = class extends CSSParserToken { + tokenType = ","; +}; +var GroupingToken = class extends CSSParserToken { + value = ""; + mirror = ""; +}; +var OpenCurlyToken = class extends GroupingToken { + tokenType = "{"; + constructor() { + super(); + this.value = "{"; + this.mirror = "}"; + } +}; +var CloseCurlyToken = class extends GroupingToken { + tokenType = "}"; + constructor() { + super(); + this.value = "}"; + this.mirror = "{"; + } +}; +var OpenSquareToken = class extends GroupingToken { + tokenType = "["; + constructor() { + super(); + this.value = "["; + this.mirror = "]"; + } +}; +var CloseSquareToken = class extends GroupingToken { + tokenType = "]"; + constructor() { + super(); + this.value = "]"; + this.mirror = "["; + } +}; +var OpenParenToken = class extends GroupingToken { + tokenType = "("; + constructor() { + super(); + this.value = "("; + this.mirror = ")"; + } +}; +var CloseParenToken = class extends GroupingToken { + tokenType = ")"; + constructor() { + super(); + this.value = ")"; + this.mirror = "("; + } +}; +var IncludeMatchToken = class extends CSSParserToken { + tokenType = "~="; +}; +var DashMatchToken = class extends CSSParserToken { + tokenType = "|="; +}; +var PrefixMatchToken = class extends CSSParserToken { + tokenType = "^="; +}; +var SuffixMatchToken = class extends CSSParserToken { + tokenType = "$="; +}; +var SubstringMatchToken = class extends CSSParserToken { + tokenType = "*="; +}; +var ColumnToken = class extends CSSParserToken { + tokenType = "||"; +}; +var EOFToken = class extends CSSParserToken { + tokenType = "EOF"; + toSource() { + return ""; + } +}; +var DelimToken = class extends CSSParserToken { + tokenType = "DELIM"; + value = ""; + constructor(code) { + super(); + this.value = stringFromCode(code); + } + toString() { + return "DELIM(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } + toSource() { + if (this.value === "\\") + return "\\\n"; + else + return this.value; + } +}; +var StringValuedToken = class extends CSSParserToken { + value = ""; + ASCIIMatch(str) { + return this.value.toLowerCase() === str.toLowerCase(); + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } +}; +var IdentToken = class extends StringValuedToken { + constructor(val) { + super(); + this.value = val; + } + tokenType = "IDENT"; + toString() { + return "IDENT(" + this.value + ")"; + } + toSource() { + return escapeIdent(this.value); + } +}; +var FunctionToken = class extends StringValuedToken { + tokenType = "FUNCTION"; + mirror; + constructor(val) { + super(); + this.value = val; + this.mirror = ")"; + } + toString() { + return "FUNCTION(" + this.value + ")"; + } + toSource() { + return escapeIdent(this.value) + "("; + } +}; +var AtKeywordToken = class extends StringValuedToken { + tokenType = "AT-KEYWORD"; + constructor(val) { + super(); + this.value = val; + } + toString() { + return "AT(" + this.value + ")"; + } + toSource() { + return "@" + escapeIdent(this.value); + } +}; +var HashToken = class extends StringValuedToken { + tokenType = "HASH"; + type; + constructor(val) { + super(); + this.value = val; + this.type = "unrestricted"; + } + toString() { + return "HASH(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + return json; + } + toSource() { + if (this.type === "id") + return "#" + escapeIdent(this.value); + else + return "#" + escapeHash(this.value); + } +}; +var StringToken = class extends StringValuedToken { + tokenType = "STRING"; + constructor(val) { + super(); + this.value = val; + } + toString() { + return '"' + escapeString(this.value) + '"'; + } +}; +var URLToken = class extends StringValuedToken { + tokenType = "URL"; + constructor(val) { + super(); + this.value = val; + } + toString() { + return "URL(" + this.value + ")"; + } + toSource() { + return 'url("' + escapeString(this.value) + '")'; + } +}; +var NumberToken = class extends CSSParserToken { + tokenType = "NUMBER"; + type; + repr; + constructor() { + super(); + this.type = "integer"; + this.repr = ""; + } + toString() { + if (this.type === "integer") + return "INT(" + this.value + ")"; + return "NUMBER(" + this.value + ")"; + } + toJSON() { + const json = super.toJSON(); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + return json; + } + toSource() { + return this.repr; + } +}; +var PercentageToken = class extends CSSParserToken { + tokenType = "PERCENTAGE"; + repr; + constructor() { + super(); + this.repr = ""; + } + toString() { + return "PERCENTAGE(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.repr = this.repr; + return json; + } + toSource() { + return this.repr + "%"; + } +}; +var DimensionToken = class extends CSSParserToken { + tokenType = "DIMENSION"; + type; + repr; + unit; + constructor() { + super(); + this.type = "integer"; + this.repr = ""; + this.unit = ""; + } + toString() { + return "DIM(" + this.value + "," + this.unit + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + json.unit = this.unit; + return json; + } + toSource() { + const source = this.repr; + let unit = escapeIdent(this.unit); + if (unit[0].toLowerCase() === "e" && (unit[1] === "-" || between(unit.charCodeAt(1), 48, 57))) { + unit = "\\65 " + unit.slice(1, unit.length); + } + return source + unit; + } +}; +function escapeIdent(string) { + string = "" + string; + let result = ""; + const firstcode = string.charCodeAt(0); + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (between(code, 1, 31) || code === 127 || i === 0 && between(code, 48, 57) || i === 1 && between(code, 48, 57) && firstcode === 45) + result += "\\" + code.toString(16) + " "; + else if (code >= 128 || code === 45 || code === 95 || between(code, 48, 57) || between(code, 65, 90) || between(code, 97, 122)) + result += string[i]; + else + result += "\\" + string[i]; + } + return result; +} +function escapeHash(string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (code >= 128 || code === 45 || code === 95 || between(code, 48, 57) || between(code, 65, 90) || between(code, 97, 122)) + result += string[i]; + else + result += "\\" + code.toString(16) + " "; + } + return result; +} +function escapeString(string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (between(code, 1, 31) || code === 127) + result += "\\" + code.toString(16) + " "; + else if (code === 34 || code === 92) + result += "\\" + string[i]; + else + result += string[i]; + } + return result; +} + +// packages/injected/src/roleUtils.ts +function hasExplicitAccessibleName(e) { + return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby"); +} +var kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]"; +var kGlobalAriaAttributes = [ + ["aria-atomic", void 0], + ["aria-busy", void 0], + ["aria-controls", void 0], + ["aria-current", void 0], + ["aria-describedby", void 0], + ["aria-details", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-disabled', undefined], + ["aria-dropeffect", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-errormessage', undefined], + ["aria-flowto", void 0], + ["aria-grabbed", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-haspopup', undefined], + ["aria-hidden", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-invalid', undefined], + ["aria-keyshortcuts", void 0], + ["aria-label", ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]], + ["aria-labelledby", ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]], + ["aria-live", void 0], + ["aria-owns", void 0], + ["aria-relevant", void 0], + ["aria-roledescription", ["generic"]] +]; +function hasGlobalAriaAttribute(element, forRole) { + return kGlobalAriaAttributes.some(([attr, prohibited]) => { + return !prohibited?.includes(forRole || "") && element.hasAttribute(attr); + }); +} +function hasTabIndex(element) { + return !Number.isNaN(Number(String(element.getAttribute("tabindex")))); +} +function isFocusable(element) { + return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element)); +} +function isNativelyFocusable(element) { + const tagName = elementSafeTagName(element); + if (["BUTTON", "DETAILS", "SELECT", "TEXTAREA"].includes(tagName)) + return true; + if (tagName === "A" || tagName === "AREA") + return element.hasAttribute("href"); + if (tagName === "INPUT") + return !element.hidden; + return false; +} +var kImplicitRoleByTagName = { + "A": (e) => { + return e.hasAttribute("href") ? "link" : null; + }, + "AREA": (e) => { + return e.hasAttribute("href") ? "link" : null; + }, + "ARTICLE": () => "article", + "ASIDE": () => "complementary", + "BLOCKQUOTE": () => "blockquote", + "BUTTON": () => "button", + "CAPTION": () => "caption", + "CODE": () => "code", + "DATALIST": () => "listbox", + "DD": () => "definition", + "DEL": () => "deletion", + "DETAILS": () => "group", + "DFN": () => "term", + "DIALOG": () => "dialog", + "DT": () => "term", + "EM": () => "emphasis", + "FIELDSET": () => "group", + "FIGURE": () => "figure", + "FOOTER": (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo", + "FORM": (e) => hasExplicitAccessibleName(e) ? "form" : null, + "H1": () => "heading", + "H2": () => "heading", + "H3": () => "heading", + "H4": () => "heading", + "H5": () => "heading", + "H6": () => "heading", + "HEADER": (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner", + "HR": () => "separator", + "HTML": () => "document", + "IMG": (e) => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img", + "INPUT": (e) => { + const type = e.type.toLowerCase(); + if (type === "search") + return e.hasAttribute("list") ? "combobox" : "searchbox"; + if (["email", "tel", "text", "url", ""].includes(type)) { + const list = getIdRefs(e, e.getAttribute("list"))[0]; + return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox"; + } + if (type === "hidden") + return null; + if (type === "file") + return "button"; + return inputTypeToRole[type] || "textbox"; + }, + "INS": () => "insertion", + "LI": () => "listitem", + "MAIN": () => "main", + "MARK": () => "mark", + "MATH": () => "math", + "MENU": () => "list", + "METER": () => "meter", + "NAV": () => "navigation", + "OL": () => "list", + "OPTGROUP": () => "group", + "OPTION": () => "option", + "OUTPUT": () => "status", + "P": () => "paragraph", + "PROGRESS": () => "progressbar", + "SEARCH": () => "search", + "SECTION": (e) => hasExplicitAccessibleName(e) ? "region" : null, + "SELECT": (e) => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox", + "STRONG": () => "strong", + "SUB": () => "subscript", + "SUP": () => "superscript", + // For we default to Chrome behavior: + // - Chrome reports 'img'. + // - Firefox reports 'diagram' that is not in official ARIA spec yet. + // - Safari reports 'no role', but still computes accessible name. + "SVG": () => "img", + "TABLE": () => "table", + "TBODY": () => "rowgroup", + "TD": (e) => { + const table = closestCrossShadow(e, "table"); + const role = table ? getExplicitAriaRole(table) : ""; + return role === "grid" || role === "treegrid" ? "gridcell" : "cell"; + }, + "TEXTAREA": () => "textbox", + "TFOOT": () => "rowgroup", + "TH": (e) => { + const scope = e.getAttribute("scope"); + if (scope === "col" || scope === "colgroup") + return "columnheader"; + if (scope === "row" || scope === "rowgroup") + return "rowheader"; + const nextSibling = e.nextElementSibling; + const prevSibling = e.previousElementSibling; + const row = !!e.parentElement && elementSafeTagName(e.parentElement) === "TR" ? e.parentElement : void 0; + if (!nextSibling && !prevSibling) { + if (row) { + const table = closestCrossShadow(row, "table"); + if (table && table.rows.length <= 1) + return null; + } + return "columnheader"; + } + if (isHeaderCell(nextSibling) && isHeaderCell(prevSibling)) + return "columnheader"; + if (isNonEmptyDataCell(nextSibling) || isNonEmptyDataCell(prevSibling)) + return "rowheader"; + return "columnheader"; + }, + "THEAD": () => "rowgroup", + "TIME": () => "time", + "TR": () => "row", + "UL": () => "list" +}; +function isHeaderCell(element) { + return !!element && elementSafeTagName(element) === "TH"; +} +function isNonEmptyDataCell(element) { + if (!element || elementSafeTagName(element) !== "TD") + return false; + return !!(element.textContent?.trim() || element.children.length > 0); +} +var kPresentationInheritanceParents = { + "DD": ["DL", "DIV"], + "DIV": ["DL"], + "DT": ["DL", "DIV"], + "LI": ["OL", "UL"], + "TBODY": ["TABLE"], + "TD": ["TR"], + "TFOOT": ["TABLE"], + "TH": ["TR"], + "THEAD": ["TABLE"], + "TR": ["THEAD", "TBODY", "TFOOT", "TABLE"] +}; +function getImplicitAriaRole(element) { + const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || ""; + if (!implicitRole) + return null; + let ancestor = element; + while (ancestor) { + const parent = parentElementOrShadowHost(ancestor); + const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)]; + if (!parents || !parent || !parents.includes(elementSafeTagName(parent))) + break; + const parentExplicitRole = getExplicitAriaRole(parent); + if ((parentExplicitRole === "none" || parentExplicitRole === "presentation") && !hasPresentationConflictResolution(parent, parentExplicitRole)) + return parentExplicitRole; + ancestor = parent; + } + return implicitRole; +} +var validRoles = [ + "alert", + "alertdialog", + "application", + "article", + "banner", + "blockquote", + "button", + "caption", + "cell", + "checkbox", + "code", + "columnheader", + "combobox", + "complementary", + "contentinfo", + "definition", + "deletion", + "dialog", + "directory", + "document", + "emphasis", + "feed", + "figure", + "form", + "generic", + "grid", + "gridcell", + "group", + "heading", + "img", + "insertion", + "link", + "list", + "listbox", + "listitem", + "log", + "main", + "mark", + "marquee", + "math", + "meter", + "menu", + "menubar", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "navigation", + "none", + "note", + "option", + "paragraph", + "presentation", + "progressbar", + "radio", + "radiogroup", + "region", + "row", + "rowgroup", + "rowheader", + "scrollbar", + "search", + "searchbox", + "separator", + "slider", + "spinbutton", + "status", + "strong", + "subscript", + "superscript", + "switch", + "tab", + "table", + "tablist", + "tabpanel", + "term", + "textbox", + "time", + "timer", + "toolbar", + "tooltip", + "tree", + "treegrid", + "treeitem" +]; +function getExplicitAriaRole(element) { + const roles = (element.getAttribute("role") || "").split(" ").map((role) => role.trim()); + return roles.find((role) => validRoles.includes(role)) || null; +} +function hasPresentationConflictResolution(element, role) { + return hasGlobalAriaAttribute(element, role) || isFocusable(element); +} +function getAriaRole(element) { + const explicitRole = getExplicitAriaRole(element); + if (!explicitRole) + return getImplicitAriaRole(element); + if (explicitRole === "none" || explicitRole === "presentation") { + const implicitRole = getImplicitAriaRole(element); + if (hasPresentationConflictResolution(element, implicitRole)) + return implicitRole; + } + return explicitRole; +} +function getAriaBoolean(attr) { + return attr === null ? void 0 : attr.toLowerCase() === "true"; +} +function isElementIgnoredForAria(element) { + return ["STYLE", "SCRIPT", "NOSCRIPT", "TEMPLATE"].includes(elementSafeTagName(element)); +} +function isElementHiddenForAria(element) { + if (isElementIgnoredForAria(element)) + return true; + const style = getElementComputedStyle(element); + const isSlot = element.nodeName === "SLOT"; + if (style?.display === "contents" && !isSlot) { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && !isElementHiddenForAria(child)) + return false; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return false; + } + return true; + } + const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select"); + if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) + return true; + return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element); +} +function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) { + let hidden = cacheIsHidden?.get(element); + if (hidden === void 0) { + hidden = false; + if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot) + hidden = true; + if (!hidden) { + const style = getElementComputedStyle(element); + hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true; + } + if (!hidden) { + const parent = parentElementOrShadowHost(element); + if (parent) + hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent); + } + cacheIsHidden?.set(element, hidden); + } + return hidden; +} +function getIdRefs(element, ref) { + if (!ref) + return []; + const root = enclosingShadowRootOrDocument(element); + if (!root) + return []; + try { + const ids = ref.split(" ").filter((id) => !!id); + const result = []; + for (const id of ids) { + const firstElement = root.querySelector("#" + CSS.escape(id)); + if (firstElement && !result.includes(firstElement)) + result.push(firstElement); + } + return result; + } catch (e) { + return []; + } +} +function trimFlatString(s) { + return s.trim(); +} +function asFlatString(s) { + return s.split("\xA0").map((chunk) => chunk.replace(/\r\n/g, "\n").replace(/[\u200b\u00ad]/g, "").replace(/\s\s*/g, " ")).join("\xA0").trim(); +} +function queryInAriaOwned(element, selector) { + const result = [...element.querySelectorAll(selector)]; + for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) { + if (owned.matches(selector)) + result.push(owned); + result.push(...owned.querySelectorAll(selector)); + } + return result; +} +function getCSSContent(element, pseudo) { + const cache = pseudo === "::before" ? cachePseudoContentBefore : pseudo === "::after" ? cachePseudoContentAfter : cachePseudoContent; + if (cache?.has(element)) + return cache?.get(element); + const style = getElementComputedStyle(element, pseudo); + let content; + if (style) { + const contentValue = style.content; + if (contentValue && contentValue !== "none" && contentValue !== "normal") { + if (style.display !== "none" && style.visibility !== "hidden") { + content = parseCSSContentPropertyAsString(element, contentValue, !!pseudo); + } + } + } + if (pseudo && content !== void 0) { + const display = style?.display || "inline"; + if (display !== "inline") + content = " " + content + " "; + } + if (cache) + cache.set(element, content); + return content; +} +function parseCSSContentPropertyAsString(element, content, isPseudo) { + if (!content || content === "none" || content === "normal") { + return; + } + try { + let tokens = tokenize(content).filter((token) => !(token instanceof WhitespaceToken)); + const delimIndex = tokens.findIndex((token) => token instanceof DelimToken && token.value === "/"); + if (delimIndex !== -1) { + tokens = tokens.slice(delimIndex + 1); + } else if (!isPseudo) { + return; + } + const accumulated = []; + let index = 0; + while (index < tokens.length) { + if (tokens[index] instanceof StringToken) { + accumulated.push(tokens[index].value); + index++; + } else if (index + 2 < tokens.length && tokens[index] instanceof FunctionToken && tokens[index].value === "attr" && tokens[index + 1] instanceof IdentToken && tokens[index + 2] instanceof CloseParenToken) { + const attrName = tokens[index + 1].value; + accumulated.push(element.getAttribute(attrName) || ""); + index += 3; + } else { + return; + } + } + return accumulated.join(""); + } catch { + } +} +function getAriaLabelledByElements(element) { + const ref = element.getAttribute("aria-labelledby"); + if (ref === null) + return null; + const refs = getIdRefs(element, ref); + return refs.length ? refs : null; +} +function allowsNameFromContent(role, targetDescendant) { + const alwaysAllowsNameFromContent = ["button", "cell", "checkbox", "columnheader", "gridcell", "heading", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "row", "rowheader", "switch", "tab", "tooltip", "treeitem"].includes(role); + const descendantAllowsNameFromContent = targetDescendant && ["", "caption", "code", "contentinfo", "definition", "deletion", "emphasis", "insertion", "list", "listitem", "mark", "none", "paragraph", "presentation", "region", "row", "rowgroup", "section", "strong", "subscript", "superscript", "table", "term", "time"].includes(role); + return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; +} +function getElementAccessibleName(element, includeHidden) { + const cache = includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName; + let accessibleName = cache?.get(element); + if (accessibleName === void 0) { + accessibleName = ""; + const elementProhibitsNaming = ["caption", "code", "definition", "deletion", "emphasis", "generic", "insertion", "mark", "paragraph", "presentation", "strong", "subscript", "suggestion", "superscript", "term", "time"].includes(getAriaRole(element) || ""); + if (!elementProhibitsNaming) { + accessibleName = asFlatString(getTextAlternativeInternal(element, { + includeHidden, + visitedElements: /* @__PURE__ */ new Set(), + embeddedInTargetElement: "self" + })); + } + cache?.set(element, accessibleName); + } + return accessibleName; +} +var kAriaInvalidRoles = [ + "application", + "checkbox", + "columnheader", + "combobox", + "gridcell", + "listbox", + "radiogroup", + "rowheader", + "searchbox", + "slider", + "spinbutton", + "switch", + "textbox", + "tree" +]; +function getAriaInvalid(element) { + const ariaInvalid = element.getAttribute("aria-invalid"); + if (!ariaInvalid || ariaInvalid.trim() === "" || ariaInvalid.toLocaleLowerCase() === "false") + return "false"; + if (ariaInvalid === "true" || ariaInvalid === "grammar" || ariaInvalid === "spelling") + return ariaInvalid; + return "true"; +} +function getTextAlternativeInternal(element, options) { + if (options.visitedElements.has(element)) + return ""; + const childOptions = { + ...options, + embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement + }; + if (!options.includeHidden) { + const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden || !!options.embeddedInDescribedBy?.hidden || !!options.embeddedInNativeTextAlternative?.hidden || !!options.embeddedInLabel?.hidden; + if (isElementIgnoredForAria(element) || !isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element)) { + options.visitedElements.add(element); + return ""; + } + } + const labelledBy = getAriaLabelledByElements(element); + if (!options.embeddedInLabelledBy) { + const accessibleName = (labelledBy || []).map((ref) => getTextAlternativeInternal(ref, { + ...options, + embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, + embeddedInDescribedBy: void 0, + embeddedInTargetElement: void 0, + embeddedInLabel: void 0, + embeddedInNativeTextAlternative: void 0 + })).join(" "); + if (accessibleName) + return accessibleName; + } + const role = getAriaRole(element) || ""; + const tagName = elementSafeTagName(element); + if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === "descendant") { + const isOwnLabel = [...element.labels || []].includes(element); + const isOwnLabelledBy = (labelledBy || []).includes(element); + if (!isOwnLabel && !isOwnLabelledBy) { + if (role === "textbox") { + options.visitedElements.add(element); + if (tagName === "INPUT" || tagName === "TEXTAREA") + return element.value; + return element.textContent || ""; + } + if (["combobox", "listbox"].includes(role)) { + options.visitedElements.add(element); + let selectedOptions; + if (tagName === "SELECT") { + selectedOptions = [...element.selectedOptions]; + if (!selectedOptions.length && element.options.length) + selectedOptions.push(element.options[0]); + } else { + const listbox = role === "combobox" ? queryInAriaOwned(element, "*").find((e) => getAriaRole(e) === "listbox") : element; + selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter((e) => getAriaRole(e) === "option") : []; + } + if (!selectedOptions.length && tagName === "INPUT") { + return element.value; + } + return selectedOptions.map((option) => getTextAlternativeInternal(option, childOptions)).join(" "); + } + if (["progressbar", "scrollbar", "slider", "spinbutton", "meter"].includes(role)) { + options.visitedElements.add(element); + if (element.hasAttribute("aria-valuetext")) + return element.getAttribute("aria-valuetext") || ""; + if (element.hasAttribute("aria-valuenow")) + return element.getAttribute("aria-valuenow") || ""; + return element.getAttribute("value") || ""; + } + if (["menu"].includes(role)) { + options.visitedElements.add(element); + return ""; + } + } + } + const ariaLabel = element.getAttribute("aria-label") || ""; + if (trimFlatString(ariaLabel)) { + options.visitedElements.add(element); + return ariaLabel; + } + if (!["presentation", "none"].includes(role)) { + if (tagName === "INPUT" && ["button", "submit", "reset"].includes(element.type)) { + options.visitedElements.add(element); + const value = element.value || ""; + if (trimFlatString(value)) + return value; + if (element.type === "submit") + return "Submit"; + if (element.type === "reset") + return "Reset"; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "INPUT" && element.type === "file") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length && !options.embeddedInLabelledBy) + return getAccessibleNameFromAssociatedLabels(labels, options); + return "Choose File"; + } + if (tagName === "INPUT" && element.type === "image") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length && !options.embeddedInLabelledBy) + return getAccessibleNameFromAssociatedLabels(labels, options); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + if (trimFlatString(title)) + return title; + return "Submit"; + } + if (!labelledBy && tagName === "BUTTON") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + } + if (!labelledBy && tagName === "OUTPUT") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + return element.getAttribute("title") || ""; + } + if (!labelledBy && (tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "INPUT")) { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + const usePlaceholder = tagName === "INPUT" && ["text", "password", "search", "tel", "email", "url"].includes(element.type) || tagName === "TEXTAREA"; + const placeholder = element.getAttribute("placeholder") || ""; + const title = element.getAttribute("title") || ""; + if (!usePlaceholder || title) + return title; + return placeholder; + } + if (!labelledBy && tagName === "FIELDSET") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "LEGEND") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const title = element.getAttribute("title") || ""; + return title; + } + if (!labelledBy && tagName === "FIGURE") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "FIGCAPTION") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "IMG") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "TABLE") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "CAPTION") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const summary = element.getAttribute("summary") || ""; + if (summary) + return summary; + } + if (tagName === "AREA") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "SVG" || element.ownerSVGElement) { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "TITLE" && child.ownerSVGElement) { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + } + if (element.ownerSVGElement && tagName === "A") { + const title = element.getAttribute("xlink:title") || ""; + if (trimFlatString(title)) { + options.visitedElements.add(element); + return title; + } + } + } + const shouldNameFromContentForSummary = tagName === "SUMMARY" && !["presentation", "none"].includes(role); + if (allowsNameFromContent(role, options.embeddedInTargetElement === "descendant") || shouldNameFromContentForSummary || !!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy || !!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) { + options.visitedElements.add(element); + const accessibleName = innerAccumulatedElementText(element, childOptions); + const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? trimFlatString(accessibleName) : accessibleName; + if (maybeTrimmedAccessibleName) + return accessibleName; + } + if (!["presentation", "none"].includes(role) || tagName === "IFRAME") { + options.visitedElements.add(element); + const title = element.getAttribute("title") || ""; + if (trimFlatString(title)) + return title; + } + options.visitedElements.add(element); + return ""; +} +function innerAccumulatedElementText(element, options) { + const tokens = []; + const visit = (node, skipSlotted) => { + if (skipSlotted && node.assignedSlot) + return; + if (node.nodeType === 1) { + const display = getElementComputedStyle(node)?.display || "inline"; + let token = getTextAlternativeInternal(node, options); + if (display !== "inline" || node.nodeName === "BR") + token = " " + token + " "; + tokens.push(token); + } else if (node.nodeType === 3) { + tokens.push(node.textContent || ""); + } + }; + tokens.push(getCSSContent(element, "::before") || ""); + const content = getCSSContent(element); + if (content !== void 0) { + tokens.push(content); + } else { + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(child, false); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) + visit(child, true); + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(child, true); + } + for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) + visit(owned, true); + } + } + tokens.push(getCSSContent(element, "::after") || ""); + return tokens.join(""); +} +var kAriaSelectedRoles = ["gridcell", "option", "row", "tab", "rowheader", "columnheader", "treeitem"]; +function getAriaSelected(element) { + if (elementSafeTagName(element) === "OPTION") + return element.selected; + if (kAriaSelectedRoles.includes(getAriaRole(element) || "")) + return getAriaBoolean(element.getAttribute("aria-selected")) === true; + return false; +} +var kAriaCheckedRoles = ["checkbox", "menuitemcheckbox", "option", "radio", "switch", "menuitemradio", "treeitem"]; +function getAriaChecked(element) { + const result = getChecked(element, true); + return result === "error" ? false : result; +} +function getChecked(element, allowMixed) { + const tagName = elementSafeTagName(element); + if (allowMixed && tagName === "INPUT" && element.indeterminate) + return "mixed"; + if (tagName === "INPUT" && ["checkbox", "radio"].includes(element.type)) + return element.checked; + if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) { + const checked = element.getAttribute("aria-checked"); + if (checked === "true") + return true; + if (allowMixed && checked === "mixed") + return "mixed"; + return false; + } + return "error"; +} +var kAriaPressedRoles = ["button"]; +function getAriaPressed(element) { + if (kAriaPressedRoles.includes(getAriaRole(element) || "")) { + const pressed = element.getAttribute("aria-pressed"); + if (pressed === "true") + return true; + if (pressed === "mixed") + return "mixed"; + } + return false; +} +var kAriaExpandedRoles = ["application", "button", "checkbox", "combobox", "gridcell", "link", "listbox", "menuitem", "row", "rowheader", "tab", "treeitem", "columnheader", "menuitemcheckbox", "menuitemradio", "rowheader", "switch"]; +function getAriaExpanded(element) { + if (elementSafeTagName(element) === "DETAILS") + return element.open; + if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) { + const expanded = element.getAttribute("aria-expanded"); + if (expanded === null) + return void 0; + if (expanded === "true") + return true; + return false; + } + return void 0; +} +var kAriaLevelRoles = ["heading", "listitem", "row", "treeitem"]; +function getAriaLevel(element) { + const native = { "H1": 1, "H2": 2, "H3": 3, "H4": 4, "H5": 5, "H6": 6 }[elementSafeTagName(element)]; + if (native) + return native; + if (kAriaLevelRoles.includes(getAriaRole(element) || "")) { + const attr = element.getAttribute("aria-level"); + const value = attr === null ? Number.NaN : Number(attr); + if (Number.isInteger(value) && value >= 1) + return value; + } + return 0; +} +var kAriaDisabledRoles = ["application", "button", "composite", "gridcell", "group", "input", "link", "menuitem", "scrollbar", "separator", "tab", "checkbox", "columnheader", "combobox", "grid", "listbox", "menu", "menubar", "menuitemcheckbox", "menuitemradio", "option", "radio", "radiogroup", "row", "rowheader", "searchbox", "select", "slider", "spinbutton", "switch", "tablist", "textbox", "toolbar", "tree", "treegrid", "treeitem"]; +function getAriaDisabled(element) { + return isNativelyDisabled(element) || hasExplicitAriaDisabled(element); +} +function isNativelyDisabled(element) { + const isNativeFormControl = ["BUTTON", "INPUT", "SELECT", "TEXTAREA", "OPTION", "OPTGROUP"].includes(elementSafeTagName(element)); + return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledOptGroup(element) || belongsToDisabledFieldSet(element)); +} +function belongsToDisabledOptGroup(element) { + return elementSafeTagName(element) === "OPTION" && !!element.closest("OPTGROUP[DISABLED]"); +} +function belongsToDisabledFieldSet(element) { + const fieldSetElement = element?.closest("FIELDSET[DISABLED]"); + if (!fieldSetElement) + return false; + const legendElement = fieldSetElement.querySelector(":scope > LEGEND"); + return !legendElement || !legendElement.contains(element); +} +function hasExplicitAriaDisabled(element, isAncestor = false) { + if (!element) + return false; + if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) { + const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase(); + if (attribute === "true") + return true; + if (attribute === "false") + return false; + return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true); + } + return false; +} +function getAccessibleNameFromAssociatedLabels(labels, options) { + return [...labels].map((label) => getTextAlternativeInternal(label, { + ...options, + embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, + embeddedInNativeTextAlternative: void 0, + embeddedInLabelledBy: void 0, + embeddedInDescribedBy: void 0, + embeddedInTargetElement: void 0 + })).filter((accessibleName) => !!accessibleName).join(" "); +} +function receivesPointerEvents(element) { + const cache = cachePointerEvents; + let e = element; + let result; + const parents = []; + for (; e; e = parentElementOrShadowHost(e)) { + const cached = cache.get(e); + if (cached !== void 0) { + result = cached; + break; + } + parents.push(e); + const style = getElementComputedStyle(e); + if (!style) { + result = true; + break; + } + const value = style.pointerEvents; + if (value) { + result = value !== "none"; + break; + } + } + if (result === void 0) + result = true; + for (const parent of parents) + cache.set(parent, result); + return result; +} +var cacheAccessibleName; +var cacheAccessibleNameHidden; +var cacheAccessibleDescription; +var cacheAccessibleDescriptionHidden; +var cacheAccessibleErrorMessage; +var cacheIsHidden; +var cachePseudoContent; +var cachePseudoContentBefore; +var cachePseudoContentAfter; +var cachePointerEvents; +var cachesCounter2 = 0; +function beginAriaCaches() { + beginDOMCaches(); + ++cachesCounter2; + cacheAccessibleName ??= /* @__PURE__ */ new Map(); + cacheAccessibleNameHidden ??= /* @__PURE__ */ new Map(); + cacheAccessibleDescription ??= /* @__PURE__ */ new Map(); + cacheAccessibleDescriptionHidden ??= /* @__PURE__ */ new Map(); + cacheAccessibleErrorMessage ??= /* @__PURE__ */ new Map(); + cacheIsHidden ??= /* @__PURE__ */ new Map(); + cachePseudoContent ??= /* @__PURE__ */ new Map(); + cachePseudoContentBefore ??= /* @__PURE__ */ new Map(); + cachePseudoContentAfter ??= /* @__PURE__ */ new Map(); + cachePointerEvents ??= /* @__PURE__ */ new Map(); +} +function endAriaCaches() { + if (!--cachesCounter2) { + cacheAccessibleName = void 0; + cacheAccessibleNameHidden = void 0; + cacheAccessibleDescription = void 0; + cacheAccessibleDescriptionHidden = void 0; + cacheAccessibleErrorMessage = void 0; + cacheIsHidden = void 0; + cachePseudoContent = void 0; + cachePseudoContentBefore = void 0; + cachePseudoContentAfter = void 0; + cachePointerEvents = void 0; + } + endDOMCaches(); +} +var inputTypeToRole = { + "button": "button", + "checkbox": "checkbox", + "image": "button", + "number": "spinbutton", + "radio": "radio", + "range": "slider", + "reset": "button", + "submit": "button" +}; + +// packages/injected/src/ariaSnapshot.ts +var lastRef = 0; +function toInternalOptions(options) { + const renderBoxes = options.boxes; + if (options.mode === "ai") { + return { + visibility: "ariaOrVisible", + refs: "interactable", + refPrefix: options.refPrefix, + includeGenericRole: true, + renderActive: !options.doNotRenderActive, + renderCursorPointer: true, + renderBoxes + }; + } + if (options.mode === "autoexpect") { + return { visibility: "ariaAndVisible", refs: "none", renderBoxes }; + } + if (options.mode === "codegen") { + return { visibility: "aria", refs: "none", renderStringsAsRegex: true, renderBoxes }; + } + return { visibility: "aria", refs: "none", renderBoxes }; +} +function generateAriaTree(rootElement, publicOptions) { + const options = toInternalOptions(publicOptions); + const visited = /* @__PURE__ */ new Set(); + const snapshot = { + root: { role: "fragment", name: "", children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, + elements: /* @__PURE__ */ new Map(), + refs: /* @__PURE__ */ new Map(), + iframeRefs: [] + }; + setAriaNodeElement(snapshot.root, rootElement); + const visit = (ariaNode, node, parentElementVisible) => { + if (visited.has(node)) + return; + visited.add(node); + if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { + if (!parentElementVisible) + return; + const text = node.nodeValue; + if (ariaNode.role !== "textbox" && text) + ariaNode.children.push(node.nodeValue || ""); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) + return; + const element = node; + const isElementVisibleForAria = !isElementHiddenForAria(element); + let visible = isElementVisibleForAria; + if (options.visibility === "ariaOrVisible") + visible = isElementVisibleForAria || isElementVisible(element); + if (options.visibility === "ariaAndVisible") + visible = isElementVisibleForAria && isElementVisible(element); + if (options.visibility === "aria" && !visible) + return; + const ariaChildren = []; + if (element.hasAttribute("aria-owns")) { + const ids = element.getAttribute("aria-owns").split(/\s+/); + for (const id of ids) { + const ownedElement = rootElement.ownerDocument.getElementById(id); + if (ownedElement) + ariaChildren.push(ownedElement); + } + } + const childAriaNode = visible ? toAriaNode(element, options) : null; + if (childAriaNode) { + if (childAriaNode.ref) { + snapshot.elements.set(childAriaNode.ref, element); + snapshot.refs.set(element, childAriaNode.ref); + if (childAriaNode.role === "iframe") + snapshot.iframeRefs.push(childAriaNode.ref); + } + ariaNode.children.push(childAriaNode); + } + processElement(childAriaNode || ariaNode, element, ariaChildren, visible); + }; + function processElement(ariaNode, element, ariaChildren, parentElementVisible) { + const display = getElementComputedStyle(element)?.display || "inline"; + const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : ""; + if (treatAsBlock) + ariaNode.children.push(treatAsBlock); + ariaNode.children.push(getCSSContent(element, "::before") || ""); + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(ariaNode, child, parentElementVisible); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (!child.assignedSlot) + visit(ariaNode, child, parentElementVisible); + } + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(ariaNode, child, parentElementVisible); + } + } + for (const child of ariaChildren) + visit(ariaNode, child, parentElementVisible); + ariaNode.children.push(getCSSContent(element, "::after") || ""); + if (treatAsBlock) + ariaNode.children.push(treatAsBlock); + if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) + ariaNode.children = []; + if (ariaNode.role === "link" && element.hasAttribute("href")) { + const href = element.getAttribute("href"); + ariaNode.props["url"] = href; + } + if (ariaNode.role === "textbox" && element.hasAttribute("placeholder") && element.getAttribute("placeholder") !== ariaNode.name) { + const placeholder = element.getAttribute("placeholder"); + ariaNode.props["placeholder"] = placeholder; + } + } + beginAriaCaches(); + try { + visit(snapshot.root, rootElement, true); + } finally { + endAriaCaches(); + } + normalizeStringChildren(snapshot.root); + normalizeGenericRoles(snapshot.root); + return snapshot; +} +function computeAriaRef(ariaNode, options) { + if (options.refs === "none") + return; + if (options.refs === "interactable" && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) + return; + const element = ariaNodeElement(ariaNode); + let ariaRef = element._ariaRef; + if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) { + ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? "") + "e" + ++lastRef }; + element._ariaRef = ariaRef; + } + ariaNode.ref = ariaRef.ref; +} +function toAriaNode(element, options) { + const active = element.ownerDocument.activeElement === element; + if (element.nodeName === "IFRAME") { + const ariaNode = { + role: "iframe", + name: "", + children: [], + props: {}, + box: computeBox(element), + receivesPointerEvents: true, + active + }; + setAriaNodeElement(ariaNode, element); + computeAriaRef(ariaNode, options); + return ariaNode; + } + const defaultRole = options.includeGenericRole ? "generic" : null; + const role = getAriaRole(element) ?? defaultRole; + if (!role || role === "presentation" || role === "none") + return null; + const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || ""); + const receivesPointerEvents2 = receivesPointerEvents(element); + const box = computeBox(element); + if (role === "generic" && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) + return null; + const result = { + role, + name, + children: [], + props: {}, + box, + receivesPointerEvents: receivesPointerEvents2, + active + }; + setAriaNodeElement(result, element); + computeAriaRef(result, options); + if (kAriaCheckedRoles.includes(role)) + result.checked = getAriaChecked(element); + if (kAriaDisabledRoles.includes(role)) + result.disabled = getAriaDisabled(element); + if (kAriaExpandedRoles.includes(role)) + result.expanded = getAriaExpanded(element); + if (kAriaInvalidRoles.includes(role)) { + const invalid = getAriaInvalid(element); + result.invalid = invalid === "false" ? false : invalid === "true" ? true : invalid; + } + if (kAriaLevelRoles.includes(role)) + result.level = getAriaLevel(element); + if (kAriaPressedRoles.includes(role)) + result.pressed = getAriaPressed(element); + if (kAriaSelectedRoles.includes(role)) + result.selected = getAriaSelected(element); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.type !== "checkbox" && element.type !== "radio" && element.type !== "file") + result.children = [element.value]; + } + return result; +} +function normalizeGenericRoles(node) { + const normalizeChildren = (node2) => { + const result = []; + for (const child of node2.children || []) { + if (typeof child === "string") { + result.push(child); + continue; + } + const normalized = normalizeChildren(child); + result.push(...normalized); + } + const removeSelf = node2.role === "generic" && !node2.name && result.length <= 1 && result.every((c) => typeof c !== "string" && !!c.ref); + if (removeSelf) + return result; + node2.children = result; + return [node2]; + }; + normalizeChildren(node); +} +function normalizeStringChildren(rootA11yNode) { + const flushChildren = (buffer, normalizedChildren) => { + if (!buffer.length) + return; + const text = normalizeWhiteSpace(buffer.join("")); + if (text) + normalizedChildren.push(text); + buffer.length = 0; + }; + const visit = (ariaNode) => { + const normalizedChildren = []; + const buffer = []; + for (const child of ariaNode.children || []) { + if (typeof child === "string") { + buffer.push(child); + } else { + flushChildren(buffer, normalizedChildren); + visit(child); + normalizedChildren.push(child); + } + } + flushChildren(buffer, normalizedChildren); + ariaNode.children = normalizedChildren.length ? normalizedChildren : []; + if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) + ariaNode.children = []; + }; + visit(rootA11yNode); +} +function buildByRefMap(root, map = /* @__PURE__ */ new Map()) { + if (root?.ref) + map.set(root.ref, root); + for (const child of root?.children || []) { + if (typeof child !== "string") + buildByRefMap(child, map); + } + return map; +} +function compareSnapshots(ariaSnapshot, previousSnapshot) { + const previousByRef = buildByRefMap(previousSnapshot?.root); + const result = /* @__PURE__ */ new Map(); + const visit = (ariaNode, previousNode) => { + let same = ariaNode.children.length === previousNode?.children.length && ariaNodesEqual(ariaNode, previousNode); + let canBeSkipped = same; + for (let childIndex = 0; childIndex < ariaNode.children.length; childIndex++) { + const child = ariaNode.children[childIndex]; + const previousChild = previousNode?.children[childIndex]; + if (typeof child === "string") { + same &&= child === previousChild; + canBeSkipped &&= child === previousChild; + } else { + let previous = typeof previousChild !== "string" ? previousChild : void 0; + if (child.ref) + previous = previousByRef.get(child.ref); + const sameChild = visit(child, previous); + if (!previous || !sameChild && !child.ref || previous !== previousChild) + canBeSkipped = false; + same &&= sameChild && previous === previousChild; + } + } + result.set(ariaNode, same ? "same" : canBeSkipped ? "skip" : "changed"); + return same; + }; + visit(ariaSnapshot.root, previousByRef.get(previousSnapshot?.root?.ref)); + return result; +} +function filterSnapshotDiff(nodes, statusMap) { + const result = []; + const visit = (ariaNode) => { + const status = statusMap.get(ariaNode); + if (status === "same") { + } else if (status === "skip") { + for (const child of ariaNode.children) { + if (typeof child !== "string") + visit(child); + } + } else { + result.push(ariaNode); + } + }; + for (const node of nodes) { + if (typeof node === "string") + result.push(node); + else + visit(node); + } + return result; +} +function indent(depth) { + return " ".repeat(depth); +} +function renderAriaTree(ariaSnapshot, publicOptions, previousSnapshot) { + const options = toInternalOptions(publicOptions); + const lines = []; + const iframeDepths = {}; + const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true; + const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str) => str; + let nodesToRender = ariaSnapshot.root.role === "fragment" ? ariaSnapshot.root.children : [ariaSnapshot.root]; + const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot); + if (previousSnapshot) + nodesToRender = filterSnapshotDiff(nodesToRender, statusMap); + const visitText = (text, depth) => { + if (publicOptions.depth && depth > publicOptions.depth) + return; + const escaped = yamlEscapeValueIfNeeded(renderString(text)); + if (escaped) + lines.push(indent(depth) + "- text: " + escaped); + }; + const createKey = (ariaNode, renderCursorPointer) => { + let key = ariaNode.role; + if (ariaNode.name && ariaNode.name.length <= 900) { + const name = renderString(ariaNode.name); + if (name) { + const stringifiedName = name.startsWith("/") && name.endsWith("/") ? name : JSON.stringify(name); + key += " " + stringifiedName; + } + } + if (ariaNode.checked === "mixed") + key += ` [checked=mixed]`; + if (ariaNode.checked === true) + key += ` [checked]`; + if (ariaNode.disabled) + key += ` [disabled]`; + if (ariaNode.expanded) + key += ` [expanded]`; + if (ariaNode.active && options.renderActive) + key += ` [active]`; + if (ariaNode.invalid === "grammar" || ariaNode.invalid === "spelling") + key += ` [invalid=${ariaNode.invalid}]`; + if (ariaNode.invalid === true) + key += ` [invalid]`; + if (ariaNode.level) + key += ` [level=${ariaNode.level}]`; + if (ariaNode.pressed === "mixed") + key += ` [pressed=mixed]`; + if (ariaNode.pressed === true) + key += ` [pressed]`; + if (ariaNode.selected === true) + key += ` [selected]`; + if (ariaNode.ref) { + key += ` [ref=${ariaNode.ref}]`; + if (renderCursorPointer && hasPointerCursor(ariaNode)) + key += " [cursor=pointer]"; + } + if (options.renderBoxes) { + const element = ariaNodeElement(ariaNode); + if (element) { + const r = element.getBoundingClientRect(); + key += ` [box=${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}]`; + } + } + return key; + }; + const getSingleInlinedTextChild = (ariaNode) => { + return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === "string" && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : void 0; + }; + const visit = (ariaNode, depth, renderCursorPointer) => { + if (publicOptions.depth && depth > publicOptions.depth) + return; + if (ariaNode.role === "iframe" && ariaNode.ref) + iframeDepths[ariaNode.ref] = depth; + if (statusMap.get(ariaNode) === "same" && ariaNode.ref) { + lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`); + return; + } + const isDiffRoot = !!previousSnapshot && !depth; + const escapedKey = indent(depth) + "- " + (isDiffRoot ? " " : "") + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); + const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); + const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth; + const hasNoChildren = !singleInlinedTextChild && (!ariaNode.children.length || isAtDepthLimit); + if (hasNoChildren && !Object.keys(ariaNode.props).length) { + lines.push(escapedKey); + } else if (singleInlinedTextChild !== void 0) { + const shouldInclude = includeText(ariaNode, singleInlinedTextChild); + if (shouldInclude) + lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild))); + else + lines.push(escapedKey); + } else { + lines.push(escapedKey + ":"); + for (const [name, value] of Object.entries(ariaNode.props)) + lines.push(indent(depth + 1) + "- /" + name + ": " + yamlEscapeValueIfNeeded(value)); + const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode); + for (const child of ariaNode.children) { + if (typeof child === "string") + visitText(includeText(ariaNode, child) ? child : "", depth + 1); + else + visit(child, depth + 1, renderCursorPointer && !inCursorPointer); + } + } + }; + for (const nodeToRender of nodesToRender) { + if (typeof nodeToRender === "string") + visitText(nodeToRender, 0); + else + visit(nodeToRender, 0, !!options.renderCursorPointer); + } + return { text: lines.join("\n"), iframeDepths }; +} +function convertToBestGuessRegex(text) { + const dynamicContent = [ + // 550e8400-e29b-41d4-a716-446655440000 + { regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, replacement: "[0-9a-fA-F-]+" }, + // 2mb + { regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: "[\\d,.]+[bkmBKM]+" }, + // 2ms, 20s + { regex: /\b\d+[hmsp]+\b/, replacement: "\\d+[hmsp]+" }, + { regex: /\b[\d,.]+[hmsp]+\b/, replacement: "[\\d,.]+[hmsp]+" }, + // Do not replace single digits with regex by default. + // 2+ digits: [Issue 22, 22.3, 2.33, 2,333] + { regex: /\b\d+,\d+\b/, replacement: "\\d+,\\d+" }, + { regex: /\b\d+\.\d{2,}\b/, replacement: "\\d+\\.\\d+" }, + { regex: /\b\d{2,}\.\d+\b/, replacement: "\\d+\\.\\d+" }, + { regex: /\b\d{2,}\b/, replacement: "\\d+" } + ]; + let pattern = ""; + let lastIndex = 0; + const combinedRegex = new RegExp(dynamicContent.map((r) => "(" + r.regex.source + ")").join("|"), "g"); + text.replace(combinedRegex, (match, ...args) => { + const offset = args[args.length - 2]; + const groups = args.slice(0, -2); + pattern += escapeRegExp(text.slice(lastIndex, offset)); + for (let i = 0; i < groups.length; i++) { + if (groups[i]) { + const { replacement } = dynamicContent[i]; + pattern += replacement; + break; + } + } + lastIndex = offset + match.length; + return match; + }); + if (!pattern) + return text; + pattern += escapeRegExp(text.slice(lastIndex)); + return String(new RegExp(pattern)); +} +function textContributesInfo(node, text) { + if (!text.length) + return false; + if (!node.name) + return true; + const substr = text.length <= 200 && node.name.length <= 200 ? longestCommonSubstring(text, node.name) : ""; + let filtered = text; + while (substr && filtered.includes(substr)) + filtered = filtered.replace(substr, ""); + return filtered.trim().length / text.length > 0.1; +} +var elementSymbol = /* @__PURE__ */ Symbol("element"); +function ariaNodeElement(ariaNode) { + return ariaNode[elementSymbol]; +} +function setAriaNodeElement(ariaNode, element) { + ariaNode[elementSymbol] = element; +} + +// esm-entry.ts +function buildSnapshot(root, options) { + const o = Object.assign({ mode: "ai" }, options || {}); + const tree = generateAriaTree(root || document.body, o); + const rendered = renderAriaTree(tree, o); + return { + text: rendered.text, + elements: tree.elements, + // Map + refs: tree.refs + // Map + }; +} +export { + buildSnapshot, + generateAriaTree, + renderAriaTree +}; diff --git a/resources/assets/js/components/AIAssistant/aria/esm-entry.ts b/resources/assets/js/components/AIAssistant/aria/esm-entry.ts new file mode 100644 index 000000000..7938ad5d5 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/aria/esm-entry.ts @@ -0,0 +1,16 @@ +import { generateAriaTree, renderAriaTree } from '@injected/ariaSnapshot'; + +// dootask AI 助手页面操作 · 描述层封装。 +// buildSnapshot(root) → { text: ariaSnapshot YAML, elements: Map, refs: Map } +export function buildSnapshot(root?: Element, options?: any) { + const o = Object.assign({ mode: 'ai' }, options || {}); + const tree = generateAriaTree((root || document.body) as Element, o); + const rendered = renderAriaTree(tree, o); + return { + text: rendered.text, + elements: tree.elements, // Map + refs: tree.refs, // Map + }; +} + +export { generateAriaTree, renderAriaTree }; diff --git a/resources/assets/js/components/AIAssistant/aria/spike-tsconfig.json b/resources/assets/js/components/AIAssistant/aria/spike-tsconfig.json new file mode 100644 index 000000000..f4d017d19 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/aria/spike-tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@injected/*": ["./packages/injected/src/*"], + "@isomorphic/*": ["./packages/isomorphic/*"] + } + } +} diff --git a/resources/assets/js/components/AIAssistant/input-backends/electron-backend.js b/resources/assets/js/components/AIAssistant/input-backends/electron-backend.js new file mode 100644 index 000000000..480eda9b1 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/input-backends/electron-backend.js @@ -0,0 +1,75 @@ +/** + * Electron 输入后端(CDP 可信输入,渲染侧) + * + * 桌面端经 preload 暴露的 window.electron.sendAsync('pageInput', ...) 把输入意图 + * 转给主进程,由 webContents.debugger 走 CDP Input 域产生 isTrusted=true 的可信输入 + * (等价 Playwright 真正可靠的那一半,但第一方、在用户会话内,且能到达同源 iframe)。 + * + * 仅覆盖最受益于可信输入的 type/click;其余操作(select 的 open→click、check、scroll + * 等)页面内合成事件已足够,交回 Web 后端。任一 CDP 调用失败 → 回退 Web 后端。 + * + * 注意:主进程 ipcMain.handle('pageInput') 见 electron/lib/page-input.js。 + */ + +export function isElectronInputAvailable() { + return !!(typeof window !== 'undefined' + && window.$A && window.$A.isElectron + && window.electron && typeof window.electron.sendAsync === 'function'); +} + +function centerOf(el) { + const r = el.getBoundingClientRect(); + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; +} + +// 选中元素现有内容,便于 insertText 覆盖 +function selectAll(el, win) { + try { + if (typeof el.select === 'function') { el.select(); return; } + if (el.isContentEditable) { + const sel = win.getSelection && win.getSelection(); + if (sel) { + const range = el.ownerDocument.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + } + } + } catch (e) { /* ignore */ } +} + +export function createElectronBackend(getFallback) { + const cdp = (op, args) => window.electron.sendAsync('pageInput', Object.assign({ op }, args)); + + return { + name: 'electron', + async perform(element, action, value, ctx) { + const win = (ctx && ctx.win) || element.ownerDocument.defaultView || window; + try { + if (action === 'type' || action === 'fill' || action === 'clear') { + const v = action === 'clear' ? '' : (value == null ? '' : String(value)); + if (typeof element.focus === 'function') element.focus(); + selectAll(element, win); + await cdp('insertText', { text: v }); // 覆盖当前选区 + const got = element.value != null ? element.value : (element.textContent || ''); + if (v && got !== v && !(got || '').includes(v)) { + throw new Error('value_not_applied: 可信输入回读不一致'); + } + return { success: true, action: 'type', value: v, backend: 'electron' }; + } + if (action === 'click') { + const { x, y } = centerOf(element); + await cdp('click', { x, y }); + return { success: true, action: 'click', backend: 'electron' }; + } + } catch (e) { + // 可信输入失败 → 回退 Web 后端 + return getFallback().perform(element, action, value, ctx); + } + // 其余操作页面内合成已足够,交回 Web 后端 + return getFallback().perform(element, action, value, ctx); + }, + }; +} + +export default createElectronBackend; diff --git a/resources/assets/js/components/AIAssistant/input-backends/index.js b/resources/assets/js/components/AIAssistant/input-backends/index.js new file mode 100644 index 000000000..4b60663b0 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/input-backends/index.js @@ -0,0 +1,28 @@ +/** + * 输入后端选择 + * + * - Electron 桌面端且 CDP 桥可用 → ElectronInputBackend(可信输入),失败回退 Web。 + * - 其他环境 → WebInputBackend(页面内合成事件,地板)。 + * + * 描述/定位层(ariaSnapshot + ref→Element)与后端无关,由 collector/executor 负责; + * 后端只接收已定位的 element + action + value。 + */ + +import { createWebBackend } from './web-backend'; +import { createElectronBackend, isElectronInputAvailable } from './electron-backend'; + +let _web = null; +let _electron = null; + +export function selectBackend() { + if (isElectronInputAvailable()) { + if (!_electron) _electron = createElectronBackend(() => getWebBackend()); + return _electron; + } + return getWebBackend(); +} + +export function getWebBackend() { + if (!_web) _web = createWebBackend(); + return _web; +} diff --git a/resources/assets/js/components/AIAssistant/input-backends/web-backend.js b/resources/assets/js/components/AIAssistant/input-backends/web-backend.js new file mode 100644 index 000000000..8617be6f2 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/input-backends/web-backend.js @@ -0,0 +1,167 @@ +/** + * Web 输入后端(DOM 兜底) + * + * 在页面内用合成事件操作元素:原生 prototype value setter(绕过 React/Vue 值追踪) + * + 完整事件序列 + 写后回读校验;自定义下拉/日期等用 open→click。无法处理的控件 + * (文件上传、动态明细表等)抛 unsupported_widget,由上层(app call/回问)兜底。 + * + * 已知边界:合成事件 isTrusted=false,部分组件(拖拽/指针捕获/原生文件框)填不动。 + * 桌面端优先走 Electron CDP 可信输入后端(见 electron-backend.js)。 + */ + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// 结构化错误:消息以 `: ` 前缀,便于上层与模型识别 +function fail(code, msg) { + return new Error(`${code}: ${msg}`); +} + +// 组件壳 → 内层可写控件 +function resolveWritable(el) { + if (!el) return el; + if (el.matches && el.matches('input, textarea')) return el; + if (el.isContentEditable) return el; + const inner = el.querySelector && el.querySelector('input, textarea, [contenteditable="true"], [contenteditable=""]'); + return inner || el; +} + +function fireInput(el, win) { + const InputEvt = win.InputEvent || win.Event; + el.dispatchEvent(new InputEvt('input', { bubbles: true })); + el.dispatchEvent(new win.Event('change', { bubbles: true })); +} + +// 原生 setter + 事件序列 + 回读 +function setValue(rawEl, value, win) { + const el = resolveWritable(rawEl); + const v = value == null ? '' : String(value); + const tag = (el.tagName || '').toLowerCase(); + + if (el.isContentEditable) { + el.focus(); + const sel = win.getSelection && win.getSelection(); + if (sel) { + const range = el.ownerDocument.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + } + const ok = el.ownerDocument.execCommand && el.ownerDocument.execCommand('insertText', false, v); + if (!ok) { el.textContent = v; } + fireInput(el, win); + if (v && !(el.textContent || '').includes(v)) throw fail('value_not_applied', '富文本写入未生效'); + return { success: true, action: 'type', value: v }; + } + + if (tag !== 'input' && tag !== 'textarea') { + throw fail('unsupported_widget', '元素不可输入文本'); + } + + el.focus(); + const proto = tag === 'textarea' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + const setter = desc && desc.set; + if (setter) setter.call(el, v); else el.value = v; // 兜底 + fireInput(el, win); + el.dispatchEvent(new win.Event('blur', { bubbles: true })); + if (el.value !== v) throw fail('value_not_applied', '写入后回读不一致'); + return { success: true, action: 'type', value: v }; +} + +function clickEl(el) { + if (typeof el.focus === 'function') { try { el.focus(); } catch (e) {} } + el.click(); + return { success: true, action: 'click' }; +} + +// 原生 select 直接设值;自定义下拉/日期 open→click 匹配项 +async function selectOption(el, value, doc, win) { + const v = value == null ? '' : String(value); + const tag = (el.tagName || '').toLowerCase(); + + if (tag === 'select') { + const opt = [...el.options].find((o) => + o.value === v || (o.textContent || '').trim() === v || (o.textContent || '').includes(v)); + if (!opt) throw fail('unsupported_widget', `原生 select 无选项 ${v}`); + el.value = opt.value; + fireInput(el, win); + return { success: true, action: 'select', value: v }; + } + + // 自定义下拉:点击展开 → 在选项/日历单元格里按文本匹配 → 点击 + el.click(); + await delay(180); + const optSelectors = '[role="option"], [role="gridcell"], [role="menuitemradio"], .ivu-select-item, li[role]'; + const opts = [...doc.querySelectorAll(optSelectors)].filter((o) => o.offsetParent !== null || o.getClientRects().length); + const exact = opts.find((o) => (o.textContent || '').trim() === v); + const fuzzy = opts.find((o) => (o.textContent || '').trim().includes(v)); + const match = exact || fuzzy; + if (!match) throw fail('unsupported_widget', `下拉未渲染匹配项 ${v}`); + match.click(); + await delay(80); + return { success: true, action: 'select', value: v }; +} + +function isChecked(el) { + const t = el.matches && el.matches('input[type="checkbox"], input[type="radio"]') + ? el : (el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]')); + if (t) return !!t.checked; + const a = el.getAttribute && el.getAttribute('aria-checked'); + return a === 'true'; +} + +function setChecked(el, want) { + const t = el.matches && el.matches('input[type="checkbox"], input[type="radio"]') + ? el : (el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]')); + if (t) { + if (!!t.checked !== want) t.click(); + return { success: true, action: want ? 'check' : 'uncheck', checked: !!t.checked }; + } + const a = el.getAttribute && el.getAttribute('aria-checked'); + if (a != null) { + if ((a === 'true') !== want) el.click(); + return { success: true, action: want ? 'check' : 'uncheck' }; + } + throw fail('unsupported_widget', '非复选/单选元素'); +} + +export function createWebBackend() { + return { + name: 'web', + async perform(element, action, value, ctx) { + const win = (ctx && ctx.win) || element.ownerDocument.defaultView || window; + const doc = (ctx && ctx.doc) || element.ownerDocument; + switch (action) { + case 'click': + return clickEl(element); + case 'type': + case 'fill': + return setValue(element, value, win); + case 'clear': + return setValue(element, '', win); + case 'select': + return selectOption(element, value, doc, win); + case 'check': + return setChecked(element, true); + case 'uncheck': + return setChecked(element, false); + case 'toggle': + return setChecked(element, !isChecked(element)); + case 'focus': + element.focus(); + return { success: true, action: 'focus' }; + case 'scroll': + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return { success: true, action: 'scroll' }; + case 'hover': + element.dispatchEvent(new win.MouseEvent('mouseenter', { bubbles: true })); + element.dispatchEvent(new win.MouseEvent('mouseover', { bubbles: true })); + return { success: true, action: 'hover' }; + default: + throw fail('not_actionable', `不支持的操作: ${action}`); + } + }, + }; +} + +export default createWebBackend; diff --git a/resources/assets/js/components/AIAssistant/operation-module.js b/resources/assets/js/components/AIAssistant/operation-module.js index de5f58e3f..f6f5f3587 100644 --- a/resources/assets/js/components/AIAssistant/operation-module.js +++ b/resources/assets/js/components/AIAssistant/operation-module.js @@ -97,7 +97,7 @@ class OperationModule { reason: active.reason, }; base.hint = `${reasonText}。可改用主界面(scope=main)操作,或改用数据命令完成。`; - this.executor.setRefMap({}, null); + this.executor.setRefMap(new Map(), null); return base; } @@ -132,15 +132,11 @@ class OperationModule { context.total_count = vectorMatches.length; context.has_more = false; context.vector_matched = true; + context.refElements = allContext.refElements; // 全量 Map 覆盖向量命中的 ref context.ref_map = {}; for (const el of vectorMatches) { if (el.ref) { - context.ref_map[el.ref] = { - role: el.role, - name: el.name, - selector: el.selector, - nth: el.nth, - }; + context.ref_map[el.ref] = { role: el.role, name: el.name }; } } } @@ -154,10 +150,11 @@ class OperationModule { operable: true, }; - // 将 refMap 与活动上下文一并存入 executor,供后续元素操作使用(含失效守卫) - if (context.ref_map && this.executor) { - this.executor.setRefMap(context.ref_map, active); + // 将 ref→Element 实时 Map 与活动上下文存入 executor,供后续元素操作解析(含失效守卫) + if (context.refElements && this.executor) { + this.executor.setRefMap(context.refElements, active); } + delete context.refElements; // Map 不参与序列化回包 return context; } diff --git a/resources/assets/js/components/AIAssistant/page-context-collector.js b/resources/assets/js/components/AIAssistant/page-context-collector.js index 8890214fd..a5bb926c8 100644 --- a/resources/assets/js/components/AIAssistant/page-context-collector.js +++ b/resources/assets/js/components/AIAssistant/page-context-collector.js @@ -3,8 +3,14 @@ * * 借鉴 agent-browser 项目的设计思想,基于 ARIA 角色收集页面元素。 * 提供结构化的页面快照,包括可交互元素和内容元素。 + * + * 描述层(元素发现 + 可访问名/角色 + ariaSnapshot 文本 + ref)复用 Playwright + * 注入脚本的 ariaSnapshot 子集(见 ./aria/aria-bundle.js);本文件负责分页、 + * 关键词/向量过滤、available_actions 等业务包装,并持有 ref→Element 实时 Map。 */ +import { buildSnapshot } from './aria/aria-bundle'; + // ========== ARIA 角色分类 ========== // 可交互角色 - 这些元素可以被点击、输入等 @@ -111,6 +117,8 @@ export function collectPageContext(store, options = {}) { context.total_count = result.totalCount; context.has_more = result.hasMore; context.ref_map = result.refMap; + context.snapshot = result.snapshot; // ariaSnapshot YAML(模型主消费) + context.refElements = result.refElements; // Map(实时,供执行器;序列化前删除) // 标记是否经过关键词过滤 if (query) { context.query = query; @@ -227,225 +235,81 @@ function getAvailableActions(routeName, store) { } /** - * 收集页面元素 + * 收集页面元素(基于 Playwright ariaSnapshot 子集发现可交互元素) + * + * 元素发现 / 可访问名·角色 / ref 分配全部来自 buildSnapshot;本函数只做关键词 + * 过滤、分页与结构化包装,并返回全量 ref→Element 实时 Map 供执行器解析。 * @param {Object} options - * @param {boolean} options.interactiveOnly - 仅返回可交互元素 * @param {number} options.maxElements - 每页最大元素数量 * @param {number} options.offset - 跳过前 N 个元素 * @param {string} options.container - 容器选择器 * @param {string} options.query - 搜索关键词 - * @returns {Object} { elements, refMap, totalCount, hasMore, keywordMatched } + * @returns {Object} { elements, refMap, refElements, snapshot, totalCount, hasMore, keywordMatched } */ export function collectElements(options = {}) { const { doc = document, - interactiveOnly = false, maxElements = 50, offset = 0, container = null, query = '', } = options; - // 确定查询的根元素 - let rootElement = doc; + const empty = { elements: [], refMap: {}, refElements: new Map(), snapshot: '', totalCount: 0, hasMore: false, keywordMatched: false }; + + // 确定快照根元素 + let rootElement = doc.body || doc.documentElement || doc; if (container) { rootElement = doc.querySelector(container); - if (!rootElement) { - return { elements: [], refMap: {}, totalCount: 0, hasMore: false }; - } + if (!rootElement) return empty; } - // 角色+名称计数器,用于处理重复元素 - const roleNameCounter = new Map(); - - // 获取所有可能的交互元素选择器 - const selectors = [ - // 按钮类 - 'button', - '[role="button"]', - 'input[type="submit"]', - 'input[type="button"]', - 'input[type="reset"]', - '.ivu-btn', - // 链接 - 'a[href]', - '[role="link"]', - // 输入框 - 'input:not([type="hidden"])', - 'textarea', - '[role="textbox"]', - '[contenteditable="true"]', - // 选择器 - 'select', - '[role="combobox"]', - '[role="listbox"]', - '.ivu-select', - // 复选框和单选框 - 'input[type="checkbox"]', - 'input[type="radio"]', - '[role="checkbox"]', - '[role="radio"]', - '.ivu-checkbox', - '.ivu-radio', - // 菜单项 - '[role="menuitem"]', - '[role="tab"]', - '.ivu-menu-item', - '.ivu-tabs-tab', - // 可点击的图标和操作按钮 - '[class*="click"]', - '[class*="btn"]', - '[class*="action"]', - '.taskfont[title]', - // 表格单元格(可能可点击) - 'td[onclick]', - 'tr[onclick]', - ]; - - // 基于 INTERACTIVE_ROLES 补全所有 ARIA 可交互角色(与上面重复的选择器会被 querySelectorAll 按元素自动去重) - for (const role of INTERACTIVE_ROLES) { - selectors.push(`[role="${role}"]`); + // 描述层:Playwright ariaSnapshot 子集 → YAML 文本 + 全量 ref→Element Map(仅可交互元素分配 ref) + let snap; + try { + snap = buildSnapshot(rootElement, { mode: 'ai' }); + } catch (e) { + return empty; } - // 可聚焦 / 自定义可交互元素(显式 tabindex 且非 -1) - selectors.push('[tabindex]:not([tabindex="-1"])'); + const refElements = snap.elements || new Map(); // Map,覆盖全部可交互元素 - // 如果不仅限交互元素,添加内容元素选择器 - if (!interactiveOnly) { - selectors.push( - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - '[role="heading"]', - 'img[alt]', - 'nav', - 'main', - ); + // 基于 ref→Element 构建结构化列表(顺序即 DOM 顺序,ref 与 YAML 一致) + const all = []; + for (const [ref, el] of refElements.entries()) { + if (!el || !el.isConnected) continue; + const info = extractElementInfo(el); + if (!info) continue; + info.ref = ref; + all.push(info); } - // 第一阶段:收集所有有效元素(不限数量) - const allValidElements = []; - const processedElements = new Set(); - - // 查询所有匹配的元素 - const candidateElements = rootElement.querySelectorAll(selectors.join(', ')); - - for (const el of candidateElements) { - processedElements.add(el); - - // 跳过不可见元素 - if (!isElementVisible(el)) continue; - - // 跳过禁用元素 - if (isElementDisabled(el)) continue; - - allValidElements.push({ el, fromPointerScan: false }); - } - - // 第二遍扫描:查找具有 cursor: pointer 但未被选择器匹配的元素 - const clickableSelectors = 'div, span, li, td, tr, section, article, aside, header, footer, label, i, svg'; - const potentialClickables = rootElement.querySelectorAll(clickableSelectors); - - for (const el of potentialClickables) { - if (processedElements.has(el)) continue; - - // label[for] 或包裹表单控件的 label:点击等价于其控件(已单独采集),跳过避免重复 - if (el.tagName === 'LABEL' && (el.htmlFor || el.querySelector('input, textarea, select'))) continue; - - // 检查是否有 cursor: pointer 样式 - const computedStyle = (el.ownerDocument.defaultView || window).getComputedStyle(el); - if (computedStyle.cursor !== 'pointer') continue; - - // 跳过不可见或禁用元素 - if (!isElementVisible(el)) continue; - if (isElementDisabled(el)) continue; - - processedElements.add(el); - allValidElements.push({ el, fromPointerScan: true }); - } - - // 第二阶段:应用关键词过滤(如果有 query) - let filteredElements = allValidElements; + // 关键词过滤(无匹配则回退到全部) + let filtered = all; let keywordMatched = false; - if (query) { - const lowerQuery = query.toLowerCase(); - filteredElements = allValidElements.filter(({ el }) => { - const name = getElementName(el).toLowerCase(); - const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase(); - const placeholder = (el.placeholder || '').toLowerCase(); - const title = (el.title || '').toLowerCase(); - - return name.includes(lowerQuery) - || ariaLabel.includes(lowerQuery) - || placeholder.includes(lowerQuery) - || title.includes(lowerQuery); - }); - keywordMatched = filteredElements.length > 0; - - // 如果关键词匹配不到任何元素,回退到全部元素 - if (filteredElements.length === 0) { - filteredElements = allValidElements; - } + const q = query.toLowerCase(); + filtered = all.filter((info) => (info.name || '').toLowerCase().includes(q) + || (info.placeholder || '').toLowerCase().includes(q) + || (info.aria_label || '').toLowerCase().includes(q) + || (info.title || '').toLowerCase().includes(q)); + keywordMatched = filtered.length > 0; + if (filtered.length === 0) filtered = all; } - // 第三阶段:应用分页,生成最终结果 - const totalCount = filteredElements.length; - const startIndex = offset; + // 分页(结构化列表分页;refElements 始终为全量,执行器可解析任意 ref) + const totalCount = filtered.length; const endIndex = Math.min(offset + maxElements, totalCount); const hasMore = endIndex < totalCount; const elements = []; const refMap = {}; - let refCounter = offset + 1; // ref 从 offset+1 开始,保持全局唯一 - - for (let i = startIndex; i < endIndex; i++) { - const { el, fromPointerScan } = filteredElements[i]; - - // 获取元素信息 - const elementInfo = extractElementInfo(el, refCounter); - if (!elementInfo) continue; - - // 如果是从 pointer 扫描来的,强制设为 button 角色 - if (fromPointerScan && elementInfo.role === 'generic') { - elementInfo.role = 'button'; - } - - // 处理重复的角色+名称组合 - const key = `${elementInfo.role}:${elementInfo.name || ''}`; - const count = roleNameCounter.get(key) || 0; - roleNameCounter.set(key, count + 1); - - if (count > 0) { - elementInfo.nth = count; - } - - // 生成 ref - const ref = `e${refCounter++}`; - elementInfo.ref = ref; - - // 存储到 refMap - refMap[ref] = { - role: elementInfo.role, - name: elementInfo.name, - selector: elementInfo.selector, - nth: elementInfo.nth, - }; - - elements.push(elementInfo); + for (let i = offset; i < endIndex; i++) { + const info = filtered[i]; + elements.push(info); + refMap[info.ref] = { role: info.role, name: info.name }; } - // 为重复元素添加 nth 标记 - for (const element of elements) { - const key = `${element.role}:${element.name || ''}`; - const roleCount = roleNameCounter.get(key); - // 只有真正重复的才保留 nth - if (roleCount <= 1) { - delete element.nth; - if (refMap[element.ref]) { - delete refMap[element.ref].nth; - } - } - } - - return { elements, refMap, totalCount, hasMore, keywordMatched }; + return { elements, refMap, refElements, snapshot: snap.text, totalCount, hasMore, keywordMatched }; } /**