Compare commits

...

59 Commits

Author SHA1 Message Date
166a284359 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	.local.config.js
#	common/js/ai-service.js
#	common/js/media-upload.js
#	docs/ai-agent-integration.md
#	pages_tool/ai-chat/ai-chat-message.vue
#	pages_tool/ai-chat/components/ai-chat-tools-panel.vue
#	pages_tool/ai-chat/index.vue
2026-09-29 11:02:53 +08:00
5efae5c38b feat(ai-chat): 增加自动定位权限提示及处理逻辑,优化用户体验 2026-09-28 13:57:46 +08:00
4168c24fd6 feat(ai-chat): 增强位置消息处理,支持透传位置、时间、页面上下文,优化用户体验 2026-09-28 13:48:56 +08:00
762401777b feat(ai-chat): 增强多媒体消息处理,支持多条语音和视频同时发送,优化消息气泡显示逻辑 2026-09-28 11:30:40 +08:00
24c4e40df9 Merge remote-tracking branch 'remotes/origin/temp221' into temp221 2026-09-28 11:12:05 +08:00
c58734fcda feat(ai-chat): 增强语音消息处理,支持多条语音同时发送及中央播放面板 2026-09-28 11:10:26 +08:00
bbc9fdd6ea feat(ai-chat): 动态切换输入框 placeholder 文案,支持语音和媒体附件提示 2026-09-28 10:18:10 +08:00
4cd21ade51 fix(ai-chat): 修复 v-if/v-else 配对问题,优化输入框和语音图标布局对称性 2026-09-28 10:16:09 +08:00
0799e47233 feat(ai-chat): 在流式回答中添加"思考中..."指示,优化操作栏显示逻辑 2026-09-28 10:05:52 +08:00
306f2f7ff7 feat(voice-input): 改进音频消息处理,支持音频时长和指令文本,优化后端交互 2026-09-28 09:33:57 +08:00
fff10a2e19 feat(media-upload): 增加全量音视频格式支持,确保与后端一致 2026-09-24 18:03:08 +08:00
9d990e6aac feat: implement voice input feature with press-and-hold gesture
- Added new language strings for voice input functionality in English and Chinese.
- Updated manifest to include microphone permission for voice recording.
- Enhanced ai-chat-message component to support voice input with visual feedback for recording and cancellation gestures.
- Integrated voice recorder functionality in ai-chat-voice-panel, allowing users to record audio with a maximum duration of 60 seconds.
- Implemented audio handling logic to upload recorded audio files and display them in the chat interface.
- Added error handling for voice recording failures and user notifications.
- Updated styles for improved UI/UX during voice recording interactions.
2026-09-24 17:47:37 +08:00
74468e2c9d feat(ai-chat): 暂时隐藏模型选择器,统一使用 Auto 模式 2026-09-24 17:31:15 +08:00
f40c4b2a69 Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-24 17:05:26 +08:00
2dae3adf66 chore:网页容器H5显示正常 2026-09-24 17:00:14 +08:00
e079afc0a2 feat(media-upload): 增加自愈机制,自动切换 STS 直传以应对后端策略变化 2026-09-24 16:27:33 +08:00
85b3e4db47 chore:AI回答的文字只有网址是蓝色,其他回答是黑色的 2026-09-24 13:59:24 +08:00
0197224c75 chore:H5所有气泡都统一 2026-09-24 11:59:50 +08:00
70f8fb867d feat(chat-ui): 增加聊天 UI 配置的本地持久化和预加载功能,优化首帧渲染体验 2026-09-24 11:10:21 +08:00
86b87eeb9d feat(ai-chat): 聊天 UI 配置 V3 — 名称/头像/时间配置项消费
- CHAT_UI_DEFAULTS 同步后端 4 新键 (show_name/show_avatar/avatar/time_format/time_humanize)
- 标题与 AI 头像按开关显隐, 自定义头像 (素材库 pic_path / URL) 优先于内置
- formatTime 支持 auto(智能分段)/full(完整) 与 humanizeTime 相对时间优先 (刚刚/N分钟前/N小时前/昨天/前天/M月D日/Y年M月D日)
- i18n 中英各+5 键
2026-09-24 10:30:28 +08:00
297b545e55 fix(media-upload): alioss 直传 endpoint 缺 oss- 前缀修复
- sts.region 是简写 (cn-hangzhou), 真实 OSS endpoint 须完整形式 (oss-cn-hangzhou)
- 优先使用后端 sts.oss_url (OssProviderRegistry::buildDirectEndpoint 已拼好), 兜底 oss-${region} 拼接
2026-09-24 10:29:55 +08:00
3e2ae9b7ca chore 2026-09-24 08:51:52 +08:00
2016b87b6e chore:回答问题视口随文字增长一直是贴着最新一句往上走。 2026-09-23 17:12:11 +08:00
a42ad4ebb3 chore:新增AI智能问题是否合并回答/单独回答 2026-09-23 16:25:39 +08:00
158750b933 chore:删除AI回答后下面出现修改图标的英文 2026-09-23 15:26:36 +08:00
3abe65de2a chore:H5的气泡修改 2026-09-23 15:22:19 +08:00
c0e432cc22 chore:微信小程序真机可以自定义复制 2026-09-23 15:04:27 +08:00
f8115f47fd chore:AI修改新对话按钮图标,搜索记录按钮向上移动,删除AI回答后的AI名称 2026-09-23 14:13:54 +08:00
66b56387e6 chore:滑动图片后台上传图片自适应 2026-09-22 15:15:24 +08:00
3079cf4ba5 chore:点赞图标正常 2026-09-22 10:39:01 +08:00
aefb0c1550 Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-21 08:53:52 +08:00
235adb5ebd chore:优化工具调用的可见性控制,确保与思考过程开关一致;统一欢迎语字段名,移除冗余信息;兼容旧数据格式,提升数据处理的健壮性 2026-09-20 18:25:59 +08:00
744981ed93 chore:添加 chat_ui_config 支持,优化前端 UI 行为开关;实现附件类型动态控制,确保与后端配置同步 2026-09-20 18:01:43 +08:00
ee20c214de chore:优化服务配置缓存逻辑,减少不必要的 API 调用;修复欢迎语加载逻辑,避免重复推送欢迎气泡;调整快捷问题样式以兼容低版本环境 2026-09-20 15:45:12 +08:00
8fd68033ed chore:添加欢迎语配套快捷问题功能,支持用户快速发送常见问题;优化数据格式处理与样式 2026-09-20 15:07:43 +08:00
62b4b44f88 chore :真机可以支持测试 2026-09-20 14:06:28 +08:00
9d599d5f47 chore:后台更改卡片底板内边距,四边样式颜色,前端和后台同步 2026-09-20 10:33:28 +08:00
2b1f8a4fe2 chore:修复清除会话的 URL 格式,确保使用 camelCase,避免 404 错误;优化 agent 切换时的会话 ID 管理 2026-09-19 16:25:16 +08:00
7a81045290 Merge branch 'temp221' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-19 15:26:53 +08:00
67ce0f8b39 chore:更新 AI 服务会话管理逻辑,优化用户状态切换时的 key 迁移与会话隔离,确保 H5 端 elicitResponse 请求正常处理 2026-09-19 15:26:38 +08:00
01df6c9116 chore:后台调节滑动图片组件的左右边距,前端运行出来和后台一致 2026-09-19 15:01:22 +08:00
480a109114 chore:商品分类模板1更改,只有「加载完成 且 该二级分类下商品列表非空」才渲染,否则标题 + 网格整块都不显示。 2026-09-18 17:44:03 +08:00
509fe203f7 chore:商品分类模板5 更改完成 2026-09-18 17:28:51 +08:00
0f9cc1087f chore:商品分类模板5 2026-09-18 16:44:14 +08:00
0f24f47048 chore:滑动图片的上下左间距可以后台自定义调节 2026-09-18 15:26:05 +08:00
a3d2ee562e chore:新增商品分类模板5 2026-09-18 13:53:47 +08:00
1b1704f7c6 chore:滑动图片是水平滑动滚动条根据后台显示,AI智能点赞svg 2026-09-18 09:58:15 +08:00
8b2670bf31 chore:客服信息组件转英文模式显示全部英文 2026-09-17 18:19:30 +08:00
e66c91acfc chore:【搜索框】的样式设置中的“背景颜色”可以控制搜索框的"背景颜色"和搜索框的“上下左右边距”的颜色 2026-09-17 15:09:42 +08:00
29699a66fd chore:AI智能输入框样式改变 2026-09-17 14:21:28 +08:00
3b4051c775 chore:上传图片可以支持3张,正常显示 2026-09-17 09:48:49 +08:00
fffb5dae18 chore:用户的消息,图片在上文字在下 2026-09-17 09:11:59 +08:00
8cfc144ba9 fix(frontend): 端用户附件走 inline base64 路径, 避开 MediaGuard SSRF 闸门
PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16)
PR-WB-MP-INBOUND-MEDIA 配套

症状: 用户在前端上传图片成功后 (OSS URL 已返回), AI Agent 仍回复
"我这边并没有收到或看到任何图片附件".

根因链路:
1. backend Upload.php::buildPublicUrl 用 request()->domain() 拼 URL
   (LAN 部署返回 http://192.168.x.x:8050/upload/..., 公网部署返回公网域名)
2. 前端把 upload 响应直接透给 AI chat attachments[] 字段
3. backend MediaGuard::urlAllowed 把 192.168.x.x 当内网 IP
   (FILTER_FLAG_NO_PRIV_RANGE) 拒绝 → attachments 整批丢
4. 静默失败: MediaGuard 在 '白名单过滤后空集' 时返回 ok=true + media=[]
   → Kefu.php 不 return error, 业务继续走
5. WB 收到 0 个 media block → Agent 看不到附件

修复:
- media-upload.js 新增 fileToBase64(filePath, opts) helper
  - H5: fetch(blobUrl) → blob → FileReader.readAsDataURL → strip data: 前缀
  - MP-WEIXIN/APP-PLUS: uni.getFileSystemManager().readFileSync(base64)
  - SIZE_LIMIT_INLINE_BASE64 = 5MB, 与后端 INLINE_MEDIA_LIMIT 对齐
  - 超限 / 失败返回 null, 调用方退回 URL 路径
- ai-chat-message.vue sendMessage 改为 await Promise.all 模式:
  - 对每个 pending item 调 fileToBase64(tempPath) 拿 base64
  - 把 data + encoding='base64' 注入 attachment
  - enrichedPendingAttachments 同时用于 readyAttachments + userMessage.attachments
  - 复用同一份 base64, 不重复读 file

后端接收 data 字段后整条链路已对齐:
- MessageMedia::normalizeInput 直接透传 data 字段
- MediaGuard::check data 非空时无条件放行, 不参与 URL 校验
- WbAgentOsClient::buildMediaBlocks (A 路) 路径 1 优先 data + image/audio
- WbAcpClient::buildMediaBlocks (B 路) data 非空也走 inline base64
- inline_media_sanity.php 39 个断言全部覆盖 (test 3.1, 3.2, 4.1, 4.2, 7.6)

测试:
- E2E test_inline_base64.py 10/10 通过
  - mock 上传返回 LAN URL 模拟 MediaGuard 拦截场景
  - 抓 /api/kefu/chat POST body, 验证 attachments[].data 是有效 PNG base64
  - magic bytes 89504E47 验证通过
- 既有 test_ai_chat_upload.py 20/20 通过 (无回归)

边界:
- >5MB 图片 fileToBase64 返回 null, 退回 URL 路径 (buildMediaBlocks 走
  路径 2 fetchInline / 路径 3 resource_link); LAN 部署下仍可能被 MediaGuard
  拦截, 但 5MB 以下的图片(主流微信/支付宝截图等)不受影响
- H5/MINIProgram/APP 三端都加了 #ifdef 编译条件
2026-09-16 17:55:49 +08:00
065fba7cf8 fix(media-upload): H5 pathToFile 修复 multipart filename 缺扩展名
PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16)

诊断: 直接打后端 (curl) 验证后端 ext_rule 完全 OK,
所有 jpg/png/heic 标准 mime 都返回 code:0, 只有 filename=空
(无扩展名) 才会被拒. 所以问题在前端.

定位: common/js/media-upload.js pathToFile() H5 分支:
  原代码 filePath.split('/').pop() 对 blob:http://xxx/uuid
  取出 'uuid' (无扩展名)
  → new File([blob], 'uuid', ...) 没有 .jpg/.png 后缀
  → multipart Content-Disposition: filename='uuid'
  → 后端 fileExt 规则检查 'uuid' 不在白名单 → 拒绝

修复: 提取 extractExt() 到模块顶层 (之前内联在 uploadViaSts 里),
pathToFile 调用 extractExt(filePath, blob.type) 三段兜底:
  1) filePath 最后一段正则
  2) filePath.name 兜底
  3) blob.type mimeHint 反查 EXT_MIME_MAP → png/jpg/heic 等

修复后 multipart filename = 'test-img1.png' (有扩展名),
后端 ext_rule 通过, 上传成功.

E2E 验证 (verify_filename2.py):
  - 修复前: filename='test-img2' → 后端拒绝
  - 修复后: filename='test-img2.png' → 后端接受

教训: PR-FRONTEND-MEDIA-UPLOAD-FIX 加 extractExt 时只修了 STS 路径,
PHP 路径漏修; H5 blob URL + multipart 是双盲点 (前端不知道后端需要
filename 扩展名, 后端拿到 filename 才检查). 任何 file.toBlob() 或
URL.createObjectURL 出来的 file 都要在 File 构造时补扩展名.
2026-09-16 17:32:03 +08:00
f2cb69cb06 fix(ai-chat): rename _pendingIdSeq → pendingIdSeq (Vue 2 strips underscore-prefix data)
PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16)

Bug: Vue 2 在组件实例化时过滤掉 _ 和 $ 开头的数据属性.
`_pendingIdSeq: 0` 在 data() 里定义但 this._pendingIdSeq === undefined.
onImagePicked 中 `++this._pendingIdSeq` → NaN, 所有 pendingImage 的
localId 都是 NaN, Vue 'Duplicate keys detected: NaN' 警告.

E2E 实测发现 (test_ai_chat_upload.py 20/20 通过):
- pendingImages 应有 3 项 ✓
- 每张图 localId 独立递增 (1, 2, 3) ✓
- 3 张图独立上传成功 ✓
- DOM 渲染 3 个 .pending-item + 3 个 uni-image 缩略图 ✓
- canSendPendingImage computed = true → .top-send-btn 出现 ✓
- removePendingImage(localId) → 剩 2 张 ✓
- 失败场景: uploadError 有值 + pending-retry-delete 出现 ✓

修复: data 里改 pendingIdSeq: 0, 代码里改 ++this.pendingIdSeq
教训: Vue 2 data() 里禁止用 _ 或 $ 前缀的属性名.
2026-09-16 17:14:11 +08:00
fe2d3dcfe2 fix(ai-chat): 全面修复前端图片附件 4 个 P0 + 3 个 P1 + 3 个 P2 bug
PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16)

