feat(ai-assistant): 页面操作搬到主程序 /ws + doo task notify + 删 OCR

页面操作传输层从 MCP 独立 WebSocket 迁到主程序常驻 /ws:
- 后端 AssistantController 加 operation__dispatch/result(fd 归属校验 +
  PushTask 精推 + Cache 轮询取结果),WebSocketService onMessage 加
  operationResult 回包分支
- 前端 actions.js 加 case "operation" 经 emitter 桥接到浮窗执行后回包;
  float-button.vue 接 aiOperationRequest;operation-module.js 解耦、
  executor 惰性化;删除 operation-client.js(不再单连 MCP WS)

ai-kb 同步:tool-binding.yaml 去掉 4 个工具(3 页面 + OCR),相关 chunk
reconcile(措辞去 MCP 化、OCR 改多模态识图),工具数 33→29

i18n:后端新增文案登记 original-api.txt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-12 01:31:21 +00:00
parent 9b41330413
commit da095a1a80
22 changed files with 222 additions and 427 deletions

View File

@ -6,9 +6,13 @@ use App\Models\AiAssistantFeedback;
use App\Models\AiAssistantSearchLog;
use App\Models\AiAssistantSession;
use App\Models\User;
use App\Models\WebSocket;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use App\Tasks\PushTask;
use Cache;
use Illuminate\Support\Str;
use Request;
/**
@ -300,6 +304,106 @@ class AssistantController extends AbstractController
]);
}
/**
* @api {post} api/assistant/operation/dispatch 派发页面操作
*
* @apiDescription 需要token身份。通过用户常驻 WebSocket/ws向其浏览器派发一次页面操作获取页面上下文 / 执行动作 / 操作元素),由前端 AI 助手执行后经 operationResult 回传,结果写入缓存供 operation/result 轮询取走。复用主程序 /ws无需为页面操作另开 WebSocket。
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName operation__dispatch
*
* @apiParam {Number} fd 目标会话 fd须为当前用户在线的 WebSocket 连接)
* @apiParam {String} action 操作类型,如 get_page_context|execute_action|execute_element_action
* @apiParam {Object} [payload] 操作参数
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.requestId 本次操作的请求ID用于轮询 operation/result
*/
public function operation__dispatch()
{
$user = User::auth();
$fd = intval(Base::headerOrInput('fd'));
$action = trim(Request::input('action', ''));
$payload = Request::input('payload', []);
if ($fd <= 0 || $action === '') {
return Base::retError('参数错误');
}
if (!is_array($payload)) {
$payload = [];
}
// fd 归属校验:在表即在线,归属即本人
$ownerId = WebSocket::whereFd($fd)->value('userid');
if (intval($ownerId) !== intval($user->userid)) {
return Base::retError('会话不存在或无权限');
}
$requestId = Str::random(24);
// 精确推送到该 fd不补发离线消息
PushTask::push([
'fd' => $fd,
'msg' => [
'type' => 'operation',
'data' => [
'requestId' => $requestId,
'action' => $action,
'payload' => $payload,
],
],
], false);
return Base::retSuccess('success', [
'requestId' => $requestId,
]);
}
/**
* @api {get} api/assistant/operation/result 取页面操作结果
*
* @apiDescription 需要token身份。轮询取走 operation/dispatch 派发的一次页面操作结果(取走即删);未回传时返回 status=pending。
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName operation__result
*
* @apiParam {String} request_id 操作请求ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.status ready|pending
*/
public function operation__result()
{
$user = User::auth();
$requestId = trim(Base::headerOrInput('request_id'));
if ($requestId === '') {
return Base::retError('参数错误');
}
$row = Cache::get("ai_op_result:{$requestId}");
if (!is_array($row)) {
return Base::retSuccess('success', ['status' => 'pending']);
}
// 命中后校验归属再取走,避免越权读取他人结果
if (intval($row['userid']) !== intval($user->userid)) {
return Base::retError('无权限');
}
Cache::forget("ai_op_result:{$requestId}");
return Base::retSuccess('success', [
'status' => 'ready',
'success' => !empty($row['success']),
'result' => $row['result'] ?? null,
'error' => $row['error'] ?? null,
]);
}
/**
* 获取会话列表
*/

