diff --git a/app/entry/src/main/ets/pages/ChatPage.ets b/app/entry/src/main/ets/pages/ChatPage.ets index c420a72..5f56eb3 100644 --- a/app/entry/src/main/ets/pages/ChatPage.ets +++ b/app/entry/src/main/ets/pages/ChatPage.ets @@ -1,17 +1,245 @@ /** - * ChatPage —— 占位桩(FE 批次对应任务填充实现)。 - * 已注册进 main_pages.json,其它页面可按路径跳转。 + * 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' + +/** wire 响应:POST /api/v1/agent/chat 的 data.usage(字段名对齐后端 JSON) */ +interface ChatUsage { + tokens_in: number + tokens_out: number + tokens_cached: number + latency_ms: number + cost_cents: number +} + +/** wire 响应:POST /api/v1/agent/chat 的 data(字段名对齐后端 JSON) */ +interface ChatResp { + reply: string + provider: string + model: string + prompt_id: string + prompt_version: string + call_id: string + usage: ChatUsage + quota_remaining: number +} + +/** 列表项:meta 存 AI 消息底部小字(成功为"模型 · 耗时",失败为 e.hint) */ +interface ChatMsg { + role: 'user' | 'ai' + text: string + meta?: string +} + +const ROLE_USER = 'user' +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 = '' + + private isUser(): boolean { + return this.role === ROLE_USER + } + + build() { + Column() { + Column() { + Text(this.text) + .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') + } + } + .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 { + /** 本地内存态:不做会话持久化(后端 ag_conversation 属后续任务) */ + @State messages: ChatMsg[] = [] + @State inputText: string = '' + @State sending: boolean = false + /** -1 = 尚未从任何响应拿到配额 → 薄条不显示 */ + @State quotaRemaining: number = -1 + private scroller: Scroller = new Scroller() + + aboutToAppear(): void { + const welcome: ChatMsg = { role: ROLE_AI, text: WELCOME_TEXT } + this.messages.push(welcome) + } + + /** 发送入口(回车/按钮共用):空消息不发,发送中防连点 */ + 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 + 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 + } catch (e) { + // 只 catch ApiError:40301 / 60003 / 网络错(50000)均包成 ApiError, + // 统一气泡位展示 message + hint;占位在 replaceThinking 里必撤 + 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) + } + build() { Column() { - Text('ChatPage(建设中)').fontSize(16).fontColor('#666666') + // 顶部栏:返回 + 标题 + 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 ?? '' + }) + } + }, (msg: ChatMsg, idx: number) => `${idx}_${msg.role}_${msg.text}`) + } + .layoutWeight(1) + .width('100%') + .backgroundColor('#F5F5F7') + .scrollBar(BarState.Off) + + // 底部输入栏:发送中禁用输入框与按钮防连点 + 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() + }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .backgroundColor('#FFFFFF') } .width('100%') .height('100%') - .justifyContent(FlexAlign.Center) } }