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' interface ChatUsage { tokens_in: number tokens_out: number tokens_cached: number latency_ms: number cost_cents: number } interface ChatResp { reply: string provider: string model: string prompt_id: string prompt_version: string call_id: string usage: ChatUsage quota_remaining: number } interface ChatMsg { role: 'user' | 'ai' text: string meta?: string } const ROLE_USER = 'user' const ROLE_AI = 'ai' const THINKING_TEXT = '正在思考…' const WELCOME_TEXT = '你好,我是你的穿搭顾问,可以问我面试怎么穿、约会怎么搭~' @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 } build() { Column() { Column() { Text(this.text) .fontSize(15) .lineHeight(22) .fontColor(this.isUser() ? '#FFFFFF' : '#333333') 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 }) .borderRadius(12) .backgroundColor(this.isUser() ? '#D9605A' : '#FFFFFF') .constraintSize({ maxWidth: '82%' }) } .width('100%') .alignItems(this.isUser() ? HorizontalAlign.End : HorizontalAlign.Start) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) } } @Entry @Component struct ChatPage { @State messages: ChatMsg[] = [] @State inputText: string = '' @State sending: boolean = false @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 { 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) { return } this.doSend(text) } private async doSend(text: string): Promise { 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 } this.messages.push(thinkingMsg) this.scrollToEnd() try { const req: Record = { 'message': text } const data: ChatResp = await ApiClient.post('/api/v1/agent/chat', req) 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) { if (e instanceof ApiError) { this.replaceThinking(e.message, e.hint ?? '') } } finally { this.sending = false } } private replaceThinking(text: string, meta: string): void { const replaced: ChatMsg = { role: ROLE_AI, text: text, meta: meta } const idx: number = this.messages.length - 1 if (idx >= 0 && this.messages[idx].text === THINKING_TEXT) { this.messages[idx] = replaced } else { this.messages.push(replaced) } this.scrollToEnd() } private scrollToEnd(): void { setTimeout(() => { this.scroller.scrollEdge(Edge.Bottom) }, 50) } private showVoiceHint(message: string): void { this.voiceHint = message setTimeout(() => { if (this.voiceHint === message) { this.voiceHint = '' } }, 3000) } private async startHold(): Promise { 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() { Stack() { 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() 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') 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%') .height('100%') if (this.holdState !== '') { this.VoiceOverlay() } } .width('100%') .height('100%') } }