View File

@ -136,6 +136,20 @@ class WebSocketService implements WebSocketHandlerInterface
}
Cache::put("User::encrypt:" . $frame->fd, Base::array2json($data), Carbon::now()->addDay());
return;
// AI 助手页面操作结果回包(由 assistant/operation/dispatch 派发,前端执行后回传)
case 'operationResult':
$requestId = trim($data['requestId'] ?? '');
if ($requestId !== '') {
$row = WebSocket::whereFd($frame->fd)->first();
Cache::put("ai_op_result:{$requestId}", [
'userid' => $row?->userid ?: 0,
'success' => !empty($data['success']),
'result' => $data['result'] ?? null,
'error' => $data['error'] ?? null,
], 60);
}
return;
}
// 返回消息

View File

@ -997,3 +997,5 @@ AI 助手
没有查看权限
当前仅指定人员可以创建项目
反馈类型错误
会话不存在或无权限
无权限

View File

@ -7,7 +7,7 @@
# id → 路由);两份的 id 集合必须一致
#
# 边界:本期仅收录"无需运行时 id 的纯导航目的地"(送到某页面/面板)。
# - 打开具体任务/对话/项目(需 task_id 等运行时 id不在此execute_action 承担
# - 打开具体任务/对话/项目(需 task_id 等运行时 id不在此AI 助手的页面操作承担
# - 项目内弹窗类project_settings/member/flow 等需组件接线)二期再加
version: 1

View File

@ -5,11 +5,15 @@
# 2. ingest 期把 related_tools 写入 chunk metadataretriever 可联动工具
# 3. 让 AI 在调 search_help_docs 时同时知道"还能调哪个工具直接操作"
#
# 工具清单来自 dootask-plugins/mcp/server/src/dootaskMcpServer.ts33 个 MCP 工具)
# 工具清单来自 dootask-plugins/mcp/server/src/dootaskMcpServer.ts29 个 MCP 工具)
# 加 helper/tools.py 中的内置工具GetSessionImageTool 等)
#
# 注:页面操作(打开任务/页面跳转/操作元素)已从 MCP 迁出,改由主程序常驻
# WebSocket/ws派发、doo CLIdoo page触发不再是 MCP 工具;图片文字提取
# OCR已下线改由多模态模型直接理解图片。
version: 1
last_updated: 2026-06-10
last_updated: 2026-06-12
tools:
@ -159,32 +163,18 @@ tools:
related_features: [report]
typical_chunk_types: [howto]
# ===== 搜索 / 内容提取 =====
# ===== 搜索 =====
intelligent_search:
description: 统一搜索(任务/项目/文件/联系人/消息),支持语义搜索
related_features: [search]
typical_chunk_types: [howto, concept]
extract_image_text:
description: 图片文字提取OCR支持中英文
related_features: [file]
typical_chunk_types: [howto]
# 注图片文字提取OCR / 原 extract_image_text已下线改由多模态模型直接理解图片
# 不再是 MCP 工具。
# ===== 页面操作 (UI 自动化, WebSocket) =====
get_page_context:
description: 获取当前页面上下文(页面类型/可交互元素/可用操作)
related_features: [ai-assistant]
typical_chunk_types: [concept]
execute_action:
description: 在用户页面执行操作(打开任务/对话/切换项目/页面跳转)
related_features: [ai-assistant]
typical_chunk_types: [howto]
execute_element_action:
description: 操作页面元素(点击/输入/选择/聚焦/滚动/悬停)
related_features: [ai-assistant]
typical_chunk_types: [howto]
# 注:页面操作(原 get_page_context / execute_action / execute_element_action已从 MCP
# 迁出,改由主程序常驻 WebSocket/ws派发、doo CLIdoo page context|action|element触发
# 不再是 MCP 工具。
# 注:原 show_guidedriver.js 分步引导)已下线,改为 AI 回复内联深链
# [显示文字](dootask://link/<id>),目录见 _meta/page-links.yaml前端渲染非 MCP 工具)

View File