配合后端 PR-WB-MP-UPLOAD-EXT (HEIC/AVIF/BMP/TIFF 双层修复), 前端用户上传链路全面打通:

**4 个 P0 (阻塞用户上传)**:
- pendingImage 单数 → pendingImages 数组: 选 9 张图只发 1 张 → 全部一起发
  - 每个图独立 localId 独立上传独立失败
  - 失败给删除按钮 (用户不再卡死)
- MP-WEIXIN 不压缩 HEIC → H5+MP-WEIXIN 双端都压缩
  - 微信 chooseImage 默认 compressed 不支持 HEIC, 必须本地 uni.compressImage
- getFileSize 失败兜底 0 → 失败抛错, 探测完全失败时兜底走 STS
  - 0 > 2MB = false 让 auto 模式永远走 PHP 中转, 大文件 OOM
  - 现: totallyUnknown → 兜底 STS
- guessMime 缺 HEIC 等 → 与后端 guessMimeFromPath 对齐 26 个 mime

**3 个 P1 (业务级)**:
- selectFile 微信小程序死按钮 → 加 #ifdef MP-WEIXIN 分支 (uni.chooseMessageFile/wx.chooseMessageFile)
  - 兼容检测两个 API 哪个可用
- 上传失败后 pendingImage 永远卡住 → 失败时给 删除 按钮 (pending-retry-delete)
- URL.createObjectURL 永不释放 → upload 完成后 (成功/失败都) revokeObjectURL

**3 个 P2 (优化)**:
- filePath.split('.').pop() 在 blob URL 翻车 → 用 lastSegment + 正则提取 + 兜底 'bin'
- STS MP-WEIXIN size 永远 0 → 用已知 detectedSize + pathToFile 再探测
- H5 压缩阈值 10MB → 3MB

**新增**:
- tests/media-upload-fix.test.js (12 个单测全过): guessMime 现代格式 / extractExt blob URL / decideSts 兜底 STS 决策
2026-09-16 16:52:49 +08:00
78973c99a3 chore: 2026-09-16 16:38:34 +08:00
171190c1cd chore:合并 2026-09-16 11:52:28 +08:00
7952f5a722 feat(ai-chat): frontend 媒体上传/播放全栈接通 (PR-FRONTEND-MEDIA-UPLOAD 2026-09-16)
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: /<addon>/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 层防盗链 / 限速
2026-09-16 11:42:18 +08:00
26 changed files with 7766 additions and 1070 deletions

View File

@@ -242,8 +242,9 @@ const localDevConfig = ({
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置
// })['2811']; // 本地
//})['5g-317']; // 电磁兼容
})['5g-351']; //
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['1']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

11
App.vue
View File

@@ -5,6 +5,9 @@
import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.js';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
import aiService from 'common/js/ai-service.js';
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
@@ -468,6 +471,14 @@
if (uni.getStorageSync('memberInfo')) {
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
try {
if (aiService && typeof aiService.onUserChange === 'function') {
aiService.onUserChange();
}
} catch (e) { /* ignore */ }
// 查询购物车信息
this.$store.dispatch('getCartNumber');
}

View File

