472 lines
13 KiB
Plaintext
472 lines
13 KiB
Plaintext
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%')
|
||
}
|
||
}
|