@ -26,13 +26,13 @@ last_verified: v1.7.90
# AI 怎么找到页面元素
## 定义
当 AI 想操作页面元素(点按钮、填表)时,先`get_page_context` 拿到候选元素列表(每条含 ref / name / role再通过后端 API `POST api/assistant/match-elements` 把"用户意图描述"和"候选列表"提交给 embedding 服务,返回按余弦相似度排序的命中元素。
当 AI 想操作页面元素(点按钮、填表)时,先采集当前页面上下文拿到候选元素列表(每条含 ref / name / role再通过后端 API `POST api/assistant/match-elements` 把"用户意图描述"和"候选列表"提交给 embedding 服务,返回按余弦相似度排序的命中元素。
## 工作流
1. **采集**:前端按 ARIA 角色扫描,给每个可交互元素分配 refe1, e2...)和 name
2. **关键词过滤**:先用 query 做子串匹配
3. **向量匹配**:关键词没命中时对 query 和元素 name 求 embedding取相似度 top-K默认 10最多 50
4. **执行**:模型拿匹配元素的 ref`execute_element_action`
4. **执行**:模型拿匹配元素的 ref,让 AI 助手在你的页面上操作该元素
## 元素信息字段
- `ref`本轮唯一标识e1, e2...
@ -46,7 +46,7 @@ last_verified: v1.7.90
- 隐藏元素:默认不采集
## 不支持
- 不支持图像 OCR 识图(仅基于文本)
- 元素匹配仅基于元素文本,不靠图像识别
- 不支持「按位置」找元素("左上角第三个"
- 不能跨 iframe 匹配

View File

@ -8,7 +8,7 @@ locale: zh
aliases:
- AI 控制页面
- AI 自动操作
- execute_action 是什么
- AI 怎么帮我操作页面
- AI 跳转页面
- 页面自动化
- AI 点按钮
@ -27,11 +27,11 @@ last_verified: v1.7.90
# AI 操作页面的机制
## 定义
AI 助手通过 `execute_action`(高层导航)和 `execute_element_action`(低层元素操作)两个 MCP 工具操作用户当前页面。后端通过 WebSocket 把指令推给前端,前端的 `action-executor.js` 执行真实 DOM 行为或路由跳转,结果回传给 AI 让对话继续。
AI 助手通过高层导航和低层元素操作两类能力操作用户当前页面。主程序常驻 WebSocket`/ws`)把指令派发给前端,前端的 `action-executor.js` 执行真实 DOM 行为或路由跳转,结果回传给 AI 让对话继续。这类页面操作不是 MCP 工具,由 AI 助手在你的页面上执行。
## 两层接口
- **高层 execute_action**:语义化命名(如 `open_task``navigate_to_dashboard`),参数明确(任务 ID由前端封装好 router 调用;优先用这层,稳定不易错
- **低层 execute_element_action**:基于元素 ref 的通用动作click/type/select/focus/scroll/hover用于没封装好的细节操作
## 两层能力
- **高层导航**:语义化命名(如 `open_task``navigate_to_dashboard`),参数明确(任务 ID由前端封装好 router 调用;优先用这层,稳定不易错
- **低层元素操作**:基于元素 ref 的通用动作click/type/select/focus/scroll/hover用于没封装好的细节操作
## 受支持的高层动作
- `open_task``open_dialog``open_project``open_file``open_folder`

View File

@ -6,13 +6,12 @@ feature: ai-assistant
scope: end-user
locale: zh
aliases:
- get_page_context
- AI 看页面
- 页面上下文
- AI 怎么知道当前页
- AI 读取页面
- AI 上下文采集
related_tools: [get_page_context]
related_tools: []
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件
@ -27,7 +26,7 @@ last_verified: v1.7.90
# AI 怎么知道你在哪个页面
## 定义
AI 通过 MCP 工具 `get_page_context` 向当前用户的浏览器请求页面上下文,包括:当前路由名(如 `manage-project`、URL、标题、可交互元素清单带 ref / name / role、该页可用的高层动作。结果由前端 `page-context-collector.js` 实时收集后回传。
当你在 AI 浮窗里问"这页有什么操作""帮我点这页的某个按钮"时AI 助手会向你当前的浏览器/桌面端页面请求页面上下文,包括:当前路由名(如 `manage-project`、URL、标题、可交互元素清单带 ref / name / role、该页可用的高层动作。结果由前端 `page-context-collector.js` 实时收集后回传给 AI
## 返回字段
- `page_type`:路由名(如 `manage-task`
@ -43,7 +42,7 @@ AI 通过 MCP 工具 `get_page_context` 向当前用户的浏览器请求页面
- **搜索**:传 `query` 先关键词后向量匹配
## 隐式触发
用户在浮窗里问"这个页面有什么操作"、"帮我点这页的某按钮"、"切到下一项目"时AI 都会先`get_page_context` 再决定下一步。
用户在浮窗里问"这个页面有什么操作"、"帮我点这页的某按钮"、"切到下一项目"时AI 都会先采集当前页面上下文再决定下一步。
## 不支持
- 不返回每个元素的位置坐标(仅 selector

View File

@ -47,7 +47,6 @@ last_verified: v1.7.90
## 文件
- `list_files``search_files``get_file_detail``fetch_file_content`
- `extract_image_text`:图片 OCR
## 工作报告
- `list_received_reports``list_my_reports``get_report_detail``create_report``mark_reports_read`
@ -56,10 +55,11 @@ last_verified: v1.7.90
## 搜索
- `intelligent_search`:跨任务/项目/文件/联系人/消息统一语义搜索
## 页面自动化
- `get_page_context`:取当前页面结构与可交互元素
- `execute_action`:打开任务/项目/对话或跳功能页
- `execute_element_action`:点击/输入/选择/聚焦/滚动/悬停
## 页面操作(非 MCP 工具)
AI 助手还能在你当前的浏览器/桌面端页面上帮你打开任务/项目/对话、跳功能页、点击/输入/选择/滚动页面元素。这类页面操作不在 MCP 工具清单内,由 AI 助手在你的页面上直接执行。
## 图片理解(非 MCP 工具)
把图片交给 AI 助手后AI 可直接识别图中内容(多模态),无需单独的图片文字提取工具。
## 知识库
- `search_help_docs`检索本知识库DooTask 功能说明)

View File

@ -32,13 +32,13 @@ last_verified: v1.7.90
DooTask AI 助手通过 MCPModel Context Protocol协议调用工具把"问 AI"扩展为"让 AI 帮我做事"。AI 收到用户请求后判断是否需要调工具,例如查任务、发消息、跳页面,由插件 `mcp_server` 把请求路由到对应的后端 API 或前端动作执行器,再把结果回灌给模型,模型基于结果继续回答。
## 工具来源
- **dootask-mcp 内置工具**33 个,覆盖任务/项目/消息/文件/报告/搜索/页面操作
- **dootask-mcp 内置工具**29 个,覆盖任务/项目/消息/文件/报告/搜索
- **AI 助手内置工具**`search_help_docs`(检索本知识库)、`get_session_image`(取多模态图片)
- 工具清单维护在仓库 `resources/ai-kb/_meta/tool-binding.yaml`
## 工具分两类
- **数据工具**:直接调后端 API`create_task``send_message`),不依赖前端 UI
- **页面工具**:通过 WebSocket 命令前端浏览器(如 `execute_action` 打开任务、`execute_element_action` 点按钮)
## 数据工具与页面操作
- **数据工具MCP**:直接调后端 API`create_task``send_message`),不依赖前端 UI
- **页面操作(非 MCP 工具)**AI 助手还能在你当前的浏览器/桌面端页面上打开任务/跳页面、点击/输入页面元素;这类能力不在 MCP 工具清单内,由 AI 助手在你的页面上执行
## 触发条件
- AI 模型本身必须支持 tool callingOpenAI / Claude / DeepSeek / Qwen 等主流模型均支持)

View File

@ -32,7 +32,7 @@ last_verified: v1.7.90
- **权限不足**:操作了你没权限访问的资源(如别人的任务、非成员的项目)
- **参数错误**AI 推断的 ID 不存在(如任务已被删)
- **超时**:单次工具调用默认 30 秒超时,长操作(大列表/复杂搜索)会断
- **页面不在线**:页面工具execute_action需要前端 socket 在线,关浏览器后立刻调用会失败
- **页面不在线**:页面操作(打开任务/跳页面/操作元素)需要前端 socket 在线,关浏览器后立刻让 AI 操作会失败
- **模型不支持 tool call**:选了纯文本模型,根本不会调
## 解决

View File

@ -12,7 +12,7 @@ aliases:
- AI 滚动页面
- AI 自动填表
- AI 点击 X
related_tools: [execute_element_action]
related_tools: []
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件
@ -28,7 +28,7 @@ last_verified: v1.7.90
# 让 AI 操作页面元素
## 这是什么
让 AI `execute_element_action` 工具直接操作当前页面的具体元素,包括点击按钮、输入文本、选下拉项、聚焦、滚动、悬停。常用于完成详细表单或触发某个隐藏在多级菜单里的功能。
让 AI 助手在你当前页面上直接操作具体元素,包括点击按钮、输入文本、选下拉项、聚焦、滚动、悬停。常用于完成详细表单或触发某个隐藏在多级菜单里的功能。操作由 AI 助手在你的浏览器/桌面端页面上执行。
## 怎么问
- "点击『保存』按钮"
@ -38,9 +38,9 @@ last_verified: v1.7.90
- "悬停在第一个项目卡片上"
## AI 的执行链路
1. 先`get_page_context` 取当前页面元素清单
1. 先采集当前页面的可交互元素清单
2. 用 `match_elements` 接口按描述("保存按钮"、"标题输入框")找到目标 ref
3. `execute_element_action` 触发 click / type / select / focus / scroll / hover
3. 在你的页面上触发 click / type / select / focus / scroll / hover
## 支持的动作
| 动作 | 说明 |

View File

@ -52,7 +52,7 @@ last_verified: v1.7.90
列出任务后可以接续操作,无需重复说"在 X 项目"
- "把第 2 条标完成" → 调 `complete_task`
- "打开第一条" → `execute_action` 跳到任务详情
- "打开第一条" → AI 在你的页面上跳到任务详情
- "给小王再加一条子任务" → 调 `create_sub_task`
## 不支持

View File

@ -12,7 +12,7 @@ aliases:
- 让 AI 帮我开 X
- AI 跳转
- AI 帮我打开
related_tools: [execute_action]
related_tools: []
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件
@ -28,7 +28,7 @@ last_verified: v1.7.90
# 让 AI 帮我跳页面/打开任务
## 这是什么
在 AI 浮窗用自然语言让 AI 把当前页跳转到任务详情、对话、项目、文件预览或功能页。AI `execute_action` 工具,通过 WebSocket 让前端执行真实路由跳转。
在 AI 浮窗用自然语言让 AI 把当前页跳转到任务详情、对话、项目、文件预览或功能页。AI 助手会在你当前的浏览器/桌面端页面上执行真实路由跳转。
## 怎么问
- "打开任务 1234"
@ -55,7 +55,7 @@ last_verified: v1.7.90
- "把它标完成" → 调 `complete_task`
- "看下讨论" → 调 `get_message_list`
- "拉到底部" → `execute_element_action` 滚动
- "拉到底部" → AI 在你的页面上滚动到底部
## 不支持
- 不能跳到外部网址(如 google.com

View File

@ -41,7 +41,7 @@ last_verified: v1.7.90
- 提问含"包含…"、"提到…"、"关于…"时按内容关键词匹配(需后端文件内容索引可用)
## 找到文件能继续做什么
- "打开第一个" → `execute_action`文件预览
- "打开第一个" → AI 在你的页面上跳到文件预览
- "下载它" → AI 会给下载链接(无法直接触发浏览器下载)
- "把摘要发我" → 调 `fetch_file_content` 取文本,再让模型总结
@ -52,5 +52,4 @@ last_verified: v1.7.90
## 相关
- 取文件文字内容:`fetch_file_content`(暂未单独 chunk
- 图片 OCR`extract_image_text`(暂未单独 chunk
- 跨类型语义搜索:[[ai-assistant.intelligent-search.howto]]

View File

@ -44,7 +44,7 @@ last_verified: v1.7.90
- 入口:全局搜索框 → 选择「文件」标签
- 索引由 Manticore 提供仅文档类document / txt / code / pdf 抽取出的文本)被索引
- office 文件doc/xls/ppt已索引后可按内容关键词命中
- 图片可走 OCRextract_image_text 工具)后再搜索
- 图片本身不参与内容索引;如需理解图中文字,可把图片交给 AI 助手直接识别(多模态)
## 不支持
- 不支持模糊匹配 / 拼音搜索

View File

@ -12,13 +12,13 @@ aliases:
- 群里发照片
- 截图发出去
- 图片消息
related_tools: [send_message, extract_image_text]
related_tools: [send_message]
related_pages: [messenger, dialog_chat]
prerequisites: []
negative:
- 不支持批量打包成相册一次性发出,多张图会逐条以独立消息发送
- 表情包emoticon虽然以 image 形式存储,但不会被识别为附件
- 图片仅按文件压缩开关执行压缩;不会自动 OCR 提取文字OCR 需另行触发)
- 图片仅按文件压缩开关执行压缩;发送时不会自动抽取图中文字
last_verified: v1.7.90
---
@ -52,9 +52,9 @@ last_verified: v1.7.90
- 普通图片mtype=image可在「文件」筛选 / 任务附件中复用
- 表情包mtype=emoticon仅展示用不在文件列表出现
## 图片 OCR
## 让 AI 理解图片内容
收到图片后可在消息上长按 / 右键调用「图片转文字」,走 extract_image_text 工具完成 OCR 文本提取,需要 AI 插件支持。
把图片转发或上传给 AI 助手后AI 可直接识别图中内容(多模态理解),据此回答、总结或转写其中文字,无需单独的文字提取步骤,需要 AI 插件支持。
## 不支持

View File

@ -11,7 +11,7 @@ aliases:
- 智能助手入口
- Claude 在哪
- 怎么找 AI
related_tools: [search_help_docs, get_page_context]
related_tools: [search_help_docs]
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件

View File

@ -152,6 +152,7 @@ export default {
window.addEventListener('resize', this.onResize);
emitter.on('openAIAssistantGlobal', this.onClick);
emitter.on('aiAssistantClosed', this.onAssistantClosed);
emitter.on('aiOperationRequest', this.onOperationRequest);
this.initOperationModule();
},
@ -159,6 +160,7 @@ export default {
window.removeEventListener('resize', this.onResize);
emitter.off('openAIAssistantGlobal', this.onClick);
emitter.off('aiAssistantClosed', this.onAssistantClosed);
emitter.off('aiOperationRequest', this.onOperationRequest);
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('contextmenu', this.onContextMenu);
@ -499,46 +501,57 @@ export default {
if (this.operationModule) {
return;
}
this.operationModule = createOperationModule({
store: this.$store,
router: this.$router,
onSessionReady: (sessionId) => {
this.operationSessionId = sessionId;
},
onSessionLost: () => {
this.operationSessionId = null;
},
});
},
/**
* 启用操作模块
* 启用操作模块绑定当前 WebSocket 会话 fd 作为页面操作会话
*/
enableOperationModule() {
if (this.operationModule) {
this.operationModule.enable();
}
this.operationSessionId = $A.getSessionStorageString("userWsFd") || null;
},
/**
* 禁用操作模块
*/
disableOperationModule() {
if (this.operationModule) {
this.operationModule.disable();
this.operationSessionId = null;
}
this.operationSessionId = null;
},
/**
* 销毁操作模块
*/
destroyOperationModule() {
if (this.operationModule) {
this.operationModule.disable();
this.operationModule = null;
this.operationSessionId = null;
this.operationModule = null;
this.operationSessionId = null;
},
/**
* 收到后端派发的页面操作type=operation执行后经 /ws 回包
*/
async onOperationRequest(data) {
const {requestId, action, payload} = data || {};
if (!requestId || !action) {
return;
}
if (!this.operationModule) {
this.initOperationModule();
}
try {
const result = await this.operationModule.handleRequest(action, payload);
this.$store.dispatch('websocketSend', {
type: 'operationResult',
data: {requestId, success: true, result},
}).catch(_ => {});
} catch (e) {
// catch doo
this.$store.dispatch('websocketSend', {
type: 'operationResult',
data: {requestId, success: false, error: e?.message || '操作执行失败'},
}).catch(_ => {});
}
},
},

View File

@ -1,231 +0,0 @@
/**
* AI 助手前端操作 WebSocket 客户端
*
* 负责与 MCP Server 建立 WebSocket 连接
* 接收来自 MCP 工具的请求并返回响应
*/
const WS_PATH = 'apps/mcp_server/mcp/operation';
const RECONNECT_DELAY = 3000;
const MAX_RECONNECT_ATTEMPTS = 5;
/**
* 前端操作客户端
*/
export class OperationClient {
/**
* @param {Object} options
* @param {Function} options.getToken - 获取用户 token 的函数
* @param {Function} options.onRequest - 处理请求的回调函数
* @param {Function} options.onConnected - 连接成功回调
* @param {Function} options.onDisconnected - 断开连接回调
* @param {Function} options.onError - 错误回调
*/
constructor(options = {}) {
this.getToken = options.getToken;
this.onRequest = options.onRequest;
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.onError = options.onError;
this.ws = null;
this.sessionId = null;
this.expiresAt = null;
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.isConnecting = false;
this.isManualClose = false;
}
/**
* 建立 WebSocket 连接
*/
connect() {
if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
return;
}
if (this.isConnecting) {
return;
}
this.isConnecting = true;
this.isManualClose = false;
const token = this.getToken?.();
if (!token) {
this.isConnecting = false;
this.onError?.('未登录或 token 不可用');
return;
}
let url = $A.mainUrl(WS_PATH);
url = url.replace("https://", "wss://");
url = url.replace("http://", "ws://");
url += `?token=${encodeURIComponent(token)}`;
try {
this.ws = new WebSocket(url);
this.setupEventHandlers();
} catch (error) {
this.isConnecting = false;
this.onError?.(error.message);
}
}
/**
* 设置 WebSocket 事件处理器
*/
setupEventHandlers() {
this.ws.onopen = () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = (event) => {
this.isConnecting = false;
this.sessionId = null;
this.onDisconnected?.();
if (!this.isManualClose && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
this.isConnecting = false;
this.onError?.('WebSocket 连接错误');
};
}
/**
* 处理收到的消息
*/
handleMessage(data) {
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
switch (msg.type) {
case 'connected':
this.sessionId = msg.session_id;
this.expiresAt = msg.expires_at;
this.onConnected?.(this.sessionId);
break;
case 'request':
this.handleRequest(msg);
break;
case 'pong':
// 心跳响应
break;
}
}
/**
* 处理来自 MCP 的请求
*/
async handleRequest(msg) {
const { id, action, payload } = msg;
if (!this.onRequest) {
this.sendResponse(id, false, null, '请求处理器未配置');
return;
}
try {
const result = await this.onRequest(action, payload);
this.sendResponse(id, true, result, null);
} catch (error) {
const errorMsg = error.message || '操作执行失败';
this.sendResponse(id, false, null, errorMsg);
}
}
/**
* 发送响应
*/
sendResponse(id, success, data, error) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const response = {
type: 'response',
id,
success,
data,
error,
};
this.ws.send(JSON.stringify(response));
}
/**
* 发送心跳
*/
ping() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}
/**
* 安排重连
*/
scheduleReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectAttempts++;
const delay = RECONNECT_DELAY * this.reconnectAttempts;
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
/**
* 断开连接
*/
disconnect() {
this.isManualClose = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.sessionId = null;
this.reconnectAttempts = 0;
}
/**
* 获取当前 session ID
*/
getSessionId() {
return this.sessionId;
}
/**
* 检查是否已连接
*/
isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN && this.sessionId;
}
}
export default OperationClient;

View File

@ -1,11 +1,12 @@
/**
* AI 助手前端操作模块
*
* 集成 WebSocket 客户端页面上下文收集器和操作执行器
* 提供给 AI 助手组件使用
* 集成页面上下文收集器和操作执行器 AI 助手组件执行页面操作
* 传输层已并入主程序常驻 WebSocket/ws后端经 assistant/operation/dispatch
* 推送 type=operation 消息浮窗组件收到后调用本模块 handleRequest 执行并回包
* 不再单独连接 MCP operation WebSocket
*/
import { OperationClient } from './operation-client';
import { collectPageContext, searchByVector } from './page-context-collector';
import { createActionExecutor } from './action-executor';
@ -24,76 +25,23 @@ class OperationModule {
constructor(options) {
this.store = options.store;
this.router = options.router;
this.enabled = false;
this.client = null;
this.executor = null;
this.sessionId = null;
// 回调函数
this.onSessionReady = options.onSessionReady;
this.onSessionLost = options.onSessionLost;
this.onError = options.onError;
}
/**
* 启用操作模块
* 确保操作执行器已创建惰性初始化
*/
enable() {
if (this.enabled) {
return;
ensureExecutor() {
if (!this.executor) {
this.executor = createActionExecutor(this.store, this.router);
}
this.enabled = true;
// 创建操作执行器
this.executor = createActionExecutor(this.store, this.router);
// 创建 WebSocket 客户端
this.client = new OperationClient({
getToken: () => this.store.state.userToken,
onRequest: this.handleRequest.bind(this),
onConnected: this.handleConnected.bind(this),
onDisconnected: this.handleDisconnected.bind(this),
onError: this.handleError.bind(this),
});
// 建立连接
this.client.connect();
// 设置心跳
this.heartbeatTimer = setInterval(() => {
if (this.client) {
this.client.ping();
}
}, 30000);
return this.executor;
}
/**
* 禁用操作模块
*/
disable() {
if (!this.enabled) {
return;
}
this.enabled = false;
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.client) {
this.client.disconnect();
this.client = null;
}
this.executor = null;
this.sessionId = null;
}
/**
* 处理来自 MCP 的请求
* 处理一次页面操作请求
* @param {string} action 操作类型
* @param {Object} payload 操作参数
*/
async handleRequest(action, payload) {
switch (action) {
@ -115,6 +63,8 @@ class OperationModule {
* 获取页面上下文
*/
async getPageContext(payload) {
this.ensureExecutor();
const includeElements = payload?.include_elements !== false;
const interactiveOnly = payload?.interactive_only || false;
const maxElements = payload?.max_elements || 100;
@ -176,9 +126,7 @@ class OperationModule {
* 执行业务操作
*/
async executeAction(payload) {
if (!this.executor) {
throw new Error('操作执行器未初始化');
}
this.ensureExecutor();
const actionName = payload?.name;
const params = payload?.params || {};
@ -194,9 +142,7 @@ class OperationModule {
* 执行元素操作
*/
async executeElementAction(payload) {
if (!this.executor) {
throw new Error('操作执行器未初始化');
}
this.ensureExecutor();
const elementUid = payload?.element_uid;
const action = payload?.action;
@ -208,52 +154,6 @@ class OperationModule {
return this.executor.executeElementAction(elementUid, action, value);
}
/**
* 处理连接成功
*/
handleConnected(sessionId) {
this.sessionId = sessionId;
this.onSessionReady?.(sessionId);
}
/**
* 处理连接断开
*/
handleDisconnected() {
this.sessionId = null;
this.onSessionLost?.();
}
/**
* 处理错误
*/
handleError(error) {
this.onError?.(error);
}
/**
* 获取当前 session ID
*/
getSessionId() {
return this.sessionId;
}
/**
* 检查是否已连接
*/
isConnected() {
return this.client?.isConnected() || false;
}
/**
* 重新连接
*/
reconnect() {
if (this.client) {
this.client.connect();
}
}
}
export default createOperationModule;

View File

@ -4776,6 +4776,11 @@ export default {
dispatch("streamMsgSubscribe", msgDetail.stream_url);
break
case "operation":
// AI 助手页面操作派发assistant/operation/dispatch交给浮窗组件执行后回包
emitter.emit('aiOperationRequest', msgDetail.data);
break
default:
msgId && dispatch("websocketSend", {type: 'receipt', msgId}).catch(_ => {});
emitter.emit('websocketMsg', msgDetail);