@@ -13,10 +13,54 @@ let currentConversationId = null;
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口
// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
export const CONVERSATION_KEY = 'ai_conversation_id';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离
// 老 key: ai_conversation_id_<agentId> (只按 agent 隔离, 跨用户/跨 uniacid 串会话)
// 新 key: ai_conv_<uniacid>_<userId>_<agentId> (按 uniacid × userId × agent 三维隔离)
// 一次性迁移: 读老 key → 新 key 为空 → 写入新 key 后保留老 key (1 版本后清理)
export const CONVERSATION_KEY_PREFIX = 'ai_conv_';
const LEGACY_KEY_PREFIX = 'ai_conversation_id_';
export function getConversationKey(agentId) {
let siteId = 0;
let userId = 'anon';
try {
siteId = uni.getStorageSync('uniacid') || Config.uniacid || 0;
} catch (e) { /* ignore */ }
try {
userId = getUserId() || 'anon';
} catch (e) { /* ignore */ }
return CONVERSATION_KEY_PREFIX + siteId + '_' + userId + '_' + (agentId || DEFAULT_AGENT_ID);
}
// 一次性老 key 迁移: 新 key 为空且老 key 有值 → 写入新 key, 保留老 key 1 版本兜底
function migrateLegacyConversationKey(agentId) {
try {
const legacyKey = LEGACY_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
const legacy = uni.getStorageSync(legacyKey);
const newKey = getConversationKey(agentId);
if (legacy && !uni.getStorageSync(newKey)) {
uni.setStorageSync(newKey, legacy);
}
} catch (e) { /* ignore */ }
}
// 兼容旧代码: 单 key 别名(指向默认 agent) — 给老代码引用,值是动态计算的
export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
// 统一 SSE/WS 事件分发器 (透出所有事件类型)
let eventHandlers = { onEvent: null };
function dispatchEvent(onEvent, event, data) {
if (typeof onEvent === 'function') {
try { onEvent(event, data); }
catch (e) { console.error('[AI Service] onEvent handler threw:', e); }
}
}
/**
* PR-7: agent_id -> 后端 addon 路由映射
@@ -65,14 +109,25 @@ function getDomainParams() {
};
}
// 初始化时从本地读取会话ID
// 初始化时从本地读取会话ID(按当前 agent 隔离)
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 先迁移老 key 再读新 key
// H5 时序坑: 模块顶层同步执行时 uni.getStorageSync 仍返回空字符串
// (uniapp H5 runtime shim 在 webpack bundle 之后才注入),延迟 1 个 tick
// 让 uni runtime 就绪后再读 — 实测 100ms 后能读到值
try {
const saved = uni.getStorageSync(CONVERSATION_KEY);
if (saved) {
currentConversationId = saved;
}
setTimeout(() => {
try {
migrateLegacyConversationKey(currentAgentId);
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) {
currentConversationId = saved;
}
} catch (e) {
console.warn('读取会话ID失败:', e);
}
}, 0);
} catch (e) {
console.warn('读取会话ID失败:', e);
console.warn('初始化会话ID任务派发失败:', e);
}
const GUEST_ID_KEY = 'ai_guest_user_id';
@@ -98,6 +153,28 @@ function getUserId() {
return guestId;
}
/**
* 递归在 info 响应中查找配置 uuid (媒体上传必填)。
* 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。
*/
function findConfigUuid(obj, depth) {
if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return '';
var keys;
try { keys = Object.keys(obj); } catch (e) { return ''; }
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]);
}
for (var j = 0; j < keys.length; j++) {
var v = obj[keys[j]];
if (v && typeof v === 'object') {
var found = findConfigUuid(v, (depth || 0) + 1);
if (found) return found;
}
}
return '';
}
/**
* 获取当前 AI Agent。
*/
@@ -198,41 +275,99 @@ function ensureWebSocketConnected() {
// 以下为聊天消息响应,只在有活跃消息时处理
if (!wsMessageActive) return;
if (data.event === 'message') {
var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
// 优先走 onEvent 事件分发(透出 thought/tool_call/question 等)
var onEvt = eventHandlers.onEvent;
switch (data.event) {
case 'message': {
var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id });
break;
}
wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text);
dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id });
break;
}
wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text);
} else if (data.event === 'message_end' || data.event === 'done') {
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(独立函数,不使用 this)
if (conversationId) {
if (conversationId !== currentConversationId) {
currentConversationId = conversationId;
try {
uni.setStorageSync(CONVERSATION_KEY, conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) {
console.error('保存会话ID失败:', e);
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id);
dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id });
break;
}
case 'tool_call':
dispatchEvent(onEvt, 'tool_call', data.tool_call || {});
break;
case 'question':
dispatchEvent(onEvt, 'question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(按当前 agent 隔离)
if (conversationId) {
if (conversationId !== currentConversationId) {
currentConversationId = conversationId;
try {
uni.setStorageSync(getConversationKey(currentAgentId), conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) {
console.error('保存会话ID失败:', e);
}
}
}
var content = wsMessageContent;
var convId = conversationId;
// 重置消息状态,但不断开连接
var endPayload = {
conversation_id: convId,
message_id: data.message_id,
content: data.content || 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 || ''
};
wsMessageActive = false;
wsMessageContent = '';
dispatchEvent(onEvt, 'message_end', endPayload);
if (wsMessageOnComplete) wsMessageOnComplete(endPayload);
if (wsMessageResolve) wsMessageResolve(endPayload);
break;
}
var content = wsMessageContent;
var convId = conversationId;
// 重置消息状态,但不断开连接
wsMessageActive = false;
wsMessageContent = '';
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId });
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId });
} else {
// 兜底日志:打印所有未识别的消息类型
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
case 'error':
dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' });
break;
case 'ping':
// keep-alive, 忽略
break;
default:
// 兜底: 老格式 data.data.content
if (data.data && data.data.content) {
var defText = data.data.content;
wsMessageContent += defText;
if (wsMessageOnChunk) wsMessageOnChunk(defText);
dispatchEvent(onEvt, 'message', { content: defText });
} else {
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
@@ -289,6 +424,64 @@ function disconnectWebSocket() {
}
// #endif
// PR-FIX (2026-09-20): getServiceConfig 短 TTL 缓存
// 服务配置(含欢迎语)变更频率极低, 每次进聊天页都打 /api/kefu/info 纯属浪费;
// TTL 60s + 失败不缓存, 配置保存后最多延迟 1 分钟生效
var _serviceConfigCache = null;
var _serviceConfigCacheAt = 0;
var SERVICE_CONFIG_CACHE_TTL = 60 * 1000;
function resetServiceConfigCache() {
_serviceConfigCache = null;
_serviceConfigCacheAt = 0;
}
// PR-CHAT-UI-CONFIG (2026-09-20): chat_ui_config 前端 fallback 默认值
// 与后端 addon/aikefu/model/Config.php::DEFAULT_UI_CONFIG 字段一一对应
// 改任何一项必须同步后端常量, 否则前后端默认分叉
//
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20):
// ui_available_models 必须完全来自后端 (Config.php::DEFAULT_UI_CONFIG),
// 前端不维护实际清单。仅保留 'Auto' 兜底: 后端 /info 调用失败 / 返回空数组时,
// 模型选择器仍可显示 Auto。
// 数据源 (后端维护): https://docs.openclaw.ai/providers
// 商家可在后台自由增删任意自定义模型名 (自部署/微调), 仅 trim + 去空 + 去重 + 单条 ≤30 字符 + 总数 ≤100。
const CHAT_UI_DEFAULTS = {
ui_show_thinking_process: true,
ui_enable_attachment_types: ['image', 'file', 'audio', 'video'],
ui_input_max_length: 500,
ui_available_models: ['Auto'],
ui_enable_feedback: true,
// PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_assistant_name: '',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_show_assistant_name: true,
ui_show_assistant_avatar: true,
ui_assistant_avatar: '',
ui_show_message_time: true,
ui_time_format: 'auto',
ui_time_humanize: false
};
// chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步);
// 不在 _serviceConfigCache 里读是因为 chat 组件可能在 getServiceConfig 还没返回前就调它
var _chatUiConfigCache = null;
var _chatUiConfigCacheAt = 0;
function mergeChatUiConfig(remote) {
// remote 是 /info 响应里的 chat_ui_config 对象, 缺字段用 defaults 兜底
// 注意: 不深拷贝数组 → 后端返回的就是最终数组, 不再覆盖
var merged = {};
Object.keys(CHAT_UI_DEFAULTS).forEach(function(k) {
if (remote && Object.prototype.hasOwnProperty.call(remote, k) && remote[k] !== null && remote[k] !== undefined) {
merged[k] = remote[k];
} else {
merged[k] = CHAT_UI_DEFAULTS[k];
}
});
return merged;
}
export default {
/**
* 获取uniacid
@@ -329,6 +522,10 @@ export default {
response_mode: 'blocking',
user: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (options && options.attachments) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
// 给 WbAgentOsClient 提供 timestamp/timezone/location/page/metadata 等基础元素
context: (options && options.context) || null,
...getDomainParams()
},
header: {
@@ -362,48 +559,106 @@ export default {
* 流式消息入口(自动适配平台)
* 微信小程序:复用长连接 WebSocket
*/
async sendStreamMessage(message, onChunk, onComplete) {
async sendStreamMessage(message, onEvent, onComplete, opts = {}) {
// #ifdef MP
try {
await ensureWebSocketConnected();
} catch (e) {
if (onComplete) onComplete({ error: e.message });
throw e;
// WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
// 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
return this._fallbackToHttp(message, onEvent, onComplete, opts);
}
wsMessageContent = '';
wsMessageActive = true;
wsMessageOnChunk = onChunk || null;
wsMessageOnComplete = onComplete || null;
eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null;
// onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete
wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null);
wsMessageOnComplete = (typeof onEvent === 'function') ? null : (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() || '',
...getDomainParams()
})
});
try {
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 || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
context: (opts && opts.context) || null,
...getDomainParams()
})
});
} catch (sendErr) {
console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message);
// 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡
this._fallbackToHttp(message, onEvent, onComplete, opts)
.then((res) => resolve(res))
.catch((err) => reject(err));
}
});
// #endif
},
/**
* [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
* 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
*/
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
try {
const result = await this.sendMessage(message, {
attachments: (opts && opts.attachments) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): MP HTTP 降级路径同步传递 context
context: (opts && opts.context) || null
});
const content = result && result.content ? result.content : '';
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
const fallbackPayload = {
content,
conversation_id: result.conversationId,
thoughts: result.thoughts || '',
tokens: result.tokens || null
};
if (content || result.thoughts) {
if (typeof onEvent === 'function') {
if (result.thoughts) onEvent('thought', { content: result.thoughts });
onEvent('message', { content });
onEvent('message_end', fallbackPayload);
} else {
if (onComplete) onComplete(fallbackPayload);
}
return fallbackPayload;
}
const emptyErr = new Error(lang.lang('aiService.serviceUnavailable'));
if (onComplete) onComplete({ error: emptyErr.message });
throw emptyErr;
} catch (err) {
console.error('[AI Service] MP HTTP 降级请求也失败:', err);
if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') });
throw err;
}
},
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/
async sendHttpStream(message, onChunk, onComplete) {
async sendHttpStream(message, onEvent, onComplete, opts = {}) {
// #ifdef H5
try {
var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
// PR-AI-CHAT-URL-NORMALIZE (2026-09-19): Config.baseUrl 文档约定以 '/' 结尾 (config.js:25),
// resolveApiUrl 返 '/api/kefu/chat' 也以 '/' 开头 → 直接拼接会出 '//api/kefu/chat' (双斜杠)。
// nginx 实际会 normalize, 但 console 会脏、第三方抓包看不懂, 单点 normalize 消除连续 //。
// 只动 URL 拼接本身, 不改 baseUrl 约定 (整个项目所有 API 都依赖这个约定)。
var url = (Config.baseUrl + chatUrl).replace(/([^:])\/\//g, '$1/');
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -419,6 +674,9 @@ export default {
agent_id: getAgentId(),
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (opts && opts.attachments) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
context: (opts && opts.context) || null,
...getDomainParams()
})
});
@@ -432,6 +690,7 @@ export default {
let buffer = '';
let content = '';
let conversationId = '';
const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); };
while (true) {
const { done, value } = await reader.read();
@@ -444,54 +703,90 @@ export default {
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
try {
const jsonStr = trimmed.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
}
if (data.event === 'message_end' || data.event === 'done') {
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
}
}
} catch (e) {
console.warn('解析失败:', e, line);
}
}
}
}
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
if (jsonStr) {
if (!trimmed.startsWith('data:')) continue;
const jsonStr = trimmed.slice(5).trim();
if (!jsonStr || jsonStr === '[DONE]') continue;
try {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
// PR-SSE-ERROR-PASSTHROUGH (2026-09-19): blocking 错误响应 (aikefu Kefu.php:558)
// 或 aiagent Chat.php catch 块会在 stream 模式下也返 {"code":-1,"message":"..."}
// 这种响应没 event 字段, 不应进 switch 的 default (会污染 console)
if (data && typeof data === 'object' && !Array.isArray(data) &&
data.code !== undefined && data.code !== 0 && !data.event) {
console.warn('[AI SSE] 后端错误响应:', data.code, data.message);
fire('error', { code: data.code, message: data.message || '请求失败' });
break;
}
// 空数组是 PHP json_encode([]) 的产物 (如 attachments 或 history 字段),
// 不是事件, 静默跳过
if (Array.isArray(data)) {
continue;
}
switch (data.event) {
case 'message': {
var text = data.answer || data.text || data.content || '';
if (!text && data.data) text = data.data.content || data.data.answer || '';
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
fire('thought', { content: text, message_id: data.message_id });
break;
}
content += text;
fire('message', { content: text, message_id: data.message_id });
break;
}
content += text;
if (onChunk) onChunk(text);
}
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
fire('thought', { content: thoughtText2, message_id: data.message_id });
break;
}
case 'tool_call':
fire('tool_call', data.tool_call || {});
break;
case 'question':
fire('question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
const cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) conversationId = cid;
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
fire('message_end', {
conversation_id: cid,
message_id: data.message_id,
content: data.content || 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':
fire('error', { code: data.code || -1, message: data.message || 'unknown' });
break;
default:
// B 路 Dify 把思考塞 answer 里 (> 前缀)
if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) {
fire('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);
}
} catch (e) {
console.warn('最后 buffer 解析失败:', e);
}
}
@@ -516,7 +811,9 @@ export default {
success: true,
content: response.data?.answer || response.data?.content || response.data?.reply || response.data,
conversationId: response.data?.conversation_id,
messageId: response.data?.message_id
messageId: response.data?.message_id,
thoughts: response.data?.thoughts || '',
tokens: response.data?.tokens || null
};
} else {
throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
@@ -532,25 +829,57 @@ export default {
/**
* 设置并持久化会话ID
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key
*/
setConversationId(id) {
if (id && id !== currentConversationId) {
currentConversationId = id;
try {
uni.setStorageSync(CONVERSATION_KEY, id);
migrateLegacyConversationKey(currentAgentId);
uni.setStorageSync(getConversationKey(currentAgentId), id);
} catch (e) {
console.error('保存会话ID失败:', e);
}
}
},
/**
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 用户切换钩子
* App.vue 在 setMemberInfo 之后调用,触发清旧 user key + 重置 currentConversationId
* 避免"用户 A 退出 → 用户 B 登录 → 直接复用 A 的会话 ID" 串会话 bug
*
* 调用时机:
* - App.vue onLaunch 时如果检测到 memberInfo 恢复 → 调一次
* - 任何 setMemberInfo 提交后调一次
* 行为:
* - 比对新老 user_id,不同时移除"旧 user"维度的 storage key
* - 重置 currentConversationId 为 null (强制下一轮 chat 用新 user 的会话或新建)
* - 记录 last_user_id 用于下次对比
*/
onUserChange() {
try {
const newUid = String(getUserId() || 'anon');
const oldUid = uni.getStorageSync('ai_last_user_id');
if (oldUid && oldUid !== newUid) {
// 清旧用户 key 维度 (按当前 agent 即可,下次 setAgentId 会重新计算)
const oldKey = CONVERSATION_KEY_PREFIX + (uni.getStorageSync('uniacid') || Config.uniacid || 0)
+ '_' + oldUid + '_' + (currentAgentId || DEFAULT_AGENT_ID);
try { uni.removeStorageSync(oldKey); } catch (e) { /* ignore */ }
}
uni.setStorageSync('ai_last_user_id', newUid);
currentConversationId = null;
} catch (e) {
console.warn('[ai-service] onUserChange failed:', e && e.message);
}
},
/**
* 清除会话ID
*/
clearConversationId() {
currentConversationId = null;
try {
uni.removeStorageSync(CONVERSATION_KEY);
uni.removeStorageSync(getConversationKey(currentAgentId));
} catch (e) {
console.error('清除会话ID失败:', e);
}
@@ -612,6 +941,12 @@ export default {
* PR-7: URL 按 agent_id 路由
*/
async getServiceConfig() {
// PR-FIX (2026-09-20): 命中缓存直接返回
const nowTs = Date.now();
if (_serviceConfigCache && (nowTs - _serviceConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
console.log('[AI Service] getServiceConfig 命中缓存 (age=' + Math.round((nowTs - _serviceConfigCacheAt) / 1000) + 's)');
return _serviceConfigCache;
}
try {
const params = {
url: resolveApiUrl('info'),
@@ -632,6 +967,18 @@ export default {
async: false,
});
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
// 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。
const cfg = response && response.data;
const uuid = findConfigUuid(cfg, 0);
if (uuid) {
uni.setStorageSync('current_config_uuid', uuid);
console.log('[AI Service] 已缓存 config_uuid:', uuid);
} else {
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
}
// PR-FIX (2026-09-20): 成功响应写缓存 (失败不缓存, 下次重试)
_serviceConfigCache = response.data;
_serviceConfigCacheAt = Date.now();
return response.data;
} catch (error) {
console.error('[AI Service] getServiceConfig error:', error);
@@ -639,14 +986,75 @@ export default {
}
},
/**
* PR-FIX (2026-09-20): 清除服务配置缓存
* 供配置变更后主动调用 (目前 60s TTL 已足够, 预留显式失效入口)
*/
clearServiceConfigCache() {
resetServiceConfigCache();
_chatUiConfigCache = null;
_chatUiConfigCacheAt = 0;
},
/**
* PR-CHAT-UI-CONFIG (2026-09-20): 获取前端 UI 行为开关
* 5 个 P0 开关由后端 Config::getChatUiConfig() 下发, 字段缺失用前端 CHAT_UI_DEFAULTS 兜底
* 缓存策略: 60s TTL, 失败不缓存 (下次重试), 与 getServiceConfig 共享 /info 调用
* 入参: force=true 跳过缓存 (用于用户切换站点/账号后强制刷新)
*/
async getChatUiConfig(force) {
const nowTs = Date.now();
if (!force && _chatUiConfigCache && (nowTs - _chatUiConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
return _chatUiConfigCache;
}
try {
// 复用 getServiceConfig() 触发 /info; 它的缓存会带回 chat_ui_config 字段
// 若 service_config 缓存未命中, getServiceConfig() 内部会发请求并填充 _serviceConfigCache
const serviceData = await this.getServiceConfig();
const remote = (serviceData && serviceData.chat_ui_config) || null;
const merged = mergeChatUiConfig(remote);
_chatUiConfigCache = merged;
_chatUiConfigCacheAt = Date.now();
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时
// getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁)
try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ }
return merged;
} catch (e) {
console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e);
// 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试
return Object.assign({}, CHAT_UI_DEFAULTS);
}
},
/**
* PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存)
* 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用,
* 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。
* 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过)
* 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新
*/
getChatUiConfigSync() {
try {
const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG');
if (cached && typeof cached === 'object' && Object.keys(cached).length) {
return mergeChatUiConfig(cached);
}
} catch (e) { /* 存储读取失败按无缓存处理 */ }
return null;
},
/**
* 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/
async clearConversation(conversationId) {
try {
// PR-AI-CLEAR-CONV-FIX (2026-09-19): URL 必须用 camelCase
// ThinkPHP 后端方法名 = clearConversation, URL 不做 kebab→camel 转换
// 之前 'clear-conversation' 拼出 /api/kefu/clear-conversation → 404 method not exists
// 改 'clearConversation' → /api/kefu/clearConversation → 200
const response = await http.sendRequest({
url: resolveApiUrl('clear-conversation'),
url: resolveApiUrl('clearConversation'),
data: {
agent_id: getAgentId(),
conversation_id: conversationId,
@@ -661,6 +1069,69 @@ export default {
}
},
/**
* 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项
* @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers }
* @returns {Promise<boolean>}
*/
async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) {
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
try {
await ensureWebSocketConnected();
} catch (e) {
console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message);
return false;
}
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
// PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点
// 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response
// (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/...
// 会拆出 module=api, controller=kefu/aiagent, action=chat → 命中 chat() 而非 elicitResponse()
// (PR-WB-MP-MEDIA 注释里记录的同款 thinkphp 路由陷阱)
// 必须用 resolveAddonName() 拿 addon 全名直接拼 URL
try {
const addon = resolveAddonName(currentAgentId); // aikefu / aiagent
const url = Config.baseUrl + '/' + addon + '/api/chat/elicit-response';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Token': store.state.token || 'test_token'
},
body: JSON.stringify({
conversation_id,
message_id,
tool_call_id,
rpc_id,
answers,
uniacid: this.getUniacid(),
user_id: getUserId(),
token: store.state.token || 'test_token'
})
});
const data = await resp.json();
return !!(data && (data.code === 0 || data.success));
} catch (e) {
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
return false;
}
// #endif
},
/**
* 获取会话历史
* PR-7: URL 按 agent_id 路由
@@ -805,15 +1276,73 @@ export default {
/**
* Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
*
* PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时
* 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑"
* - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对)
* - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话"
*
* @param {string} agentId
* @param {object} opts { silent: bool } true = 不回调, 默认 false
*/
setAgentId(agentId) {
setAgentId(agentId, opts) {
var oldAgentId = currentAgentId;
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
// PR-AGENT-LIFECYCLE-CLEANUP: agent 字符串真变化 (A → B) → 清 conv_id 缓存
// 不变化 (aikefu → aikefu 同 string) → 保留缓存, 让用户继续之前的多轮对话
var agentChanged = oldAgentId && oldAgentId !== agentId;
currentConversationId = null;
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved && !agentChanged) currentConversationId = saved;
else if (saved && agentChanged) {
// agent 变了, 把老 agent 的 conv_id 也清掉, 避免老 user 换个 product page 又用回
try {
const oldKey = getConversationKey(oldAgentId);
if (oldKey) uni.removeStorageSync(oldKey);
} catch (e) {}
}
} catch (e) { /* ignore */ }
},
/**
* 获取当前 agent 的会话存储 key (per-agent 隔离)
*/
getConversationKey(agentId) {
return getConversationKey(agentId);
},
/**
* 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选)
*/
getAllConversations() {
const out = {};
try {
const info = uni.getStorageInfoSync();
(info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0)
.forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); });
} catch (e) { /* ignore */ }
return out;
},
/**
* 获取当前 config_uuid (媒体上传路由用)
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
*/
getCurrentConfigUuid() {
return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || '';
},
/**
* 获取用户 ID (媒体上传字段用)
*/
getUserId() {
return getUserId();
},
/**

View File

@@ -63,7 +63,7 @@ if (process?.env?.NODE_ENV === 'development') {
}
// #endif
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
export default {
/**
@@ -80,6 +80,10 @@ export default {
//api请求地址
baseUrl: domain,
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
aikefuConfigUuid: aikefuConfigUuid,
// 图片域名
imgDomain: domain,

View File

@@ -489,36 +489,27 @@ function isBundleComplete(tree) {
return false;
}
// 2) tree 不能是空对象(空对象没有 common 也没有 components, 显然不可用)
// 2) tree 不能是空对象(空对象没有任何可用文案, 显然不可用)
const treeKeys = Object.keys(tree);
if (treeKeys.length === 0) {
return false;
}
// 3) tree 必须有 common 节点(基础文案, 几乎所有 UI 都依赖)
if (!tree.common || typeof tree.common !== 'object') {
return false;
// === 粗粒度校验(与上方设计哲学一致) ===
// 不再硬性要求 common / components 节点必须同时存在。
// 远端可能在某些时刻少发某个节点(部署期间、缓存未刷新、租户未配置 common 等),
// 此时仍应让 boot 成功写入 remoteTreeCache, 缺失字段由 $lang() 自然回落为空,
// UI 降级显示(空文案/空按钮文字), 而不是让整个 boot 失败导致整站变英文 / raw key。
// 软校验: components.privacyPopup(节点存在才检查, 避免 tree.components 为 undefined 时报错)
const privacyPopup = tree.components && tree.components.privacyPopup;
if (privacyPopup && typeof privacyPopup === 'object') {
// defaultAppNameMp 存在性仅作诊断, 不阻止 boot
if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
}
}
// 4) tree 必须有 components 节点(组件级文案)
if (!tree.components || typeof tree.components !== 'object') {
return false;
}
// === 以下为软校验(只 warn 不 return false) ===
// 设计: 不再因为"缺某个 key"就让整个 boot 失败
// 缺失的字段让 $lang() 自然返回空, UI 降级显示
// 软校验 1: components.privacyPopup
const privacyPopup = tree.components.privacyPopup;
if (!privacyPopup || typeof privacyPopup !== 'object') {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
} else if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
}
// 软校验 2: 哪些 key 看起来像隐私弹窗,但在错误位置?(诊断)
// 此处仅保留注释说明, 不在生产代码中输出
return true;
}

View File

@@ -24,22 +24,67 @@ import Config from './config.js'
import store from '@/store/index.js'
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
const EXT_MIME_MAP = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip', rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
pdf: 'application/pdf', txt: 'text/plain',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
/**
* 上传图片 (type=image)
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
*/
export async function uploadImage(filePath) {
export async function uploadImage(filePath, opts = {}) {
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
const config = await aiService.getServiceConfig()
const strategy = config?.upload_strategy || 'backend'
// H5 端: >10MB 先压
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
let compressedPath = filePath
// #ifdef H5
const fileSize = await getFileSize(filePath)
if (fileSize > 10 * 1024 * 1024) {
let detectedSize = 0
try {
detectedSize = await getFileSize(filePath)
} catch (e) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
console.warn('[media-upload] getFileSize failed:', e)
}
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
// #ifdef H5 || MP-WEIXIN
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
try {
const compressed = await uni.compressImage({
src: filePath,
@@ -48,6 +93,7 @@ export async function uploadImage(filePath) {
compressedHeight: 2048,
})
compressedPath = compressed.tempFilePath || filePath
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
} catch (e) {
console.warn('[media-upload] compressImage failed, use original:', e)
}
@@ -55,29 +101,108 @@ export async function uploadImage(filePath) {
// #endif
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
const fileSizeFinal = await getFileSize(compressedPath)
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
// fileSizeFinal = 探测失败的兜底:
// - 第一次探测失败(原始大小未知) → 保守走 STS
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
let fileSizeFinal = detectedSize
if (compressedPath !== filePath) {
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
fileSizeFinal = detectedSize
}
}
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
if (useSts) {
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config)
} else {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId())
if (finalUseSts) {
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
}
try {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
} catch (e) {
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
if (e && e.code === 'OSS_STS_REQUIRED') {
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
}
throw e
}
}
/**
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
* 上传 audio / video / file
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
* 未配云存储 (backend) → 直接 PHP 中转
* @param {string} filePath
* @param {'audio'|'video'|'file'} kind
* @param {{name?:string, mime?:string, size?:number}} [opts]
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
*/
export async function uploadMedia(filePath, kind) {
export async function uploadMedia(filePath, kind, opts = {}) {
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)
const agentId = aiService.getAgentId()
const hints = { name: opts.name || '', mime: opts.mime || '' }
const size = opts.size || 0
const strategy = (config && config.upload_strategy) || 'backend'
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
const doPhpUpload = async (fallbackErr) => {
let phpRes = null
try {
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
} catch (err) {
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
}
if (!phpRes || !phpRes.url) {
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
}
return Object.assign({}, phpRes, { kind })
}
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
if (strategy === 'backend') {
try {
return await doPhpUpload()
} catch (e) {
if (e && e.code === 'OSS_STS_REQUIRED') {
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
}
throw e
}
}
try {
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
} catch (e) {
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
try {
return await doPhpUpload(e)
} catch (e2) {
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
throw e2
}
}
}
/**
@@ -87,7 +212,7 @@ export async function uploadMedia(filePath, kind) {
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
*/
async function uploadViaPhp(filePath, kind, addon, agentId) {
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
+ (kind === 'image' ? 'image' : 'file')
@@ -99,8 +224,10 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
name: 'file',
formData: {
kind,
agent_id: addon === 'aiagent' ? agentId : undefined,
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
@@ -111,14 +238,18 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
resolve({
url: data.data.url,
mime: data.data.mime,
size: data.data.size,
size: data.data.size || knownSize || 0,
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 || '上传失败'))
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
const err = new Error(data.data || data.msg || data.message || '上传失败')
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
reject(err)
}
} catch (e) {
reject(new Error('解析响应失败: ' + e.message))
@@ -135,7 +266,13 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
form.append('file', file)
form.append('kind', kind)
if (addon === 'aiagent') form.append('agent_id', agentId)
else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '')
else {
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
const uuid = aiService.getCurrentConfigUuid?.() || ''
form.append('config_uuid', uuid)
form.append('config_uid', uuid)
form.append('agent_id', agentId)
}
form.append('uniacid', aiService.getUniacid())
form.append('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, {
@@ -148,13 +285,19 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
return {
url: data.data.url,
mime: data.data.mime,
size: data.data.size,
size: data.data.size || knownSize || 0,
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,
}
}
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
if (data.oss_sts_required === true) {
const err = new Error(data.data || data.msg || data.message || '上传失败')
err.code = 'OSS_STS_REQUIRED'
throw err
}
throw new Error(data.msg || data.message || '上传失败')
// #endif
}
@@ -173,10 +316,200 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
* { 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) {
/**
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
* 1) filePath 最后一段正则匹配 .xxx
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
*
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
*/
function extractExt(filePath, mimeHint = '', nameHint = '') {
let ext = 'bin'
if (typeof filePath === 'string') {
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
if (m) ext = m[1].toLowerCase()
}
if (ext === 'bin' && filePath && filePath.name) {
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
if (m2) ext = m2[1].toLowerCase()
}
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
if (ext === 'bin' && nameHint) {
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
if (m3) ext = m3[1].toLowerCase()
}
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
if (ext === 'bin' && mimeHint) {
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
if (m === mimeHint) { ext = e; break }
}
}
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
return ext
}
/**
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
*
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
*
* 端点: POST /<addon>/api/upload/ossPresign
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
*/
/**
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
*/
function uniRequest(opts) {
return new Promise((resolve, reject) => {
uni.request(Object.assign({}, opts, {
success: resolve,
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
}))
})
}
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
let mimeHint = hints.mime || ''
let nameHint = hints.name || ''
let probeFile = null
// #ifdef H5
try {
probeFile = await pathToFile(filePath, kind)
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
} catch (e) {
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
console.warn('[media-upload] presign probe failed:', e && e.message)
}
// #endif
const ext = extractExt(filePath, mimeHint, nameHint)
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
const contentType = mimeHint || guessMime(finalExt)
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
? nameHint
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
const preResp = await uniRequest({
url: Config.baseUrl + preUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
filename,
content_type: contentType,
uniacid: aiService.getUniacid(),
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
},
})
if (preResp.statusCode === 404) {
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
err.code = 'PRESIGN_UNSUPPORTED'
throw err
}
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
}
const pre = preResp.data.data
const uploadUrl = pre.upload_url
const finalContentType = pre.content_type || contentType
let uploadedSize = knownSize || 0
// #ifdef H5
if (probeFile && probeFile.size) uploadedSize = probeFile.size
// #endif
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
// #ifdef H5
const file = probeFile || await pathToFile(filePath, kind)
const ossRes = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': finalContentType },
body: file,
})
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
// #endif
// #ifdef MP-WEIXIN
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath,
success: (res) => {
uni.request({
url: uploadUrl,
method: 'PUT',
header: { 'Content-Type': finalContentType },
data: res.data,
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
? resolve()
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
fail: reject,
})
},
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
})
})
// #endif
return {
url: (pre.public_url || uploadUrl.split('?')[0]),
mime: finalContentType,
size: uploadedSize,
name: (pre.object_key || filename).split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider: pre.provider || 'alioss',
}
}
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
const providerHint = (config && config.oss_provider) || ''
if (!providerHint || providerHint === 'alioss') {
try {
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
} catch (e) {
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
}
}
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
// 1. 拿 STS 凭证 (addon 模式 URL)
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
const stsResp = await uni.request({
const stsResp = await uniRequest({
url: Config.baseUrl + stsUrl,
method: 'POST',
header: {
@@ -184,8 +517,9 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
'token': store.state.token || ''
},
data: {
agent_id: addon === 'aiagent' ? agentId : undefined,
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
@@ -197,7 +531,6 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
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)
@@ -232,7 +565,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
} 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`
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
const aliossTpl = sts.oss_url
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
const tpl = (provider === 'alioss') ? aliossTpl
: (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`
@@ -246,6 +587,16 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
}
}
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
let uploadedSize = knownSize || 0
// #ifdef H5
try {
const probe = await pathToFile(filePath, kind)
if (probe && probe.size) uploadedSize = probe.size
} catch (e) {}
// #endif
// 3. 直传云存储
// #ifdef MP-WEIXIN
await new Promise((resolve, reject) => {
@@ -277,7 +628,7 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
return {
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
mime: guessMime(ext),
size: file.size || 0,
size: uploadedSize,
name: key.split('/').pop(),
kind,
used_strategy: 'oss_sts',
@@ -286,12 +637,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
}
async function getFileSize(filePath) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
uni.getFileInfo({
filePath,
success: (res) => resolve(res.size),
fail: () => resolve(0),
success: (res) => resolve(res.size || 0),
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
})
})
// #endif
@@ -300,7 +654,9 @@ async function getFileSize(filePath) {
const res = await fetch(filePath)
const blob = await res.blob()
return blob.size
} catch (e) { return 0 }
} catch (e) {
throw new Error('getFileSize fetch failed: ' + e.message)
}
// #endif
}
@@ -308,18 +664,105 @@ 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 })
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
const rawName = filePath.split('/').pop() || `${kind}.bin`
const ext = extractExt(filePath, blob.type)
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
const mime = blob.type || guessMime(ext)
return new File([blob], finalName, { type: mime })
// #endif
}
/**
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
* 而不是路径 ②/③ (URL fetch / resource_link).
*
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
*
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
*
* 返回: { data: '<bare-base64>', size, mime } | null
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
*
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
* @param {object} [opts]
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
*/
export async function fileToBase64(filePath, opts = {}) {
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
if (!filePath) return null
// #ifdef H5
try {
const res = await fetch(filePath)
if (!res.ok) throw new Error('fetch failed: ' + res.status)
const blob = await res.blob()
if (blob.size > maxBytes) {
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
return null
}
const dataUri = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('FileReader failed'))
reader.readAsDataURL(blob)
})
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
return { data: m[2], size: blob.size, mime: m[1] }
} catch (e) {
console.warn('[media-upload] fileToBase64 H5 失败:', e)
return null
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
try {
const fsm = uni.getFileSystemManager()
// 1. 探测大小, 超限直接返回 null
let size = 0
try {
const stat = fsm.statSync(filePath)
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
size = (stat && (stat.size || stat.fileSize)) || 0
} catch (e) {
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
}
if (size > maxBytes) {
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
return null
}
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
const data = fsm.readFileSync(filePath, 'base64')
return { data, size: size || 0, mime: opts.mimeHint || '' }
} catch (e) {
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
return null
}
// #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'
return EXT_MIME_MAP[ext] || 'application/octet-stream'
}
export default { uploadImage, uploadMedia }

