feat(voice-input): 改进音频消息处理,支持音频时长和指令文本,优化后端交互
This commit is contained in:
@@ -1519,6 +1519,7 @@ async loadChatHistoryIfExist() {
|
||||
// backend MediaGuard 旁路 (≤5MB 时直接送 data+encoding, 不依赖 URL 公网可达)。
|
||||
// 这与图片的 enrichedPendingAttachments 行为对齐, 后端 WbAgentOsClient::buildMediaBlocks
|
||||
// 已经按 docs §3.1.5 处理 audio + data 内联。
|
||||
// 顺带把录音时长 (ms) 一起塞给后端, 方便 query 文案生成 ("语音消息 (12 秒)")
|
||||
const fileAttachments = await Promise.all(
|
||||
pendingFilesList
|
||||
.filter(p => p.uploaded)
|
||||
@@ -1530,7 +1531,9 @@ async loadChatHistoryIfExist() {
|
||||
url: p.uploaded.url,
|
||||
mime: p.uploaded.mime,
|
||||
name: p.uploaded.name,
|
||||
size: p.uploaded.size
|
||||
size: p.uploaded.size,
|
||||
// PR-FRONTEND-VOICE-INPUT: 录音时长 (ms) 仅对 audio 有意义; 文件/视频缺省 0
|
||||
duration: p.duration || 0
|
||||
}
|
||||
// audio 走 inline-base64 路径 (≤5MB) → 避免 LAN URL 被 MediaGuard SSRF 闸门拦截
|
||||
if (base.type === 'audio' && p.tempPath) {
|
||||
@@ -1577,12 +1580,24 @@ async loadChatHistoryIfExist() {
|
||||
// 实际发送给后端的内容 = 图片(含 base64) + 文件/视频/音频
|
||||
const readyAttachments = [...fileAttachments, ...enrichedPendingAttachments]
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-25): 不再给 attachment 挂自定义 instruction 字段.
|
||||
// 实测反馈: 后端 Agent framework 不识别 attachment.instruction 字段,
|
||||
// 会把它当成"事件标识"误读, 反而干扰 STT/视觉链路触发.
|
||||
// 改为纯 queryText 携带指令 (见下方 _buildAttachmentQuery + merge 逻辑).
|
||||
// 将来后端若支持 instruction 字段, 再单独 PR 加回.
|
||||
|
||||
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
|
||||
this._aiRequestStart = Date.now()
|
||||
|
||||
const queryText = hasContent
|
||||
? this.inputText.trim()
|
||||
: (readyAttachments.length ? '[附件]' : '')
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-25): content 只发用户文本, 不再拼接指令 hint
|
||||
// 历史: 之前给 query 拼 "请听一下 / 请查看图片..." 指令, 实测 AI 会把这条 hint
|
||||
// 当成"事件标识"误读 (raw i18n key 漏出 / 后端透传逻辑不一致),
|
||||
// 还不如信任后端 Agent framework 自己按 type 分发处理附件 (语音→STT, 图片→视觉).
|
||||
// 现在: text + 语音 时 content 只有 text, 音频附件独立传给后端, AI 自行判断.
|
||||
// 纯附件场景 (无用户文本) 保留 _buildAttachmentQuery 的回退逻辑作为类型标识兜底,
|
||||
// 后续如果后端 Agent 完全不识别某类附件, 再单独 PR 加回 hint.
|
||||
const userText = hasContent ? this.inputText.trim() : ''
|
||||
const queryText = userText || this._buildAttachmentQuery(readyAttachments)
|
||||
// 带图片时消息类型为 image, 图片 + 配文一起显示; 纯文件/文字时仍为 text
|
||||
const firstPendingImage = pendingImagesList.length ? pendingImagesList[0] : null
|
||||
// 用户气泡内展示用的附件: 图片(供 messageImages 渲染多图) + 文件/视频/音频
|
||||
@@ -1623,7 +1638,9 @@ async loadChatHistoryIfExist() {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: queryText,
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 用户气泡只显示用户自己输入的文字
|
||||
// 给 AI 的指令 hint 在 queryText 里, 这里不要污染用户气泡
|
||||
content: userText,
|
||||
// 纯文件(无图)时把文件挂到 attachments, 用户消息气泡下方展示文件卡片
|
||||
attachments: displayAttachments,
|
||||
timestamp: Date.now()
|
||||
@@ -1658,7 +1675,9 @@ async loadChatHistoryIfExist() {
|
||||
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
|
||||
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
|
||||
// 纯图片无文字时, 给 AI 的 query 用占位文案, 避免空 query
|
||||
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content || (readyAttachments.length ? '[图片]' : ''))
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-25): content 只发用户文本 (有文本时),
|
||||
// 纯附件时 fallback 到 _buildAttachmentQuery 的类型标识, 不再 append 指令 hint.
|
||||
const enhancedMessage = this.enhanceEmojiMessage(queryText)
|
||||
if (this.enableStreaming) {
|
||||
await this.sendStreamMessage(enhancedMessage, readyAttachments)
|
||||
} else {
|
||||
@@ -3355,6 +3374,46 @@ _handleStreamError(msg) {
|
||||
this._discardVoiceRecorder()
|
||||
},
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按附件类型生成给后端 AI 的 query 文案
|
||||
// 纯图片: "我发送了一张图片,请查看图片内容并回复我"
|
||||
// 纯语音: "我发送了一段语音,请听一下并回复我" / "…({n} 秒)…"
|
||||
// 纯视频: "我发送了一段视频,请查看视频内容并回复我"
|
||||
// 纯文件: "我发送了一个文件,请查看文件内容并回复我"
|
||||
// 多类型混合: "我发送了附件,请查看附件内容并回复我"
|
||||
// 空: ''
|
||||
// 必须是带动作的指令 (请听 / 请查看), 不是单纯的类型标签.
|
||||
// AI 只看到 "语音消息" 不会主动调 STT, 必须有 "请听一下" 才会触发.
|
||||
//
|
||||
// 返回合并后的单一字符串, 作为 query text 的 hint 兜底 (老 Agent 不识
|
||||
// attachment.instruction 字段时仍能据此判断如何处理附件).
|
||||
_buildAttachmentQuery(attachments) {
|
||||
if (!attachments || !attachments.length) return ''
|
||||
// 取首个非空 type; 多类型时降级到 attachmentInstruction
|
||||
const seen = new Set()
|
||||
let firstAudio = null
|
||||
for (const a of attachments) {
|
||||
const kind = a.type || ''
|
||||
if (!seen.has(kind)) seen.add(kind)
|
||||
if (kind === 'audio' && !firstAudio) firstAudio = a
|
||||
}
|
||||
if (seen.size === 1) {
|
||||
const onlyKind = attachments[0].type
|
||||
if (onlyKind === 'image') return this.$lang('ai-chat.index.imageInstruction')
|
||||
if (onlyKind === 'video') return this.$lang('ai-chat.index.videoInstruction')
|
||||
if (onlyKind === 'file') return this.$lang('ai-chat.index.fileInstruction')
|
||||
if (onlyKind === 'audio') {
|
||||
// 音频带时长 (毫秒 → 秒, 至少 1s 起步避免 0s 看起来像出 bug)
|
||||
const dur = (firstAudio && Number(firstAudio.duration)) || 0
|
||||
if (dur >= 1000) {
|
||||
const seconds = Math.max(1, Math.round(dur / 1000))
|
||||
return this.$lang('ai-chat.index.audioInstructionWithDuration', { seconds })
|
||||
}
|
||||
return this.$lang('ai-chat.index.audioInstruction')
|
||||
}
|
||||
}
|
||||
return this.$lang('ai-chat.index.attachmentInstruction')
|
||||
},
|
||||
|
||||
// ============== PR-FRONTEND-VOICE-INPUT: 录音产物处理 ==============
|
||||
|
||||
/**
|
||||
@@ -3395,9 +3454,11 @@ _handleStreamError(msg) {
|
||||
// 录完自动退出语音模式, 让发送按钮显出来 (用户可加文字或直接发)
|
||||
if (this.voiceMode) this.exitVoiceMode()
|
||||
try {
|
||||
// H5 优先传 blob (uploadMedia 内部会处理 blob URL → fetch 上传),
|
||||
// 非 H5 传 filePath (uni.uploadFile)
|
||||
const inputArg = result.blob || result.filePath
|
||||
// uploadMedia 要求 inputArg 是 URL/path 字符串:
|
||||
// H5 → blob URL (URL.createObjectURL), 内部 fetch → Blob → File
|
||||
// MP / APP / 鸿蒙 → tempFilePath (uni.uploadFile 直传)
|
||||
// 不能传 raw Blob — uploadMedia 内部 pathToFile 会调 filePath.split(), Blob 没该方法
|
||||
const inputArg = result.filePath
|
||||
const uploaded = await uploadMedia(inputArg, 'audio', {
|
||||
name: pending.fileName,
|
||||
mime: result.mime,
|
||||
@@ -3409,6 +3470,10 @@ _handleStreamError(msg) {
|
||||
this.$set(pending, 'imageUrl', uploaded.url)
|
||||
this.$set(pending, 'url', uploaded.url)
|
||||
}
|
||||
// PR-FRONTEND-VOICE-INPUT: 不主动 revoke blob URL — sendMessage 时
|
||||
// 还要 fileToBase64(tempPath) 再 fetch 一次 (走 base64 内联避开 MediaGuard),
|
||||
// revoke 后 fetch 失效, 退回 URL-only 路径, LAN 环境会被后端 SSRF 闸门丢。
|
||||
// blob URL 生命周期跟随 pendingFiles, 用户「删除附件 / 发送后」自然随 JS GC 释放。
|
||||
} catch (e) {
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploadError', e.message || this.$lang('ai-chat.index.uploadFailed'))
|
||||
|
||||
Reference in New Issue
Block a user