feat(app): AI 对话语音输入(微信式)+ 回复语音朗读——ASR V2 引擎自采修复 1002200006 busy(recognitionMode 0 不再自建采集器)+ busy 自愈;TTS V2 长文分段串行朗读(单次 500 字符上限)+ 状态回调;按住说话/松开发送/上滑取消,麦克风权限运行时申请

This commit is contained in:
xsl
2026-08-16 11:40:47 +08:00
parent db9c4da0e3
commit 83fd48b910
5 changed files with 900 additions and 111 deletions
+334 -108
View File
@@ -1,21 +1,9 @@
/**
* ChatPage —— AI 穿搭对话页(F4)。
*
* 任务契约:docs/开发任务包/F4-AI对话页.md
* - 消息列表 @State ChatMsg[]:本地内存态,退出即失(V1 不做会话持久化,
* 后端 ag_conversation 会话落库属后续任务)
* - 发送流程:push 用户消息 → AI 侧"正在思考…"占位 → POST /api/v1/agent/chat
* → 成功替换占位(meta = "模型 · 耗时")并更新顶部配额薄条 quota_remaining
* - 失败态:只 catch ApiError(40301 额度用尽 / 60003 今日服务上限 / 50000 网络错
* 与未知异常,均已在 ApiClient 内包成 ApiError),统一在 AI 气泡位展示
* message + hint 次行小字,"正在思考…"占位必撤
* - 发送中禁用输入防连点;空消息不发送;首次进入插入本地静态欢迎语(不调接口)
*/
import { router } from '@kit.ArkUI'
import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes'
import { VoiceInputEngine } from '../components/f4/VoiceInputEngine'
import { TtsEngine } from '../components/f4/TtsEngine'
/** wire 响应:POST /api/v1/agent/chat 的 data.usage(字段名对齐后端 JSON) */
interface ChatUsage {
tokens_in: number
tokens_out: number
@@ -24,7 +12,6 @@ interface ChatUsage {
cost_cents: number
}
/** wire 响应:POST /api/v1/agent/chat 的 data(字段名对齐后端 JSON) */
interface ChatResp {
reply: string
provider: string
@@ -36,7 +23,6 @@ interface ChatResp {
quota_remaining: number
}
/** 列表项:meta 存 AI 消息底部小字(成功为"模型 · 耗时",失败为 e.hint) */
interface ChatMsg {
role: 'user' | 'ai'
text: string
@@ -48,12 +34,14 @@ const ROLE_AI = 'ai'
const THINKING_TEXT = '正在思考…'
const WELCOME_TEXT = '你好,我是你的穿搭顾问,可以问我面试怎么穿、约会怎么搭~'
/** 单条消息气泡:用户右对齐主色底,AI 左对齐白底;meta 为底部小字 */
@Component
struct MessageBubble {
@Prop role: string = ROLE_AI
@Prop text: string = ''
@Prop meta: string = ''
@Prop isSpeaking: boolean = false
@Prop msgIdx: number = -1
speakHandler: (idx: number) => void = () => {}
private isUser(): boolean {
return this.role === ROLE_USER
@@ -66,12 +54,25 @@ struct MessageBubble {
.fontSize(15)
.lineHeight(22)
.fontColor(this.isUser() ? '#FFFFFF' : '#333333')
if (this.meta.length > 0) {
Text(this.meta)
.fontSize(11)
.margin({ top: 4 })
.fontColor(this.isUser() ? '#FFDCD8' : '#999999')
Row() {
if (this.meta.length > 0) {
Text(this.meta)
.fontSize(11)
.fontColor(this.isUser() ? '#FFDCD8' : '#999999')
}
if (!this.isUser()) {
Blank()
Text(this.isSpeaking ? '🔊' : '🔈')
.fontSize(14)
.fontColor('#999999')
.margin({ left: 4 })
.onClick(() => {
this.speakHandler(this.msgIdx)
})
}
}
.width('100%')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
@@ -88,20 +89,81 @@ struct MessageBubble {
@Entry
@Component
struct ChatPage {
/** 本地内存态:不做会话持久化(后端 ag_conversation 属后续任务) */
@State messages: ChatMsg[] = []
@State inputText: string = ''
@State sending: boolean = false
/** -1 = 尚未从任何响应拿到配额 → 薄条不显示 */
@State quotaRemaining: number = -1
@State voiceMode: boolean = false
@State holdState: string = ''
@State partialText: string = ''
@State voiceHint: string = ''
@State speakingMsgIdx: number = -1
private fingerDown: boolean = false
private holdStartY: number = 0
private voiceEngine: VoiceInputEngine = new VoiceInputEngine()
private ttsEngine: TtsEngine = new TtsEngine()
private scroller: Scroller = new Scroller()
private autoSpeak: boolean = true
aboutToAppear(): void {
const welcome: ChatMsg = { role: ROLE_AI, text: WELCOME_TEXT }
this.messages.push(welcome)
// 朗读结束(播完/被停/出错)→ 复位消息气泡的 🔊 高亮
this.ttsEngine.setOnStateChanged((speaking: boolean) => {
if (!speaking) {
this.speakingMsgIdx = -1
}
})
this.voiceEngine.setCallbacks({
onPartial: (text: string): void => {
this.partialText = text
},
onFinal: (text: string): void => {
const finalText: string = text.trim()
if (finalText.length === 0) {
this.showVoiceHint('没听清,请再按住说一次')
return
}
this.inputText = finalText
this.send()
},
onError: (message: string): void => {
this.showVoiceHint(message)
}
})
}
aboutToDisappear(): void {
this.voiceEngine.release()
this.ttsEngine.release()
}
private async speakText(text: string, msgIdx: number, manual: boolean): Promise<void> {
if (this.speakingMsgIdx === msgIdx && this.ttsEngine.isSpeaking()) {
this.ttsEngine.stop()
this.speakingMsgIdx = -1
return
}
this.speakingMsgIdx = msgIdx
try {
await this.ttsEngine.speak(text)
} catch (e) {
this.speakingMsgIdx = -1
console.error(`MEIDA_TTS speak failed: ${String(e)}`)
if (manual) {
this.showVoiceHint('语音朗读启动失败(模拟器无语音服务,请用真机)')
}
}
}
private onSpeakToggle(idx: number): void {
if (idx < 0 || idx >= this.messages.length) {
return
}
const msg: ChatMsg = this.messages[idx]
this.speakText(msg.text, idx, true)
}
/** 发送入口(回车/按钮共用):空消息不发,发送中防连点 */
private send(): void {
const text: string = this.inputText.trim()
if (text.length === 0 || this.sending) {
@@ -113,6 +175,8 @@ struct ChatPage {
private async doSend(text: string): Promise<void> {
this.inputText = ''
this.sending = true
this.ttsEngine.stop()
this.speakingMsgIdx = -1
const userMsg: ChatMsg = { role: ROLE_USER, text: text }
this.messages.push(userMsg)
const thinkingMsg: ChatMsg = { role: ROLE_AI, text: THINKING_TEXT }
@@ -124,9 +188,11 @@ struct ChatPage {
const meta: string = `${data.model} · ${data.usage.latency_ms}ms`
this.replaceThinking(data.reply, meta)
this.quotaRemaining = data.quota_remaining
if (this.autoSpeak) {
const aiIdx: number = this.messages.length - 1
this.speakText(data.reply, aiIdx, false)
}
} catch (e) {
// 只 catch ApiError:40301 / 60003 / 网络错(50000)均包成 ApiError,
// 统一气泡位展示 message + hint;占位在 replaceThinking 里必撤
if (e instanceof ApiError) {
this.replaceThinking(e.message, e.hint ?? '')
}
@@ -135,7 +201,6 @@ struct ChatPage {
}
}
/** 把末尾"正在思考…"占位替换为结果;占位缺失时兜底补一条,确保占位一定被撤掉 */
private replaceThinking(text: string, meta: string): void {
const replaced: ChatMsg = { role: ROLE_AI, text: text, meta: meta }
const idx: number = this.messages.length - 1
@@ -153,91 +218,252 @@ struct ChatPage {
}, 50)
}
private showVoiceHint(message: string): void {
this.voiceHint = message
setTimeout(() => {
if (this.voiceHint === message) {
this.voiceHint = ''
}
}, 3000)
}
private async startHold(): Promise<void> {
this.fingerDown = true
this.partialText = ''
this.ttsEngine.stop()
this.speakingMsgIdx = -1
const granted = await VoiceInputEngine.ensureMicrophonePermission()
if (!this.fingerDown) {
return
}
if (!granted) {
this.showVoiceHint('未获得麦克风权限,无法语音输入')
return
}
try {
await this.voiceEngine.start()
} catch (e) {
console.error(`MEIDA_ASR startHold failed: ${String(e)}`)
this.showVoiceHint('语音引擎启动失败,请重试')
return
}
if (!this.fingerDown) {
this.voiceEngine.cancel()
return
}
this.holdState = 'rec'
}
private endHold(wantCancel: boolean): void {
this.fingerDown = false
if (this.holdState === '') {
return
}
this.holdState = ''
this.partialText = ''
if (wantCancel) {
this.voiceEngine.cancel()
} else {
this.voiceEngine.finish()
}
}
private onHoldTouch(event: TouchEvent): void {
if (event.type === TouchType.Down) {
if (event.touches.length === 0) {
return
}
this.holdStartY = event.touches[0].displayY
this.startHold()
} else if (event.type === TouchType.Move) {
if (this.holdState === '' || event.touches.length === 0) {
return
}
const dy: number = this.holdStartY - event.touches[0].displayY
this.holdState = dy > 80 ? 'cancel' : 'rec'
} else if (event.type === TouchType.Up) {
this.endHold(this.holdState === 'cancel')
} else if (event.type === TouchType.Cancel) {
this.endHold(true)
}
}
@Builder
VoiceOverlay() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
Column({ space: 10 }) {
Text(this.holdState === 'cancel' ? '✕' : '🎙')
.fontSize(40)
.fontColor(Color.White)
Text(this.holdState === 'cancel' ? '松开 取消' : '松开 发送')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
Text(this.holdState === 'cancel' ? '' : (this.partialText !== '' ? this.partialText : '正在聆听…'))
.fontSize(13)
.fontColor('#DDDDDD')
.maxLines(2)
.textAlign(TextAlign.Center)
Text('↑ 上滑取消')
.fontSize(11)
.fontColor('#999999')
}
.width('72%')
.padding({ top: 24, bottom: 24, left: 16, right: 16 })
.borderRadius(20)
.backgroundColor('#CC1F1F1F')
.alignItems(HorizontalAlign.Center)
}
build() {
Column() {
// 顶部栏:返回 + 标题
Row() {
Text('‹')
.fontSize(26)
.fontColor('#333333')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.onClick(() => {
router.back()
})
Text('AI 穿搭顾问')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1F1F1F')
.margin({ left: 4 })
Blank()
}
.width('100%')
.height(56)
.padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
// 配额薄条:拿到首个响应后展示"今日剩余 N 次"
if (this.quotaRemaining >= 0) {
Text(`今日剩余 ${this.quotaRemaining} 次`)
.fontSize(12)
.fontColor('#B3554D')
.backgroundColor('#FBEAE8')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 4, bottom: 4 })
}
// 消息列表(key 带内容:占位被替换时 key 变化触发该项重渲染)
List({ scroller: this.scroller }) {
ForEach(this.messages, (msg: ChatMsg, idx: number) => {
ListItem() {
MessageBubble({
role: msg.role,
text: msg.text,
meta: msg.meta ?? ''
Stack() {
Column() {
Row() {
Text('‹')
.fontSize(26)
.fontColor('#333333')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.onClick(() => {
router.back()
})
}
}, (msg: ChatMsg, idx: number) => `${idx}_${msg.role}_${msg.text}`)
}
.layoutWeight(1)
.width('100%')
.backgroundColor('#F5F5F7')
.scrollBar(BarState.Off)
Text('AI 穿搭顾问')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1F1F1F')
.margin({ left: 4 })
Blank()
Text(this.autoSpeak ? '🔊' : '🔇')
.fontSize(18)
.fontColor('#666666')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.onClick(() => {
this.autoSpeak = !this.autoSpeak
if (!this.autoSpeak) {
this.ttsEngine.stop()
this.speakingMsgIdx = -1
}
})
}
.width('100%')
.height(56)
.padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
// 底部输入栏:发送中禁用输入框与按钮防连点
Row() {
TextInput({
text: this.inputText,
placeholder: this.sending ? 'AI 正在回复…' : '聊聊穿搭,比如:面试怎么穿?'
})
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor('#F2F2F4')
.borderRadius(8)
.enabled(!this.sending)
.enterKeyType(EnterKeyType.Send)
.onChange((value: string) => {
this.inputText = value
})
.onSubmit(() => {
this.send()
})
Button(this.sending ? '回复中' : '发送')
.fontSize(14)
.height(36)
.margin({ left: 8 })
.backgroundColor(this.sending ? '#C9C9CD' : '#D9605A')
.enabled(!this.sending)
.onClick(() => {
this.send()
})
if (this.quotaRemaining >= 0) {
Text(`今日剩余 ${this.quotaRemaining} 次`)
.fontSize(12)
.fontColor('#B3554D')
.backgroundColor('#FBEAE8')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 4, bottom: 4 })
}
List({ scroller: this.scroller }) {
ForEach(this.messages, (msg: ChatMsg, idx: number) => {
ListItem() {
MessageBubble({
role: msg.role,
text: msg.text,
meta: msg.meta ?? '',
isSpeaking: this.speakingMsgIdx === idx,
msgIdx: idx as number,
speakHandler: (i: number) => {
this.onSpeakToggle(i)
}
})
}
}, (msg: ChatMsg, idx: number) => `${idx}_${msg.role}_${msg.text}`)
}
.layoutWeight(1)
.width('100%')
.backgroundColor('#F5F5F7')
.scrollBar(BarState.Off)
if (this.voiceHint !== '') {
Text(this.voiceHint)
.fontSize(12)
.fontColor('#C0392B')
.backgroundColor('#FDECEC')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 4, bottom: 4 })
}
Row() {
Text(this.voiceMode ? '⌨' : '🎤')
.fontSize(20)
.fontColor(this.voiceMode ? '#D9605A' : '#666666')
.width(36)
.height(40)
.textAlign(TextAlign.Center)
.align(Alignment.Center)
.onClick(() => {
if (this.holdState !== '') {
this.endHold(true)
}
this.voiceMode = !this.voiceMode
})
if (this.voiceMode) {
Text(this.holdState === 'cancel' ? '松开 取消' : (this.holdState === 'rec' ? '松开 发送' : '按住 说话'))
.layoutWeight(1)
.height(40)
.fontSize(14)
.textAlign(TextAlign.Center)
.fontColor(this.holdState === 'cancel' ? '#C0392B' : '#333333')
.backgroundColor(this.holdState === 'cancel' ? '#FDECEC' : '#F2F2F4')
.borderRadius(8)
.onTouch((event: TouchEvent) => {
this.onHoldTouch(event)
})
} else {
TextInput({
text: this.inputText,
placeholder: this.sending ? 'AI 正在回复…' : '聊聊穿搭,比如:面试怎么穿?'
})
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor('#F2F2F4')
.borderRadius(8)
.enabled(!this.sending)
.enterKeyType(EnterKeyType.Send)
.onChange((value: string) => {
this.inputText = value
})
.onSubmit(() => {
this.send()
})
Button(this.sending ? '回复中' : '发送')
.fontSize(14)
.height(36)
.margin({ left: 8 })
.backgroundColor(this.sending ? '#C9C9CD' : '#D9605A')
.enabled(!this.sending)
.onClick(() => {
this.send()
})
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
.height('100%')
if (this.holdState !== '') {
this.VoiceOverlay()
}
}
.width('100%')
.height('100%')