feat(app): AI chat page with quota hint and error states(F4)
This commit is contained in:
@@ -1,17 +1,245 @@
|
|||||||
/**
|
/**
|
||||||
* ChatPage —— 占位桩(FE 批次对应任务填充实现)。
|
* ChatPage —— AI 穿搭对话页(F4)。
|
||||||
* 已注册进 main_pages.json,其它页面可按路径跳转。
|
*
|
||||||
|
* 任务契约: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
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
struct ChatPage {
|
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<void> {
|
||||||
|
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<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
|
||||||
|
} 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() {
|
build() {
|
||||||
Column() {
|
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%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
.justifyContent(FlexAlign.Center)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user