From 7952f5a7226bbebbad0cefcb55d910c0b7fa77af Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 16 Sep 2026 11:42:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-chat):=20frontend=20=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0/=E6=92=AD=E6=94=BE=E5=85=A8=E6=A0=88?= =?UTF-8?q?=E6=8E=A5=E9=80=9A=20(PR-FRONTEND-MEDIA-UPLOAD=202026-09-16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — 新建 common/js/media-upload.js (12KB) - uploadImage(filePath) / uploadMedia(filePath, kind) 落地 - 此前 ai-chat-tools-panel.vue 引用但文件从未入库, 选图立即抛 "Cannot find module" - 12 cloud provider 分流: 4-field STS (alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) vs single-URL signed PUT (huaweiobs/volcengine/awsoss/minio) - 端点必须 addon namespace: //api/upload/{image|file|ossSts}, 普通 /api/kefu/upload/* 不命中 InitRoute - 补 docs 设计稿漏写的 `import store from '@/store/index.js'` (H5 上传需带 token) P1 — ai-chat-message.vue 用户附件全支持 - P1.1 filter 不再丢 file/audio/video 消息 - P1.2 用户气泡加 type==='file' 分支: 文件图标 + 名称 + 大小 + 上传状态 - P1.3 sendMessage type 不再强制把 audio/video 折成 file P2 — AI 侧 audio 播放引擎接通 - P2.1 previewAttachment audio 分支 → 跳 file-preview - P2.2 音频气泡接 uni.createInnerAudioContext: data 加 innerAudioCtx/_innerAudioSrc, 改写 playAudio/pauseAudio, beforeDestroy 必 destroy, slider 区分 change (显示) / changeend (实际 seek) - P2.3 file-preview.vue 加 image-viewer + audio-viewer 两个分支;inferFileType 识别 jpg/png/mp3/wav 等扩展 P3 — sendMessageWithContent 透传 attachments 参数 - 默认 attachments = [], 传给 sendStreamMessage/sendNormalMessage - tools panel 选完文件 push 到 attachments → 链到 send lang — zh-cn + en-us file-preview 加 4 项 - audioUrlNotExist / audioDefaultTitle / audioPlaying / audioPaused docs — docs/ai-agent-integration.md (跨 PR 累积同步, 2149 行) - 版本基线提到 PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后 - 加 §3.1.5 attachments.data 字段协议 (PR-WB-MP-INLINE-MEDIA) - 12 cloud provider 替换原"阿里云"单点 (PR-OSS-REGISTRY) - media-upload.js 章节补 fileToBase64 (PR-FRONTEND-MEDIA-UPLOAD) - 上传策略表 backend/oss_sts/auto 全替换为"云存储"措辞 对齐后端: - PR-WB-MP-INLINE-MEDIA (a707b90): media data 字段 - PR-OSS-REGISTRY (12 provider): OssProviderRegistry - PR-FRONTEND-MEDIA-UPLOAD (本 commit): 前端落地 兼容性: - 后端未升级到 PR-WB-MP-INLINE-MEDIA 之前, attachments 不带 data 字段, 走 URL 路径 (退化到老行为, 不破) - 后端 oss_sts 单点 alioss 时, securityToken 不带 -SIGNED 后缀, 走 4-field STS 分支 (与 PR-OSS-REGISTRY 之前兼容) 回归: - node --check media-upload.js OK - lang zh ESM import OK, 输出 8 个 key 全对 - ai-chat-message.vue 11 处 PR-FRONTEND-MEDIA-UPLOAD 标记, file-preview.vue 9 处 - 旧 chat 路径不带 attachments 走默认 [], 不破坏现有响应结构 安全约束: 不涉及 nginx 层防盗链 / 限速 --- common/js/media-upload.js | 325 ++++ docs/ai-agent-integration.md | 2149 ++++++++++++++++++++++ lang/en-us/file-preview/file-preview.js | 5 + lang/zh-cn/file-preview/file-preview.js | 5 + pages_tool/ai-chat/ai-chat-message.vue | 224 ++- pages_tool/file-preview/file-preview.vue | 291 ++- 6 files changed, 2957 insertions(+), 42 deletions(-) create mode 100644 common/js/media-upload.js create mode 100644 docs/ai-agent-integration.md diff --git a/common/js/media-upload.js b/common/js/media-upload.js new file mode 100644 index 0000000..004a9ba --- /dev/null +++ b/common/js/media-upload.js @@ -0,0 +1,325 @@ +// common/js/media-upload.js +// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件 +// +// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了 +// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛 +// "Cannot find module '@/common/js/media-upload.js'". 此次按 +// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏). +// +// 调用方: +// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile() +// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场) +// +// 上传策略: +// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传 +// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB) +// +// 端点: +// PHP 中转: POST //api/upload/ multipart/form-data +// STS 凭证: POST //api/upload/ossSts application/json +// STS 直传: PUT / 云存储协议 (4-field STS 或 single-URL) + +import aiService from './ai-service.js' +import Config from './config.js' +import store from '@/store/index.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 + + // 检查是否需要走 STS (>2MB 或商家选 oss_sts) + const fileSizeFinal = await getFileSize(compressedPath) + const useSts = strategy === 'oss_sts' + || (strategy === 'auto' && fileSizeFinal > 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, 因为体积大概率 >2MB) + * @param {string} filePath + * @param {'audio'|'video'|'file'} kind + */ +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/ + * 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下, + * /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中 + * (那是普通接口命名空间, 上传走的是 addon 专属路由) + */ +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 || data.message || '上传失败')) + } + } 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()) + form.append('user_id', aiService.getUserId?.() || '') + 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 || data.message || '上传失败') + // #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 || stsResp.data?.message || '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 || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}') + .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 } \ No newline at end of file diff --git a/docs/ai-agent-integration.md b/docs/ai-agent-integration.md new file mode 100644 index 0000000..86be5c3 --- /dev/null +++ b/docs/ai-agent-integration.md @@ -0,0 +1,2149 @@ +# 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 长连接, HTTP SSE 也没有 elicit_response 端点 + // 兜底: 复用 chat 端点 + action=elicit_response body + // (此端点需后端补 — 当前后端 5 个 chat 入口 (aiagent Chat + aikefu Kefu B+A + 2 个 WS) 都没注册 elicit-response HTTP 路由) + // 临时方案: 30s 超时后端 emit pending_stale, 用户的选项卡片就当 UI 提示展示, 不真回传给 Agent + console.warn('[ai-service] H5 elicit_response 无可用通道, 仅展示用户选项 + 30s 后由 pending_stale 兜底'); + 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, 可直接 `` / `