# AI Agent 接入使用说明 (Uniapp 前端)
> 适用版本: backend PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后; PR-OSS-REGISTRY (2026-09-15) 之后 12 个云存储 provider
> 适用前端: 本仓库 (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` (本文档对应的完整后端协议规范, 仓内后端目录)
> - WB Cloud 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
{{ msg.thoughtsText }}
{{ msg.content }}
🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? ` (${tc.duration_ms}ms)` : '' }}
📎
{{ att.name || '文件' }}
{{ formatSize(att.size) }}
tokens: {{ msg.tokens.input }}+{{ msg.tokens.output }}
```
### 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.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, 可直接 `` / `