diff --git a/app/Http/Controllers/Api/AssistantController.php b/app/Http/Controllers/Api/AssistantController.php index d3f0942dc..c87a97abb 100644 --- a/app/Http/Controllers/Api/AssistantController.php +++ b/app/Http/Controllers/Api/AssistantController.php @@ -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, + ]); + } + /** * 获取会话列表 */ diff --git a/app/Services/WebSocketService.php b/app/Services/WebSocketService.php index 409a2941f..1b833cffa 100644 --- a/app/Services/WebSocketService.php +++ b/app/Services/WebSocketService.php @@ -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; } // 返回消息 diff --git a/language/original-api.txt b/language/original-api.txt index 5585e35b9..6b98ddcfe 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -997,3 +997,5 @@ AI 助手 没有查看权限 当前仅指定人员可以创建项目 反馈类型错误 +会话不存在或无权限 +无权限 diff --git a/resources/ai-kb/_meta/page-links.yaml b/resources/ai-kb/_meta/page-links.yaml index c7987679a..4555c54a3 100644 --- a/resources/ai-kb/_meta/page-links.yaml +++ b/resources/ai-kb/_meta/page-links.yaml @@ -7,7 +7,7 @@ # (id → 路由);两份的 id 集合必须一致 # # 边界:本期仅收录"无需运行时 id 的纯导航目的地"(送到某页面/面板)。 -# - 打开具体任务/对话/项目(需 task_id 等运行时 id)不在此,由 execute_action 承担 +# - 打开具体任务/对话/项目(需 task_id 等运行时 id)不在此,由 AI 助手的页面操作承担 # - 项目内弹窗类(project_settings/member/flow 等需组件接线)二期再加 version: 1 diff --git a/resources/ai-kb/_meta/tool-binding.yaml b/resources/ai-kb/_meta/tool-binding.yaml index 158f9f13a..9ff6aef5e 100644 --- a/resources/ai-kb/_meta/tool-binding.yaml +++ b/resources/ai-kb/_meta/tool-binding.yaml @@ -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/),目录见 _meta/page-links.yaml(前端渲染,非 MCP 工具) diff --git a/resources/ai-kb/zh/concept/ai-assistant/match-elements.md b/resources/ai-kb/zh/concept/ai-assistant/match-elements.md index fb0ea5425..afcddfefb 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/match-elements.md +++ b/resources/ai-kb/zh/concept/ai-assistant/match-elements.md @@ -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 匹配 diff --git a/resources/ai-kb/zh/concept/ai-assistant/page-action.md b/resources/ai-kb/zh/concept/ai-assistant/page-action.md index 2fc97a158..47045308b 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/page-action.md +++ b/resources/ai-kb/zh/concept/ai-assistant/page-action.md @@ -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` diff --git a/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md b/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md index 22134f9f2..b075c28a6 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md +++ b/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md @@ -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) diff --git a/resources/ai-kb/zh/concept/ai-assistant/tools-list.md b/resources/ai-kb/zh/concept/ai-assistant/tools-list.md index a57e2a4a1..6b6dad8a5 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/tools-list.md +++ b/resources/ai-kb/zh/concept/ai-assistant/tools-list.md @@ -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 功能说明) diff --git a/resources/ai-kb/zh/concept/ai-assistant/tools.md b/resources/ai-kb/zh/concept/ai-assistant/tools.md index b55a3fef1..655f96045 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/tools.md +++ b/resources/ai-kb/zh/concept/ai-assistant/tools.md @@ -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 等主流模型均支持) diff --git a/resources/ai-kb/zh/faq/ai-assistant/tool-failed.md b/resources/ai-kb/zh/faq/ai-assistant/tool-failed.md index 941e7f20b..aee81adad 100644 --- a/resources/ai-kb/zh/faq/ai-assistant/tool-failed.md +++ b/resources/ai-kb/zh/faq/ai-assistant/tool-failed.md @@ -32,7 +32,7 @@ last_verified: v1.7.90 - **权限不足**:操作了你没权限访问的资源(如别人的任务、非成员的项目) - **参数错误**:AI 推断的 ID 不存在(如任务已被删) - **超时**:单次工具调用默认 30 秒超时,长操作(大列表/复杂搜索)会断 -- **页面不在线**:页面工具(execute_action)需要前端 socket 在线,关浏览器后立刻调用会失败 +- **页面不在线**:页面操作(打开任务/跳页面/操作元素)需要前端 socket 在线,关浏览器后立刻让 AI 操作会失败 - **模型不支持 tool call**:选了纯文本模型,根本不会调 ## 解决 diff --git a/resources/ai-kb/zh/howto/ai-assistant/element-action.md b/resources/ai-kb/zh/howto/ai-assistant/element-action.md index 5a48de76f..cd13fb798 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/element-action.md +++ b/resources/ai-kb/zh/howto/ai-assistant/element-action.md @@ -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 ## 支持的动作 | 动作 | 说明 | diff --git a/resources/ai-kb/zh/howto/ai-assistant/list-tasks.md b/resources/ai-kb/zh/howto/ai-assistant/list-tasks.md index 55191b010..91a33705b 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/list-tasks.md +++ b/resources/ai-kb/zh/howto/ai-assistant/list-tasks.md @@ -52,7 +52,7 @@ last_verified: v1.7.90 列出任务后可以接续操作,无需重复说"在 X 项目": - "把第 2 条标完成" → 调 `complete_task` -- "打开第一条" → 调 `execute_action` 跳到任务详情 +- "打开第一条" → AI 在你的页面上跳到任务详情 - "给小王再加一条子任务" → 调 `create_sub_task` ## 不支持 diff --git a/resources/ai-kb/zh/howto/ai-assistant/page-action.md b/resources/ai-kb/zh/howto/ai-assistant/page-action.md index 567cecd1f..1714e5f6e 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/page-action.md +++ b/resources/ai-kb/zh/howto/ai-assistant/page-action.md @@ -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) diff --git a/resources/ai-kb/zh/howto/ai-assistant/search-files.md b/resources/ai-kb/zh/howto/ai-assistant/search-files.md index b9532ec4e..a4dc081fb 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/search-files.md +++ b/resources/ai-kb/zh/howto/ai-assistant/search-files.md @@ -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]] diff --git a/resources/ai-kb/zh/howto/file/search.md b/resources/ai-kb/zh/howto/file/search.md index cafb6d847..6897b5508 100644 --- a/resources/ai-kb/zh/howto/file/search.md +++ b/resources/ai-kb/zh/howto/file/search.md @@ -44,7 +44,7 @@ last_verified: v1.7.90 - 入口:全局搜索框 → 选择「文件」标签 - 索引由 Manticore 提供,仅文档类(document / txt / code / pdf 抽取出的文本)被索引 - office 文件(doc/xls/ppt)已索引后可按内容关键词命中 -- 图片可走 OCR(extract_image_text 工具)后再搜索 +- 图片本身不参与内容索引;如需理解图中文字,可把图片交给 AI 助手直接识别(多模态) ## 不支持 - 不支持模糊匹配 / 拼音搜索 diff --git a/resources/ai-kb/zh/howto/messenger/send-image.md b/resources/ai-kb/zh/howto/messenger/send-image.md index c18d8e534..f8e2405eb 100644 --- a/resources/ai-kb/zh/howto/messenger/send-image.md +++ b/resources/ai-kb/zh/howto/messenger/send-image.md @@ -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 插件支持。 ## 不支持 diff --git a/resources/ai-kb/zh/menu-map/menu-navigation/ai-assistant.md b/resources/ai-kb/zh/menu-map/menu-navigation/ai-assistant.md index 538c37a13..1de7d8da5 100644 --- a/resources/ai-kb/zh/menu-map/menu-navigation/ai-assistant.md +++ b/resources/ai-kb/zh/menu-map/menu-navigation/ai-assistant.md @@ -11,7 +11,7 @@ aliases: - 智能助手入口 - Claude 在哪 - 怎么找 AI -related_tools: [search_help_docs, get_page_context] +related_tools: [search_help_docs] related_pages: [] prerequisites: - 应用市场已安装 ai 插件 diff --git a/resources/assets/js/components/AIAssistant/float-button.vue b/resources/assets/js/components/AIAssistant/float-button.vue index e768d0aae..6c84bed6a 100644 --- a/resources/assets/js/components/AIAssistant/float-button.vue +++ b/resources/assets/js/components/AIAssistant/float-button.vue @@ -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(_ => {}); } }, }, diff --git a/resources/assets/js/components/AIAssistant/operation-client.js b/resources/assets/js/components/AIAssistant/operation-client.js deleted file mode 100644 index a44ca61b4..000000000 --- a/resources/assets/js/components/AIAssistant/operation-client.js +++ /dev/null @@ -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; diff --git a/resources/assets/js/components/AIAssistant/operation-module.js b/resources/assets/js/components/AIAssistant/operation-module.js index 751ce072d..e6142b821 100644 --- a/resources/assets/js/components/AIAssistant/operation-module.js +++ b/resources/assets/js/components/AIAssistant/operation-module.js @@ -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; diff --git a/resources/assets/js/store/actions.js b/resources/assets/js/store/actions.js index 9eac59b08..ce3375b3a 100644 --- a/resources/assets/js/store/actions.js +++ b/resources/assets/js/store/actions.js @@ -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);