kuaifan 0ac4b546ba feat(ai-assistant): 实现 AI 前端操作能力
新增三个 MCP 工具的前端支持:
  - get_page_context: 基于 ARIA 角色收集页面元素,支持分页和区域筛选
  - execute_action: 执行导航操作(打开任务/对话、切换项目/页面)
  - execute_element_action: 元素级操作(click/type/select/focus/scroll/hover)

  新增文件:
  - operation-client.js: WebSocket 客户端,处理与 MCP Server 的通信
  - page-context-collector.js: 页面上下文收集器,ref 系统和 cursor:pointer 扫描
  - action-executor.js: 操作执行器,支持智能解析如 open_task_123
  - operation-module.js: 模块编排,整合上述模块

  修改文件:
  - float-button.vue: 集成 operation-module,AI 助手打开时启用
  - index.vue: 发射关闭事件供 float-button 监听
2026-01-18 01:35:13 +00:00

333 lines
11 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 { findElementByRef } from './page-context-collector';
/**
* 创建操作执行器
* @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(refMap) {
this.currentRefMap = refMap;
}
/**
* 执行元素级操作
* @param {string} elementUid - 元素标识 (e1, e2, ... 或选择器)
* @param {string} action - 操作类型
* @param {string} value - 操作值
* @returns {Promise<Object>} 执行结果
*/
async executeElementAction(elementUid, action, value) {
const element = this.findElement(elementUid);
if (!element) {
throw new Error(`找不到元素: ${elementUid}`);
}
switch (action) {
case 'click':
element.click();
return { success: true, action: 'click', element: elementUid };
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 Event('input', { bubbles: true }));
element.dispatchEvent(new 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 Event('change', { bubbles: true }));
return { success: true, action: 'select', value, element: elementUid };
}
// iView Select 组件 - 先点击打开下拉
element.click();
await this.delay(200);
const options = document.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 MouseEvent('mouseenter', { bubbles: true }));
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
return { success: true, action: 'hover', element: elementUid };
default:
throw new Error(`不支持的元素操作: ${action}`);
}
}
/**
* 查找元素
* 支持多种格式e1, @e1, ref=e1, CSS选择器
*/
findElement(identifier) {
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 格式,使用 refMap 查找
if (ref && this.currentRefMap) {
const element = findElementByRef(ref, this.currentRefMap);
if (element) return element;
}
// 尝试作为 CSS 选择器
try {
const element = document.querySelector(identifier);
if (element) return element;
} catch (e) {
// 选择器无效,忽略
}
return null;
}
/**
* 延迟工具方法
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
export default createActionExecutor;