578
common/js/voice-recorder.js Normal file
View File

@@ -0,0 +1,578 @@
/**
* voice-recorder.js — 跨端录音抽象层
*
* PR-FRONTEND-VOICE-INPUT (2026-09-24)
* 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口,
* 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。
*
* PR-FRONTEND-VOICE-WAV (2026-09-25)
* H5 分支从 MediaRecorder (产出 webm/opus) 改为 AudioContext + ScriptProcessor
* 采原始 PCM, 停止时手动封装成标准 WAV (PCM 16-bit LE mono, 16kHz).
* 原因: Chrome MediaRecorder 只支持 webm/ogg 容器, 不支持 wav/mp3 录制;
* 大多数 STT / 后端 Agent 框架无法解析 webm/opus.
* 产 wav 后所有 STT 服务 (Whisper / 达摩院 / 腾讯 ASR / ...) 都吃.
*
* 平台分支:
* - H5 : navigator.mediaDevices.getUserMedia → AudioContext
* → ScriptProcessor 采 PCM Float32 → 停止时封装 WAV.
* 振幅采样走 AnalyserNode (与采 PCM 并行, 不冲突).
* - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射)
* 走 onFrameRecorded 推 progress 事件.
* uni-app format 参数可指定 wav/mp3/aac.
*
* 统一事件:
* progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画
* error → Error 录音失败
*
* 统一结果 (stop() 解析):
* {
* filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL
* blob?: Blob, // H5: WAV Blob (供 FileReader / uploadMedia 二次处理)
* fileName?: string, // H5: voice_<ts>.wav; MP 按 uni 约定
* duration: number, // ms
* mime: string, // 录音产物 MIME: H5='audio/wav', MP/APP/鸿蒙按 format
* size: number, // bytes
* cancelled: false
* }
*
* 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。
*/
const DEFAULT_MAX_DURATION_MS = 60 * 1000
// H5 录音参数: 16kHz mono PCM → 16-bit LE WAV
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
const H5_SAMPLE_RATE = 16000
const H5_NUM_CHANNELS = 1
const H5_SCRIPT_PROCESSOR_BUFFER_SIZE = 4096
/**
* 构造带 code 字段的 Error
* - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译
* - message: 兜底英文 message, 用于开发/调试或未识别 code
* 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成
*/
function _err(code, message) {
const e = new Error(message || code)
e.code = code
return e
}
/**
* 检测当前运行环境 (避免编译器把所有分支都打进去)
* - true / false 都是运行时结果;条件编译靠注释里的 #ifdef
*/
function isH5() {
// #ifdef H5
return true
// #endif
// #ifndef H5
return false
// #endif
}
function isMpOrApp() {
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
return true
// #endif
// #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY
return false
// #endif
}
/**
* 由 mime 反推文件扩展名
*/
function mimeToExt(mime) {
if (!mime) return 'wav'
if (mime.indexOf('wav') !== -1) return 'wav'
if (mime.indexOf('webm') !== -1) return 'webm'
if (mime.indexOf('ogg') !== -1) return 'ogg'
if (mime.indexOf('mp4') !== -1) return 'm4a'
if (mime.indexOf('mpeg') !== -1) return 'mp3'
if (mime.indexOf('aac') !== -1) return 'aac'
return 'audio'
}
/**
* 合并多个 Float32Array (PCM 帧缓冲) 为单个 Float32Array
*/
function mergeFloat32Arrays(arrays) {
let totalLength = 0
for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].length
const out = new Float32Array(totalLength)
let offset = 0
for (let i = 0; i < arrays.length; i++) {
out.set(arrays[i], offset)
offset += arrays[i].length
}
return out
}
/**
* 将 Float32 PCM (-1..1) 编码为标准 WAV (PCM 16-bit LE, RIFF/WAVE 头).
* 所有 STT 服务都吃这个格式; 浏览器 H5 录音不依赖 MediaRecorder 也能产出.
*
* 参考 RIFF/WAVE 规范:
* https://docs.fileformat.com/audio/wav/
* http://soundfile.sapp.org/doc/WaveFormat/
*/
function encodeWavPcm16LE(samples, sampleRate, numChannels) {
const bytesPerSample = 2 // 16-bit
const dataSize = samples.length * numChannels * bytesPerSample
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
// RIFF chunk descriptor (12 bytes)
writeAscii(view, 0, 'RIFF')
view.setUint32(4, 36 + dataSize, true) // ChunkSize = 文件总字节 - 8
writeAscii(view, 8, 'WAVE')
// fmt subchunk (24 bytes)
writeAscii(view, 12, 'fmt ')
view.setUint32(16, 16, true) // Subchunk1Size for PCM = 16
view.setUint16(20, 1, true) // AudioFormat: 1 = PCM
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true) // ByteRate
view.setUint16(32, numChannels * bytesPerSample, true) // BlockAlign
view.setUint16(34, 16, true) // BitsPerSample
// data subchunk (8 + N bytes)
writeAscii(view, 36, 'data')
view.setUint32(40, dataSize, true)
// PCM 样本: Float32 (-1..1) → Int16 LE (clamp 到 [-1, 1] 防溢出)
let offset = 44
for (let i = 0; i < samples.length; i++) {
let s = samples[i]
if (s > 1) s = 1
else if (s < -1) s = -1
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7FFF)
view.setInt16(offset, int16, true)
offset += 2
}
return buffer
}
function writeAscii(view, offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i))
}
}
/**
* 创建录音器实例。
*
* @param {object} [opts]
* @param {number} [opts.maxDuration=60000] 自动停止时长 (ms)
* @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档)
* @param {number} [opts.sampleRate=16000]
* @param {number} [opts.numberOfChannels=1]
* @param {number} [opts.encodeBitRate=48000]
*
* @returns {VoiceRecorder}
*/
export function createVoiceRecorder(opts = {}) {
return new VoiceRecorder(opts)
}
class VoiceRecorder {
constructor(opts) {
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
this._format = opts.format || 'mp3'
this._sampleRate = opts.sampleRate || 16000
this._channels = opts.numberOfChannels || 1
this._bitRate = opts.encodeBitRate || 48000
this._listeners = { progress: [], error: [] }
this._isRecording = false
this._startTs = 0
this._autoStopTimer = null
// 平台相关句柄
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
this._uniRec = null // uni.getRecorderManager() 返回值
}
on(event, cb) {
if (!this._listeners[event]) this._listeners[event] = []
this._listeners[event].push(cb)
}
off(event, cb) {
const arr = this._listeners[event]
if (!arr) return
const idx = arr.indexOf(cb)
if (idx !== -1) arr.splice(idx, 1)
}
_emit(event, payload) {
const arr = this._listeners[event]
if (!arr) return
for (const cb of arr.slice()) {
try { cb(payload) } catch (e) { /* swallow */ }
}
}
get isRecording() { return this._isRecording }
/**
* 开始录音。
* resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。
*/
start() {
if (this._isRecording) {
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
}
if (isH5()) {
return this._startH5()
}
if (isMpOrApp()) {
return this._startUni()
}
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
}
/**
* 停止录音并返回产物。
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
*/
stop() {
if (!this._isRecording) return Promise.resolve(null)
if (isH5()) return this._stopH5()
return this._stopUni()
}
/**
* 中断录音并丢弃产物。
*/
cancel() {
if (!this._isRecording) {
this._clearAutoStopTimer()
return Promise.resolve()
}
if (isH5()) return this._cancelH5()
return this._cancelUni()
}
// ===================== H5 实现 =====================
// PR-FRONTEND-VOICE-WAV (2026-09-25): 改为 AudioContext + ScriptProcessor 采 PCM,
// 停止时手动封装标准 WAV. 不再依赖 MediaRecorder, 解决 Chrome 只能产 webm/opus
// 导致后端 STT / Agent framework 无法解析的问题.
//
// 关键设计:
// 1. PCM 采样率固定 16kHz mono, 浏览器会自动 resample 麦克风输入
// (实际由 AudioContext sampleRate 参数保证)
// 2. ScriptProcessor 必须 connect 到 audioCtx.destination 才会触发
// onaudioprocess; 通过 GainNode(gain=0) 静音防止麦克风外放回声
// 3. 振幅采样走 AnalyserNode (与 PCM 采样并行, 不冲突)
// 4. onaudioprocess 每帧必须拷贝 channelData (new Float32Array(input)),
// 否则下一帧会覆盖导致 PCM 数据丢失
_startH5() {
return new Promise((resolve, reject) => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)'))
return
}
const Ctx = window.AudioContext || window.webkitAudioContext
if (!Ctx) {
reject(_err('H5_NO_AUDIO_CTX', 'AudioContext not available'))
return
}
const buffers = []
let stream = null
let audioCtx = null
let source = null
let processor = null
let analyser = null
let silentGain = null
let rafId = null
// 清理 H5 资源 (失败 / 异常路径统一调用)
const cleanup = () => {
try { if (rafId) cancelAnimationFrame(rafId) } catch (e) { /* noop */ }
try { if (processor) processor.disconnect() } catch (e) { /* noop */ }
try { if (analyser) analyser.disconnect() } catch (e) { /* noop */ }
try { if (source) source.disconnect() } catch (e) { /* noop */ }
try { if (silentGain) silentGain.disconnect() } catch (e) { /* noop */ }
try { if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {}) } catch (e) { /* noop */ }
try { if (stream) stream.getTracks().forEach(t => t.stop()) } catch (e) { /* noop */ }
}
navigator.mediaDevices.getUserMedia({
audio: {
channelCount: H5_NUM_CHANNELS,
// 注意: 部分浏览器会忽略 sampleRate 约束, 实际采样率以 AudioContext 为准
sampleRate: H5_SAMPLE_RATE,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
.then((s) => {
stream = s
audioCtx = new Ctx({ sampleRate: H5_SAMPLE_RATE })
// 麦克风 → ScriptProcessor → 静音 GainNode → destination
// (ScriptProcessor 必须连到 destination 才会触发 onaudioprocess;
// 用 GainNode(gain=0) 静音, 防止麦克风声音外放产生回声)
source = audioCtx.createMediaStreamSource(stream)
const BufferSize = H5_SCRIPT_PROCESSOR_BUFFER_SIZE
processor = audioCtx.createScriptProcessor(BufferSize, H5_NUM_CHANNELS, H5_NUM_CHANNELS)
processor.onaudioprocess = (e) => {
const channelData = e.inputBuffer.getChannelData(0)
// 必须拷贝: inputBuffer 数据会被下一帧覆盖, 不拷贝会导致 PCM 丢失
buffers.push(new Float32Array(channelData))
}
silentGain = audioCtx.createGain()
silentGain.gain.value = 0
source.connect(processor)
processor.connect(silentGain)
silentGain.connect(audioCtx.destination)
// AnalyserNode 并行采振幅 (驱动波形动画)
analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const freqBuf = new Uint8Array(analyser.frequencyBinCount)
const tick = () => {
if (!this._isRecording) return
try {
analyser.getByteFrequencyData(freqBuf)
let sum = 0
for (let i = 0; i < freqBuf.length; i++) sum += freqBuf[i]
const level = Math.min(1, sum / (freqBuf.length * 255))
this._emit('progress', { level, duration: Date.now() - this._startTs })
} catch (e) { /* noop */ }
rafId = requestAnimationFrame(tick)
}
tick()
this._h5 = { stream, audioCtx, source, processor, analyser, silentGain, rafId, buffers, sampleRate: audioCtx.sampleRate, cleanup }
this._isRecording = true
this._startTs = Date.now()
this._setAutoStopTimer()
resolve()
})
.catch((err) => {
// 麦克风权限拒绝 / 设备不可用
cleanup()
reject(_err('H5_MIC_PERMISSION', (err && err.message) || 'mic permission denied or device unavailable'))
})
})
}
_stopH5() {
return new Promise((resolve) => {
const h5 = this._h5
if (!h5) { resolve(null); return }
// 1) 拆 audio graph + 停流 (避免 onprocess 继续累积 buffer)
try { h5.cleanup() } catch (e) { /* noop */ }
// 2) 合并 PCM 帧缓冲
const sampleRate = h5.sampleRate || H5_SAMPLE_RATE
const merged = mergeFloat32Arrays(h5.buffers || [])
// 3) 编码标准 WAV (PCM 16-bit LE mono)
const wavBuffer = encodeWavPcm16LE(merged, sampleRate, H5_NUM_CHANNELS)
const blob = new Blob([wavBuffer], { type: 'audio/wav' })
this._h5 = null
this._isRecording = false
this._clearAutoStopTimer()
if (merged.length === 0) {
// 没有采到任何 PCM (例如用户没说话或立即 stop)
resolve(null)
return
}
const fileName = `voice_${Date.now()}.wav`
const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL)
? URL.createObjectURL(blob)
: ''
resolve({
filePath: blobUrl,
blob,
fileName,
duration: Date.now() - this._startTs,
mime: 'audio/wav',
size: blob.size,
cancelled: false
})
})
}
_cancelH5() {
return new Promise((resolve) => {
const h5 = this._h5
if (!h5) {
this._isRecording = false
this._clearAutoStopTimer()
resolve()
return
}
try { h5.cleanup() } catch (e) { /* noop */ }
this._h5 = null
this._isRecording = false
this._clearAutoStopTimer()
resolve()
})
}
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
_startUni() {
return new Promise((resolve, reject) => {
let rec = null
try {
rec = uni.getRecorderManager()
} catch (e) {
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
return
}
if (!rec) {
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
return
}
this._uniRec = rec
rec.onStart(() => {
this._isRecording = true
this._startTs = Date.now()
this._setAutoStopTimer()
resolve()
})
rec.onStop((res) => {
// res = { tempFilePath, duration, fileSize }
this._uniRec = null
this._isRecording = false
this._clearAutoStopTimer()
const mime = this._format === 'wav' ? 'audio/wav'
: this._format === 'aac' ? 'audio/aac'
: 'audio/mpeg'
if (this._uniResolve) {
const r = this._uniResolve
this._uniResolve = null
r({
filePath: res.tempFilePath,
blob: null,
fileName: '',
duration: typeof res.duration === 'number' ? res.duration : (Date.now() - this._startTs),
mime,
size: res.fileSize || 0,
cancelled: false
})
}
})
rec.onError((err) => {
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
this._uniRec = null
this._isRecording = false
this._clearAutoStopTimer()
this._emit('error', e)
if (this._uniResolve) {
const r = this._uniResolve
this._uniResolve = null
r(null)
} else {
// 启动阶段的 reject
reject(e)
}
})
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
if (typeof rec.onFrameRecorded === 'function') {
rec.onFrameRecorded((res) => {
if (!res || !res.frameBuffer) return
// frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅
try {
const view = new DataView(res.frameBuffer)
let sum = 0
const len = res.frameBuffer.byteLength
const step = Math.max(2, Math.floor(len / 256))
for (let i = 0; i < len; i += step) {
const v = view.getInt16(i, true)
sum += Math.abs(v)
}
const avg = sum / Math.max(1, Math.floor(len / step))
const level = Math.min(1, avg / 16384)
this._emit('progress', { level, duration: Date.now() - this._startTs })
} catch (e) { /* noop */ }
})
}
try {
rec.start({
duration: this._maxDuration / 1000, // uni-app 单位:秒
sampleRate: this._sampleRate,
numberOfChannels: this._channels,
encodeBitRate: this._bitRate,
format: this._format,
frameSize: 4 // KB,触发 onFrameRecorded 频率
})
} catch (e) {
this._uniRec = null
reject(e)
}
})
}
_stopUni() {
return new Promise((resolve) => {
const rec = this._uniRec
if (!rec) { resolve(null); return }
this._uniResolve = resolve
try { rec.stop() } catch (e) { resolve(null) }
})
}
_cancelUni() {
return new Promise((resolve) => {
const rec = this._uniRec
if (!rec) {
this._isRecording = false
this._clearAutoStopTimer()
resolve()
return
}
this._uniResolve = () => resolve()
try { rec.stop() } catch (e) {
this._isRecording = false
this._clearAutoStopTimer()
resolve()
}
})
}
// ===================== 工具 =====================
_setAutoStopTimer() {
this._clearAutoStopTimer()
this._autoStopTimer = setTimeout(() => {
// 自动停止 (上限到达)
if (this._isRecording) {
this.stop().catch(() => { /* swallow */ })
}
}, this._maxDuration)
}
_clearAutoStopTimer() {
if (this._autoStopTimer) {
clearTimeout(this._autoStopTimer)
this._autoStopTimer = null
}
}
}
export default createVoiceRecorder

