mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-30 03:15:47 +00:00
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:
parent
9b41330413
commit
da095a1a80
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// 返回消息
|
||||
|
||||
@ -997,3 +997,5 @@ AI 助手
|
||||
没有查看权限
|
||||
当前仅指定人员可以创建项目
|
||||
反馈类型错误
|
||||
会话不存在或无权限
|
||||
无权限
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
# (id → 路由);两份的 id 集合必须一致
|
||||
#
|
||||
# 边界:本期仅收录"无需运行时 id 的纯导航目的地"(送到某页面/面板)。
|
||||
# - 打开具体任务/对话/项目(需 task_id 等运行时 id)不在此,由 execute_action 承担
|
||||
# - 打开具体任务/对话/项目(需 task_id 等运行时 id)不在此,由 AI 助手的页面操作承担
|
||||
# - 项目内弹窗类(project_settings/member/flow 等需组件接线)二期再加
|
||||
|
||||
version: 1
|
||||
|
||||
@ -5,11 +5,15 @@
|
||||
# 2. ingest 期把 related_tools 写入 chunk metadata,retriever 可联动工具
|
||||
# 3. 让 AI 在调 search_help_docs 时同时知道"还能调哪个工具直接操作"
|
||||
#
|
||||
# 工具清单来自 dootask-plugins/mcp/server/src/dootaskMcpServer.ts(33 个 MCP 工具)
|
||||
# 工具清单来自 dootask-plugins/mcp/server/src/dootaskMcpServer.ts(29 个 MCP 工具)
|
||||
# 加 helper/tools.py 中的内置工具(GetSessionImageTool 等)
|
||||
#
|
||||
# 注:页面操作(打开任务/页面跳转/操作元素)已从 MCP 迁出,改由主程序常驻
|
||||
# WebSocket(/ws)派发、doo CLI(doo 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 CLI(doo page context|action|element)触发,
|
||||
# 不再是 MCP 工具。
|
||||
|
||||
# 注:原 show_guide(driver.js 分步引导)已下线,改为 AI 回复内联深链
|
||||
# [显示文字](dootask://link/<id>),目录见 _meta/page-links.yaml(前端渲染,非 MCP 工具)
|
||||
|
||||
@ -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 角色扫描,给每个可交互元素分配 ref(e1, 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 匹配
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 功能说明)
|
||||
|
||||
@ -32,13 +32,13 @@ last_verified: v1.7.90
|
||||
DooTask AI 助手通过 MCP(Model 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 calling(OpenAI / Claude / DeepSeek / Qwen 等主流模型均支持)
|
||||
|
||||
@ -32,7 +32,7 @@ last_verified: v1.7.90
|
||||
- **权限不足**:操作了你没权限访问的资源(如别人的任务、非成员的项目)
|
||||
- **参数错误**:AI 推断的 ID 不存在(如任务已被删)
|
||||
- **超时**:单次工具调用默认 30 秒超时,长操作(大列表/复杂搜索)会断
|
||||
- **页面不在线**:页面工具(execute_action)需要前端 socket 在线,关浏览器后立刻调用会失败
|
||||
- **页面不在线**:页面操作(打开任务/跳页面/操作元素)需要前端 socket 在线,关浏览器后立刻让 AI 操作会失败
|
||||
- **模型不支持 tool call**:选了纯文本模型,根本不会调
|
||||
|
||||
## 解决
|
||||
|
||||
@ -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
|
||||
|
||||
## 支持的动作
|
||||
| 动作 | 说明 |
|
||||
|
||||
@ -52,7 +52,7 @@ last_verified: v1.7.90
|
||||
列出任务后可以接续操作,无需重复说"在 X 项目":
|
||||
|
||||
- "把第 2 条标完成" → 调 `complete_task`
|
||||
- "打开第一条" → 调 `execute_action` 跳到任务详情
|
||||
- "打开第一条" → AI 在你的页面上跳到任务详情
|
||||
- "给小王再加一条子任务" → 调 `create_sub_task`
|
||||
|
||||
## 不支持
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -44,7 +44,7 @@ last_verified: v1.7.90
|
||||
- 入口:全局搜索框 → 选择「文件」标签
|
||||
- 索引由 Manticore 提供,仅文档类(document / txt / code / pdf 抽取出的文本)被索引
|
||||
- office 文件(doc/xls/ppt)已索引后可按内容关键词命中
|
||||
- 图片可走 OCR(extract_image_text 工具)后再搜索
|
||||
- 图片本身不参与内容索引;如需理解图中文字,可把图片交给 AI 助手直接识别(多模态)
|
||||
|
||||
## 不支持
|
||||
- 不支持模糊匹配 / 拼音搜索
|
||||
|
||||
@ -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 插件支持。
|
||||
|
||||
## 不支持
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ aliases:
|
||||
- 智能助手入口
|
||||
- Claude 在哪
|
||||
- 怎么找 AI
|
||||
related_tools: [search_help_docs, get_page_context]
|
||||
related_tools: [search_help_docs]
|
||||
related_pages: []
|
||||
prerequisites:
|
||||
- 应用市场已安装 ai 插件
|
||||
|
||||
@ -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(_ => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
5
resources/assets/js/store/actions.js
vendored
5
resources/assets/js/store/actions.js
vendored
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user