dootask/resources/assets/js/components/AIAssistant/page-context-collector.js
kuaifan f8b335a003 feat(ai-assistant): 增加元素向量匹配与关键词搜索能力
- 新增后端 match_elements API,使用向量相似度匹配页面元素
  - 页面上下文采集支持关键词过滤,按 name/aria-label/placeholder/title 匹配
  - 关键词匹配失败时自动降级为向量搜索
  - 改进 findElementByRef 函数,使用 selector + name 双重匹配提高准确性
2026-01-18 11:50:27 +00:00

842 lines
24 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 页面上下文收集器
*
* 借鉴 agent-browser 项目的设计思想,基于 ARIA 角色收集页面元素。
* 提供结构化的页面快照,包括可交互元素和内容元素。
*/
// ========== ARIA 角色分类 ==========
// 可交互角色 - 这些元素可以被点击、输入等
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio',
'combobox', 'listbox', 'menuitem', 'menuitemcheckbox',
'menuitemradio', 'option', 'searchbox', 'slider',
'spinbutton', 'switch', 'tab', 'treeitem', 'gridcell',
]);
// 内容角色 - 这些元素包含重要内容
const CONTENT_ROLES = new Set([
'heading', 'cell', 'columnheader', 'rowheader',
'listitem', 'article', 'region', 'main', 'navigation',
'img', 'figure',
]);
// HTML 元素到 ARIA 角色的映射
const ELEMENT_ROLE_MAP = {
'button': 'button',
'a': 'link',
'input': (el) => {
const type = el.type?.toLowerCase() || 'text';
switch (type) {
case 'checkbox': return 'checkbox';
case 'radio': return 'radio';
case 'submit':
case 'reset':
case 'button': return 'button';
case 'search': return 'searchbox';
case 'range': return 'slider';
case 'number': return 'spinbutton';
default: return 'textbox';
}
},
'textarea': 'textbox',
'select': 'combobox',
'option': 'option',
'h1': 'heading',
'h2': 'heading',
'h3': 'heading',
'h4': 'heading',
'h5': 'heading',
'h6': 'heading',
'img': 'img',
'nav': 'navigation',
'main': 'main',
'article': 'article',
'li': 'listitem',
'td': 'cell',
'th': 'columnheader',
};
// ========== 元素收集器 ==========
/**
* 收集当前页面上下文
* @param {Object} store - Vuex store 实例
* @param {Object} options - 收集选项
* @param {boolean} options.include_elements - 是否包含可交互元素
* @param {boolean} options.interactive_only - 仅返回可交互元素
* @param {number} options.max_elements - 每页最大元素数量,默认 50
* @param {number} options.offset - 跳过前 N 个元素(分页用),默认 0
* @param {string} options.container - 容器选择器,只扫描该容器内的元素
* @param {string} options.query - 搜索关键词,用于过滤相关元素
* @returns {Object} 页面上下文
*/
export function collectPageContext(store, options = {}) {
const routeName = store?.state?.routeName;
const includeElements = options.include_elements !== false;
const interactiveOnly = options.interactive_only || false;
const maxElements = options.max_elements || 50;
const offset = options.offset || 0;
const container = options.container || null;
const query = options.query || '';
// 基础上下文
const context = {
page_type: routeName || 'unknown',
page_url: window.location.href,
page_title: document.title,
timestamp: Date.now(),
elements: [],
element_count: 0,
total_count: 0,
offset: offset,
has_more: false,
available_actions: getAvailableActions(routeName, store),
};
// 收集可交互元素
if (includeElements) {
const result = collectElements({
interactiveOnly,
maxElements,
offset,
container,
query,
});
context.elements = result.elements;
context.element_count = result.elements.length;
context.total_count = result.totalCount;
context.has_more = result.hasMore;
context.ref_map = result.refMap;
// 标记是否经过关键词过滤
if (query) {
context.query = query;
context.keyword_matched = result.keywordMatched;
}
}
return context;
}
/**
* 根据页面类型获取可用的导航操作
* @param {string} routeName - 路由名称
* @param {Object} store - Vuex store 实例
* @returns {Array} 可用操作列表
*/
function getAvailableActions(routeName, store) {
// 通用导航操作 - 在所有页面都可用
const commonActions = [
{
name: 'navigate_to_dashboard',
description: '跳转到仪表盘',
},
{
name: 'navigate_to_messenger',
description: '跳转到消息页面',
},
{
name: 'navigate_to_calendar',
description: '跳转到日历页面',
},
{
name: 'navigate_to_files',
description: '跳转到文件管理页面',
},
];
// 根据页面类型添加特定操作
const pageSpecificActions = [];
switch (routeName) {
case 'manage-project':
// 项目页面:可以打开任务
pageSpecificActions.push({
name: 'open_task',
description: '打开任务详情',
params: { task_id: '任务ID' },
});
break;
case 'manage-messenger':
// 消息页面:可以打开特定对话
pageSpecificActions.push({
name: 'open_dialog',
description: '打开/切换对话',
params: { dialog_id: '对话ID', msg_id: '(可选)跳转到指定消息' },
});
break;
case 'manage-file':
// 文件页面:可以打开文件夹或文件
pageSpecificActions.push(
{
name: 'open_folder',
description: '打开文件夹',
params: { folder_id: '文件夹ID' },
},
{
name: 'open_file',
description: '打开文件预览',
params: { file_id: '文件ID' },
}
);
break;
case 'manage-dashboard':
// 仪表盘:可以快速跳转到项目或打开任务
pageSpecificActions.push(
{
name: 'open_project',
description: '打开/切换到项目',
params: { project_id: '项目ID' },
},
{
name: 'open_task',
description: '打开任务详情',
params: { task_id: '任务ID' },
}
);
break;
default:
// 其他页面:提供基础的打开操作
pageSpecificActions.push(
{
name: 'open_project',
description: '打开/切换到项目',
params: { project_id: '项目ID' },
},
{
name: 'open_task',
description: '打开任务详情',
params: { task_id: '任务ID' },
},
{
name: 'open_dialog',
description: '打开对话',
params: { dialog_id: '对话ID' },
}
);
}
return [...pageSpecificActions, ...commonActions];
}
/**
* 收集页面元素
* @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 }
*/
function collectElements(options = {}) {
const {
interactiveOnly = false,
maxElements = 50,
offset = 0,
container = null,
query = '',
} = options;
// 确定查询的根元素
let rootElement = document;
if (container) {
rootElement = document.querySelector(container);
if (!rootElement) {
return { elements: [], refMap: {}, totalCount: 0, hasMore: false };
}
}
// 角色+名称计数器,用于处理重复元素
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]',
];
// 如果不仅限交互元素,添加内容元素选择器
if (!interactiveOnly) {
selectors.push(
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'[role="heading"]',
'img[alt]',
'nav',
'main',
);
}
// 第一阶段:收集所有有效元素(不限数量)
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;
// 检查是否有 cursor: pointer 样式
const computedStyle = 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 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 totalCount = filteredElements.length;
const startIndex = offset;
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);
}
// 为重复元素添加 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 };
}
/**
* 提取元素信息
* @param {Element} el
* @param {number} index
* @returns {Object|null}
*/
function extractElementInfo(el, index) {
const tagName = el.tagName.toLowerCase();
const role = getElementRole(el);
// 获取元素名称/文本
const name = getElementName(el);
// 生成选择器
const selector = generateSelector(el);
// 基础信息
const info = {
role,
tag: tagName,
name: name || undefined,
selector,
};
// 添加特定属性
if (el.id) {
info.id = el.id;
}
if (el.type && (tagName === 'input' || tagName === 'button')) {
info.input_type = el.type;
}
if (el.placeholder) {
info.placeholder = el.placeholder;
}
if (el.value && (tagName === 'input' || tagName === 'textarea')) {
info.value = el.value.substring(0, 50);
}
if (el.href && tagName === 'a') {
info.href = el.href;
}
if (el.checked !== undefined) {
info.checked = el.checked;
}
if (el.title) {
info.title = el.title;
}
// 添加 aria 属性
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
info.aria_label = ariaLabel;
}
return info;
}
/**
* 获取元素的 ARIA 角色
* @param {Element} el
* @returns {string}
*/
function getElementRole(el) {
// 首先检查显式的 role 属性
const explicitRole = el.getAttribute('role');
if (explicitRole) {
return explicitRole;
}
// 使用映射表
const tagName = el.tagName.toLowerCase();
const roleMapping = ELEMENT_ROLE_MAP[tagName];
if (typeof roleMapping === 'function') {
return roleMapping(el);
}
if (typeof roleMapping === 'string') {
return roleMapping;
}
// 检查是否可点击
if (el.onclick || el.hasAttribute('onclick') ||
el.style.cursor === 'pointer' ||
window.getComputedStyle(el).cursor === 'pointer') {
return 'button';
}
return 'generic';
}
/**
* 获取元素的可访问名称
* @param {Element} el
* @returns {string}
*/
function getElementName(el) {
// 优先级aria-label > aria-labelledby > 内容文本 > title > placeholder > alt
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) return ariaLabel.trim().substring(0, 100);
const ariaLabelledBy = el.getAttribute('aria-labelledby');
if (ariaLabelledBy) {
const labelEl = document.getElementById(ariaLabelledBy);
if (labelEl) {
return getTextContent(labelEl).substring(0, 100);
}
}
// 对于输入元素,查找关联的 label
if (el.id) {
const label = document.querySelector(`label[for="${el.id}"]`);
if (label) {
return getTextContent(label).substring(0, 100);
}
}
// 获取元素内的文本内容
const text = getTextContent(el);
if (text) return text.substring(0, 100);
// 其他属性
if (el.title) return el.title.substring(0, 100);
if (el.placeholder) return el.placeholder.substring(0, 100);
if (el.alt) return el.alt.substring(0, 100);
if (el.value && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
return el.value.substring(0, 100);
}
return '';
}
/**
* 获取元素的文本内容(排除子元素中隐藏的文本)
* @param {Element} el
* @returns {string}
*/
function getTextContent(el) {
// 克隆元素以避免修改原始 DOM
const clone = el.cloneNode(true);
// 移除脚本和样式
clone.querySelectorAll('script, style, [hidden], [aria-hidden="true"]').forEach(e => e.remove());
// 获取文本并清理
let text = clone.textContent || clone.innerText || '';
text = text.replace(/\s+/g, ' ').trim();
return text;
}
/**
* 检查元素是否可见
* @param {Element} el
* @returns {boolean}
*/
function isElementVisible(el) {
if (!el) return false;
// 检查元素本身
const style = window.getComputedStyle(el);
if (style.display === 'none') return false;
if (style.visibility === 'hidden') return false;
if (style.opacity === '0') return false;
// 检查边界框
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return false;
// 检查是否在视口内或附近(允许稍微超出)
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// 元素完全在视口外
if (rect.bottom < -100 || rect.top > viewportHeight + 100) return false;
if (rect.right < -100 || rect.left > viewportWidth + 100) return false;
// 检查父元素的可见性
let parent = el.parentElement;
while (parent) {
const parentStyle = window.getComputedStyle(parent);
if (parentStyle.display === 'none') return false;
if (parentStyle.visibility === 'hidden') return false;
parent = parent.parentElement;
}
return true;
}
/**
* 检查元素是否禁用
* @param {Element} el
* @returns {boolean}
*/
function isElementDisabled(el) {
if (el.disabled) return true;
if (el.getAttribute('aria-disabled') === 'true') return true;
// 检查是否在禁用的 fieldset 中
const fieldset = el.closest('fieldset');
if (fieldset && fieldset.disabled) {
// legend 中的元素不受影响
const legend = fieldset.querySelector('legend');
if (legend && legend.contains(el)) return false;
return true;
}
// 检查 iView 组件的禁用状态
if (el.classList.contains('ivu-btn-disabled')) return true;
if (el.classList.contains('ivu-input-disabled')) return true;
if (el.classList.contains('ivu-select-disabled')) return true;
return false;
}
/**
* 生成元素选择器
* @param {Element} el
* @returns {string}
*/
function generateSelector(el) {
// 如果有 ID直接使用
if (el.id) {
return `#${el.id}`;
}
// 尝试生成唯一选择器
const parts = [];
let current = el;
let depth = 0;
while (current && current !== document.body && depth < 5) {
let selector = current.tagName.toLowerCase();
// 添加重要的类名(排除动态类)
if (current.className && typeof current.className === 'string') {
const classes = current.className
.split(' ')
.filter(c => c && !c.startsWith('ivu-') && !c.includes('--') && !c.includes('active') && !c.includes('hover'))
.slice(0, 2);
if (classes.length) {
selector += '.' + classes.join('.');
}
}
// 添加有用的属性
if (current.getAttribute('role')) {
selector += `[role="${current.getAttribute('role')}"]`;
} else if (current.getAttribute('data-id')) {
selector += `[data-id="${current.getAttribute('data-id')}"]`;
} else if (current.getAttribute('name')) {
selector += `[name="${current.getAttribute('name')}"]`;
}
parts.unshift(selector);
current = current.parentElement;
depth++;
}
return parts.join(' > ');
}
/**
* 根据 ref 查找元素
* @param {string} ref - 元素引用 (e1, e2, ...)
* @param {Object} refMap - 引用映射表
* @returns {Element|null}
*/
export function findElementByRef(ref, refMap) {
const refData = refMap[ref];
if (!refData) {
return null;
}
// 首先尝试使用选择器 + name 双重匹配
if (refData.selector) {
const elements = document.querySelectorAll(refData.selector);
if (elements.length === 1) {
return elements[0];
}
// 多个匹配时,用 name 进一步筛选
if (elements.length > 1 && refData.name) {
for (const el of elements) {
const elName = getElementName(el);
if (elName === refData.name) {
return el;
}
}
}
// 如果 name 匹配失败,尝试用 nth仅作为最后手段
if (refData.nth !== undefined && elements.length > refData.nth) {
return elements[refData.nth];
}
if (elements.length > 0) {
return elements[0];
}
}
// 回退到角色+名称匹配
const roleSelector = `[role="${refData.role}"]`;
const candidates = document.querySelectorAll(roleSelector);
for (const candidate of candidates) {
if (refData.name) {
const candidateName = getElementName(candidate);
if (candidateName === refData.name) {
return candidate;
}
}
}
return null;
}
/**
* 通过向量搜索匹配元素
* @param {Object} store - Vuex store 实例
* @param {string} query - 搜索查询
* @param {Array} elements - 元素列表
* @param {number} topK - 返回结果数量
* @returns {Promise<Array>} 匹配的元素列表
*/
export async function searchByVector(store, query, elements, topK = 10) {
if (!query || !elements || elements.length === 0) {
return [];
}
// 只传有 name 的元素,减少 API 调用
const minimalElements = elements
.filter(el => el.name && el.name.trim())
.map(el => ({
ref: el.ref,
name: el.name,
}));
if (minimalElements.length === 0) {
return [];
}
try {
const response = await store.dispatch('call', {
url: 'assistant/match_elements',
method: 'post',
data: {
query,
elements: minimalElements,
top_k: topK,
},
});
const matches = response?.data?.matches;
if (matches && matches.length > 0) {
const refOrder = matches.map(m => m.element.ref);
return elements
.filter(el => refOrder.includes(el.ref))
.sort((a, b) => refOrder.indexOf(a.ref) - refOrder.indexOf(b.ref));
}
} catch (e) {
// 向量搜索失败,静默处理
}
return [];
}
export default collectPageContext;
// 暴露到 window 供调试使用
if (typeof window !== 'undefined') {
window.__testPageContext = (options = {}) => {
// 简化版,不需要 store
const context = {
page_url: window.location.href,
page_title: document.title,
timestamp: Date.now(),
};
const result = collectElements({
interactiveOnly: options.interactive_only || false,
maxElements: options.max_elements || 50,
offset: options.offset || 0,
container: options.container || null,
});
context.elements = result.elements;
context.element_count = result.elements.length;
context.total_count = result.totalCount;
context.offset = options.offset || 0;
context.has_more = result.hasMore;
context.ref_map = result.refMap;
return context;
};
}