kuaifan a3d5bdf93c feat(ai-assistant): 重构页面操作为统一执行器(Web DOM floor + Electron CDP 双后端)
页面操作(doo page → /ws → 浮窗 operation-module → action-executor)原用手写
iView-only 选择器 + 朴素 value= 赋值,在 React+Radix 同源 iframe 业务表单上几乎
填不进。重构为「复用 Playwright 注入脚本的描述层 + 按环境选后端的执行层」:

描述层(共用):
- 新增 aria/aria-bundle.js,vendoring Playwright ariaSnapshot/roleUtils/domUtils
  子集预构建为 ESM(Apache-2.0 NOTICE + 固定上游 commit),产出 YAML 快照与活的
  ref→Element Map。
- page-context-collector 改用 buildSnapshot 采集,operation-module 经 setRefMap
  以 Map 直接持有元素取代 selector 反查。

执行层(按环境选后端,失败回退):
- web-backend:原生 prototype value setter + beforeinput/input/change/blur 序列
  + 回读校验(不符报 value_not_applied,绝不假报成功);自定义下拉/日期 open→click。
- electron-backend + electron/lib/page-input.js:经 webContents.debugger 走 CDP
  Input 域产生 isTrusted=true 可信输入(insertText/dispatchMouseEvent/KeyEvent),
  任一步失败回退 Web 后端。
- 复杂控件(动态明细表/成员选择器/文件上传)如实返回 unsupported_widget。

协议与链路(doo page、active-context 失效守卫、导航类 action)保持不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:03:59 +00:00

312 lines
10 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.

