# AI Agent 接入使用说明 (Uniapp 前端) > 适用版本: backend PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后; PR-OSS-REGISTRY (2026-09-15) 之后 12 个云存储 provider > 适用前端: `D:/projects/shop-projects/frontend` (uniapp 多端: 微信小程序 MP / H5 / App / Harmony) > 配套阅读: > - `common/js/ai-service.js`、`pages_tool/ai-chat/` > - 后端 `docs/mp-media-upload-integration.md` (媒体上传后端单点真源) > - 后端 `docs/ai-agent-protocol-spec.md` (本文档对应的完整后端协议规范, 仓内后端目录) > - `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 协议约束矩阵 — J7/J9/J10/J1/Q6) 本文档面向前端工程师, 解决把 WorkBuddy Agent / AI 智能客服集成进 uniapp 时 最常踩的 4 个坑: 1. **AI 思考流数据** (`thought` event + `message_end.thoughts` 总结) 2. **Question 选项及反馈** (`ask_user_question` + `elicit_response` 回传) 3. **图片/音视频/文件上传下载** (走我们服务器 vs 走云存储直传, 何时切) 4. **多轮会话** (`conversation_id` 持久化 + per-agent 隔离) 每个坑都按 "现状 → 目标 → 代码示例 → 关键注意点" 4 段写, 可直接复制粘贴。 --- ## 0. 30 秒读懂 (TL;DR) | 主题 | 现状 (PR 前) | 目标 (本文档落地后) | |---|---|---| | 思考流 | 丢, 不渲染 | 折叠面板渲染, 与正文区分 | | Question 选项 | 丢, 用户看不到 | 渲染为可点击选项卡片, 选择后回传 | | 媒体上传 | 完全没接 `wx.uploadFile` | 接 `wx.uploadFile` + 12 个云存储直传 + 类型/大小分流 | | attachments.data 字段 | 没概念 (后端 attachments 全 url-only, 部署到云上后 WB 服务端 fetch 不一定能通) | ≤5MB 图片/音频塞 base64 inline, 完全不依赖 J9;>5MB 才走 resource_link | | 多轮会话 | `conversation_id` 已持久化但 per-agent 隔离缺失 | per-agent 隔离 + 历史消息渲染 | 涉及到的核心代码改动点 (按文件): - `common/js/ai-service.js` — SSE/WS 中间层改成 `onEvent(event, data)`, 透出所有 SSE event 类型 - `pages_tool/ai-chat/ai-chat-message.vue` — 思考流折叠 + 选项卡片渲染 + 媒体上传按钮接 `wx.uploadFile` - `pages_tool/ai-chat/components/ai-chat-tools-panel.vue` — `selectImage`/`selectFile` 改成真上传 - `common/js/media-upload.js` — **新增**, 封装走我们服务器 + 12 个云存储直传决策 + fileToBase64 - `common/js/ai-chat-message-store.js` — **新增**, 集中管理消息状态 (思考流/选项/附件) --- ## 1. AI 思考流数据 ### 1.1 后端协议 (单点真源) **关键事实: 思考流有 3 条下发通道 (2 类 × 2/1 路), 行为各不同:** | 通道 | event | 频次 | 用途 | |---|---|---|---| | WS (`/ws/aiagent` 或 `/ws/aikefu`) | `thought` | 每个 WB Agent `agent_thought_chunk` 增量 | 实时渲染 | | HTTP SSE aikefu (`/api/aikefu/kefu/chat`) | `thought` | 每个增量 | 实时渲染 | | HTTP SSE aiagent (`/api/aiagent/chat`) | **(无独立 event)** | — | **完全不带 thought 流** (后端不抛) | **重点:** - **WS 路径 (微信小程序主用)**: 实时推 `thought` 增量 event, 直接拼到 UI 即可 - **HTTP SSE aikefu 路径**: 实时推 `thought` 增量 event + 流结束 `message_end.thoughts` (累计完整文本) - **HTTP SSE aiagent 路径**: **不推任何 thought 数据** — 既无中途 event, `message_end` 也不带 `thoughts` 字段. 前端如果用 aiagent HTTP, 必须告诉用户"看不到 AI 思考过程" **SSE 帧列表 (完整, 按通道分别说明):** | event | aiagent HTTP | aikefu HTTP | WS (两者) | |---|---|---|---| | `message` | ✓ `{answer}` | ✓ `{answer}` | ✓ `{answer}` | | `thought` | ✗ (无) | ✓ `{thought}` (实时) | ✓ `{thought}` (实时) | | `tool_call` | ✗ (无单独 event, 见下) | ✓ `{tool_call: {...}}` | ✓ `{tool_call: {...}}` | | `question` | ✗ (无, message_end 也没有 pending_question — **完全丢失**) | ✓ `{pending_question: {...}}` | ✓ `{pending_question: {...}}` | | `message_end` | **极简** (`conversation_id, agent_id, stop_reason` 三字段) | **完整快照** (见下) | **完整快照** | | `error` | JSON envelope (非 SSE 流) | ✓ SSE `{code, message}` | ✓ `{message, code, line, file}` | **message_end 字段 (按通道差异):** ```jsonc // aiagent HTTP (Chat.php:530-545) — 极简, 只有 3 字段 { "event": "message_end", "conversation_id": "openai_1700000000_xxxx", "agent_id": "agent_aikefu", "stop_reason": "end_turn" | "pending_stale" | "" // 没有 message_id / content / thoughts / tool_calls / tokens / phase / effective_model } // aikefu HTTP B-path (Kefu.php:1138-1157) + A-path (Kefu.php:1449-1465) + WS — 完整快照 { "event": "message_end", "message_id": "wb_xxxx", // 一定有 "conversation_id": "...", "thoughts": "完整思考 (累计)", // aikefu 才有, aiagent 没 "pending_question": null | {...}, // aikefu 才有, aiagent HTTP 完全没这字段 "tool_calls": [{ // aikefu 才有 "tool_call_id": "tc_xxx", "tool_name": "RAGSearch / AskUserQuestion", "status": "completed", "started_at": ..., "completed_at": ..., "duration_ms": 10000, "raw_input": "...", "raw_output": "...", "error": null }], "tokens": { // aikefu 才有 "input": 100, "output": 200, "cached_read": 50, "cached_write": 0, "cost_amount": 0.002, "cost_currency": "CNY", "model": "gpt-4o" }, "phase": "planning|working|completed|idle", // aikefu 才有 "effective_model": "gpt-4o", // aikefu 才有 "stop_reason": "end_turn" // 注意: 没有 "content" 字段! 前端拿最终正文必须从 message 事件流累积, 不能等 message_end } ``` **关键不对称 (前端必须知道):** - **aiagent HTTP `message_end` 只有 3 字段**. 想拿完整快照必须换 aikefu HTTP 或 WS - **aiagent HTTP 没有独立 `thought` event 也没有 `message_end.thoughts`**. 思考过程**完全不可见** - **aiagent HTTP 没有独立 `tool_call` event**. 要看工具调用得等流结束后查 DB 历史 - **aiagent HTTP 没有独立 `question` event**. 用户没答导致 `pending_stale` 时, 也没法做 fallback 渲染 - **`effective_model` / `tokens` / `phase` 仅 aikefu 有**. aiagent HTTP 流调试时这些字段永远是 `undefined` > **修复建议**: 集成时如果产品需要"显示 AI 思考过程", 必须走 WS 或 aikefu HTTP. 走 aiagent HTTP 的话, 思考面板要么不渲染要么显示 "思考过程不可用". ### 1.2 现状 (PR 前) `common/js/ai-service.js` 的 WS 消息处理只识别 `event === 'message'`, 其他事件被丢: ```js // ai-service.js:201-209 if (data.event === 'message') { var text = data.answer || data.content || data.text || ''; // ... append ... } else if (data.event === 'message_end' || data.event === 'done') { // ... save conversation_id ... } else { console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); } ``` H5 的 `sendHttpStream` 同样 (L452-466)。 **结论: 思考流后端已经在发, 前端 100% 丢。** ### 1.3 目标行为 思考流以**折叠面板**形式渲染在 AI 消息**上方**: ``` ┌─────────────────────────────────────┐ │ 🧠 思考中... (点击展开) │ ← 折叠态 (流未结束) │ 用户问的是订单问题, 我需要... │ ← 展开后 │ 查订单 API... │ ├─────────────────────────────────────┤ │ 您的订单已发货, 预计明天到达 │ ← 用户可见正文 └─────────────────────────────────────┘ ``` 要点: - 流未结束 (收到 `thought` 增量) → 面板显示 "🧠 思考中..." + 累计字符数 - 流结束 (`message_end`) → 面板变成可点击的 "💡 思考过程 (N 步)" 折叠 - aiagent HTTP 路径: 流过程中不渲染, 流结束从 `message_end.thoughts` 一次渲染 - B 路 Dify 兼容 (`> ` 前缀) : 把 `answer` 里 `> ` 前缀行也塞同一面板 ### 1.4 代码示例 **Step 1: 改造 `common/js/ai-service.js`** — 把 onChunk 改成 onEvent, 透出所有事件类型 ```js // common/js/ai-service.js // 在文件顶部, 加 helper 把 SSE event 标准化 /** * 统一 SSE/WS 事件分发器 * 事件类型: 'message' | 'thought' | 'tool_call' | 'question' * | 'message_end' | 'error' | 'attachments' | 'ping' */ function dispatchEvent(onEvent, event, data) { if (typeof onEvent === 'function') { try { onEvent(event, data); } catch (e) { console.error('[AI Service] onEvent handler threw:', e); } } } // === sendStreamMessage (MP, WS) === async sendStreamMessage(message, onEvent, onComplete, opts = {}) { // #ifdef MP try { await ensureWebSocketConnected(); } catch (e) { if (onComplete) onComplete({ error: e.message }); throw e; } wsMessageActive = true; eventHandlers.onEvent = onEvent || null; wsMessageOnComplete = onComplete || null; return new Promise((resolve, reject) => { wsMessageResolve = resolve; wsMessageReject = reject; wsSocketTask.send({ data: JSON.stringify({ action: 'chat', agent_id: getAgentId(), uniacid: this.getUniacid(), query: message, user_id: getUserId(), conversation_id: this.getConversationId() || '', attachments: opts.attachments || [], ...getDomainParams() }) }); }); // #endif }, // === sendHttpStream (H5, SSE) === async sendHttpStream(message, onEvent, onComplete, opts = {}) { // #ifdef H5 var chatUrl = resolveApiUrl('chat'); var url = Config.baseUrl + chatUrl; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }, body: JSON.stringify({ uniacid: this.getUniacid(), token: store.state.token || 'test_token', inputs: {}, query: message, response_mode: 'streaming', agent_id: getAgentId(), user_id: getUserId(), conversation_id: this.getConversationId() || '', attachments: opts.attachments || [], ...getDomainParams() }) }); if (!response.ok || !response.body) { throw new Error(lang.lang('aiService.invalidResponse')); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const jsonStr = trimmed.slice(5).trim(); if (!jsonStr || jsonStr === '[DONE]') continue; try { const data = JSON.parse(jsonStr); // ★ ★ ★ 核心改动: 按 event 分类分发 ★ ★ ★ switch (data.event) { case 'message': var text = data.answer || data.text || data.content || ''; if (!text && data.data) text = data.data.content || ''; dispatchEvent(onEvent, 'message', { content: text, message_id: data.message_id }); break; case 'thought': // WS 路径和 aikefu HTTP 才有这个; aiagent HTTP 不发独立 thought dispatchEvent(onEvent, 'thought', { content: data.thought || '', message_id: data.message_id }); break; case 'tool_call': // tool_call 是 upsert 语义, 不是 append dispatchEvent(onEvent, 'tool_call', data.tool_call || {}); break; case 'question': // ★ 见 §2: AskUserQuestion 单独处理 dispatchEvent(onEvent, 'question', { pending_question: data.pending_question || data.data?.pending_question, message_id: data.message_id, conversation_id: data.conversation_id, }); break; case 'message_end': dispatchEvent(onEvent, 'message_end', { conversation_id: data.conversation_id || data.data?.conversation_id, message_id: data.message_id, content: data.content, thoughts: data.thoughts || '', tool_calls: data.tool_calls || [], tokens: data.tokens || null, phase: data.phase || 'completed', stop_reason: data.stop_reason || '', effective_model: data.effective_model || '', }); break; case 'error': dispatchEvent(onEvent, 'error', { code: data.code || -1, message: data.message || 'unknown' }); break; default: // 兜底: B 路 Dify 把思考塞 answer 里 (> 前缀) if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) { dispatchEvent(onEvent, 'thought', { content: data.answer }); } else if (data.event === 'ping') { // keep-alive, 忽略 } else { console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200)); } } } catch (e) { console.warn('解析失败:', e, line); } } } // 流结束: 触发 onComplete if (onComplete) onComplete({}); // #endif }, ``` WS 路径同样改造 `onMessage` (L181-240): ```js // ai-service.js WS onMessage 改造 wsSocketTask.onMessage(function(res) { try { var data = JSON.parse(res.data); if (data.type === 'auth_success') { wsAuthenticated = true; resolve(); return; } if (data.type === 'auth_failed') { /* ... */ return; } if (!wsMessageActive) return; switch (data.event) { case 'message': var text = data.answer || data.text || data.content || ''; if (!text && data.data) text = data.data.content || ''; wsMessageContent += text; dispatchEvent(eventHandlers.onEvent, 'message', { content: text, message_id: data.message_id }); break; case 'thought': dispatchEvent(eventHandlers.onEvent, 'thought', { content: data.thought || '' }); break; case 'tool_call': dispatchEvent(eventHandlers.onEvent, 'tool_call', data.tool_call || {}); break; case 'question': dispatchEvent(eventHandlers.onEvent, 'question', { pending_question: data.pending_question, message_id: data.message_id, conversation_id: data.conversation_id, }); break; case 'message_end': case 'done': var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; if (conversationId) this.setConversationId(conversationId); wsMessageActive = false; wsMessageContent = ''; var endPayload = { conversation_id: conversationId, message_id: data.message_id, content: data.content || wsMessageContent, thoughts: data.thoughts || '', tool_calls: data.tool_calls || [], tokens: data.tokens || null, phase: data.phase || 'completed', stop_reason: data.stop_reason || '', }; dispatchEvent(eventHandlers.onEvent, 'message_end', endPayload); if (wsMessageOnComplete) wsMessageOnComplete(endPayload); if (wsMessageResolve) wsMessageResolve(endPayload); break; case 'error': dispatchEvent(eventHandlers.onEvent, 'error', { code: data.code || -1, message: data.message }); break; case 'ping': // WB B-path keep-alive, 忽略 break; default: // 兼容老格式: data.data.content if (data.data && data.data.content) { wsMessageContent += data.data.content; dispatchEvent(eventHandlers.onEvent, 'message', { content: data.data.content }); } } } catch (e) { console.error('[AI WS] 解析失败:', e, res.data); } }); ``` 顶部加 `eventHandlers`: ```js let eventHandlers = { onEvent: null }; ``` **Step 2: 渲染层 — `ai-chat-message.vue`** — 思考流面板 ```vue ``` ### 1.5 关键注意点 1. **aiagent HTTP 路径下, 中途没有 `thought` event**: 流式渲染期间, 思考面板一直空; 流结束后从 `message_end.thoughts` 一次渲染完整文本。这是协议事实, 不是 bug 2. **WS 路径下 `thought` 是增量**: 必须用 `+=` 拼接, 不要 set 覆盖; 同一段思考可能被切成多个 chunk 3. **`tool_call` 必须 upsert**: WB 协议对同一个 `tool_call_id` 会推 10+ 次 `pending`, 必须按 id 合并, 不能 append 4. **`tokens` 数据 (input/output/cost/model)**: 调试面板用, 普通用户不需要看到 5. **`stop_reason` 区分**: `end_turn` = 正常结束, `pending_stale` = 用户长时间没回答 + 后端 reject, `error` = 异常 6. **B 路 Dify 兼容 `> `> 前缀**: 不是协议标准, 是 Dify 把思考内容混进 `answer` 的习惯, 后端透传, 前端要兜底拆 7. **思考步数限制**: 折叠面板最多展示 20 步 / 累计 4000 字符, 超出折叠 ("还有 5 步未显示") ### 1.6 后端 SSE event 来源 (供调试) 如果前端收不到 `thought`, 用 curl 验 WS 路径 (aikefu HTTP 路径也行): ```bash # aikefu HTTP (B-path 或 A-path 都行, 这里是 B-path) curl -N 'https://your-domain.com/api/aikefu/kefu/chat' \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' \ -d '{ "config_uuid":"your_uuid", "agent_id":"agent_aikefu", "query":"hi", "user_id":"guest_test", "response_mode":"streaming" }' ``` 应该看到 (aikefu HTTP 走 `event:` + `data:` 两行): ``` : open\n\n ← aikefu HTTP 立即推, 让响应头 flush event: message data: {"event":"message","answer":"您好...","message_id":"wb_xxx",...} event: thought data: {"event":"thought","thought":"用户打招呼...",...} event: message_end data: {"event":"message_end","conversation_id":"...","message_id":"wb_xxx","thoughts":"...","tokens":{...},"stop_reason":"end_turn"} ``` > **注意格式差异**: aikefu HTTP 写 `event: \ndata: \n\n` 两行式; aiagent HTTP 只写 `data: \n\n` 一行式. 上面的示例只适用于 aikefu. 验 aiagent HTTP 时, `event` 字段只能从 data 行的 JSON 体内取. 如果只有 `message` 和 `message_end`, 看: - `runtime/log/app_run_info/2026-09-14-wb-rpc.log` 里 `processSseEvent` 日志 - `runtime/log/app_run_info/2026-09-14-wb-rest.log` 看是 A-path 还是 B-path --- ## 2. Question 选项及反馈 ### 2.1 后端协议 (真实格式, 不要再用旧的 `options[]` 假设) WB Agent 用 `AskUserQuestion` 工具触发提问, 后端 `WbAgentOsClient::processSseEvent` 解析两种来源: 1. **JSON-RPC 协议** `method === '_codebuddy.ai/question'`, 携带 `params.schema.questions[]` 2. **Session update 协议** `sessionUpdate === 'tool_call_update'`, `_meta.codebuddy.ai.toolName === 'AskUserQuestion'` 两种协议解析后统一抛 `pending_question`: ```json { "tool_call_id": "tc_xxx", "questions": [ { "question": "您想了解哪种商品的售后政策?", "options": [ {"label": "退款", "description": "7 天无理由退款"}, {"label": "换货", "description": "30 天内可换同款"}, {"label": "维修", "description": "1 年保修"}, {"label": "仅咨询"} ], "header": "售后类型", "multiSelect": false } ], "source": "elicitation", "asked_at": 1700000000, "rpc_id": 0 } ``` **关键字段 (跟旧版假设差别很大):** | 字段 | 含义 | |---|---| | `tool_call_id` | 工具调用 ID, 回传时必须带 | | `questions[]` | 数组! 不是单 question 字符串 | | `questions[].question` | 问题文本 | | `questions[].options[]` | 选项数组 | | `options[].label` | 显示给用户的文本, **也是回传的值** (没有 `value` 字段) | | `options[].description` | 鼠标 hover 提示, 可选 | | `questions[].header` | 短标签 (顶部 chip), 可选 | | `questions[].multiSelect` | 是否多选 | | `rpc_id` | **JSON-RPC 的请求 ID, 回传答案时必带** | ### 2.2 Answer 回传机制 (3 种, 不要瞎写) | 场景 | 端点 / 动作 | 字段 | |---|---|---| | **管理员后台测试对话框** (aiagent shop controller) | `POST /aiagent/shop/agent/testChatRespond` (form, 无 `/addon/` 前缀) | `conversation_id, message_id, tool_call_id, rpc_id, answers[]` | | **WS 通道** (MP / H5 任意, 流还没结束) | WS `{action: 'elicit_response', ...}` | `conversation_id, message_id, tool_call_id, rpc_id, answers: {"q0":"label",...}` | | **HTTP SSE aiagent 通道** | **没有原生 answer 回传路径** | 必须改用 WS, 或等 `pending_stale` (≈10s) 后端 emit message_end | | **HTTP SSE aikefu 通道** | 同样没有原生路径 | 同上 | **`answers` 格式:** ```json // 单选 (multiSelect=false) { "q0": "退款" } // 多选 (multiSelect=true), 多个 label 用数组 { "q0": ["退款", "换货"] } ``` `q0` 是 question 在数组里的 index (字符串化), value 是 label 文本 (不是 id, 不是 value)。 ### 2.3 现状 (PR 前) `ai-service.js` SSE 解析只识别 `event === 'message'`, `pending_question` / `question` event 被丢 (`ai-service.js:233-236` 的 `else` 分支只 log)。 用户看到的是 AI 一句问话后**直接卡住**, 实际是后端在等前端回传选项。 ### 2.4 目标行为 AI 消息末尾渲染**选项卡片**: ``` ┌─────────────────────────────────────┐ │ 售后类型 │ ← header (可选) │ 您想了解哪种商品的售后政策? │ ← question text │ │ │ [退款] [换货] [维修] [仅咨询] │ ← 选项 (label) │ 7天无理由 30天可换 1年保修 ← description (可选) └─────────────────────────────────────┘ ``` 要点: - 单选: 点击选项 → 卡片折叠 ("已选择: 退款 ✓") + 自动发送 - 多选: 多个选项可点 → 底部出现 "确认 (N)" 按钮 - 30 秒未选 → 倒计时 + 自动选第一个, 后端 emit `stop_reason='pending_stale'` ### 2.5 代码示例 **Step 1: ai-service 加 `elicitResponse` 方法** ```js // common/js/ai-service.js 末尾追加 /** * 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项 * * @param {Object} params * @param {string} params.conversation_id * @param {string} params.message_id * @param {string} params.tool_call_id * @param {number} params.rpc_id * @param {Object} params.answers - {q0: 'label', q1: ['a','b']} * @returns {Promise} */ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) { const addon = resolveAddonName(currentAgentId); // ★ aiagent + aikefu 都支持 WS elicit_response (WebSocket.php:146/147) // 走同一段 WS 发送逻辑 // #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY await ensureWebSocketConnected(); wsSocketTask.send({ data: JSON.stringify({ action: 'elicit_response', conversation_id, message_id, tool_call_id, rpc_id, answers, uniacid: this.getUniacid(), token: store.state.token || 'test_token', }) }); return true; // #endif // #ifdef H5 // H5 端没有原生 WS 长连接, 走 PR-ELICIT-RESPONSE-HTTP (2026-09-18) 新端点: // POST /api/kefu/chat/elicit-response (aikefu) // POST /api/aiagent/chat/elicit-response (aiagent) // 后端会从 conversation 行读 wb_runtime {url,token,connection_id}, 然后 curl POST // jsonrpc 应答帧到 WB Runtime. blocking JSON, 不阻塞聊天主链路. try { const resp = await fetch(Config.baseUrl + resolveApiUrl('elicit-response'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversation_id, message_id, tool_call_id, rpc_id, answers, uniacid: this.getUniacid(), token: store.state.token || '' }) }); const data = await resp.json(); return data && data.code === 0; } catch (e) { console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message); return false; } // #endif }, ``` **Step 2: 渲染层 — 选项卡片** ```vue {{ q.header }} {{ q.question }} ⏱ {{ msg.questionTimeout }}s {{ opt.label }} {{ opt.description }} ``` ### 2.6 关键注意点 1. **`label` 是回传的值, 不是 `value`**: 协议没有 `value` 字段, 显示啥就回传啥 (改文案会导致原选项答非所问) 2. **answers 是对象 `{q0: label, q1: [label,...]}`**, 不是数组 3. **多 question**: WB 协议允许一次推多个 question, UI 要按数组渲染, 全部答完才提交 4. **WS 是 HTTP SSE 的兜底**: HTTP SSE aiagent 通道没有原生 answer 回传, 实际项目里建议前端始终连 WS, elicit_response 走 WS 5. **后端等答案 ≈ 10s × 3 协议尝试** (`PENDING_STALE_AFTER`), 超时后 emit `message_end` with `stop_reason='pending_stale'`, 前端要展示 "对方没回应, 已跳过提问" 6. **不同步 `pending_question` 到 history**: 历史消息不带 question, 用户重看历史不会触发 "重新答" 7. **`rpc_id` 是 int**: JSON-RPC 的请求 ID, 回传时原样带回去, 后端用它绑回原始 RPC 调用 8. **`description` 是可选 hover 提示**: WB Agent 可能不填, 前端判断 `opt.description` 再渲染 ### 2.7 后端兜底 (前端不用管) - 后端 `tool_call_id` 找不到 → 回 `{code: -1, msg: '找不到对应的提问'}` - 后端超时 (10s × 3 协议) → 走 `pending_stale` 分支, emit message_end, `stop_reason='pending_stale'` --- ## 3. 图片/音视频/文件上传下载 ### 3.1 后端协议 (单点真源, 详见 `docs/mp-media-upload-integration.md`) **端点汇总 (按 agent addon 分组):** | 用途 | aiagent addon | aikefu addon | |---|---|---| | 图片上传 (走我们服务器) | `POST /aiagent/api/upload/image` | `POST /aikefu/api/upload/image` | | 文件上传 (audio/video/file, 走我们服务器) | `POST /aiagent/api/upload/file` | `POST /aikefu/api/upload/file` | | 拿云存储直传凭证 | `POST /aiagent/api/upload/ossSts` | `POST /aikefu/api/upload/ossSts` | | 拿 agent 配置 (含 upload_strategy) | `POST /api/aiagent/info` | `POST /api/kefu/info` | **URL 模式说明 (2026-09-14 PR-WB-MP-MEDIA-API-PREFIX 修正):** 上传端点必须用 **addon 模式 URL** (`//api/upload/`,addon 名在前),原因: - 之前曾用 `/api//upload/` (addon 名在后) — 后端 InitRoute 默认 system_array 命中分支不切 namespace,精确路由注册也实测未生效,fallback 默认解析 → `controller not exists: app\api\controller\Aikefu` - addon 模式 URL (`/aikefu/api/upload/image`) 经 InitRoute 的 addon 分支 (L131-162) 自动切 namespace 到 `addon\aikefu\api`,L187 简写 `Route::rule($pathinfo, 'api/upload/image')` 即可 dispatch 到正确 controller - 历史教训: 不要在前端用 `'/api/aikefu/upload/...'` 这种不规范路径,即使后端有显式路由注册也会被 thinkphp 路由匹配漏掉 - nginx 已加 `merge_slashes on;` 兜底前端拼 baseURL+path 时的双斜杠 (`//aikefu/api/upload/image` → `/aikefu/api/upload/image`) **注意 info 端点路径差异 (与上传端点不一致):** - aiagent: `/api/aiagent/info` (Chat.php 的 info() 方法) - aikefu: **`/api/kefu/info`** (app\api\controller\Kefu.php:182, 触发 `KefuGetInfo` event), **不是** `/api/aikefu/kefu/getInfo` 也不是 `/api/aikefu/info` > **关键**: aikefu 端的 info 端点挂在**主项目** `/api/kefu/info` (不进 addon 路由), 是历史遗留. 调上传时 `Config.baseUrl + '/api/kefu/info'` 即可, 别拼 addon 名前缀. addon's `getInfo` 事件不是 REST 端点, 是 ThinkPHP 事件总线调用, 仅后端内部用. 前端 `aiService.getApiPrefix()` 当前返回 `'/api/kefu'` 或 `'/api/aiagent'` (用于 chat/info/history 端点),**不**用于上传端点 — 上传端点必须直接拼 addon 模式 URL `'/aikefu/api/upload/'` 或 `'/aiagent/api/upload/'`。 **关键决策点 (按 agent 配置 `upload_strategy`):** > 本表 `upload_strategy` 内部代号 vs 后台 UI 商家看到的文字: > > | 后端代号 | 后台 UI 商家看到的文字 | > |---|---| > | `backend` | 走我们服务器传 (推荐, 默认) | > | `oss_sts` | 走云存储传 (高级, 大视频用) | > | `auto` | 智能选 (小图走我们, 大文件走云存储) | > > 前端代码按后端代号 (`backend` / `oss_sts` / `auto`) 读, 不要把 UI 文字写死. > > **oss_sts 走哪家云** = `media_policy.oss_provider` 字段,可选 12 个 provider (alioss/qiniu/txcos/huaweiobs/baidubos/qingstor/jdoss/volcengine/ucloud/wyoss/awsoss/minio);空串 = 后端按 OssProviderRegistry 自动选. PR-OSS-REGISTRY (2026-09-15) 后已是 12 provider,不再仅阿里云. | 后端代号 (UI 别名) | image ≤2MB | image >2MB | audio/video/file | |---|---|---|---| | `backend` (走我们服务器) | 走 PHP 中转 (默认, 小白选) | 走 PHP 中转 (后端再转云存储) | 走 PHP 中转 | | `oss_sts` (走云存储) | **强制云存储直传** | **强制云存储直传** | **强制云存储直传** | | `auto` (智能选) | 走 PHP 中转 (省事) | 云存储直传 (性能) | 云存储直传 | **核心结论: 前端先 `POST /info` 拿 `upload_strategy`, 再决定走哪条路径。** ### 3.1.5 attachments.data 字段协议 (PR-WB-MP-INLINE-MEDIA 2026-09-16 新增) > 这是**最容易被忽略但最重要的协议层细节**。背景: backend 模式上传的文件 URL 落在 `/upload/{site_id}/...`,**`/upload/` 路径 nginx 无鉴权**,把这种 URL 透传给 WB Agent 让它 fetch — 等于把"任意访客"的边界扩到 WB 服务端。base64 inline 是唯一不依赖 WB 服务端 fetch 公网 URL 的稳路径。 **attachments[] 单条 dict 协议 (PR-WB-MP-INLINE-MEDIA 之后):** ```jsonc { "type": "image", // image | audio | video | file "url": "https://...", // 可选 (data 存在时不强求; 否则必填) "data": "", // ★ PR-WB-MP-INLINE-MEDIA 新增, JSON 上下文必为 base64 "encoding": "base64", // 可选, 显式标记 (默认 base64) "mime": "image/png", // data 内联时必填 (ACP ContentBlock mimeType) "size": 1024, // 原始字节 (WB 阈值 5MB 判断用, 后端计算 sizeof(base64_decode(data)) 兜底) "name": "x.png" } ``` **核心规则:** | 场景 | 后端处理 (A 路 `WbAgentOsClient::buildMediaBlocks` / B 路 `WbAcpClient::buildMediaBlocks`) | |---|---| | `image` 或 `audio` + `data` + `size ≤ 5MB` (5242880 字节) | ACP ContentBlock `image`/`audio` base64 inline, **WB 完全不 fetch URL** | | `data` 超限 (5MB) 或 `type` 不支持 inline (video/file) | fallback 到 `resource_link` (透传 URL 给 WB, 依赖 J9 服务端 fetch) | | 仅 `url`,无 `data` | `WbAgentOsClient` 服务端自 curl (`fetchInline`) 转 base64 后 inline; `WbAcpClient` 直接 resource_link (J9 依赖) | | 无 `url` 且无 `data` | drop + error_log 留 trace (避免静默丢) | | URL 是 `javascript:` / 非 http(s) 协议 | drop (MediaGuard SSRF 闸门) | **storage/transport 分离 (后端保证,前端不用关心):** - `data` 字段**只在 process memory 流动**,**不会写 DB** (attachments 列存的是 url+meta, 不存 base64) - 后端 `MessageMedia::buildFields()` 落库前**显式 `unset(data, encoding)`** - 前端回显只需要 `url`,数据库无 base64 也不会被泄露 **协议层 A/B 对称 (后端 2026-09-16 修复):** | 路径 | 旧行为 | 新行为 | |---|---|---| | A `WbAgentOsClient::buildMediaBlocks` (L2763) | `url` 空即**静默丢** | `data` 优先, `url` 兜底 | | B `WbAcpClient::buildMediaBlocks` (L349-419) | 已认 `data` 字段 | 0 改动 | | `MediaGuard::check` (L116-122) | `data` 字段被 SSRF 闸门拦掉 | `data` 短路放行 (无出站请求) | **前端必须做的 (本期新增):** 1. `uni.uploadFile()` 上传成功后, 再调 `uni.getFileSystemManager().readFile({filePath, encoding:'base64'})` 读 base64 2. 把 `data` (base64) + `url` + `mime` + `size` + `name` 一起塞进 chat 入参 `attachments[]` 3. **大文件 (>5MB) 不要塞 `data`** — base64 会让 chat JSON 膨胀 ~4/3 倍, 走 URL 透传 (依赖 J9, 见下面 caveat) 4. H5 端用 `FileReader.readAsDataURL()` 拿 base64 **WB SDK 协议层约束 (详见 `wb-cloud-sdk-coverage.md`):** | 编号 | 含义 | SDK 支持 | 我们怎么走 | |---|---|---|---| | **J7** | ACP ContentBlock union (image/audio/resource) 支持 base64 inline | ✅ session.py:59-65 | ≤5MB 文件走这条 | | **J9** | WB 服务端是否 fetch 外部 URL | ❌ 不在 SDK (服务端策略) | >5MB / 非 image-audio 走这条 (兜底) | | **J10** | WB `/uploads/*` 分片上传 API | ❌ grep 无 | **目前无解**, 等 WB 出官方 API 后再优化 | **仍未覆盖的边界 (本期没办法):** - >5MB 文件仍依赖 J9, 部署到云服务器后 WB 服务端 fetch `/upload/{site_id}/...` 不一定通 — 此时只能让 Agent 在前端提示用户 "请发小图" - 想 100% 消除 J9 依赖, 等 WB 出 `/uploads/*` 分片上传 API (PR-J10, P2 阻塞) ### 3.2 现状 (PR 前) `pages_tool/ai-chat/components/ai-chat-tools-panel.vue:76-94` 的 `selectImage()`: ```js selectImage() { uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (res) => { const imageMessage = { id: ++this.messageId, role: 'user', type: 'image', content: '', imageUrl: res.tempFilePaths[0], // ★ 只存本地路径, 没真上传 timestamp: Date.now() } this.$emit('image-selected', imageMessage) } }) } ``` `selectFile()` 同样: 只拿到 `file.name / file.size`, 没 `wx.uploadFile`。 `ai-chat-message.vue` 渲染 `message.imageUrl` 时**直接显示本地路径**, AI 端永远看不到这张图。 **结论: 上传功能完全没接通, 用户发的图/文件 AI 端收到的 attachments 是空的。** > 仓库里有 `common/js/util.js:1005-1031` 的 `upload_file_server` 是走 PHP 中转到 `/api/upload/...` (产品图/头像用), **不能直接借给 AI chat**, 因为 AI chat 需要带 `agent_id` / `config_uuid` 路由到对应 addon, 还要走 upload_strategy 决策。 ### 3.3 目标行为 ``` 用户点 [图片] 按钮 ↓ uni.chooseImage → 选图完成 ↓ aiService.getServiceConfig() → 拿 upload_strategy (后端代号, 参见 §3.1 表) ↓ 判断: - upload_strategy=backend + size ≤2MB → POST /upload/image (走我们服务器) - upload_strategy=oss_sts 或 auto+size>2MB → POST /upload/ossSts → uni.uploadFile 直传云存储 - audio/video/file → 一律 POST /upload/ossSts ↓ 拿到 { url, mime, size, name } 后, **fileToBase64 拿 data 字段** (≤5MB 时), 发聊天时塞 attachments[] (见 §3.1.5) ``` ### 3.4 代码示例 > **前置依赖 (PR 前必须先做)**: `common/js/ai-service.js` 当前**没有暴露**下面两个方法: > - `getCurrentConfigUuid()` — 全仓 grep 0 命中, 不存在 > - `getUserId()` — `ai-service.js:83` 是 private module function, 不在 `export default {}` 里 (line 292) > > 本节示例假定已经加了 (PR 提交时一起加). 改动小, diff 如下: > > ```diff > // common/js/ai-service.js > + import Config from './config.js' > > // 已有 (line 83) — 私有的, 改成 public > - function getUserId() { ... } > + export function getUserId() { ... } > > // 新增 (插在 export default 之前) > + export function getCurrentConfigUuid() { > + return uni.getStorageSync('current_config_uuid') || ''; > + } > ``` > > 否则下面所有 `aiService.getCurrentConfigUuid?.()` 会拿到 `undefined` 静默走空, `aiService.getUserId?.()` 同理, 导致上传请求少字段被后端拒. > **代码块术语速查 (后续 §3.4 出现的内部代号 vs 后台 UI 别名)**: > - `uploadViaPhp()` / `PHP 中转` / `PHP passthrough` / `backend` = "走我们服务器" (商家看到的) > - `uploadViaSts()` / `STS 直传` / `STS 凭证` / `oss_sts` = "走云存储直传" (商家看到的) > - 端点路径 `/upload/ossSts` 后端真名是 `Upload::ossSts()`, 别名沿用 (历史叫法, 实际后端走 12 个云存储 provider 任选) > - HTTP header `Authorization: STS :` 是**仅 8 个 4-field STS provider** (alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) 的协议规范, 另外 4 个 single-URL provider (huaweiobs/volcengine/awsoss/minio) 走预签 URL, 不带 Authorization header — 详见 §3.4 uploadViaSts 内的 provider 分流注释 **Step 1: 新建 `common/js/media-upload.js`** ```js // common/js/media-upload.js (新增) import aiService from './ai-service.js' import Config from './config.js' const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB /** * 上传图片 (type=image) * @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0] * @returns {Promise<{url, mime, size, name, kind, used_strategy}>} */ export async function uploadImage(filePath) { const addon = aiService.getAddonName(); // 'aikefu' | 'aiagent' const config = await aiService.getServiceConfig(); const strategy = config?.upload_strategy || 'backend'; // H5 端: >10MB 先压 let compressedPath = filePath; // #ifdef H5 const fileSize = await getFileSize(filePath); if (fileSize > 10 * 1024 * 1024) { try { const compressed = await uni.compressImage({ src: filePath, quality: 80, compressedWidth: 2048, compressedHeight: 2048, }); compressedPath = compressed.tempFilePath || filePath; } catch (e) { console.warn('[media-upload] compressImage failed, use original:', e); } } // #endif const useSts = strategy === 'oss_sts' || (strategy === 'auto' && (await getFileSize(compressedPath)) > SIZE_LIMIT_PHP_PASSTHROUGH); if (useSts) { return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config); } else { return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId()); } } /** * 上传 audio / video / file (一律 STS) */ export async function uploadMedia(filePath, kind) { if (!['audio', 'video', 'file'].includes(kind)) { throw new Error(`invalid kind: ${kind}`); } const addon = aiService.getAddonName(); const config = await aiService.getServiceConfig(); return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config); } /** * PHP 中转 (multipart/form-data) * 端点必须用 addon 模式 URL: //api/upload/ * (不能用 /api//upload/<...>, InitRoute 在 system_array 命中分支不切 namespace) */ async function uploadViaPhp(filePath, kind, addon, agentId) { const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + (kind === 'image' ? 'image' : 'file'); // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { uni.uploadFile({ url: Config.baseUrl + url, filePath, name: 'file', formData: { kind, agent_id: addon === 'aiagent' ? agentId : undefined, config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, uniacid: aiService.getUniacid(), user_id: aiService.getUserId?.() || '', }, success: (res) => { try { const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data; if (data.code === 0 || data.success) { resolve({ url: data.data.url, mime: data.data.mime, size: data.data.size, name: data.data.name, kind: data.data.kind || kind, used_strategy: data.data.used_strategy || 'backend', oss_sts_required: data.data.oss_sts_required || false, }); } else { reject(new Error(data.msg || '上传失败')); } } catch (e) { reject(new Error('解析响应失败: ' + e.message)); } }, fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))), }); }); // #endif // #ifdef H5 const form = new FormData(); const file = await pathToFile(filePath, kind); form.append('file', file); form.append('kind', kind); if (addon === 'aiagent') form.append('agent_id', agentId); else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || ''); form.append('uniacid', aiService.getUniacid()); const res = await fetch(Config.baseUrl + url, { method: 'POST', headers: { 'token': store.state.token || '' }, body: form, }); const data = await res.json(); if (data.code === 0 || data.success) { return { url: data.data.url, mime: data.data.mime, size: data.data.size, name: data.data.name, kind: data.data.kind || kind, used_strategy: data.data.used_strategy || 'backend', oss_sts_required: data.data.oss_sts_required || false, }; } throw new Error(data.msg || '上传失败'); // #endif } /** * STS 直传 (拿临时凭证 → 直传云存储) * * PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类: * A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) * → 前端拼 PUT URL, Authorization header = STS : * B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio) * → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='-SIGNED' 标记, 不带 Authorization * * 后端 stsResp.data.data 可能字段: * { provider, bucket, region, accessKeyId, accessKeySecret, securityToken, * key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) } */ async function uploadViaSts(filePath, kind, addon, agentId, config) { // 1. 拿 STS 凭证 (addon 模式 URL) const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'; const stsResp = await uni.request({ url: Config.baseUrl + stsUrl, method: 'POST', header: { 'Content-Type': 'application/json', 'token': store.state.token || '' }, data: { agent_id: addon === 'aiagent' ? agentId : undefined, config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, uniacid: aiService.getUniacid(), user_id: aiService.getUserId?.() || '', }, }); if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) { throw new Error(stsResp.data?.msg || 'STS 凭证获取失败'); } const sts = stsResp.data.data; const provider = sts.provider || 'alioss'; // 老后端默认 alioss // 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符) const ext = (filePath.split('.').pop() || 'bin').toLowerCase(); const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); const now = new Date(); const key = sts.key_prefix_tpl .replace('{site_id}', sts.site_id || '0') .replace('{agent_id}', agentId || '0') .replace('{yyyy}', now.getFullYear().toString()) .replace('{mm}', String(now.getMonth() + 1).padStart(2, '0')) .replace('{dd}', String(now.getDate()).padStart(2, '0')) .replace('{rand}', uuid) + '.' + ext; // ★ PR-OSS-REGISTRY (2026-09-15): provider 分流 // single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED) const isSingleUrlMode = typeof sts.securityToken === 'string' && /-(SIGNED)$/.test(sts.securityToken); let ossUrl, headers; if (isSingleUrlMode) { // B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header ossUrl = sts.accessKeyId; headers = { 'Content-Type': guessMime(ext) }; } else if (provider === 'qiniu') { // A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key; headers = { 'Authorization': 'UpToken ' + sts.accessKeyId, 'Content-Type': guessMime(ext), }; } else { // A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) // endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼 const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com` : (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com` : (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com` : (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com` : (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}` : (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`); ossUrl = `${tpl}/${key}`; headers = { 'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`, 'x-oss-security-token': sts.securityToken, 'Content-Type': guessMime(ext), }; } // 3. 直传云存储 // #ifdef MP-WEIXIN await new Promise((resolve, reject) => { uni.uploadFile({ url: ossUrl, filePath, name: 'file', formData: {}, header: headers, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) resolve(); else reject(new Error(`${provider} PUT ${res.statusCode}`)); }, fail: reject, }); }); // #endif // #ifdef H5 const file = await pathToFile(filePath, kind); const ossRes = await fetch(ossUrl, { method: 'PUT', headers, body: file, }); if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`); // #endif // 4. 返回公网 URL return { url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL mime: guessMime(ext), size: file.size || 0, name: key.split('/').pop(), kind, used_strategy: 'oss_sts', provider, }; } async function getFileSize(filePath) { // #ifdef MP-WEIXIN return new Promise((resolve) => { uni.getFileInfo({ filePath, success: (res) => resolve(res.size), fail: () => resolve(0), }); }); // #endif // #ifdef H5 try { const res = await fetch(filePath); const blob = await res.blob(); return blob.size; } catch (e) { return 0; } // #endif } async function pathToFile(filePath, kind) { // #ifdef H5 const res = await fetch(filePath); const blob = await res.blob(); return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type }); // #endif } function guessMime(ext) { const map = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac', mp4: 'video/mp4', mov: 'video/quicktime', pdf: 'application/pdf', txt: 'text/plain', }; return map[ext] || 'application/octet-stream'; } export default { uploadImage, uploadMedia }; ``` **Step 2: 改造 `selectImage()`** ```vue ``` **Step 3: 发送消息时携带 attachments (含 `data` 字段, PR-WB-MP-INLINE-MEDIA)** ```js // ai-chat-message.vue sendMessage() async sendMessage() { const hasContent = this.inputText?.trim() !== ''; const readyAttachments = this.messages .filter(m => m.role === 'user' && m.uploaded && !m.sendWithBatch) .map(m => { m.sendWithBatch = true; const a = { type: m.uploaded.kind, url: m.uploaded.url, mime: m.uploaded.mime, name: m.uploaded.name, size: m.uploaded.size, }; // ★ PR-WB-MP-INLINE-MEDIA: ≤5MB 图片/音频塞 base64, 让 WB ContentBlock 内联 // >5MB 不要塞 data (避免 chat JSON 膨胀 ~4/3 倍, 走 URL 透传依赖 J9) if ((a.type === 'image' || a.type === 'audio') && a.size > 0 && a.size <= 5 * 1024 * 1024 && m.uploaded.base64) { a.data = m.uploaded.base64; a.encoding = 'base64'; } return a; }); if (!hasContent && !readyAttachments.length) return; const queryText = this.inputText?.trim() || (readyAttachments.length ? '[附件]' : ''); await this._doSendStream(queryText, readyAttachments); } async _doSendStream(queryText, attachments) { // 改造后的 sendStreamMessage 接 attachments 参数 await this.sendStreamMessage(queryText, attachments); } ``` **Step 4: 上传成功后 fileToBase64 拿 data 字段** `m.uploaded.base64` 字段需要在 §3.4 Step 2 `_uploadOneFile()` 拿到上传结果后补: ```js // ai-chat-message.vue / ai-chat-tools-panel.vue _uploadOneFile 改造 async _uploadOneFile(file, kind) { // ... 原有 pending 占位代码 ... try { const uploaded = kind === 'image' ? await uploadImage(file.path || file) : await uploadMedia(file.path || file, kind); pending.uploading = false; pending.uploaded = uploaded; if (uploaded.url) pending.imageUrl = uploaded.url; // ★ PR-WB-MP-INLINE-MEDIA: 上传成功后, ≤5MB 文件再读 base64 塞进 uploaded if ((kind === 'image' || kind === 'audio') && uploaded.size > 0 && uploaded.size <= 5 * 1024 * 1024) { try { pending.uploaded.base64 = await readFileAsBase64(file.path || file); } catch (e) { console.warn('[ai-chat] fileToBase64 失败, 后端会走 URL 透传 (依赖 J9):', e); // 不阻断, 大不了降级到 J9 } } this.$emit('file-uploaded', pending); } catch (e) { // ... 原有错误处理 ... } }, async function readFileAsBase64(filePath) { // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { uni.getFileSystemManager().readFile({ filePath, encoding: 'base64', success: (res) => resolve(res.data), fail: reject, }); }); // #endif // #ifdef H5 const res = await fetch(filePath); const blob = await res.blob(); return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); // 去掉 "data:;base64," 前缀 reader.onerror = reject; reader.readAsDataURL(blob); }); // #endif // #ifdef APP-PLUS / MP-HARMONY return new Promise((resolve, reject) => { plus.io.resolveLocalFileSystemURL(filePath, (entry) => { entry.file((f) => { const reader = new plus.io.FileReader(); reader.onload = (e) => resolve(e.target.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(f); }, reject); }, reject); }); // #endif } ``` **为什么必须 fileToBase64**: 后端 upload 接口只返 `{url, mime, size, name}`,**不含 base64 字节**。base64 必须在上传后**前端读本地文件**重新拿, 然后跟 url 一起塞 chat attachment。 `sendStreamMessage` 在 §1.4 已改造接 `attachments`, 无需再改。 ### 3.5 下载 / 预览 OSS 直传后返回的 `url` 是公网 URL, 可直接 `` / `