View File

@@ -64,6 +64,13 @@
<diy-rich-text :value="item"></diy-rich-text>
</template>
<!-- #ifdef H5 -->
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
<!-- 网页容器 -->
<diy-webview :value="item"></diy-webview>
</template>
<!-- #endif -->
<template v-if="item.componentName == 'HorzLine'">
<!-- 辅助线 -->
<diy-horz-line :value="item"></diy-horz-line>

View File

@@ -6,13 +6,18 @@
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
</view>
<view class="fui-list-inner">
<view class="title" style="height: 75rpx;">
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
</view>
<view class="title">
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
<!-- #ifndef H5 -->
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
<!-- #endif -->
</view>
</view>
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
</view>
@@ -35,15 +40,31 @@ export default {
data() {
return {
loading: true,
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
mpTitleSize: 0,
mpDescSize: 0
};
},
created() {
// this.getDataList();
},
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
mounted() {
this.$nextTick(this.fitOneLineText)
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) {
// this.getDataList();
this.$nextTick(this.fitOneLineText)
},
value: {
deep: true,
handler() {
this.$nextTick(this.fitOneLineText)
}
}
},
computed: {
@@ -66,9 +87,130 @@ export default {
current: img,
urls: [img]
})
},
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
// #ifdef H5
fitOneLineText() {
this.$nextTick(() => {
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
if (!root) return
const nodes = root.querySelectorAll('.title-text, .subtitle')
nodes.forEach((el) => {
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
el.style.fontSize = ''
let size = parseFloat(window.getComputedStyle(el).fontSize)
const MIN_PX = 10 // 下限 10px, 再小可读性太差
const target = el.clientWidth - 3
let guard = 0
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
size -= 0.5
el.style.fontSize = size + 'px'
guard++
}
})
})
},
// #endif
// #ifndef H5
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
fitOneLineText() {
uni.createSelectorQuery().in(this)
.select('.fui-list-inner').boundingClientRect()
.select('.measure-title').boundingClientRect()
.select('.measure-desc').boundingClientRect()
.exec((rects) => {
if (!rects || !rects[0]) return
const sys = uni.getSystemInfoSync()
const pxPerRpx = (sys.windowWidth || 375) / 750
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
const avail = rects[0].width - 16 * pxPerRpx
if (avail <= 0) return
// 标题: 默认 28rpx, 最小 16rpx
if (rects[1] && rects[1].width > avail) {
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
} else {
this.mpTitleSize = 0
}
// 描述: 默认 24rpx, 最小 16rpx
if (rects[2] && rects[2].width > avail) {
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
} else {
this.mpDescSize = 0
}
})
},
// #endif
titleStyle() {
// #ifndef H5
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
// #endif
// #ifdef H5
return ''
// #endif
},
descStyle() {
// #ifndef H5
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
// #endif
// #ifdef H5
return ''
// #endif
}
}
};
</script>
<style lang="scss"></style>
<style lang="scss">
// 客服展示:标题/描述需要完整展示(中英文均不截断)
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
.diy-kefu {
.fui-list-group {
.fui-list {
align-items: center;
.fui-list-inner {
overflow: hidden;
min-width: 0;
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
padding-right: 8rpx;
box-sizing: border-box;
.title {
height: auto;
line-height: 1.4;
.title-text {
display: block;
font-size: 28rpx;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
.measure-text {
position: absolute;
left: -9999rpx;
top: 0;
white-space: nowrap;
visibility: hidden;
}
}
}
}
}
</style>

View File

@@ -12,10 +12,10 @@
<!-- #ifdef MP -->
<block
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
</block>
<view class="nickname" v-else>
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
<view class="member-level"
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
<!-- <text class="icondiy icon-system-huangguan"></text> -->
@@ -39,7 +39,7 @@
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
</block>
<view class="nickname" v-else>
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
}}</text>
<view class="member-level"
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
@@ -69,7 +69,7 @@
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
</view>
<view class="info">
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
</view>
@@ -367,6 +367,26 @@ export default {
isDisabled() {
if (this.nickName.length > 0) return false;
return true;
},
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
nameStyle() {
let name = '';
if (this.memberInfo && this.memberInfo.nickname) {
name = this.memberInfo.nickname;
} else {
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
}
if (!name) return '';
let units = 0;
for (let i = 0; i < name.length; i++) {
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
}
if (units <= 10) return ''; // 短标题走默认 38rpx
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
return 'font-size:' + size + 'rpx;';
}
},
methods: {

View File

@@ -10,8 +10,8 @@
@error="imgError(index)" show-menu-by-longpress />
</view>
<view class="info-wrap">
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
<text class="desc">{{ item.desc }}</text>
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
<view class="read-wrap"></view>
</view>
</view>
@@ -179,6 +179,25 @@ export default {
this.toDetail(item);
}
})
},
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
oneLineFontStyle(text, defaultSize, minSize) {
if (!text) return '';
let units = 0;
for (let i = 0; i < text.length; i++) {
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
}
if (units <= 12) return '';
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
return 'font-size:' + size + 'rpx;';
},
titleStyle(item) {
return this.oneLineFontStyle(item.merch_name, 30, 20);
},
descStyle(item) {
return this.oneLineFontStyle(item.desc, 24, 20);
}
}
};
@@ -296,13 +315,19 @@ export default {
}
.info-wrap {
flex: 1;
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
// flex: 1;
// display: flex;
// flex-direction: column;
// justify-content: space-between;
.desc {
color: #888
color: #888;
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
@@ -311,8 +336,9 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
-webkit-box-orient: vertical;
white-space: nowrap;
font-size: 30rpx;
line-height: 1.5;
}

View File

@@ -765,12 +765,27 @@ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answer
// #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;
// 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
},
```
@@ -2082,6 +2097,46 @@ curl -X POST 'https://your-domain.com/api/aiagent/clear-conversation' \
- 用户复制了 conversation_id 直接调后端 API (绕过前端) → 上下文乱了
- `clearConversation` 后端成功但前端没 clear → 下一条还带旧 id
#### 6.4.1 Storage key 三维化 (PR-MULTI-TURN-KEY-BINDING 2026-09-18)
**老 key (2026-09-18 前)**:
```js
ai_conversation_id_<agentId> // 例: ai_conversation_id_agent_aikefu
```
只按 agent 维度隔离 — 跨用户/跨 uniacid 都会串会话。
**新 key (2026-09-18 起)**:
```js
ai_conv_<uniacid>_<userId>_<agentId>
// 例: ai_conv_1_guest_e2e_test_agent_aikefu
```
按 uniacid × userId × agentId 三维隔离。
**一次性迁移** (前端 `ai-service.js` 模块 init 时自动跑, 在 `setTimeout(0)` 里执行避开 uniapp H5 storage shim 时序坑):
```js
// ai-service.js module-level init
setTimeout(() => {
migrateLegacyConversationKey(currentAgentId);
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) currentConversationId = saved;
}, 0);
```
- 老 key 有值 + 新 key 为空 → 写入新 key
- 老 key 保留 1 版本 (兜底, 不删)
- 下次发版独立 PR `uni.removeStorageSync('ai_conversation_id_*')` 清老 key
**onUserChange 钩子** (用户登录态变化时清旧 user 的 key):
```js
// App.vue setMemberInfo 之后
aiService.onUserChange();
// 内部: 检测 ai_last_user_id 与新 userId 不同 → removeStorageSync 旧 key → setStorageSync ai_last_user_id
```
**H5 时序坑** (E2E 2026-09-19 发现):
uniapp H5 的 `uni.getStorageSync` 在 webpack module 顶层同步执行时返回空字符串,
100ms 后才读到真值 (uniapp H5 runtime shim 在 bundle 之后才挂上 `window.uni`)。
所有 ai-service.js 顶层的 storage 读/写必须包 `setTimeout(..., 0)` 或挂生命周期。
### 6.5 attachments 上传后 AI 端收不到
排查顺序:

View File

@@ -12,12 +12,29 @@ export const lang = {
timeFormatToday: 'Today',
timeFormatYesterday: 'Yesterday',
timeFormatBeforeYesterday: 'Day before yesterday',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
timeJustNow: 'Just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeMonthDay: '{m}/{d}',
timeYearMonthDay: '{y}/{m}/{d}',
// ====== ai-chat-message ======
aiNickname: 'AI Assistant',
defaultNickname: 'Me',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): query text for attachment-only messages
// audioMessage / videoMessage: short label for the user-bubble filename fallback (still used by bubble template)
// *Instruction* keys: explicit directive sent to the AI as the query.
// Just a label gets ignored — the AI needs an explicit "please listen/look"
// to actually invoke STT/vision/file parsing on the attachment payload.
audioMessage: 'Voice Message',
videoMessage: 'Video Message',
audioInstruction: 'I sent a voice message. Please listen to it and reply.',
audioInstructionWithDuration: 'I sent a voice message ({seconds}s). Please listen to it and reply.',
imageInstruction: 'I sent an image. Please look at it and reply.',
videoInstruction: 'I sent a video. Please watch it and reply.',
fileInstruction: 'I sent a file. Please review it and reply.',
attachmentInstruction: 'I sent attachments. Please review them and reply.',
inputPlaceholder: 'Please enter your question...',
expandEditorTitle: 'Edit Content',
loadMoreHistory: 'Load more history',
@@ -112,15 +129,47 @@ export const lang = {
// ====== ai-chat-tools-panel ======
moreFunctions: 'More Features',
image: 'Image',
video: 'Video/Audio',
file: 'File',
voice: 'Voice',
recordVoice: 'Record Voice',
uploadAudio: 'Upload Audio',
pickVideo: 'Choose Video',
pickAudio: 'Choose Audio',
chooseAudioFail: 'Failed to select audio',
chooseVideoFail: 'Failed to select video',
audioNotReady: 'Audio not ready',
location: 'Location',
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
locationPermissionTitle: 'Location Permission Required',
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
locationSettingBtn: 'Open Settings',
locationSkipBtn: 'Not Now',
locationH5GuideBtn: 'Got it',
// ====== ai-chat-voice-panel ======
voiceInputTitle: 'Voice Input',
recordingTip: 'Recording, release to end',
startRecordTip: 'Tap to start recording',
voiceInputFailed: 'Voice input failed',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
holdToTalk: 'Hold to talk',
releaseToSend: 'Release to send',
releaseToCancel: 'Release to cancel',
slideUpToCancel: '↑ Slide up to cancel',
slideUpToCancelHint: '↑ Slide up to cancel',
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): dynamic input placeholder by context
placeholderDefault: 'Type a message or hold to talk',
placeholderWithAudio: 'Add a note for your voice (optional)',
placeholderWithMedia: 'Add a caption (optional)',
slideUpToCancelHint: '↑ Release to cancel',
voiceStartFailed: 'Cannot start recording',
voiceStopFailed: 'Recording failed, please try again',
voiceNotSupported: 'Voice input is not supported on this platform',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
uploadFailed: 'Upload failed',
uploadFailedWithReason: 'Upload failed: {reason}',
// ====== index.vue - Menu ======
menuClearChat: 'Clear Chat',

View File

@@ -6,6 +6,11 @@ export const lang = {
fileUrlNotExist: 'File URL does not exist',
// Video URL does not exist
videoUrlNotExist: 'Video URL does not exist',
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): audio related
audioUrlNotExist: 'Audio URL does not exist',
audioDefaultTitle: 'Voice message',
audioPlaying: 'Playing…',
audioPaused: 'Paused',
// This file type preview is not supported
unsupportedFileType: 'This file type preview is not supported',
}

View File

@@ -12,12 +12,28 @@ export const lang = {
timeFormatToday: '今天',
timeFormatYesterday: '昨天',
timeFormatBeforeYesterday: '前天',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
timeJustNow: '刚刚',
timeMinutesAgo: '{n}分钟前',
timeHoursAgo: '{n}小时前',
timeMonthDay: '{m}月{d}日',
timeYearMonthDay: '{y}年{m}月{d}日',
// ====== ai-chat-message ======
aiNickname: '智能助手',
defaultNickname: '我',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
audioMessage: '语音消息',
videoMessage: '视频消息',
audioInstruction: '我发送了一段语音,请听一下并回复我',
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
inputPlaceholder: '请输入您的问题...',
expandEditorTitle: '编辑内容',
loadMoreHistory: '加载更多历史消息',
@@ -112,15 +128,47 @@ export const lang = {
// ====== ai-chat-tools-panel ======
moreFunctions: '更多功能',
image: '图片',
video: '视频/音频',
file: '文件',
voice: '语音',
recordVoice: '录制语音',
uploadAudio: '上传音频',
pickVideo: '选择视频',
pickAudio: '选择音频',
chooseAudioFail: '选择音频失败',
chooseVideoFail: '选择视频失败',
audioNotReady: '音频尚未就绪',
location: '位置',
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
locationPermissionTitle: '需要位置权限',
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
locationSettingBtn: '去设置',
locationSkipBtn: '暂不开启',
locationH5GuideBtn: '我知道了',
// ====== ai-chat-voice-panel ======
voiceInputTitle: '语音输入',
recordingTip: '正在录音,松开结束',
startRecordTip: '点击开始录音',
voiceInputFailed: '语音输入失败',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
holdToTalk: '按住说话',
releaseToSend: '松开发送',
releaseToCancel: '松开取消',
slideUpToCancel: '↑ 上滑取消',
slideUpToCancelHint: '↑ 松开取消',
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
// 默认 / 有语音附件 / 有其他媒体附件, 三态文案互不冲突
placeholderDefault: '发消息或按住说话',
placeholderWithAudio: '给语音留言加个说明(可选)',
placeholderWithMedia: '添加文字说明(可选)',
voiceStartFailed: '无法开始录音',
voiceStopFailed: '录音失败,请重试',
voiceNotSupported: '当前平台不支持语音输入',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
uploadFailed: '上传失败',
uploadFailedWithReason: '上传失败:{reason}',
// ====== index.vue - 菜单 ======
menuClearChat: '清空聊天',

View File

@@ -6,6 +6,11 @@ export const lang = {
fileUrlNotExist: '文件地址不存在',
// 视频地址不存在
videoUrlNotExist: '视频地址不存在',
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
audioUrlNotExist: '音频地址不存在',
audioDefaultTitle: '语音消息',
audioPlaying: '播放中…',
audioPaused: '已暂停',
// 暂不支持该类型文件预览
unsupportedFileType: '暂不支持该类型文件预览',
}

View File

@@ -79,6 +79,9 @@
},
"scope.writePhotosAlbum" : {
"desc" : "用于从相册选择和保存头像"
},
"scope.record" : {
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],

View File

@@ -13,7 +13,8 @@
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0"
"jweixin-module": "^1.6.0",
"playwright-core": "^1.63.0"
},
"uni-app": {
"scripts": {
@@ -89,4 +90,4 @@
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -263,7 +263,10 @@ export default {
// 搜索入口按钮
.search-entrance {
position: absolute;
top: 44rpx;
/* 与新对话按钮垂直居中对齐:
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
top: 14rpx;
right: 16rpx;
z-index: 10;
display: flex;

View File

@@ -9,27 +9,37 @@
</button>
</view>
<view class="tools-grid">
<view class="tool-item" @click="selectImage">
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
location 不属于附件, 不受此开关控制 -->
<view v-if="isTypeEnabled('image')" class="tool-item" @click="selectImage">
<view class="tool-icon">
<text class="iconfont icon-image"></text>
<text class="icondiy icon-system-image-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
</view>
<view class="tool-item" @click="selectFile">
<view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
<view class="tool-icon">
<text class="iconfont icon-file"></text>
<text class="icondiy icon-system-video"></text>
</view>
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
</view>
<view v-if="isTypeEnabled('file')" class="tool-item" @click="selectFile">
<view class="tool-icon">
<text class="icondiy icon-system-file-2-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
</view>
<view class="tool-item" @click="startRecord">
<view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
<view class="tool-icon">
<text class="iconfont icon-voice"></text>
<text class="icondiy icon-system-chat-voice-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
</view>
<view class="tool-item" @click="selectLocation">
<view class="tool-icon">
<text class="iconfont icon-location"></text>
<text class="icondiy icon-system-map-pin-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
</view>
@@ -39,16 +49,20 @@
</template>
<script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/**
* ai-chat-tools-panel - 更多工具面板组件
*
* Props:
* visible: Boolean - 控制显示/隐藏
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
* location 不属于附件, 不受此 prop 影响
*
* Events:
* update:visible - 同步关闭状态
* image-selected - 选择图片,参数:图片消息对象
* file-selected - 选择文件,参数:文件消息对象
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
* voice-start - 开始语音
* location-selected - 选择位置,参数:位置消息对象
*/
@@ -59,6 +73,12 @@ export default {
visible: {
type: Boolean,
default: false
},
attachmentTypes: {
type: Array,
default: function () {
return ['image', 'file', 'audio', 'video']
}
}
},
@@ -73,75 +93,308 @@ export default {
this.$emit('update:visible', false)
},
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
isTypeEnabled(kind) {
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
return this.attachmentTypes.indexOf(kind) !== -1
},
selectImage() {
const that = this
uni.chooseImage({
count: 1,
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const imageMessage = {
id: ++this.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: res.tempFilePaths[0],
timestamp: Date.now()
that.hideToolsPanel()
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
// 用户可继续输入文字, 点发送时图文一起发出
that.$emit('image-picked', res.tempFilePaths || [])
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
}
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
}
})
},
selectFile() {
const that = this
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = (e) => {
input.onchange = async (e) => {
const file = e.target.files[0]
if (file) {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
}
input.click()
// #endif
// #ifdef MP-WEIXIN
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 微信小程序需要用 wx.chooseMessageFile
// 原代码 selectFile 只有 H5 + APP-PLUS 分支, 微信小程序里 "文件" 按钮是死按钮
// uni-app 3.x 暴露了 uni.chooseMessageFile; 老版本/部分编译目标只有 wx.chooseMessageFile, 都兼容
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
? uni.chooseMessageFile
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
if (!chooseFileApi) {
uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
return
}
chooseFileApi({
count: 1,
type: 'all', // 'all' | 'video' | 'image' | 'file' — 'all' 最宽松, 用户可发任何文件
success: async (res) => {
that.hideToolsPanel()
// wx.chooseMessageFile 返回 tempFiles; uni 端 res.tempFiles 兼容
const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && res.tempFiles[0])
if (!file) return
// 微信选文件返回 path + size + name (无 type); 用 ext 推断
const ext = (file.name || file.path || '').split('.').pop().toLowerCase()
const kind = ['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','tif','tiff'].includes(ext) ? 'image' : 'file'
await that._uploadOneFile(file, kind)
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选文件失败', icon: 'none' })
}
},
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: (res) => {
success: async (res) => {
that.hideToolsPanel()
const file = res.tempFiles[0]
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
url: file.path,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
await that._uploadOneFile(file, 'file')
}
})
// #endif
},
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
selectVideo() {
const that = this
// #ifdef H5
const mediaInput = document.createElement('input')
mediaInput.type = 'file'
mediaInput.accept = 'video/*,audio/*'
mediaInput.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, that._guessMediaKind(file))
}
mediaInput.click()
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
uni.showActionSheet({
itemList: [
that.$lang('ai-chat.index.pickVideo', '选择视频'),
that.$lang('ai-chat.index.pickAudio', '选择音频')
],
success: (res) => {
if (res.tapIndex === 0) that._chooseVideo()
else if (res.tapIndex === 1) that.selectAudio()
},
fail: () => {}
})
// #endif
},
// 按 mime / 扩展名判断选中的文件是视频还是音频
_guessMediaKind(file) {
const type = (file && file.type ? file.type : '').toLowerCase()
if (type.indexOf('audio/') === 0) return 'audio'
if (type.indexOf('video/') === 0) return 'video'
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
// PR-AV-FULL-SUPPORT (2026-09-24): 与后端 MediaUploadPolicy::allowedUploadExtensions 音频段对齐
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3']
if (audioExt.indexOf(ext) !== -1) return 'audio'
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
return type ? 'video' : 'audio'
},
// 选视频 (微信小程序 / App 共用)
_chooseVideo() {
const that = this
uni.chooseVideo({
sourceType: ['album', 'camera'],
compressed: true,
maxDuration: 60,
success: async (res) => {
that.hideToolsPanel()
await that._uploadOneFile({
path: res.tempFilePath,
size: res.size || 0,
duration: res.duration || 0,
name: 'video_' + Date.now() + '.mp4',
type: 'video/mp4'
}, 'video')
},
fail: (err) => {
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
}
}
})
},
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
selectVoiceOrAudio() {
uni.showActionSheet({
itemList: [
this.$lang('ai-chat.index.recordVoice'),
this.$lang('ai-chat.index.uploadAudio')
],
success: (res) => {
if (res.tapIndex === 0) {
this.startRecord()
} else if (res.tapIndex === 1) {
this.selectAudio()
}
},
fail: () => {}
})
},
// 选择音频文件并上传 (H5 / 微信小程序 / App)
selectAudio() {
const that = this
// #ifdef H5
const audioInput = document.createElement('input')
audioInput.type = 'file'
audioInput.accept = 'audio/*'
audioInput.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, 'audio')
}
audioInput.click()
// #endif
// #ifdef MP-WEIXIN
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
? uni.chooseMessageFile
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
if (!chooseFileApi) {
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
return
}
chooseFileApi({
count: 1,
type: 'file',
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
success: async (res) => {
const file = (res.tempFiles && res.tempFiles[0]) || null
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, 'audio')
},
fail: (err) => {
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
}
}
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
success: async (res) => {
const file = res.tempFiles[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, 'audio')
}
})
// #endif
},
async _uploadOneFile(file, kind) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
// 统一: H5 创建 blob URL 用于预览, 上传完 (成功/失败都) revoke
let blobUrl = ''
let displayPath = ''
// #ifdef H5
// H5 file 对象没有 .path, 用 blob URL 给 imageUrl 预览, 并把 file 本身传给 uploadImage
blobUrl = file.name ? URL.createObjectURL(file) : ''
displayPath = blobUrl
// #endif
// #ifndef H5
// MP-WEIXIN / APP-PLUS 都用 file.path (tempFilePath)
displayPath = file.path || ''
// #endif
const pending = {
id: ++this.messageId,
role: 'user',
type: kind,
content: '',
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
fileSize: file.size || 0,
fileType: file.type || '',
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
url: displayPath,
duration: file.duration || 0,
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
playing: false,
currentTime: 0,
imageUrl: displayPath,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
this.$emit('file-selected', pending)
try {
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
// 非 H5 传 file.path (uni.uploadFile 需要)
const inputArg = blobUrl || file.path || file
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
const uploaded = kind === 'image'
? await uploadImage(inputArg)
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploaded', uploaded)
if (uploaded.url) {
this.$set(pending, 'imageUrl', uploaded.url)
this.$set(pending, 'url', uploaded.url)
}
} catch (e) {
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
} finally {
// PR-FRONTEND-MEDIA-UPLOAD-FIX: blob URL 用完即释放 (无论成败)
if (blobUrl) {
try { URL.revokeObjectURL(blobUrl) } catch (e) {}
}
}
},
startRecord() {
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
this.$emit('voice-start')
this.hideToolsPanel()
},
@@ -209,6 +462,7 @@ export default {
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
position: relative; // 为 .close-btn 绝对定位提供参考
text {
font-size: 34rpx;
@@ -225,7 +479,9 @@ export default {
align-items: center;
justify-content: center;
border: none;
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
top: -8rpx;
right: 10rpx; //
.iconfont {
font-size: 28rpx;
color: $color-text-light;
@@ -257,7 +513,8 @@ export default {
margin-bottom: 16rpx;
box-shadow: $shadow-sm;
.iconfont {
.iconfont,
.icondiy {
font-size: 48rpx;
color: $color-primary;
}

View File

@@ -1,239 +1,402 @@
<template>
<view class="voice-popup" v-if="visible">
<view class="voice-mask" @click="hideVoicePanel"></view>
<view class="voice-input-panel">
<view class="voice-header">
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
<button class="close-btn" @click="hideVoicePanel">
<text class="iconfont icon-close"></text>
</button>
</view>
<view class="voice-content">
<view class="voice-wave" :class="{ recording: voiceInputing }">
<view
v-for="i in 8"
:key="i"
class="wave-bar"
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
</view>
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
</view>
<view class="voice-actions">
<button class="voice-btn" @click="toggleVoiceInput">
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
</button>
</view>
</view>
</view>
</template>
<script>
/**
* ai-chat-voice-panel - 语音输入面板组件
*
* Props:
* visible: Boolean - 控制显示/隐藏
*
* Events:
* update:visible - 同步关闭状态
* close - 请求关闭面板
*/
export default {
name: 'AiChatVoicePanel',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
voiceInputing: false
}
},
watch: {
visible(val) {
if (!val) {
this.voiceInputing = false
}
}
},
methods: {
hideVoicePanel() {
this.voiceInputing = false
this.$emit('update:visible', false)
this.$emit('close')
},
toggleVoiceInput() {
this.voiceInputing = !this.voiceInputing
if (this.voiceInputing) {
this.startVoiceInput()
} else {
this.stopVoiceInput()
}
},
startVoiceInput() {
// #ifdef H5
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
// 录音中,可扩展实时转文字
})
.catch(error => {
uni.showToast({ title: this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
this.voiceInputing = false
})
}
// #endif
},
stopVoiceInput() {
this.voiceInputing = false
}
}
}
</script>
<style lang="scss" scoped>
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
@import '../styles/ai-chat-global.scss';
.voice-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
.voice-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.voice-input-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: $bg-card;
border-radius: $radius-lg;
padding: 48rpx;
width: 60%;
max-width: 520rpx;
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
.voice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.close-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: $bg-main;
display: flex;
align-items: center;
justify-content: center;
border: none;
.iconfont {
font-size: 28rpx;
color: $color-text-light;
}
}
}
.voice-content {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 48rpx;
.voice-wave {
width: 280rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
&.recording .wave-bar {
animation: wave 1.2s infinite ease-in-out;
}
.wave-bar {
width: 8rpx;
height: 40rpx;
margin: 0 4rpx;
background-color: $color-primary;
border-radius: 4rpx;
animation: none;
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
}
}
.voice-tip {
font-size: 28rpx;
color: $color-text-light;
}
}
.voice-actions {
display: flex;
justify-content: center;
.voice-btn {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: $color-primary;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
border: none;
.iconfont {
font-size: 56rpx;
color: white;
}
}
}
}
}
@keyframes wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1.2); }
}
</style>
<template>
<view class="voice-popup" v-if="visible">
<view class="voice-mask" @click="hideVoicePanel"></view>
<view class="voice-input-panel">
<view class="voice-header">
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
<button class="close-btn" @click="hideVoicePanel">
<text class="iconfont icon-close"></text>
</button>
</view>
<view class="voice-content">
<view class="voice-wave" :class="{ recording: voiceInputing }">
<view
v-for="i in 8"
:key="i"
class="wave-bar"
:style="waveBarStyle(i)"></view>
</view>
<text class="voice-tip">{{ currentTip }}</text>
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
</view>
<view class="voice-actions">
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
</button>
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
</view>
</view>
</view>
</template>
<script>
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
/**
* ai-chat-voice-panel - 语音输入面板组件
*
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
* - 波形振幅由 progress 事件 (level 0..1) 驱动
* - 录音上限 60s,自动停止
* - 关闭面板时如果还在录音,先 cancel 释放资源
*
* Props:
* visible: Boolean - 控制显示/隐藏
*
* Events:
* update:visible - 同步关闭状态
* close - 请求关闭面板
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
*/
export default {
name: 'AiChatVoicePanel',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
voiceInputing: false,
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
voiceLevel: 0,
// 录音累计时长 (秒,整数)
voiceDuration: 0,
_durationTimer: null,
_voiceRecorder: null,
_onProgress: null,
_onError: null,
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
_resolved: false
}
},
computed: {
currentTip() {
if (this.voiceInputing) {
return this.$lang('ai-chat.index.recordingTip')
}
return this.$lang('ai-chat.index.startRecordTip')
},
formattedDuration() {
const s = Math.max(0, Math.floor(this.voiceDuration))
return s + '″'
}
},
watch: {
visible(val) {
if (val) {
// 每次重新打开都重置
this.voiceInputing = false
this.voiceLevel = 0
this.voiceDuration = 0
this._resolved = false
} else {
// 关闭时如果还在录音,丢弃
this._discardRecorderIfAny()
}
}
},
beforeDestroy() {
this._discardRecorderIfAny()
},
methods: {
hideVoicePanel() {
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
this._discardRecorderIfAny()
this.$emit('update:visible', false)
this.$emit('close')
},
toggleVoiceInput() {
if (this.voiceInputing) {
this._finishRecording()
} else {
this._beginRecording()
}
},
cancelVoiceInput() {
this._discardRecorderIfAny()
this.hideVoicePanel()
},
waveBarStyle(i) {
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
const base = 24
const offset = ((i * 17) % 32) + 12 // 12..44
const levelPx = Math.round(this.voiceLevel * 80)
const h = Math.max(16, base + offset + levelPx) + 'rpx'
const delay = (i * 0.08) + 's'
return { height: h, animationDelay: delay }
},
_beginRecording() {
if (this._voiceRecorder) return
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
this._voiceRecorder = rec
this._onProgress = ({ level }) => {
// 平滑一下,避免波形抖动太狠
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
}
this._onError = (err) => {
console.warn('[voice-panel] recorder error:', err)
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
this._discardRecorderIfAny()
}
rec.on('progress', this._onProgress)
rec.on('error', this._onError)
rec.start()
.then(() => {
this.voiceInputing = true
this.voiceDuration = 0
this._startDurationTimer()
})
.catch((err) => {
this._teardownRecorder()
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
})
},
async _finishRecording() {
if (!this._voiceRecorder || this._resolved) return
this._resolved = true
this._stopDurationTimer()
try {
const result = await this._voiceRecorder.stop()
if (!result || !result.filePath) {
// 录音产物为空,可能是被系统打断
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
this._teardownRecorder()
this.voiceInputing = false
return
}
this._teardownRecorder()
this.voiceInputing = false
this.$emit('audio-ready', {
filePath: result.filePath,
blob: result.blob || null,
fileName: result.fileName || '',
duration: result.duration,
mime: result.mime,
size: result.size
})
this.hideVoicePanel()
} catch (e) {
this._teardownRecorder()
this.voiceInputing = false
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
}
},
_discardRecorderIfAny() {
if (!this._voiceRecorder) return
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
this._teardownRecorder()
this.voiceInputing = false
},
_teardownRecorder() {
this._stopDurationTimer()
if (this._voiceRecorder) {
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
if (this._onError) this._voiceRecorder.off('error', this._onError)
}
this._voiceRecorder = null
this._onProgress = null
this._onError = null
this._resolved = false
this.voiceLevel = 0
},
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
_voiceErrMsg(err) {
if (!err) return ''
switch (err.code) {
case 'NOT_SUPPORTED':
case 'NO_RECORDER_MANAGER':
return this.$lang('ai-chat.index.voiceNotSupported')
case 'H5_NO_MIC_API':
case 'ALREADY_STARTED':
return this.$lang('ai-chat.index.voiceStartFailed')
case 'UNI_RECORD_ERROR':
return this.$lang('ai-chat.index.voiceStopFailed')
default:
return err.message || ''
}
},
_startDurationTimer() {
this._stopDurationTimer()
this._durationTimer = setInterval(() => {
this.voiceDuration += 1
}, 1000)
},
_stopDurationTimer() {
if (this._durationTimer) {
clearInterval(this._durationTimer)
this._durationTimer = null
}
}
}
}
</script>
<style lang="scss" scoped>
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
@import '../styles/ai-chat-global.scss';
.voice-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
.voice-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.voice-input-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: $bg-card;
border-radius: $radius-lg;
padding: 48rpx;
width: 60%;
max-width: 520rpx;
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
.voice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.close-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: $bg-main;
display: flex;
align-items: center;
justify-content: center;
border: none;
.iconfont {
font-size: 28rpx;
color: $color-text-light;
}
}
}
.voice-content {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
.voice-wave {
width: 280rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
&.recording .wave-bar {
animation: wave 1.0s infinite ease-in-out;
}
.wave-bar {
width: 8rpx;
margin: 0 4rpx;
background-color: $color-primary;
border-radius: 4rpx;
transition: height 0.12s ease-out;
animation: none;
}
}
.voice-tip {
font-size: 28rpx;
color: $color-text-light;
}
.voice-duration {
font-size: 36rpx;
font-weight: 600;
color: $color-primary;
margin-top: 8rpx;
}
}
.voice-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.voice-btn {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: $color-primary;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
border: none;
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
&.is-recording {
background-color: #e94e4e;
transform: scale(0.92);
}
.iconfont {
font-size: 56rpx;
color: white;
}
}
.cancel-link {
margin-top: 20rpx;
font-size: 26rpx;
color: $color-text-light;
padding: 8rpx 16rpx;
}
}
}
}
@keyframes wave {
0%, 100% { transform: scaleY(0.6); }
50% { transform: scaleY(1.15); }
}
</style>

View File

@@ -13,9 +13,12 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content" :style="{ bottom: tabBarHeight }">
<ai-chat-message
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
<view class="chat-content" :style="{ bottom: '0rpx' }">
<ai-chat-message
v-if="uiCfgReady"
ref="chat"
:preloaded-ui-config="preloadedUiCfg"
:initial-messages="initialMessages"
:fallback-welcome="fallbackWelcome"
:user-avatar="userAvatar"
@@ -69,14 +72,9 @@
</view>
</view>
<!-- 底部导航栏(仅在 tabBar 场景下显示):
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
组件内部已绑定 data-component-name="diy-bottom-nav"。
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
→ shouldShowTabBar=false → tabBar 隐藏
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
<diy-bottom-nav></diy-bottom-nav>
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
<diy-bottom-nav v-if="false"></diy-bottom-nav>
</view>
</template>
@@ -100,6 +98,12 @@ export default {
agentId: 'agent_aikefu',
pageTitle: '',
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
uiCfgReady: false,
preloadedUiCfg: null,
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
statusBarHeight: 0, // 状态栏高度,默认0
@@ -119,12 +123,9 @@ export default {
// 事件处理器引用(用于清理)
safeEventHandlers: new Map(),
// 是否从浮动按钮进入
// 用来在运行时区分两种场景:
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
// 并在 onUnload 还原,避免影响其他页面。
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
isFromFloatBtn: false,
}
},
@@ -147,6 +148,34 @@ export default {
async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
try {
const cached = aiService.getChatUiConfigSync();
if (cached) {
this.preloadedUiCfg = cached;
this.uiCfgReady = true;
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
}
} catch (e) { /* 缓存读取失败走预取 */ }
if (!this.uiCfgReady) {
try {
const cfg = await Promise.race([
aiService.getChatUiConfig(true),
new Promise(resolve => setTimeout(() => resolve(null), 6000))
]);
if (cfg) this.preloadedUiCfg = cfg;
else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新');
} catch (e) { /* 失败兜底默认值 */ }
this.uiCfgReady = true;
console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg);
}
// 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取,
// 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
@@ -171,24 +200,16 @@ export default {
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
// (见 diy-float-btn.vue#onFloatBtnClick)
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
// (业务方不会使用 __ 开头的短变量名)。
const dyuEntry = (options && options.__dyu) || '';
if (dyuEntry === 'FloatBtn') {
this.isFromFloatBtn = true;
// 临时隐藏底部导航栏;onUnload 会还原。
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
} else {
this.isFromFloatBtn = false;
}
// ====== 进入 AI 智能页始终隐藏底部 tabBar ======
// 原因:AI 智能页是全屏聊天体验,不论是
// 1) 底部导航栏"智能客服"项直接点击进入
// 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
// 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
// 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.initChat();
},

View File

@@ -36,6 +36,47 @@
<!-- #endif -->
<view class="content">
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览分支
aikefu 测试对话附件 type='image' / AI 侧 attachments.image 跳转时传 fileType=image -->
<view v-if="fileType === 'image'" class="image-viewer">
<image v-if="fileUrl" :src="fileUrl" mode="aspectFit" class="image-element" @click="previewImageFullscreen" />
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view>
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览分支
AI 侧 attachments.audio 跳转时传 fileType=audio, 用 createInnerAudioContext 播放 -->
<view v-else-if="fileType === 'audio'" class="audio-viewer">
<view v-if="fileUrl" class="audio-player-card">
<view class="audio-icon">
<text class="iconfont icon-voice"></text>
</view>
<view class="audio-info">
<view class="audio-title">{{ fileName || $lang('file-preview.file-preview.audioDefaultTitle') }}</view>
<view class="audio-status">{{ audioPlaying ? $lang('file-preview.file-preview.audioPlaying') : $lang('file-preview.file-preview.audioPaused') }}</view>
<view class="audio-progress">
<text class="audio-time">{{ formatAudioTime(audioCurrentTime) }}</text>
<view class="audio-progress-bar">
<view class="audio-progress-fill" :style="{ width: audioProgressPct + '%' }"></view>
</view>
<text class="audio-time">{{ formatAudioTime(audioDuration) }}</text>
</view>
</view>
<button class="audio-play-btn" @click="toggleAudioPlay">
<text class="iconfont" :class="audioPlaying ? 'icon-pause' : 'icon-play'"></text>
</button>
</view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.audioUrlNotExist') }}</view>
<!-- #ifdef H5 -->
<view class="h5-action-bar">
<view class="h5-action-tip">如无法播放,请使用下方按钮:</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
</view>
</view>
<!-- #endif -->
</view>
<!-- PDF 文件预览 -->
<view v-if="fileType === 'pdf'" class="file-viewer">
<!-- #ifdef H5 -->
@@ -101,9 +142,20 @@ export default {
fileUrl: '',
fileType: '',
statusBarHeight: 20,
windowWidth: 375
windowWidth: 375,
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频播放引擎
audioCtx: null,
audioPlaying: false,
audioCurrentTime: 0,
audioDuration: 0
};
},
computed: {
audioProgressPct() {
if (!this.audioDuration || this.audioDuration <= 0) return 0;
return Math.min(100, (this.audioCurrentTime / this.audioDuration) * 100);
}
},
computed: {
// #ifdef MP-WEIXIN
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
@@ -178,6 +230,13 @@ export default {
}
// #endif
},
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 离开页面销毁音频 ctx
onUnload() {
if (this.audioCtx) {
try { this.audioCtx.stop(); this.audioCtx.destroy(); } catch (e) {}
this.audioCtx = null;
}
},
// #ifdef MP-WEIXIN
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
onShareAppMessage() {
@@ -196,25 +255,24 @@ export default {
// #endif
methods: {
inferFileType() {
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 加 image / audio 识别
const classify = (ext) => {
if (['pdf'].includes(ext)) return 'pdf';
if (['doc', 'docx'].includes(ext)) return 'word';
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext)) return 'video';
if (['mp3', 'wav', 'aac', 'm4a', 'ogg', 'flac'].includes(ext)) return 'audio';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
return null;
};
if (this.fileUrl) {
const clean = String(this.fileUrl).split('?')[0];
const ext = clean.split('.').pop().toLowerCase();
if (['pdf'].includes(ext)) {
this.fileType = 'pdf';
} else if (['doc', 'docx'].includes(ext)) {
this.fileType = 'word';
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
this.fileType = 'video';
}
const t = classify(ext);
if (t) this.fileType = t;
} else if (this.fileName) {
const ext = this.fileName.split('.').pop().toLowerCase();
if (['pdf'].includes(ext)) {
this.fileType = 'pdf';
} else if (['doc', 'docx'].includes(ext)) {
this.fileType = 'word';
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
this.fileType = 'video';
}
const t = classify(ext);
if (t) this.fileType = t;
}
},
goBack() {
@@ -257,8 +315,89 @@ export default {
// 兜底:新标签页打开
this.openInNewTab();
}
}
},
// #endif
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片分支 — 点击全屏预览
previewImageFullscreen() {
if (!this.fileUrl) return;
// #ifdef H5 || MP-WEIXIN
uni.previewImage({ urls: [this.fileUrl], current: this.fileUrl });
// #endif
},
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频分支 — 切换播放/暂停
toggleAudioPlay() {
if (!this.fileUrl) {
uni.showToast({ title: '音频地址为空', icon: 'none' });
return;
}
if (this.audioPlaying) {
this._pauseAudio();
} else {
this._playAudio();
}
},
_ensureAudioCtx() {
if (this.audioCtx) return this.audioCtx;
try {
const ctx = uni.createInnerAudioContext();
ctx.src = this.fileUrl;
ctx.onTimeUpdate(() => {
this.audioCurrentTime = ctx.currentTime || 0;
if (ctx.duration && !isNaN(ctx.duration)) {
this.audioDuration = ctx.duration;
}
});
ctx.onEnded(() => {
this.audioPlaying = false;
this.audioCurrentTime = 0;
});
ctx.onError((e) => {
this.audioPlaying = false;
uni.showToast({ title: '音频播放失败', icon: 'none' });
console.error('[file-preview audio] error:', e);
});
this.audioCtx = ctx;
return ctx;
} catch (e) {
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' });
console.error('[file-preview audio] createInnerAudioContext failed:', e);
return null;
}
},
_playAudio() {
const ctx = this._ensureAudioCtx();
if (!ctx) return;
if (this.audioCurrentTime > 0) {
try { ctx.seek(this.audioCurrentTime); } catch (e) { /* ignore */ }
}
this.audioPlaying = true;
try {
ctx.play();
} catch (e) {
this.audioPlaying = false;
uni.showToast({ title: '音频播放失败', icon: 'none' });
}
},
_pauseAudio() {
if (this.audioCtx) {
try { this.audioCtx.pause(); } catch (e) { /* ignore */ }
}
this.audioPlaying = false;
},
// 00:00 格式
formatAudioTime(seconds) {
if (!seconds || isNaN(seconds)) return '00:00';
const total = Math.floor(seconds);
const mm = String(Math.floor(total / 60)).padStart(2, '0');
const ss = String(total % 60).padStart(2, '0');
return mm + ':' + ss;
}
}
};
</script>
@@ -486,6 +625,128 @@ export default {
background-color: #000;
}
}
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览 */
.image-viewer {
width: 100%;
min-height: 80vh;
background-color: #000;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.image-element {
width: 100%;
max-height: 80vh;
display: block;
}
}
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览 */
.audio-viewer {
width: 100%;
min-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.audio-player-card {
width: 90%;
max-width: 560rpx;
padding: 40rpx 32rpx;
background-color: #fff;
border-radius: 16rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
.audio-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
.iconfont {
font-size: 48rpx;
color: #fff;
}
}
.audio-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
.audio-title {
font-size: 30rpx;
font-weight: 600;
color: #2c3e50;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-status {
font-size: 22rpx;
color: #8a9fb8;
}
.audio-progress {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
.audio-time {
font-size: 22rpx;
color: #8a9fb8;
flex-shrink: 0;
min-width: 60rpx;
text-align: center;
}
.audio-progress-bar {
flex: 1;
height: 6rpx;
background-color: #e8edf3;
border-radius: 3rpx;
overflow: hidden;
.audio-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6b7c93 0%, #4a5a72 100%);
transition: width 0.1s linear;
}
}
}
}
.audio-play-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
border: none;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
flex-shrink: 0;
padding: 0;
line-height: 1;
&::after { border: none; }
.iconfont {
font-size: 36rpx;
color: #fff;
}
}
}
}
}
}
</style>

View File

@@ -65,6 +65,16 @@
"when": "always"
}
},
{
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 元服务录音需要麦克风权限
"name": "ohos.permission.MICROPHONE",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "always"
}
},
{
"name": "ohos.permission.VIBRATE"
}

View File

@@ -0,0 +1,144 @@
// tests/media-upload-fix.test.js
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 单元测试核心逻辑修复
// - getFileSize 失败抛错而不是 resolve(0)
// - ext 提取: blob URL → 不取错 URL 整段
// - guessMime 包含 HEIC/HEIF/AVIF/BMP/TIFF 等现代格式
// - uploadViaSts 返回的 size 不再是 0 (用已知 size)
//
// 注意: 这是白盒 smoke, 不 mock uni.* API. 测试纯函数层逻辑.
const { test: nodeTest } = require('node:test')
const assert = require('node:assert/strict')
// 加载被测模块: 用 rewire 风格手动注入 mock
// 因为 media-upload.js 直接 import ai-service, 这里只测可独立验证的 helper
// 我们直接 require 然后 inspect 内部状态. 不行就复制粘贴. 这里走第二种.
// === guessMime 等价实现 (复制自 media-upload.js, 测试时和源文件保持同步) ===
const EXT_MIME_MAP = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip', rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
pdf: 'application/pdf', txt: 'text/plain',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
function guessMime(ext) {
return EXT_MIME_MAP[ext] || 'application/octet-stream'
}
// === ext 提取 (复制自 uploadViaSts 顶部) ===
function extractExt(filePath) {
let ext = 'bin'
if (typeof filePath === 'string') {
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
if (m) ext = m[1].toLowerCase()
}
if (ext === 'bin' && filePath && filePath.name) {
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
if (m2) ext = m2[1].toLowerCase()
}
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
return ext
}
nodeTest('guessMime 现代格式 (HEIC/AVIF/BMP/TIFF)', () => {
assert.equal(guessMime('heic'), 'image/heic')
assert.equal(guessMime('heif'), 'image/heif')
assert.equal(guessMime('avif'), 'image/avif')
assert.equal(guessMime('bmp'), 'image/bmp')
assert.equal(guessMime('tif'), 'image/tiff')
assert.equal(guessMime('tiff'), 'image/tiff')
// 老格式不破坏
assert.equal(guessMime('jpg'), 'image/jpeg')
assert.equal(guessMime('png'), 'image/png')
})
nodeTest('guessMime 未知扩展名降级', () => {
assert.equal(guessMime('xyz'), 'application/octet-stream')
assert.equal(guessMime(''), 'application/octet-stream')
})
nodeTest('guessMime 办公格式', () => {
assert.equal(guessMime('docx'), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
assert.equal(guessMime('pptx'), 'application/vnd.openxmlformats-officedocument.presentationml.presentation')
assert.equal(guessMime('zip'), 'application/zip')
})
nodeTest('extractExt 普通路径', () => {
assert.equal(extractExt('/tmp/upload/abc.heic'), 'heic')
assert.equal(extractExt('blob:https://example.com/photo.png'), 'png')
assert.equal(extractExt('http://x.com/y/test.jpg?ver=1'), 'jpg')
assert.equal(extractExt('C:\\Users\\test\\photo.heic'), 'heic')
})
nodeTest('extractExt blob URL 无扩展名时降级', () => {
// 关键 case: blob:https://xxx/uuid 没有扩展名
const ext = extractExt('blob:https://example.com/uuid-12345')
// 不应该返回 'https' 或 'example' 这种 URL 片段
assert.match(ext, /^[a-z0-9]{2,5}$/)
assert.notEqual(ext, 'https')
assert.notEqual(ext, 'example')
// 应该是 'bin' (兜底)
assert.equal(ext, 'bin')
})
nodeTest('extractExt file 对象带 name', () => {
const fileObj = { name: 'report.pdf', size: 1024 }
assert.equal(extractExt(fileObj), 'pdf')
assert.equal(extractExt({ name: 'image.HEIC' }), 'heic') // 大写也接受
})
nodeTest('extractExt 极端情况', () => {
assert.equal(extractExt(null), 'bin')
assert.equal(extractExt(undefined), 'bin')
assert.equal(extractExt(''), 'bin')
assert.equal(extractExt(123), 'bin') // 非字符串
})
// === 测试 useSts 决策逻辑 ===
// 复制自 uploadImage 末尾 (行为必须和源文件保持同步)
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024
function decideSts(detectedSize, fileSizeFinal, totallyUnknown, strategy) {
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
return finalUseSts
}
nodeTest('decideSts: oss_sts 永远走 STS', () => {
assert.equal(decideSts(100, 100, false, 'oss_sts'), true)
assert.equal(decideSts(0, 0, true, 'oss_sts'), true)
})
nodeTest('decideSts: backend 永不 STS', () => {
assert.equal(decideSts(5 * 1024 * 1024, 5 * 1024 * 1024, false, 'backend'), false)
})
nodeTest('decideSts: auto + 小图走 PHP', () => {
assert.equal(decideSts(500 * 1024, 500 * 1024, false, 'auto'), false)
})
nodeTest('decideSts: auto + 大图走 STS', () => {
assert.equal(decideSts(5 * 1024 * 1024, 5 * 1024 * 1024, false, 'auto'), true)
})
nodeTest('decideSts: auto + 探测完全失败兜底走 STS (防 OOM 关键 case)', () => {
// 修复前: fileSizeFinal=0 → useSts=false → 走 PHP 中转 → 大图 OOM
// 修复后: totallyUnknown=true → finalUseSts=true → 走 STS 直传
assert.equal(decideSts(0, 0, true, 'auto'), true)
})
console.log('✓ 所有 PR-FRONTEND-MEDIA-UPLOAD-FIX 单测通过')