/**
* 操作执行器
*
* 执行来自 MCP 工具的操作请求,包括:
* - 导航操作(打开任务、切换项目、跳转页面等)
* - 元素级操作(点击、输入等,作为兜底)
*
* 注意:数据操作(创建任务、发送消息等)应使用 MCP DooTask 工具直接调用 API
* 本模块只负责前端导航和 UI 操作。
*/
import { resolveActiveContext } from './active-context';
import { selectBackend } from './input-backends';
/**
* 创建操作执行器
* @param {Object} store - Vuex store 实例
* @param {Object} router - Vue Router 实例
* @returns {Object} 执行器实例
*/
export function createActionExecutor(store, router) {
return new ActionExecutor(store, router);
}
class ActionExecutor {
constructor(store, router) {
this.store = store;
this.router = router;
// 导航操作注册表
this.actionHandlers = {
// 打开资源详情(通过 Vuex action
open_task: this.openTask.bind(this),
open_dialog: this.openDialog.bind(this),
// 页面导航(通过 goForward
open_project: this.openProject.bind(this),
open_file: this.openFile.bind(this),
open_folder: this.openFolder.bind(this),
// 功能页面导航
navigate_to_dashboard: this.navigateToDashboard.bind(this),
navigate_to_messenger: this.navigateToMessenger.bind(this),
navigate_to_calendar: this.navigateToCalendar.bind(this),
navigate_to_files: this.navigateToFiles.bind(this),
// 别名支持
goto_task: this.openTask.bind(this),
goto_project: this.openProject.bind(this),
goto_dialog: this.openDialog.bind(this),
navigate_to_task: this.openTask.bind(this),
navigate_to_project: this.openProject.bind(this),
navigate_to_dialog: this.openDialog.bind(this),
};
}
/**
* 执行导航操作
* @param {string} actionName - 操作名称
* @param {Object} params - 操作参数
* @returns {Promise<Object>} 执行结果
*/
async executeAction(actionName, params = {}) {
// 智能解析操作名,支持 open_task_358 这样的格式
const { normalizedAction, extractedParams } = this.parseActionName(actionName);
const mergedParams = { ...extractedParams, ...params };
const handler = this.actionHandlers[normalizedAction];
if (!handler) {
throw new Error(`不支持的操作: ${actionName}。支持的操作: ${Object.keys(this.actionHandlers).join(', ')}`);
}
try {
const result = await handler(mergedParams);
return {
success: true,
action: normalizedAction,
result,
};
} catch (error) {
throw new Error(`执行操作失败: ${error.message}`);
}
}
/**
* 解析操作名,提取嵌入的参数
* 支持格式: open_task_358 -> { normalizedAction: 'open_task', extractedParams: { task_id: 358 } }
*/
parseActionName(actionName) {
const patterns = [
{ regex: /^(open_task|goto_task|navigate_to_task)_(\d+)$/, paramName: 'task_id' },
{ regex: /^(open_project|goto_project|navigate_to_project)_(\d+)$/, paramName: 'project_id' },
{ regex: /^(open_dialog|goto_dialog|navigate_to_dialog)_(\d+)$/, paramName: 'dialog_id' },
{ regex: /^(open_file)_(\d+)$/, paramName: 'file_id' },
{ regex: /^(open_folder)_(\d+)$/, paramName: 'folder_id' },
];
for (const { regex, paramName } of patterns) {
const match = actionName.match(regex);
if (match) {
return {
normalizedAction: match[1],
extractedParams: { [paramName]: parseInt(match[2], 10) },
};
}
}
return { normalizedAction: actionName, extractedParams: {} };
}
// ========== 打开资源详情 ==========
/**
* 打开任务详情
*/
async openTask(params) {
const taskId = params.task_id;
if (!taskId) {
throw new Error('缺少 task_id 参数');
}
this.store.dispatch('openTask', taskId);
return { opened: true, task_id: taskId };
}
/**
* 打开对话
*/
async openDialog(params) {
const dialogId = params.dialog_id;
if (!dialogId) {
throw new Error('缺少 dialog_id 参数');
}
// 支持高级参数:跳转到特定消息
const dialogParams = params.msg_id
? { dialog_id: dialogId, search_msg_id: params.msg_id }
: dialogId;
this.store.dispatch('openDialog', dialogParams);
return { opened: true, dialog_id: dialogId };
}
// ========== 页面导航 ==========
/**
* 打开/切换到项目
*/
async openProject(params) {
const projectId = params.project_id;
if (!projectId) {
throw new Error('缺少 project_id 参数');
}
window.$A.goForward({ name: 'manage-project', params: { projectId } });
return { navigated: true, project_id: projectId };
}
/**
* 打开文件预览
*/
async openFile(params) {
const fileId = params.file_id;
if (!fileId) {
throw new Error('缺少 file_id 参数');
}
window.$A.goForward({ name: 'manage-file', params: { fileId } });
return { navigated: true, file_id: fileId };
}
/**
* 打开文件夹
*/
async openFolder(params) {
const folderId = params.folder_id;
if (!folderId) {
throw new Error('缺少 folder_id 参数');
}
window.$A.goForward({ name: 'manage-file', params: { folderId, fileId: null } });
return { navigated: true, folder_id: folderId };
}
/**
* 导航到仪表盘
*/
async navigateToDashboard() {
window.$A.goForward({ name: 'manage-dashboard' });
return { navigated: true, page: 'dashboard' };
}
/**
* 导航到消息页面
*/
async navigateToMessenger() {
window.$A.goForward({ name: 'manage-messenger' });
return { navigated: true, page: 'messenger' };
}
/**
* 导航到日历页面
*/
async navigateToCalendar() {
window.$A.goForward({ name: 'manage-calendar' });
return { navigated: true, page: 'calendar' };
}
/**
* 导航到文件管理页面
*/
async navigateToFiles() {
window.$A.goForward({ name: 'manage-file' });
return { navigated: true, page: 'files' };
}
// ========== 元素级操作 ==========
/**
* 设置当前的 refMap 与活动上下文(由 operation-module 在获取上下文后调用)
*/
setRefMap(refElements, context = null) {
// refElements: Map<ref, Element>(描述层产出,直接持有元素);容错旧的 plain object
this.currentRefElements = refElements instanceof Map ? refElements : null;
this.currentContext = context;
}
/**
* 解析当前应执行元素操作的文档,并做失效守卫校验。
* 当上次采集发生在某个微应用 iframe 内时,执行前重新解析最前上下文,
* 若 frameKey 不一致(用户切了应用 / 重开过 / 刷新了页面)则拒绝,避免误操作。
* @returns {Document}
*/
resolveContextDoc() {
const saved = this.currentContext;
// 无上下文信息或主文档:直接用主文档
if (!saved || saved.kind === 'main' || saved.frameKey === 'main') {
return document;
}
const now = resolveActiveContext(this.store, saved.scope || 'auto');
if (now.frameKey !== saved.frameKey || !now.reachable || !now.doc) {
throw new Error('页面上下文已变更(用户切换了应用或刷新了页面),请重新获取页面上下文后再操作');
}
return now.doc;
}
/**
* 执行元素级操作
* @param {string} elementUid - 元素标识 (e1, e2, ... 或选择器)
* @param {string} action - 操作类型
* @param {string} value - 操作值
* @returns {Promise<Object>} 执行结果
*/
async executeElementAction(elementUid, action, value) {
const doc = this.resolveContextDoc();
const element = this.findElement(elementUid, doc);
if (!element) {
throw new Error(`element_not_found: 找不到元素 ${elementUid}(可能页面已变更,请重新获取页面上下文)`);
}
// 元素所在 window主文档或微应用 iframe合成事件需用它的构造器才被框架信任
const win = element.ownerDocument.defaultView || window;
// 选择输入后端Electron CDP 可信输入优先,否则页面内合成事件(地板)
if (!this.backend) this.backend = selectBackend();
const result = await this.backend.perform(element, action, value, { doc, win, elementUid });
return Object.assign({ element: elementUid }, result);
}
/**
* 查找元素
* 支持多种格式e1, @e1, ref=e1, CSS选择器
*/
findElement(identifier, doc = document) {
let ref = null;
if (identifier.startsWith('@')) {
ref = identifier.slice(1);
} else if (identifier.startsWith('ref=')) {
ref = identifier.slice(4);
} else if (/^e\d+$/.test(identifier)) {
ref = identifier;
}
// 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 选择器(兜底)
try {
const element = doc.querySelector(identifier);
if (element) return element;
} catch (e) {
// 选择器无效,忽略
}
return null;
}
/**
* 延迟工具方法
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
export default createActionExecutor;