Files
meida/app/entry/src/main/ets/pages/ChatPage.ets
T

472 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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) {
return
}
this.doSend(text)
}
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 }
this.messages.push(thinkingMsg)
this.scrollToEnd()
try {
const req: Record<string, Object> = { 'message': text }
const data: ChatResp = await ApiClient.post<ChatResp>('/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<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() {
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%')
}
}