From e0514c3692a95cacf6d500d373bb7f47db0e44a7 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 22:19:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ohos):=20ChatPage=201962=E2=86=92207?= =?UTF-8?q?=20=E8=A1=8C=EF=BC=88=E7=8A=B6=E6=80=81=E6=9C=BA/SSE/=E6=B0=94?= =?UTF-8?q?=E6=B3=A1/=E8=BE=93=E5=85=A5=E5=8C=BA=E6=8B=86=E5=88=86?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/ChatStore.ets(388):消息数组、分页游标、新消息入场标记、 SSE 连接与重连、防抖刷新(50ms)统一成一个单例状态机 - common/ChatSse.ets(192):SSE 事件 → 状态的翻译层,逐分支照搬 channel_output/agent_output/reasoning/delta/tool_call/stage/agent_error/ sync_required;通过 ChatStreamSink 接口写入,避免与 ChatStore 形成循环依赖 - common/ChatSession.ets(176):POST /chat 与 POST /chat/file 的发送、 超时兜底、POST 响应与 SSE 的合并判定 - common/ChatFormat.ets(223):mime 推断、ForEach 键 structSig、工具卡 状态/颜色、渠道判定与头像配色 - common/ChatHistory.ets(96):历史载荷与 tool_calls 解析 - components/ChatBubble.ets(247):气泡(头像/渠道名/思考卡/工具卡/附件卡/正文) - components/ChatToolCard.ets(253):思考卡 + 工具卡 - components/ChatStream.ets(210):消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载 - components/ChatComposer.ets(357):输入行、选图/选文件、沙箱落盘、发送 - components/ChatAttachBar.ets(139):加号菜单 + 待发送附件条 响应式语义刻意保持不变:数组不进 AppStorage,改用自增版本号 K_CHAT_REV 通知订阅组件重取快照(ChatStream 把它镜像进 @State messages,ForEach 每次 拿到的仍是新数组引用,与拆分前 this.messages = this.messages.slice() 等价); structSig 仍不含 content,正文靠 MarkdownView 的 @Prop 流式更新; markNew 仍不切片、入场动画交给紧随其后的 refresh(); animateTo 只能存在于组件里,所以折叠翻转的动作留在 ChatStream 内。 验证:hvigorw assembleHap BUILD SUCCESSFUL;SSE 各分支、ensureToolCall、 markNew、sendChat/sendWithAttachment 的守卫顺序均与原实现逐条比对过, 守卫从"页面方法内"移到输入区组件时保持了原有先后(连接判定先于清空输入)。 --- .../entry/src/main/ets/common/ChatFormat.ets | 223 ++ .../entry/src/main/ets/common/ChatHistory.ets | 96 + .../entry/src/main/ets/common/ChatSession.ets | 176 ++ .../entry/src/main/ets/common/ChatSse.ets | 192 ++ .../entry/src/main/ets/common/ChatStore.ets | 389 ++++ .../src/main/ets/components/ChatAttachBar.ets | 139 ++ .../src/main/ets/components/ChatBubble.ets | 247 +++ .../src/main/ets/components/ChatComposer.ets | 361 ++++ .../src/main/ets/components/ChatStream.ets | 210 ++ .../src/main/ets/components/ChatToolCard.ets | 253 +++ .../entry/src/main/ets/pages/ChatPage.ets | 1831 +---------------- 11 files changed, 2324 insertions(+), 1793 deletions(-) create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatToolCard.ets diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets new file mode 100644 index 0000000..2f8248b --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets @@ -0,0 +1,223 @@ +/** + * 聊天页的纯格式化/判定逻辑(无 UI 依赖)。 + * + * 从 pages/ChatPage.ets 抽出:这些函数只吃数据吐字符串/布尔, + * 抽出来后气泡、工具卡、渠道头像三个组件可以共用同一份口径。 + */ + +import { ChatMessage, ToolCallInfo } from '../model/Model'; + +/** + * 由文件名后缀推断 Content-Type。 + * 后端按 multipart 部件的 Content-Type 判定 image/file, + * 给错会让图片被当成普通文件(缩略图就没了)。 + */ +export function mimeOf(name: string, isImage: boolean): string { + const i: number = name.lastIndexOf('.'); + const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : ''; + if (ext === 'png') { + return 'image/png'; + } + if (ext === 'jpg' || ext === 'jpeg') { + return 'image/jpeg'; + } + if (ext === 'webp') { + return 'image/webp'; + } + if (ext === 'gif') { + return 'image/gif'; + } + if (ext === 'bmp') { + return 'image/bmp'; + } + if (ext === 'heic' || ext === 'heif') { + return 'image/heic'; + } + if (isImage) { + return 'image/jpeg'; + } + if (ext === 'pdf') { + return 'application/pdf'; + } + if (ext === 'txt' || ext === 'log' || ext === 'md') { + return 'text/plain'; + } + if (ext === 'json') { + return 'application/json'; + } + return 'application/octet-stream'; +} + +/** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */ +export function stringifyField(raw: Object | undefined): string { + if (raw === undefined || raw === null) { + return ''; + } + if (typeof raw === 'string') { + return raw as string; + } + try { + return JSON.stringify(raw); + } catch (e) { + return String(raw); + } +} + +/** + * ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的 + * if 分支重新求值。这是 ArkUI V1 渲染模型决定的:ForEach 对相同键 + * 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以 + * 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后 + * 永远不会再次求值——气泡里的这些面板就永远不出现。 + * + * 反过来,content_delta 不进 structSig:正文文本靠 MarkdownView + * 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。 + * 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段 + * 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。 + */ +export function structSig(msg: ChatMessage): string { + let s: string = msg.id.toString(); + const rc: string | undefined = msg.reasoningContent; + s += '_r' + (rc !== undefined ? rc.length.toString() : '0'); + s += '_ro' + (msg.reasoningOpen === true ? '1' : '0'); + const tcs: ToolCallInfo[] | undefined = msg.toolCalls; + if (tcs !== undefined) { + s += '_t' + tcs.length.toString(); + for (let i = 0; i < tcs.length; i++) { + const tc: ToolCallInfo = tcs[i]; + s += '_' + (tc.status ?? ''); + s += '_' + (tc.open === true ? 'o' : 'c'); + s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0'); + s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0'); + s += '_' + (tc.plugin ?? ''); + } + } else { + s += '_t0'; + } + s += '_a' + (msg.attachment !== undefined ? '1' : '0'); + s += '_src' + (msg.source ?? ''); + s += '_f' + (msg.isFinal === true ? '1' : '0'); + s += '_s' + (msg.isStreaming === true ? '1' : '0'); + return s; +} + +/** 折叠时也要能看出思考在增长:显示字数 */ +export function reasoningLenLabel(msg: ChatMessage): string { + const rc: string | undefined = msg.reasoningContent; + if (rc === undefined || rc.length === 0) { + return ''; + } + return rc.length.toString() + ' 字'; +} + +/** + * 是否仍在执行。 + * 判据是 status 而不是 result:后端 status=ok 的工具也可能返回空串, + * 用 result 判断会让这类调用永远显示"调用中"。 + */ +export function tcRunning(tc: ToolCallInfo): boolean { + const s: string | undefined = tc.status; + return s === undefined || s.length === 0 || s === 'running'; +} + +export function tcError(tc: ToolCallInfo): boolean { + return tc.status === 'denied' || tc.status === 'error'; +} + +/** 工具卡左侧色条(accent 由调用方从 palette 取) */ +export function tcLeftColor(tc: ToolCallInfo, accent: string): string { + if (tcError(tc)) { + return '#DB3694'; + } + if (tcRunning(tc)) { + return accent; + } + return 'rgba(23, 169, 100, 0.8)'; +} + +/** 工具卡状态图标颜色 */ +export function tcIcoColor(tc: ToolCallInfo, accent: string): string { + if (tcError(tc)) { + return '#DB3694'; + } + if (tcRunning(tc)) { + return accent; + } + return 'rgba(23, 169, 100, 0.9)'; +} + +export function tcStateLabel(tc: ToolCallInfo): string { + if (tc.status === 'denied') { + return '已拒绝'; + } + if (tcRunning(tc)) { + return '调用中'; + } + return '完成'; +} + +export function tcStateColor(tc: ToolCallInfo): string { + if (tc.status === 'denied') { + return '#FF9EC6'; + } + if (tcRunning(tc)) { + return '#A3B8FF'; + } + return '#6EE7A8'; +} + +/** + * 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。 + * 纯文本 78% 好看;但工具卡/思考卡是"面板",78% 会把里面的状态文字和 + * 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。 + */ +export function bubbleMaxWidth(msg: ChatMessage): string { + const hasPanels: boolean = + (msg.toolCalls !== undefined && msg.toolCalls.length > 0) || + (msg.reasoningContent !== undefined && msg.reasoningContent.length > 0); + return hasPanels ? '92%' : '78%'; +} + +/** + * 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项: + * 本机自己发的消息在后端会被写成 webui/,那仍然是"我发的", + * 不能当成渠道消息挂上别人的头像。 + */ +export function isChannelMsg(source: string, deviceId: string): boolean { + if (source.length === 0 || source === 'webui') { + return false; + } + return source !== 'webui/' + deviceId; +} + +/** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */ +export function isSelfMsg(role: string, channel: boolean): boolean { + return role === 'user' && !channel; +} + +/** 渠道名展示:webui/ 只显示 ,其余原样。 */ +export function chanLabel(src: string): string { + if (src.startsWith('webui/')) { + return src.substring(6); + } + return src; +} + +/** 渠道首字母(大写),用作头像文字。 */ +export function chanLetter(src: string): string { + const label: string = chanLabel(src); + if (label.length === 0) { + return '?'; + } + return label.substring(0, 1).toUpperCase(); +} + +/** 由渠道名散列出稳定色,避免每次渲染换色。 */ +export function chanColor(src: string): string { + const label: string = chanLabel(src); + let h: number = 0; + for (let i = 0; i < label.length; i++) { + h = (h * 31 + label.charCodeAt(i)) % 360; + } + return 'hsl(' + h.toString() + ', 52%, 46%)'; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets new file mode 100644 index 0000000..432d806 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets @@ -0,0 +1,96 @@ +/** + * /chat/history 响应解析(无 UI 依赖)。 + * + * 从 pages/ChatPage.ets 抽出:首屏与向上翻页共用同一套解析口径, + * 消息 id 由调用方提供的分配器给出(页面自己维护 id 计数器)。 + */ + +import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; +import { parseAttachment } from '../components/Attachment'; +import { stringifyField } from './ChatFormat'; + +/** 分页历史解析结果:消息列表 + 服务端分页元数据 */ +export interface ParsedHistory { + msgs: ChatMessage[]; + /** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */ + offset: number; + /** 服务端是否还有更早的历史 */ + hasMore: boolean; +} + +/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */ +export function parseHistoryPayload( + obj: Record, alloc: () => number): ParsedHistory { + const rawList: Object | undefined = obj['messages'] as Object | undefined; + if (rawList === undefined || rawList === null) { + return { msgs: [], offset: 0, hasMore: false }; + } + const arr: Object[] = rawList as Object[]; + const msgs: ChatMessage[] = []; + for (let i = 0; i < arr.length; i++) { + const item: Record = arr[i] as Record; + const role: string = item['role'] as string ?? ''; + const content: string = item['content'] as string ?? ''; + const att: ChatAttachment | undefined = parseAttachment(item['attachment']); + // 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理 + if (role.length === 0 || (content.length === 0 && att === undefined)) { + continue; + } + const msg: ChatMessage = { + id: alloc(), + role: role, + content: content, + isFinal: true, + }; + if (att !== undefined) { + msg.attachment = att; + } + // 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls, + // 这里必须还原,否则刷新后思考与工具卡就凭空消失。 + const rc: string = item['reasoning_content'] as string ?? ''; + if (rc.length > 0) { + msg.reasoningContent = rc; + } + const tcs: ToolCallInfo[] | undefined = parseHistoryToolCalls(item['tool_calls']); + if (tcs !== undefined) { + msg.toolCalls = tcs; + } + // 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息 + const src: string = item['source'] as string ?? ''; + if (src.length > 0) { + msg.source = src; + } + msgs.push(msg); + } + const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0; + const hasMore: boolean = obj['has_more'] === true; + return { msgs: msgs, offset: offset, hasMore: hasMore }; +} + +/** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */ +export function parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined { + if (raw === undefined || raw === null) { + return undefined; + } + const arr: Object[] = raw as Object[]; + if (arr.length === 0) { + return undefined; + } + const tcs: ToolCallInfo[] = []; + for (let i = 0; i < arr.length; i++) { + const item: Record = arr[i] as Record; + const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? ''); + if (name.length === 0) { + continue; + } + const tc: ToolCallInfo = { + name: name, + args: stringifyField(item['args']), + result: stringifyField(item['result']), + status: item['status'] as string ?? undefined, + plugin: item['plugin'] as string ?? undefined, + }; + tcs.push(tc); + } + return tcs.length > 0 ? tcs : undefined; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets new file mode 100644 index 0000000..02225ee --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets @@ -0,0 +1,176 @@ +/** + * 发送 / 中断(无 UI 依赖)。 + * + * 从 pages/ChatPage.ets 抽出:POST /chat 与 POST /chat/file 的请求体、 + * 兜底消息合并、超时口径都收在这里,输入区组件只负责把文本/附件递进来。 + */ + +import { ChatMessage, ChatAttachment } from '../model/Model'; +import { apiClient } from './ApiClient'; +import { connStore } from './ConnStore'; +import { userMessage, isTimeout } from './UserError'; +import { chatStore } from './ChatStore'; +import { parseAttachment } from '../components/Attachment'; +import { http } from '@kit.NetworkKit'; + +interface SendChatBody { + message: string; + client_msg_id: string; + /** 非空时后端编码 source = "webui/",agent 可见来源设备 */ + device_id?: string; + device_name?: string; +} + +/** + * 纯文本发送:POST /chat。 + * 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。 + */ +export async function sendChatText(text: string): Promise { + const trimmed: string = text.trim(); + if (chatStore.isLoading()) { + return; + } + const cur = connStore.getCurrentConnection(); + if (cur === null) { + return; + } + if (trimmed.length === 0) { + return; + } + // 重置 SSE 标记 + chatStore.setSseActive(false); + + const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: trimmed }; + chatStore.pushNew(userMsg); + chatStore.setLoading(true); + chatStore.setStage('等待 AI 回复...'); + chatStore.forceRefresh(); + chatStore.requestScroll(); + + const bodyObj: SendChatBody = { + message: trimmed, + client_msg_id: Date.now().toString(36), + // 必须带设备身份:后端没有 device_id 就把来源编码成 webui, + // agent 会以为消息来自网页端。device_id 非空时后端编码 + // source = "webui/" 并注入设备上下文。 + device_id: connStore.ensureDeviceId(), + device_name: connStore.getDeviceName(), + }; + + // 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染); + // 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。 + try { + const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000); + + // SSE 已经处理了响应,跳过 POST 消息创建 + if (chatStore.sseActive()) { + chatStore.setLoading(false); + chatStore.setStage(''); + chatStore.forceRefresh(); + chatStore.requestScroll(); + return; + } + + const parsed: Record = JSON.parse(resp.body) as Record; + const respText: string = parsed['response'] ?? '(无响应)'; + const reasoning: string = parsed['reasoning_content'] ?? ''; + const last: ChatMessage | null = chatStore.lastMessage(); + if (last !== null && last.role === 'assistant' && !last.isFinal) { + last.content = respText; + last.isFinal = true; + last.isStreaming = false; + if (reasoning.length > 0 && last.reasoningContent === undefined) { + last.reasoningContent = reasoning; + } + } else if (last !== null && last.role === 'assistant' && last.isFinal) { + // 已有最终消息,合并(不应发生,但防御性处理) + if (respText.length > last.content.length) { + last.content = respText; + } + } else { + const msg: ChatMessage = { + id: chatStore.allocId(), + role: 'assistant', + content: respText, + isFinal: true, + }; + if (reasoning.length > 0) { + msg.reasoningContent = reasoning; + } + chatStore.pushNew(msg); + } + chatStore.setLoading(false); + chatStore.setStage(''); + chatStore.forceRefresh(); + chatStore.requestScroll(); + } catch (e) { + // 超时通常意味着后端仍在生成,不算失败;其余一律显示人话, + // 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把 + // "Failed to connect to the server."、内网地址直接摆到聊天流里)。 + if (isTimeout(e)) { + chatStore.setStage('请求已发送,等待回复...'); + } else { + chatStore.setStage(userMessage('chat.send', e)); + } + chatStore.forceRefresh(); + chatStore.requestScroll(); + } +} + +/** + * 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。 + * 后端收下后自身会把用户消息与附件写进会话并触发 agent, + * 回复照常从 SSE 过来,所以这里不再走 /chat。 + */ +export async function sendChatFile(text: string, path: string, name: string, + size: number, isImage: boolean, mime: string): Promise { + if (connStore.getCurrentConnection() === null) { + return; + } + const att: ChatAttachment = { + type: isImage ? 'image' : 'file', + // 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL + url: 'file://' + path, + size: size, + name: name, + }; + const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: text }; + userMsg.attachment = att; + chatStore.pushNew(userMsg); + chatStore.setLoading(true); + chatStore.setStage('正在上传附件...'); + chatStore.setSseActive(false); + chatStore.forceRefresh(); + chatStore.requestScroll(); + + const parts: http.MultiFormData[] = [ + { name: 'file', contentType: mime, remoteFileName: name, filePath: path }, + { name: 'message', contentType: 'text/plain', data: text }, + { name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) }, + { name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() }, + { name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() }, + ]; + + try { + const resp = await apiClient.postMultipart('/chat/file', parts, 180000); + const obj: Record = JSON.parse(resp.body) as Record; + const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']); + if (uploaded !== undefined) { + userMsg.attachment = uploaded; + } + chatStore.setStage('等待 AI 回复...'); + } catch (e) { + chatStore.setStage(userMessage('chat.upload', e)); + chatStore.setLoading(false); + } + chatStore.forceRefresh(); + chatStore.requestScroll(); +} + +export async function interruptChat(): Promise { + try { + await apiClient.post('/chat/interrupt', null); + } catch (e) { + // ignore + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets new file mode 100644 index 0000000..cb34c39 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets @@ -0,0 +1,192 @@ +/** + * SSE 事件 → 聊天流状态的翻译层(无 UI 依赖)。 + * + * 从 pages/ChatPage.ets 抽出。这里只认「事件帧」与「往状态里写什么」, + * 具体状态由 ChatStreamSink 提供 —— 这样本文件不必 import ChatStore, + * 两边不会形成 ArkTS 里被拒绝的模块循环依赖。 + */ + +import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; +import { attachmentFromChannelOutput } from '../components/Attachment'; +import { SseEvent } from './SseClient'; +import { stringifyField } from './ChatFormat'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +/** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */ +export interface ChatStreamSink { + allocId(): number; + lastMessage(): ChatMessage | null; + /** 取/建"未定稿的助手消息" */ + ensureAssistant(): ChatMessage; + /** 取/建同名未完成工具卡 */ + ensureToolCall(name: string): ToolCallInfo; + /** 追加一条新消息并播入场动画 */ + pushNew(msg: ChatMessage): void; + setLoading(v: boolean): void; + setStage(s: string): void; + setSseActive(v: boolean): void; + /** 防抖刷新(合并高频 delta) */ + refresh(): void; + /** 请求滚到底 */ + requestScroll(): void; + /** sync_required:补拉历史 */ + reloadHistory(): void; +} + +export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void { + try { + // 服务端 data 字段是完整 sdk.Event:{type, source, payload, timestamp} + // 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。 + const frame: Record = JSON.parse(ev.data) as Record; + const inner: Object | undefined = frame['payload']; + const payload: Record = + inner !== undefined && inner !== null ? inner as Record : frame; + const frameType: string = frame['type'] as string ?? ''; + const type: string = ev.event.length > 0 ? ev.event : frameType; + // 诊断只记事件类型(内容可能含隐私,不落盘) + hilog.debug(0x0000, 'HomeAgent', 'sse %{public}s', type); + + if (type === 'agent_output') { + const content: string = payload['content'] as string ?? ''; + // channel_output 携带图片/文件:url/size/output_type 三个字段在 payload 顶层, + // 它是一条独立的附件消息,不能合并进上一条文本气泡。 + const kind: string = payload['kind'] as string ?? ''; + if (kind === 'channel_output') { + const att: ChatAttachment | undefined = attachmentFromChannelOutput( + payload['output_type'] as string ?? '', + payload['url'] as string ?? '', + payload['size'] as number ?? 0); + if (att !== undefined) { + const amsg: ChatMessage = { + id: sink.allocId(), + role: 'assistant', + content: content, + isFinal: true, + source: 'channel', + attachment: att, + }; + sink.pushNew(amsg); + sink.setLoading(false); + sink.setStage(''); + sink.setSseActive(false); + sink.refresh(); + sink.requestScroll(); + return; + } + } + const last: ChatMessage | null = sink.lastMessage(); + if (last !== null && last.role === 'assistant' && !last.isFinal) { + last.content = content; + last.isFinal = true; + last.isStreaming = false; + } else if (last !== null && last.role === 'assistant' && last.isFinal) { + // POST 已经创建了最终消息,仅合并内容(如果有增量) + if (content.length > last.content.length) { + last.content = content; + } + } else { + const msg: ChatMessage = { + id: sink.allocId(), + role: 'assistant', + content: content, + isFinal: true, + }; + sink.pushNew(msg); + } + sink.setLoading(false); + sink.setStage(''); + sink.setSseActive(false); + sink.refresh(); + sink.requestScroll(); + } else if (type === 'reasoning') { + const rc: string = payload['content'] as string ?? ''; + if (rc.length > 0) { + sink.setStage('AI 思考中...'); + // 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条), + // 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。 + const last: ChatMessage = sink.ensureAssistant(); + last.reasoningContent = rc; + sink.refresh(); + sink.requestScroll(); + } + } else if (type === 'sync_required') { + // 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件) + sink.reloadHistory(); + } else if (type === 'agent_error') { + // 后端错误一律转人话,技术细节不上 UI + sink.setLoading(false); + sink.setStage('本轮处理失败,请重试'); + sink.refresh(); + } else if (type === 'content_delta') { + const delta: string = payload['content'] as string ?? ''; + if (delta.length > 0) { + sink.setSseActive(true); + const last: ChatMessage = sink.ensureAssistant(); + last.content += delta; + sink.refresh(); + sink.requestScroll(); + } + } else if (type === 'reasoning_delta') { + const delta: string = payload['content'] as string ?? ''; + if (delta.length > 0) { + sink.setSseActive(true); + sink.setStage('AI 思考中...'); + const last: ChatMessage = sink.ensureAssistant(); + if (last.reasoningContent === undefined) { + last.reasoningContent = ''; + } + last.reasoningContent += delta; + sink.refresh(); + } + } else if (type === 'tool_call') { + const toolName: string = payload['tool'] as string ?? ''; + const toolStatus: string = payload['status'] as string ?? ''; + const toolPlugin: string = payload['plugin'] as string ?? ''; + if (toolName.length > 0) { + sink.setStage('工具调用: ' + toolName); + const target: ToolCallInfo = sink.ensureToolCall(toolName); + if (toolPlugin.length > 0) { + target.plugin = toolPlugin; + } + const argsText: string = stringifyField(payload['args']); + if (argsText.length > 0) { + target.args = argsText; + } + if (toolStatus.length > 0) { + // 后端只在工具执行【结束】时发 tool_call(status=ok/denied/interrupted), + // 所以拿到 status 就意味着这次调用已收尾,result 一并落卡。 + target.status = toolStatus; + target.result = stringifyField(payload['result']); + } else { + target.status = 'running'; + } + sink.refresh(); + sink.requestScroll(); + } + } else if (type === 'stage') { + const phase: string = payload['phase'] as string ?? ''; + const channel: string = payload['channel'] as string ?? ''; + const stageTool: string = payload['tool'] as string ?? ''; + if (channel !== '_consolidation_') { + if (phase === 'pre_action') { + sink.setStage('AI 思考中...'); + } else if (phase === 'before_toolcall') { + sink.setStage('工具调用: ' + stageTool); + // 关键:tool_call 事件只在执行【结束】后才发,所以"调用中"这一态 + // 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。 + if (stageTool.length > 0) { + const tc: ToolCallInfo = sink.ensureToolCall(stageTool); + if (tc.status === undefined) { + tc.status = 'running'; + } + } + } else if (phase === 'before_output') { + sink.setStage('生成回复中...'); + } + sink.refresh(); + } + } + } catch (e) { + // ignore parse errors + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets new file mode 100644 index 0000000..7dfa88a --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets @@ -0,0 +1,389 @@ +/** + * 聊天流状态源(单例)。 + * + * 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、 + * SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。 + * + * 为什么数组不进 AppStorage:StatusStore 已经踩过一次 —— 数组同步语义不可靠。 + * 这里沿用同一套做法:数组留在 store 内部,标量走 AppStorage 广播, + * 另加一个自增版本号 K_CHAT_REV 通知订阅组件"重取一次快照"。 + * + * 订阅组件的接法(见 components/ChatStream.ets): + * @StorageProp(K_CHAT_REV) @Watch('onRev') private rev: number = 0; + * onRev(): void { this.messages = chatStore.messages(); } + * ForEach 拿到的仍然是"每次刷新一个新数组引用",与拆分前 + * (this.messages = this.messages.slice())的渲染语义完全一致。 + * + * SSE 事件的翻译在 common/ChatSse.ets:本类实现它的 ChatStreamSink 接口, + * 依赖方向只有"ChatStore → ChatSse"一条,不构成循环。 + */ + +import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; +import { SseClient, SseEvent } from './SseClient'; +import { connStore } from './ConnStore'; +import { apiClient } from './ApiClient'; +import { CHAT_PAGE_SIZE } from './Constants'; +import { ParsedHistory, parseHistoryPayload } from './ChatHistory'; +import { applyChatSse, ChatStreamSink } from './ChatSse'; + +// ===== AppStorage 键:页面/聊天流/输入区共用 ===== +export const K_CHAT_REV: string = 'chatRev'; +export const K_CHAT_SCROLL_REV: string = 'chatScrollRev'; +export const K_CHAT_LOADING: string = 'chatBusy'; +export const K_CHAT_STAGE: string = 'chatStageText'; +export const K_CHAT_CONNECTED: string = 'chatSseUp'; + +const SSE_RECONNECT_MS: number = 5000; + +class ChatStore implements ChatStreamSink { + private msgs: ChatMessage[] = []; + private nextId: number = 1; + private refreshTimer: number = -1; + private reconnectTimer: number = -1; + /** 分页历史:当前已加载消息在服务端全量中的起始下标 */ + private offset: number = 0; + /** 是否还有更早历史可向上加载 */ + private hasEarlier: boolean = false; + private loadingOlder: boolean = false; + /** 正在为新消息播入场动画的 id */ + private newIds: number[] = []; + // SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息 + private sseActiveForTurn: boolean = false; + private sse: SseClient = new SseClient(); + + init(): void { + AppStorage.setOrCreate(K_CHAT_LOADING, false); + AppStorage.setOrCreate(K_CHAT_STAGE, ''); + AppStorage.setOrCreate(K_CHAT_CONNECTED, false); + AppStorage.setOrCreate(K_CHAT_REV, 0); + AppStorage.setOrCreate(K_CHAT_SCROLL_REV, 0); + } + + // ===================== 读取 ===================== + + messages(): ChatMessage[] { + return this.msgs; + } + + findMessage(id: number): ChatMessage | undefined { + for (let i = 0; i < this.msgs.length; i++) { + if (this.msgs[i].id === id) { + return this.msgs[i]; + } + } + return undefined; + } + + lastMessage(): ChatMessage | null { + if (this.msgs.length === 0) { + return null; + } + return this.msgs[this.msgs.length - 1]; + } + + isFresh(id: number): boolean { + return this.newIds.indexOf(id) >= 0; + } + + hasMore(): boolean { + return this.hasEarlier; + } + + isFetchingOlder(): boolean { + return this.loadingOlder; + } + + isLoading(): boolean { + return AppStorage.get(K_CHAT_LOADING) ?? false; + } + + setLoading(v: boolean): void { + AppStorage.set(K_CHAT_LOADING, v); + } + + setStage(s: string): void { + AppStorage.set(K_CHAT_STAGE, s); + } + + setConnected(v: boolean): void { + AppStorage.set(K_CHAT_CONNECTED, v); + } + + sseActive(): boolean { + return this.sseActiveForTurn; + } + + setSseActive(v: boolean): void { + this.sseActiveForTurn = v; + } + + /** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */ + latestAttachment(): ChatAttachment | undefined { + for (let i = this.msgs.length - 1; i >= 0; i--) { + const a: ChatAttachment | undefined = this.msgs[i].attachment; + if (a !== undefined) { + return a; + } + } + return undefined; + } + + // ===================== 列表变更 ===================== + + allocId(): number { + return this.nextId++; + } + + /** 追加一条消息并播入场动画 */ + pushNew(msg: ChatMessage): void { + this.msgs.push(msg); + this.markNew(msg.id); + } + + /** 标记新消息,触发入场动画 */ + markNew(msgId: number): void { + const arr: number[] = this.newIds.slice(); + arr.push(msgId); + this.newIds = arr; + // 这里不切片也不广播:与拆分前一致,入场动画的开场交给紧随其后的 + // refresh()(50ms 防抖后换新数组引用)那一次一起触发。 + setTimeout(() => { + const idx: number = this.newIds.indexOf(msgId); + if (idx >= 0) { + const updated: number[] = this.newIds.slice(); + updated.splice(idx, 1); + this.newIds = updated; + this.forceRefresh(); + } + }, 250); + } + + /** + * 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。 + * 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。 + */ + ensureToolCall(name: string): ToolCallInfo { + const last: ChatMessage = this.ensureAssistant(); + if (last.toolCalls === undefined) { + last.toolCalls = []; + } + for (let i = 0; i < last.toolCalls.length; i++) { + const t: ToolCallInfo = last.toolCalls[i]; + // 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时, + // 已完成的那张不能被后来的调用覆盖。 + const st: string | undefined = t.status; + if (t.name === name && (st === undefined || st.length === 0 || st === 'running')) { + return t; + } + } + const created: ToolCallInfo = { name: name, args: '' }; + last.toolCalls.push(created); + return created; + } + + /** + * 保证存在一条"未定稿的助手消息",返回它。 + * 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条), + * 此时还没有"未完成的助手消息",必须新建一条,否则内容直接丢失。 + */ + ensureAssistant(): ChatMessage { + const last: ChatMessage | null = this.lastMessage(); + if (last !== null && last.role === 'assistant' && last.isFinal !== true) { + return last; + } + const msg: ChatMessage = { + id: this.allocId(), + role: 'assistant', + content: '', + isStreaming: true, + isFinal: false, + }; + this.pushNew(msg); + return msg; + } + + /** 流式/高频变更的通知信号:让 ChatStream 滚到底 */ + requestScroll(): void { + const cur: number = AppStorage.get(K_CHAT_SCROLL_REV) ?? 0; + AppStorage.set(K_CHAT_SCROLL_REV, cur + 1); + } + + /** 防抖刷新:合并高频 SSE delta,最多 ~20fps */ + refresh(): void { + if (this.refreshTimer >= 0) { + return; + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = -1; + this.msgs = this.msgs.slice(); + this.bump(); + }, 50); + } + + /** 强制立即刷新(用于状态切换等需要即时响应的场景) */ + forceRefresh(): void { + if (this.refreshTimer >= 0) { + clearTimeout(this.refreshTimer); + this.refreshTimer = -1; + } + this.msgs = this.msgs.slice(); + this.bump(); + } + + /** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */ + bump(): void { + const cur: number = AppStorage.get(K_CHAT_REV) ?? 0; + AppStorage.set(K_CHAT_REV, cur + 1); + } + + cancelRefresh(): void { + if (this.refreshTimer >= 0) { + clearTimeout(this.refreshTimer); + this.refreshTimer = -1; + } + } + + // ===================== 折叠开关 ===================== + + reasoningOpen(msgId: number): boolean { + const m: ChatMessage | undefined = this.findMessage(msgId); + return m !== undefined && m.reasoningOpen === true; + } + + setReasoningOpen(msgId: number, open: boolean): void { + const m: ChatMessage | undefined = this.findMessage(msgId); + if (m !== undefined) { + m.reasoningOpen = open; + } + } + + toolOpen(msgId: number, index: number): boolean { + const m: ChatMessage | undefined = this.findMessage(msgId); + if (m === undefined || m.toolCalls === undefined || index >= m.toolCalls.length) { + return false; + } + return m.toolCalls[index].open === true; + } + + setToolOpen(msgId: number, index: number, open: boolean): void { + const m: ChatMessage | undefined = this.findMessage(msgId); + if (m !== undefined && m.toolCalls !== undefined && index < m.toolCalls.length) { + m.toolCalls[index].open = open; + } + } + + // ===================== 历史 ===================== + + private parseHistory(obj: Record): ParsedHistory { + return parseHistoryPayload(obj, () => this.allocId()); + } + + async loadHistory(): Promise { + try { + // 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。 + const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000); + const obj: Record = JSON.parse(resp.body) as Record; + const parsed: ParsedHistory = this.parseHistory(obj); + if (parsed.msgs.length === 0) { + return; + } + this.msgs = parsed.msgs; + this.offset = parsed.offset; + this.hasEarlier = parsed.hasMore; + this.forceRefresh(); + this.requestScroll(); + } catch (e) { + // ignore history load failure + } + } + + /** + * 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。 + * 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。 + */ + async loadOlder(): Promise { + if (this.loadingOlder || !this.hasEarlier) { + return; + } + this.loadingOlder = true; + try { + const before: number = this.offset; + if (before <= 0) { + this.hasEarlier = false; + return; + } + const resp = await apiClient.getWithTimeout( + '/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000); + const obj: Record = JSON.parse(resp.body) as Record; + const parsed: ParsedHistory = this.parseHistory(obj); + if (parsed.msgs.length === 0) { + this.hasEarlier = false; + return; + } + // 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号 + this.msgs = parsed.msgs.concat(this.msgs); + this.offset = parsed.offset; + this.hasEarlier = parsed.hasMore; + this.bump(); + } catch (e) { + // 失败保留 hasEarlier,允许下次滚动重试 + } finally { + this.loadingOlder = false; + } + } + + /** sync_required:断线重连后服务端要求补拉历史 */ + reloadHistory(): void { + this.loadHistory(); + } + + // ===================== SSE 连接 ===================== + + connect(): void { + const cur = connStore.getCurrentConnection(); + if (cur === null) { + return; + } + this.sse.close(); + this.sse.connect(cur, '/chat/events', + (ev: SseEvent) => { + applyChatSse(ev, this); + }, + () => { + this.setConnected(false); + this.scheduleReconnect(); + }, + () => { + this.setConnected(true); + }).catch(() => { + this.setConnected(false); + this.scheduleReconnect(); + }); + } + + scheduleReconnect(): void { + if (this.reconnectTimer >= 0) { + return; + } + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = -1; + this.connect(); + }, SSE_RECONNECT_MS); + } + + cancelReconnect(): void { + if (this.reconnectTimer >= 0) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = -1; + } + } + + /** 页面消失:断线、停表,避免后台空转 */ + disconnect(): void { + this.cancelReconnect(); + this.sse.close(); + this.cancelRefresh(); + } +} + +export const chatStore: ChatStore = new ChatStore(); diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets new file mode 100644 index 0000000..635cd21 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets @@ -0,0 +1,139 @@ +/** + * 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。 + * + * 从 components/ChatComposer.ets 拆出 —— 两者都是"输入区上方的独立浮层", + * 数据与动作全部由输入区传入。 + */ + +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL, + ANIM_NORMAL } from '../common/Constants'; +import { MotionBase } from './MotionBase'; +import { formatBytes } from './Attachment'; + +/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */ +@Component +export struct ChatAttachMenu { + @StorageProp('themeIsDark') private isDark: boolean = true; + onPickImage?: () => void; + onPickFile?: () => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + @Builder + AttachOption(icon: Resource, label: string, tap: () => void) { + // 按压反馈交给 MotionBase:每个实例自带独立按压态, + // 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。 + MotionBase({ pressEnabled: true, fillWidth: false }) { + Row({ space: 6 }) { + Image(icon) + .width(15) + .height(15) + .fillColor(this.palette().textSecondary) + .draggable(false) + Text(label) + .fontSize(12) + .fontColor(this.palette().textPrimary) + } + .padding({ left: 12, right: 14, top: 8, bottom: 8 }) + .backgroundColor(this.palette().navBarBg) + .borderRadius(RADIUS_PILL) + .border({ width: 1, color: this.palette().navBarBorder }) + .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) + .onClick(tap) + } + } + + build() { + // 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐 + Row() { + Row({ space: 8 }) { + this.AttachOption($r('app.media.ic_image'), '图片', () => { + const cb: (() => void) | undefined = this.onPickImage; + if (cb !== undefined) { + cb(); + } + }) + this.AttachOption($r('app.media.ic_file'), '文件', () => { + const cb: (() => void) | undefined = this.onPickFile; + if (cb !== undefined) { + cb(); + } + }) + } + } + .width('100%') + .justifyContent(FlexAlign.Start) + .margin({ bottom: 8 }) + .hitTestBehavior(HitTestMode.Transparent) + // 加号菜单由 if 控制,进出场只能靠 transition;配合 toggle 处的 + // animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) + } +} + +/** 待发送附件预览条:缩略信息 + 一个移除按钮 */ +@Component +export struct ChatPendingChip { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop name: string = ''; + @Prop byteSize: number = 0; + @Prop isImage: boolean = false; + @Prop uploading: boolean = false; + onRemove?: () => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + // 按压反馈交给 MotionBase(全宽预览条) + MotionBase({ pressEnabled: true }) { + Row({ space: 8 }) { + Image(this.isImage ? $r('app.media.ic_image') : $r('app.media.ic_file')) + .width(15) + .height(15) + .fillColor(this.palette().accent) + .draggable(false) + Column({ space: 1 }) { + Text(this.name) + .fontSize(12) + .fontColor(this.palette().textPrimary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.uploading ? '上传中...' : formatBytes(this.byteSize)) + .fontSize(10) + .fontColor(this.palette().textMuted) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + if (this.uploading) { + LoadingProgress() + .width(14) + .height(14) + .color(this.palette().accent) + } else { + Image($r('app.media.ic_close')) + .width(13) + .height(13) + .fillColor(this.palette().textMuted) + .draggable(false) + .onClick(() => { + const cb: (() => void) | undefined = this.onRemove; + if (cb !== undefined) { + cb(); + } + }) + } + } + .width('100%') + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .margin({ bottom: 8 }) + .backgroundColor(this.palette().navBarBg) + .borderRadius(RADIUS_MD) + .border({ width: 1, color: this.palette().navBarBorder }) + .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) + } + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets new file mode 100644 index 0000000..05b07aa --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets @@ -0,0 +1,247 @@ +/** + * 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。 + * + * 从 pages/ChatPage.ets 抽出(原来是 Avatar / ChanAvatar / BubbleSlot / + * MessageBubble / BubbleBody 五个 @Builder)。 + * + * 传参约定:一切都在构造时快照进来。ForEach 的键(structSig)只在 + * 消息"结构"变化时改变(新增思考/工具/附件/来源、定稿),结构一变 + * 气泡就整条重建,因此结构类字段不需要二次更新;唯一会在键不变时 + * 持续变化的是正文 content,所以它单独用基本类型 @Prop 传(与 + * MarkdownView 的 @Prop content 走同一条响应式链路,流式渲染不变)。 + */ + +import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL, + ANIM_NORMAL } from '../common/Constants'; +import { bubbleMaxWidth, chanColor, chanLabel, chanLetter } from '../common/ChatFormat'; +import { AttachmentCard } from './Attachment'; +import { MarkdownView } from './MarkdownView'; +import { ChatReasoningCard, ChatToolCard } from './ChatToolCard'; + +@Component +export struct ChatBubble { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop msgId: number = 0; + @Prop role: string = ''; + /** 正文:唯一会在 ForEach 键不变时持续变化的字段 */ + @Prop content: string = ''; + @Prop isStreaming: boolean = false; + @Prop isFinal: boolean = false; + @Prop reasoningContent: string = ''; + @Prop reasoningOpen: boolean = false; + @Prop toolCalls: ToolCallInfo[] = []; + @Prop attachment: ChatAttachment | undefined = undefined; + @Prop source: string = ''; + /** 是否"别处来的"消息(渠道/设备) */ + @Prop channel: boolean = false; + /** 是否自己发的(右对齐、"我"头像) */ + @Prop mine: boolean = false; + /** 入场动画阶段 */ + @Prop fresh: boolean = false; + onOpenAttachment?: (att: ChatAttachment) => void; + onToggleReasoning?: (msgId: number) => void; + onToggleTool?: (msgId: number, index: number) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + /** 思考卡/工具卡是否是"面板":气泡宽度要放宽,见 common/ChatFormat */ + private maxWidth(): string { + const msg: ChatMessage = { + id: this.msgId, + role: this.role, + content: this.content, + toolCalls: this.toolCalls, + }; + if (this.reasoningContent.length > 0) { + msg.reasoningContent = this.reasoningContent; + } + return bubbleMaxWidth(msg); + } + + @Builder + Avatar() { + Text(this.role === 'user' ? '我' : 'AI') + .fontSize(11) + .fontWeight(FontWeight.Bold) + .fontColor(this.role === 'user' ? this.palette().msgUserText : this.palette().accent) + .textAlign(TextAlign.Center) + .width(28) + .height(28) + .borderRadius(RADIUS_PILL) + .backgroundColor(this.role === 'user' ? this.palette().msgUserBg : this.palette().accentBg) + .margin({ top: 2 }) + } + + @Builder + ChanAvatar() { + Text(chanLetter(this.source)) + .fontSize(12) + .fontWeight(FontWeight.Bold) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .width(28) + .height(28) + .borderRadius(RADIUS_PILL) + .backgroundColor(chanColor(this.source)) + .margin({ top: 2 }) + } + + /** + * 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。 + * + * 原来 BubbleBody 直接放进 Row,它的 constraintSize maxWidth 是百分比 + * (78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带 + * 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding, + * 于是 92% 算出来的宽度里包含了这些 padding,再加上 28 的头像和 8 的 + * 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来, + * 分栏后左栏只有 420vp,溢出的十几 vp 直接被栏宽裁掉 —— 表现为 + * 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp + * 被 clip 的问题是同一个成因)。 + * + * 修法同 MarkdownView:用 layoutWeight(1) 拿"剩余空间"而不是百分比。 + * 槽自身无 padding,外框宽 == 内容宽 == 头像与间距之外的真实可用宽度, + * 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。 + */ + @Builder + BubbleSlot() { + Column() { + this.BubbleBody() + } + .layoutWeight(1) + .alignItems(this.mine ? HorizontalAlign.End : HorizontalAlign.Start) + } + + @Builder + BubbleBody() { + Column() { + // 渠道来源名(对齐 GUI 的 msg-chan-name):只有别处来的消息才显示 + if (this.channel) { + Text(chanLabel(this.source)) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textMuted) + .margin({ bottom: 4 }) + } + + // Reasoning card (assistant only) + if (this.role === 'assistant' && this.reasoningContent.length > 0) { + ChatReasoningCard({ + content: this.reasoningContent, + open: this.reasoningOpen, + sweeping: this.isFinal !== true && this.isStreaming === true, + fresh: this.fresh, + onToggle: () => { + const cb: ((id: number) => void) | undefined = this.onToggleReasoning; + if (cb !== undefined) { + cb(this.msgId); + } + }, + }) + } + + // 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页 + if (this.attachment !== undefined) { + AttachmentCard({ + att: this.attachment, + mine: this.role === 'user', + onTap: () => { + const a: ChatAttachment | undefined = this.attachment; + const cb: ((att: ChatAttachment) => void) | undefined = this.onOpenAttachment; + if (a !== undefined && cb !== undefined) { + cb(a); + } + }, + }) + } + + // Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn + if (this.content.length > 0) { + if (this.role === 'assistant') { + MarkdownView({ + content: this.content, + isStreaming: this.isStreaming === true, + isDark: this.isDark, + }) + } else { + Text(this.content) + .fontSize(15) + .lineHeight(24) + .fontColor(this.palette().msgBubbleText) + .textAlign(TextAlign.Start) + .wordBreak(WordBreak.BREAK_ALL) + .constraintSize({ maxWidth: '100%' }) + .margin({ top: this.attachment !== undefined ? 8 : 0 }) + } + } + + // Tool cards + if (this.toolCalls.length > 0) { + Column() { + ForEach(this.toolCalls, (tc: ToolCallInfo, tci: number) => { + ChatToolCard({ + tc: tc, + index: tci, + onToggle: (index: number) => { + const cb: ((id: number, i: number) => void) | undefined = this.onToggleTool; + if (cb !== undefined) { + cb(this.msgId, index); + } + }, + }) + }, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name) + } + // 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。 + // 让它自适应,最大宽约束由气泡内容框向下传递,ChatToolCard 内部用 layoutWeight 取满。 + .alignItems(HorizontalAlign.Start) + .margin({ top: this.content.length > 0 ? 6 : 0 }) + } + } + .constraintSize({ maxWidth: this.maxWidth() }) + .clip(true) + .padding({ left: 12, right: 12, top: 9, bottom: 9 }) + .backgroundColor(this.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg) + .borderRadius({ + topLeft: RADIUS_MD, + topRight: RADIUS_MD, + bottomLeft: this.role === 'assistant' ? 4 : RADIUS_MD, + bottomRight: this.role === 'user' ? 4 : RADIUS_MD, + }) + .border({ width: 1, color: this.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder }) + .shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 }) + .alignItems(HorizontalAlign.Start) + // bubbleGrow: 气泡入场缩放效果 + .scale({ + x: this.fresh ? 0.95 : 1, + y: this.fresh ? 0.95 : 1, + }) + .animation({ duration: 200, curve: Curve.EaseOut }) + } + + build() { + Row({ space: 8 }) { + if (this.channel) { + this.ChanAvatar() + this.BubbleSlot() + } else if (this.role === 'user') { + this.BubbleSlot() + this.Avatar() + } else { + this.Avatar() + this.BubbleSlot() + } + } + .width('100%') + .alignItems(VerticalAlign.Top) + // 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配, + // 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。 + .justifyContent(this.mine ? FlexAlign.End : FlexAlign.Start) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + // 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0) + .opacity(this.fresh ? 0 : 1) + .translate({ y: this.fresh ? 8 : 0 }) + .animation({ duration: 180, curve: Curve.EaseOut }) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets new file mode 100644 index 0000000..d8145b4 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets @@ -0,0 +1,361 @@ +/** + * 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。 + * + * 从 pages/ChatPage.ets 抽出(原来是 ChatBody 的层2 + AttachMenu / + * AttachOption / PendingAttachmentChip 三个 @Builder,外加选图/选文件、 + * 沙箱落盘、带附件发送这一整套方法)。 + * + * 输入区自己持有文本与附件状态;只有会影响【列表底部留白】的三项 + * (inputMultiLine / attachMenuOpen / pendingName)用 @Link 与页面共享。 + */ + +import { ChatMessage } from '../model/Model'; +import { userMessage } from '../common/UserError'; +import { connStore } from '../common/ConnStore'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } from '../common/Constants'; +import { mimeOf } from '../common/ChatFormat'; +import { chatStore, K_CHAT_LOADING } from '../common/ChatStore'; +import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession'; +import { fileNameOf } from './Attachment'; +import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar'; +import { NavFloatOverlay, FloatIconButton } from './PageTopBar'; +import { picker, fileIo } from '@kit.CoreFileKit'; +import { common } from '@kit.AbilityKit'; +import { MeasureOptions } from '@ohos.measure'; + +/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */ +const INPUT_FONT_SIZE: number = 14; +const INPUT_INNER_PAD: number = 16; +/** 单行态左右让位:左边加号 42+8,右边发送键 44+8 */ +const INPUT_LEFT_GAP: number = 50; +const INPUT_RIGHT_GAP: number = 52; + +@Component +export struct ChatComposer { + @StorageProp('themeIsDark') private isDark: boolean = true; + @StorageProp(K_CHAT_LOADING) private loading: boolean = false; + /** 输入框是否已进入多行态(页面用它算列表底部留白) */ + @Link inputMultiLine: boolean; + /** 加号菜单是否展开(同上) */ + @Link attachMenuOpen: boolean; + /** 待发送附件的展示名(同上) */ + @Link pendingName: string; + @State inputText: string = ''; + @State pendingSize: number = 0; + @State pendingIsImage: boolean = false; + @State uploading: boolean = false; + private pendingPath: string = ''; + private pendingMime: string = ''; + /** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */ + private inputRowWidth: number = 0; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + /** 输入框底:高不透明度 + blur,保证背景内容不会透过输入文字 */ + private inputSolidBg(): string { + return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)'; + } + + /** + * 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。 + * + * 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。 + * 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关, + * 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。 + */ + private recomputeMultiLine(text: string): void { + const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP + - INPUT_INNER_PAD * 2; + if (avail <= 0) { + return; + } + let multi: boolean = text.indexOf('\n') >= 0; + if (!multi && text.length > 0) { + const opt: MeasureOptions = { + textContent: text, + fontSize: INPUT_FONT_SIZE, + }; + const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt); + // measureTextSize 返回 px,可用宽度是 vp,换算后再比 + const widthVp: number = this.getUIContext().px2vp(size.width as number); + multi = widthVp > avail; + } + if (multi !== this.inputMultiLine) { + this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => { + this.inputMultiLine = multi; + }); + } + } + + // ===================== 附件:选择与上传 ===================== + + /** 从图库挑一张图 */ + private async pickImage(): Promise { + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.attachMenuOpen = false; + }); + try { + const options = new picker.PhotoSelectOptions(); + options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + options.maxSelectNumber = 1; + const photoPicker = new picker.PhotoViewPicker(); + const result = await photoPicker.select(options); + if (result.photoUris.length === 0) { + return; + } + this.stagePickedFile(result.photoUris[0], true); + } catch (e) { + chatStore.setStage(userMessage('chat.pickImage', e)); + chatStore.forceRefresh(); + } + } + + /** 从文件管理器挑一个文件 */ + private async pickFile(): Promise { + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.attachMenuOpen = false; + }); + try { + const options = new picker.DocumentSelectOptions(); + options.maxSelectNumber = 1; + const docPicker = new picker.DocumentViewPicker(); + const uris: string[] = await docPicker.select(options); + if (uris.length === 0) { + return; + } + this.stagePickedFile(uris[0], false); + } catch (e) { + chatStore.setStage(userMessage('chat.pickFile', e)); + chatStore.forceRefresh(); + } + } + + /** + * 把 picker 给的 URI 复制到应用沙箱。 + * http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径, + * 直接把 picker 的 media:// URI 交过去会读不到内容。 + */ + private stagePickedFile(srcUri: string, isImage: boolean): void { + try { + const ctx = getContext(this) as common.UIAbilityContext; + const name: string = fileNameOf(srcUri); + const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name; + const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY); + const destFile = fileIo.openSync(destPath, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC); + fileIo.copyFileSync(srcFile.fd, destFile.fd); + fileIo.closeSync(srcFile); + fileIo.closeSync(destFile); + const stat = fileIo.statSync(destPath); + this.clearPendingFile(); + this.pendingPath = destPath; + this.pendingName = name; + this.pendingSize = stat.size; + this.pendingIsImage = isImage; + this.pendingMime = mimeOf(name, isImage); + } catch (e) { + chatStore.setStage(userMessage('chat.stageFile', e)); + } + chatStore.forceRefresh(); + } + + /** 丢弃待发送附件,并删掉沙箱里的临时副本 */ + private clearPendingFile(): void { + if (this.pendingPath.length > 0) { + try { + fileIo.unlinkSync(this.pendingPath); + } catch (e) { + // 已不存在,忽略 + } + } + this.pendingPath = ''; + this.pendingName = ''; + this.pendingSize = 0; + this.pendingIsImage = false; + this.pendingMime = ''; + } + + /** 发送:有附件走 multipart(POST /chat/file),否则走 POST /chat */ + private async send(): Promise { + if (this.loading || this.uploading) { + return; + } + // 没连后端时直接返回、且不清输入/不动附件:与拆分前 sendChat 的守卫顺序一致 + if (connStore.getCurrentConnection() === null) { + return; + } + const text: string = this.inputText.trim(); + if (this.pendingPath.length > 0) { + const path: string = this.pendingPath; + const name: string = this.pendingName; + const size: number = this.pendingSize; + const isImage: boolean = this.pendingIsImage; + const mime: string = this.pendingMime; + this.inputText = ''; + this.inputMultiLine = false; + this.uploading = true; + await sendChatFile(text, path, name, size, isImage, mime); + this.uploading = false; + this.clearPendingFile(); + chatStore.forceRefresh(); + chatStore.requestScroll(); + return; + } + if (text.length === 0) { + return; + } + this.inputText = ''; + this.inputMultiLine = false; + await sendChatText(text); + } + + // ===================== UI ===================== + + build() { + NavFloatOverlay({ tab: 0 }) { + // 加号展开的两个选项(图片 / 文件),点一次收起 + if (this.attachMenuOpen) { + ChatAttachMenu({ + onPickImage: () => { + this.pickImage(); + }, + onPickFile: () => { + this.pickFile(); + }, + }) + } + + // 待发送附件预览(选好图片/文件、还没点发送时显示) + if (this.pendingName.length > 0) { + ChatPendingChip({ + name: this.pendingName, + byteSize: this.pendingSize, + isImage: this.pendingIsImage, + uploading: this.uploading, + onRemove: () => { + this.clearPendingFile(); + chatStore.forceRefresh(); + }, + }) + } + + // Stack 而不是 Column:加号与发送按钮钉死在底部这一行不动, + // 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。 + Stack({ alignContent: Alignment.Bottom }) { + // 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮 + Row() { + FloatIconButton({ + icon: $r('app.media.ic_plus'), + onTap: () => { + // 菜单展开会同时改变列表底部留白,用 animateTo 把 + // 列表内边距和菜单进出场拉到同一个时钟上。 + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.attachMenuOpen = !this.attachMenuOpen; + }); + }, + }) + Blank() + if (this.loading) { + Button() { + Image($r('app.media.ic_stop')) + .width(16) + .height(16) + .fillColor(Color.White) + } + .width(44) + .height(44) + .type(ButtonType.Circle) + .backgroundColor('#77809A') + // 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) + .onClick(() => { + interruptChat(); + }) + } else { + Button() { + Image($r('app.media.ic_send')) + .width(18) + .height(18) + .fillColor(Color.White) + } + .width(44) + .height(44) + .type(ButtonType.Circle) + .backgroundColor(this.palette().accent) + .enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0) + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) + .onClick(() => { + this.send(); + }) + } + } + .width('100%') + .height(44) + .alignItems(VerticalAlign.Center) + .onAreaChange((_o: Area, n: Area) => { + // 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。 + const w: number = n.width as number; + if (Math.abs(w - this.inputRowWidth) > 0.5) { + this.inputRowWidth = w; + this.recomputeMultiLine(this.inputText); + } + }) + + // 上层:输入框。 + // 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行; + // 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。 + // + // 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成 + // 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。 + // 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行, + // 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。 + Row() { + TextArea({ + placeholder: '输入消息...', + text: this.inputText, + }) + .layoutWeight(1) + // 不写死高度:单行 44,随文字换行自动增高,最多约 5 行后内部滚动 + .constraintSize({ minHeight: 44, maxHeight: 168 }) + .fontSize(INPUT_FONT_SIZE) + .fontColor(this.palette().textPrimary) + .placeholderFont({ size: 13 }) + .placeholderColor(this.palette().textMuted) + .backgroundColor(this.inputSolidBg()) + .backdropBlur(24) + .borderRadius(22) + .border({ width: 1, color: this.palette().glassBorder }) + .padding({ + left: INPUT_INNER_PAD, + right: INPUT_INNER_PAD, + top: 11, + bottom: 11, + }) + .enterKeyType(EnterKeyType.Send) + .onChange((value: string) => { + this.inputText = value; + this.recomputeMultiLine(value); + }) + .onSubmit(() => { + this.send(); + }) + } + .width('100%') + // 多行时必须显式写 0:给 .padding() 传 undefined 在增量更新时会被当作 + // "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。 + .padding(this.inputMultiLine + ? { left: 0, right: 0 } + : { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP }) + .margin({ bottom: this.inputMultiLine ? 52 : 0 }) + // 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。 + // 不设 None 的话 padding 区域仍属于 Row,会把点击吞掉 —— 发送键点不动。 + // None = 自身不响应、子节点(TextArea)照常响应,触摸落到下层那一行。 + .hitTestBehavior(HitTestMode.None) + .animation({ duration: 260, curve: Curve.Friction }) + } + .width('100%') + } + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets new file mode 100644 index 0000000..97b596f --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets @@ -0,0 +1,210 @@ +/** + * 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。 + * + * 从 pages/ChatPage.ets 抽出(原来是 ChatBody 里除悬浮输入区之外的三层)。 + * 消息数组来自 common/ChatStore.ets:用版本号 K_CHAT_REV 订阅, + * 版本变化时重取一次快照(数组引用每次都是新的,ForEach 的渲染语义 + * 与拆分前 this.messages = this.messages.slice() 完全一致)。 + */ + +import { ChatMessage } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants'; +import { navBar } from '../common/NavBarController'; +import { ChatAttachment } from '../model/Model'; +import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore'; +import { isChannelMsg, isSelfMsg, structSig } from '../common/ChatFormat'; +import { connStore } from '../common/ConnStore'; +import { ChatBubble } from './ChatBubble'; +import { PageTopBar } from './PageTopBar'; + +@Component +export struct ChatStream { + @StorageProp('themeIsDark') private isDark: boolean = true; + @StorageProp(K_CHAT_LOADING) private loading: boolean = false; + @StorageProp(K_CHAT_STAGE) private stage: string = ''; + /** 数组快照的订阅信号 */ + @StorageProp(K_CHAT_REV) @Watch('onChatRev') private rev: number = 0; + /** "滚到底"请求信号 */ + @StorageProp(K_CHAT_SCROLL_REV) @Watch('onScrollReq') private scrollRev: number = 0; + @State messages: ChatMessage[] = []; + /** 列表底部留白:随输入区展开/加号菜单/待发送附件变化 */ + @Prop bottomPad: number = 210; + onOpenAttachment?: (att: ChatAttachment) => void; + + private scroller: Scroller = new Scroller(); + private autoScrolling: boolean = false; + private navHidden: boolean = false; + + aboutToAppear(): void { + this.messages = chatStore.messages(); + } + + private onChatRev(): void { + this.messages = chatStore.messages(); + } + + private onScrollReq(): void { + this.scrollToBottom(); + } + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + /** + * 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。 + * bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar + * 顶部淡出用的是同一手法,保证上下两端的融入观感一致。 + */ + private opaqueBottomBg(): string { + return '#FF' + this.palette().bgPrimary.substring(1); + } + + private transparentBottomBg(): string { + return '#00' + this.palette().bgPrimary.substring(1); + } + + private scrollToBottom(): void { + this.autoScrolling = true; + setTimeout(() => { + this.scroller.scrollEdge(Edge.Bottom); + }, 50); + setTimeout(() => { + this.autoScrolling = false; + this.navHidden = false; + navBar.setVisible(true); + }, 450); + } + + /** + * 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar, + * 不在回调里做任何耗时操作。navBar.setVisible 内部已去重, + * 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换, + * 不会引起布局回流——这是滑动流畅的关键。 + */ + private handleScrollDirection(yOffset: number, state: ScrollState): void { + if (this.autoScrolling) { + return; + } + // 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页 + if (yOffset < 60 && chatStore.hasMore()) { + chatStore.loadOlder(); + } + if (state === ScrollState.Idle) { + if (this.navHidden) { + this.navHidden = false; + navBar.setVisible(true); + } + } else { + // Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏 + if (!this.navHidden) { + this.navHidden = true; + navBar.setVisible(false); + } + } + } + + build() { + Stack({ alignContent: Alignment.Bottom }) { + // 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出) + Column() { + Scroll(this.scroller) { + Column() { + ForEach(this.messages, (msg: ChatMessage, idx: number) => { + ChatBubble({ + msgId: msg.id, + role: msg.role, + content: msg.content, + isStreaming: msg.isStreaming === true, + isFinal: msg.isFinal === true, + reasoningContent: msg.reasoningContent ?? '', + reasoningOpen: msg.reasoningOpen === true, + toolCalls: msg.toolCalls ?? [], + attachment: msg.attachment, + source: msg.source ?? '', + channel: isChannelMsg(msg.source ?? '', connStore.ensureDeviceId()), + mine: isSelfMsg(msg.role, isChannelMsg(msg.source ?? '', connStore.ensureDeviceId())), + fresh: chatStore.isFresh(msg.id), + onOpenAttachment: (att: ChatAttachment) => { + const cb: ((a: ChatAttachment) => void) | undefined = this.onOpenAttachment; + if (cb !== undefined) { + cb(att); + } + }, + onToggleReasoning: (id: number) => { + // 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation, + // 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。 + this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => { + chatStore.setReasoningOpen(id, !chatStore.reasoningOpen(id)); + chatStore.forceRefresh(); + }); + }, + onToggleTool: (id: number, index: number) => { + this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => { + chatStore.setToolOpen(id, index, !chatStore.toolOpen(id, index)); + chatStore.forceRefresh(); + }); + }, + }) + }, (msg: ChatMessage, idx: number) => structSig(msg)) + + if (this.loading) { + Row({ space: 8 }) { + LoadingProgress() + .width(16) + .height(16) + .color(this.palette().accent) + Text(this.stage.length > 0 ? this.stage : '处理中...') + .fontSize(12) + .fontColor(this.palette().textMuted) + } + .width('100%') + .justifyContent(FlexAlign.Start) + .padding({ left: 52, top: 6, bottom: 6 }) + // if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新); + // 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。 + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) + } + } + .width('100%') + .padding({ + left: 14, + right: 14, + top: 76, + bottom: this.bottomPad, + }) + } + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .align(Alignment.Top) + .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => { + this.handleScrollDirection(yOffset, state); + }) + } + .width('100%') + .height('100%') + + // 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透) + PageTopBar({ title: '聊天' }) + + // 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景, + // 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。 + Column() + .width('100%') + .height(170) + .linearGradient({ + direction: GradientDirection.Bottom, + colors: [ + [this.transparentBottomBg(), 0.0], + [this.opaqueBottomBg(), 0.6], + [this.opaqueBottomBg(), 1.0], + ], + }) + .hitTestBehavior(HitTestMode.None) + } + .width('100%') + .height('100%') + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatToolCard.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatToolCard.ets new file mode 100644 index 0000000..dbaeb25 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatToolCard.ets @@ -0,0 +1,253 @@ +/** + * 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。 + * + * 从 pages/ChatPage.ets 抽出(原来分别是 ReasoningCard / ToolCard 两个 @Builder)。 + * 折叠状态与开关动作都交回调用方(状态在 chatStore 里,且展开/收起要在 + * animateTo 帧内完成 —— 那需要组件上下文)。 + */ + +import { ToolCallInfo } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM, + ANIM_NORMAL } from '../common/Constants'; +import { tcRunning, tcError, tcLeftColor, tcIcoColor, tcStateLabel, tcStateColor } from '../common/ChatFormat'; + +@Component +export struct ChatReasoningCard { + @StorageProp('themeIsDark') private isDark: boolean = true; + /** 思考正文(已折叠时也带着,展开不再请求) */ + @Prop content: string = ''; + @Prop open: boolean = false; + /** 流式中:显示转圈 + 扫光条 */ + @Prop sweeping: boolean = false; + /** 入场动画阶段:扫光条起始偏移靠它切换 */ + @Prop fresh: boolean = false; + onToggle?: () => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + // 两层结构,原因见 ChatBubble 的布局说明: + // 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度, + // 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满; + // 内层 holder Column 自身无 padding,所以它的子节点写 width('100%') + // 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。 + Row() { + Column() { + Row({ space: 7 }) { + Image($r('app.media.ic_sparkle')) + .width(12) + .height(12) + .fillColor(this.palette().accent) + Text('思考过程') + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textPrimary) + // 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡 + if (this.sweeping) { + LoadingProgress() + .width(11) + .height(11) + .color(this.palette().accent) + } + Blank() + Text(this.content.length > 0 ? this.content.length.toString() + ' 字' : '') + .fontSize(9.5) + .fontColor(this.palette().textMuted) + Image($r('app.media.ic_chevron_down')) + .width(14) + .height(14) + .fillColor(this.palette().textMuted) + .rotate({ angle: this.open ? 180 : 0 }) + .animation({ duration: 200, curve: Curve.EaseOut }) + } + .width('100%') + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .onClick(() => { + const cb: (() => void) | undefined = this.onToggle; + if (cb !== undefined) { + cb(); + } + }) + + if (this.open) { + Text(this.content) + .fontSize(11.5) + .lineHeight(17) + .fontColor(this.palette().textTertiary) + .width('100%') + .padding({ left: 10, right: 10, bottom: 8 }) + .maxLines(24) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .wordBreak(WordBreak.BREAK_ALL) + // 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) + } + + // 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep) + if (this.sweeping) { + Stack() { + Row() + .height(2) + .borderRadius(2) + .width('200%') + .linearGradient({ + angle: 90, + colors: [ + ['rgba(255,255,255,0.01)', 0], + [this.palette().accent, 0.35], + ['rgba(255,255,255,0.01)', 0.5], + [this.palette().accent, 0.65], + ['rgba(255,255,255,0.01)', 1], + ], + }) + .opacity(0.7) + .translate({ x: this.fresh ? '0%' : '-50%' }) + .animation({ duration: 1200, curve: Curve.Linear }) + } + .width('100%') + .clip(true) + .height(2) + .margin({ top: 6 }) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .alignItems(VerticalAlign.Top) + .margin({ bottom: 6 }) + .borderRadius(RADIUS_SM) + .backgroundColor(this.palette().bgHover) + .border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed }) + } +} + +@Component +export struct ChatToolCard { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop tc: ToolCallInfo; + /** 在所属消息 toolCalls 里的下标:开关动作要交回调用方按 (msgId, index) 定位 */ + @Prop index: number = 0; + onToggle?: (index: number) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + // 同 ChatReasoningCard:外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽 + Row() { + Column() { + Row({ space: 6 }) { + if (tcError(this.tc)) { + Image($r('app.media.ic_error')) + .width(13).height(13) + .fillColor(tcIcoColor(this.tc, this.palette().accent)) + } else if (tcRunning(this.tc)) { + LoadingProgress() + .width(12).height(12) + .color(tcIcoColor(this.tc, this.palette().accent)) + } else { + Image($r('app.media.ic_check')) + .width(13).height(13) + .fillColor(tcIcoColor(this.tc, this.palette().accent)) + } + Text(this.tc.name) + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textPrimary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + // 名字可长可短,必须让它占剩余宽度并可收缩, + // 否则右侧状态文字会被挤出气泡(78% 宽度 + clip 直接切掉) + .layoutWeight(1) + if (this.tc.plugin !== undefined && this.tc.plugin.length > 0) { + Text(this.tc.plugin) + .fontSize(9.5) + .fontColor(this.palette().textMuted) + .maxLines(1) + .flexShrink(0) + } + // 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标") + Text(tcStateLabel(this.tc)) + .fontSize(9.5) + .fontWeight(FontWeight.Medium) + .fontColor(tcStateColor(this.tc)) + .flexShrink(0) + Image($r('app.media.ic_chevron_down')) + .width(13).height(13) + .fillColor(this.palette().textMuted) + .flexShrink(0) + .rotate({ angle: this.tc.open === true ? 180 : 0 }) + .animation({ duration: 150, curve: Curve.EaseOut }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + .onClick(() => { + const cb: ((index: number) => void) | undefined = this.onToggle; + if (cb !== undefined) { + cb(this.index); + } + }) + + if (this.tc.open === true) { + Column() { + if (this.tc.args.length > 0 && this.tc.args !== '{}') { + Text('参数') + .fontSize(9.5).fontWeight(FontWeight.Medium) + .fontColor(this.palette().textMuted) + .margin({ top: 6, bottom: 2 }) + Text(this.tc.args) + .fontSize(11) + .fontColor(this.palette().preText) + .backgroundColor(this.palette().preBg) + .borderRadius(4) + .padding({ left: 7, right: 7, top: 5, bottom: 5 }) + .width('100%') + .textAlign(TextAlign.Start) + .wordBreak(WordBreak.BREAK_ALL) + } + if (this.tc.result !== undefined && this.tc.result.length > 0) { + Text('结果') + .fontSize(9.5).fontWeight(FontWeight.Medium) + .fontColor(this.palette().textMuted) + .margin({ top: 6, bottom: 2 }) + Text(this.tc.result) + .fontSize(11) + .fontColor(this.palette().preText) + .backgroundColor(this.palette().preBg) + .borderRadius(4) + .padding({ left: 7, right: 7, top: 5, bottom: 5 }) + .width('100%') + .textAlign(TextAlign.Start) + .wordBreak(WordBreak.BREAK_ALL) + .maxLines(8) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + // 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .alignItems(VerticalAlign.Top) + .padding({ left: 10, right: 10, top: 7, bottom: 7 }) + .margin({ bottom: 4 }) + .borderRadius(RADIUS_SM) + .backgroundColor(this.palette().bgHover) + .border({ + width: { left: 3, top: 1, right: 1, bottom: 1 }, + color: { + left: tcLeftColor(this.tc, this.palette().accent), + top: this.palette().kvBorder, + right: this.palette().kvBorder, + bottom: this.palette().kvBorder, + }, + }) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets index 9a8547a..c37b844 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets @@ -1,142 +1,43 @@ -import { apiClient, ApiError } from '../common/ApiClient'; -import { userMessage, isTimeout } from '../common/UserError'; -import { SseClient, SseEvent } from '../common/SseClient'; import { connStore } from '../common/ConnStore'; -import { navBar } from '../common/NavBarController'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; -import { ChatMessage, ToolCallInfo, HistoryMessage, ChatAttachment } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; -import { RADIUS_MD, RADIUS_PILL, RADIUS_SM } from '../common/Constants'; -import { WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; -import { CHAT_PAGE_SIZE } from '../common/Constants'; -import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; -import { MotionBase } from '../components/MotionBase'; -import { MarkdownView } from '../components/MarkdownView'; -import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; +import { ChatAttachment } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; +import { ChatStream } from '../components/ChatStream'; +import { ChatComposer } from '../components/ChatComposer'; import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage'; -import { - AttachmentCard, AttachmentDetailContent, parseAttachment, attachmentFromChannelOutput, - fileNameOf, formatBytes, -} from '../components/Attachment'; -import { http } from '@kit.NetworkKit'; -import { hilog } from '@kit.PerformanceAnalysisKit'; -import { picker, fileIo } from '@kit.CoreFileKit'; -import { common } from '@kit.AbilityKit'; -import { MeasureOptions } from '@ohos.measure'; - -const SSE_RECONNECT_MS: number = 5000; - -/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */ -const INPUT_FONT_SIZE: number = 14; -const INPUT_INNER_PAD: number = 16; -/** 单行态左右让位:左边加号 42+8,右边发送键 44+8 */ -const INPUT_LEFT_GAP: number = 50; -const INPUT_RIGHT_GAP: number = 52; +import { AttachmentDetailContent } from '../components/Attachment'; +import { chatStore } from '../common/ChatStore'; /** 二级页面标识:附件(图片/文件)详情 */ const SUB_NONE: string = ''; const SUB_ATTACHMENT: string = 'attachment'; -interface SendChatBody { - message: string; - client_msg_id: string; - /** 非空时后端编码 source = "webui/",agent 可见来源设备 */ - device_id?: string; - device_name?: string; -} - -/** 分页历史解析结果:消息列表 + 服务端分页元数据 */ -interface ParsedHistory { - msgs: ChatMessage[]; - /** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */ - offset: number; - /** 服务端是否还有更早的历史 */ - hasMore: boolean; -} - /** - * 由文件名后缀推断 Content-Type。 - * 后端按 multipart 部件的 Content-Type 判定 image/file, - * 给错会让图片被当成普通文件(缩略图就没了)。 + * 聊天页:只做"页面壳"。 + * + * 拆分后的分工(拆分前这里是 1962 行的单文件): + * - 消息流状态机(SSE / 历史 / 新消息动画)→ common/ChatStore.ets + * - SSE 事件翻译 → common/ChatSse.ets + * - 发送与中断 → common/ChatSession.ets + * - 消息列表 + 滚动 → components/ChatStream.ets + * - 气泡(含思考卡/工具卡) → components/ChatBubble.ets + * - 悬浮输入区 + 附件选择/上传 → components/ChatComposer.ets + * 本文件保留:导航栈与附件详情二级页、以及"列表底部留白"这个 + * 输入区与列表之间的耦合点(所以三个相关标志位用 @Link 与输入区共享)。 */ -function mimeOf(name: string, isImage: boolean): string { - const i: number = name.lastIndexOf('.'); - const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : ''; - if (ext === 'png') { - return 'image/png'; - } - if (ext === 'jpg' || ext === 'jpeg') { - return 'image/jpeg'; - } - if (ext === 'webp') { - return 'image/webp'; - } - if (ext === 'gif') { - return 'image/gif'; - } - if (ext === 'bmp') { - return 'image/bmp'; - } - if (ext === 'heic' || ext === 'heif') { - return 'image/heic'; - } - if (isImage) { - return 'image/jpeg'; - } - if (ext === 'pdf') { - return 'application/pdf'; - } - if (ext === 'txt' || ext === 'log' || ext === 'md') { - return 'text/plain'; - } - if (ext === 'json') { - return 'application/json'; - } - return 'application/octet-stream'; -} - @Component export struct ChatPage { @StorageProp('themeIsDark') private isDark: boolean = true; - @StorageProp('currentTab') private currentTab: number = 0; /** 宽屏:左边聊天流(含底部导航栏),右边附件详情 */ @StorageProp('isWideScreen') private isWide: boolean = false; - @State messages: ChatMessage[] = []; - @State inputText: string = ''; - @State chatLoading: boolean = false; - @State chatStage: string = ''; - @State connected: boolean = false; - /** 分页历史:当前已加载消息在服务端全量中的起始下标 */ - private chatOffset: number = 0; - /** 是否还有更早历史可向上加载 */ - private chatHasMore: boolean = false; - @StorageProp('navVisible') private navVisible: boolean = true; - @State newMsgIds: number[] = []; - @State inputMultiLine: boolean = false; /** 当前在右栏/二级页展示的附件;未打开时为 undefined */ @State activeAtt: ChatAttachment | undefined = undefined; @State activeSub: string = SUB_NONE; - /** 加号菜单是否展开 */ + /** 以下三项影响列表底部留白,与输入区共享(输入区负责改) */ + @State inputMultiLine: boolean = false; @State attachMenuOpen: boolean = false; - /** 待发送附件:沙箱内的本地副本路径 + 展示名 + 字节数 + 类型 */ @State pendingName: string = ''; - @State pendingSize: number = 0; - @State pendingIsImage: boolean = false; - @State uploading: boolean = false; - private pendingPath: string = ''; - private pendingMime: string = ''; - /** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */ - private inputRowWidth: number = 0; private navStack: NavPathStack = new NavPathStack(); - private sse: SseClient = new SseClient(); - private scroller: Scroller = new Scroller(); - private reconnectTimer: number = -1; - private refreshTimer: number = -1; - private autoScrolling: boolean = false; - private navHidden: boolean = false; - private nextMsgId: number = 1; - // SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息 - private sseActiveForTurn: boolean = false; aboutToAppear(): void { // 把本页导航栈登记给 Index:返回手势/三键返回按当前 Tab 精确派发到这里 @@ -144,504 +45,21 @@ export struct ChatPage { this.activeSub = SUB_NONE; this.activeAtt = undefined; }); + chatStore.init(); const cur = connStore.getCurrentConnection(); if (cur !== null) { - this.loadHistory(); - this.connectSSE(); + chatStore.loadHistory(); + chatStore.connect(); } } aboutToDisappear(): void { unregisterNavStack(0); - this.cancelReconnect(); - this.sse.close(); - if (this.refreshTimer >= 0) { - clearTimeout(this.refreshTimer); - this.refreshTimer = -1; - } + chatStore.disconnect(); } - private allocId(): number { - return this.nextMsgId++; - } - - private cancelReconnect(): void { - if (this.reconnectTimer >= 0) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = -1; - } - } - - private connectSSE(): void { - const cur = connStore.getCurrentConnection(); - if (cur === null) { - return; - } - this.sse.close(); - this.sse.connect(cur, '/chat/events', - (ev: SseEvent) => { - this.handleSSE(ev); - }, - () => { - this.connected = false; - this.scheduleReconnect(); - }, - () => { - this.connected = true; - }).catch(() => { - this.connected = false; - this.scheduleReconnect(); - }); - } - - private scheduleReconnect(): void { - if (this.reconnectTimer >= 0) { - return; - } - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = -1; - this.connectSSE(); - }, SSE_RECONNECT_MS); - } - - private async loadHistory(): Promise { - try { - // 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。 - const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000); - const obj: Record = JSON.parse(resp.body) as Record; - const parsed: ParsedHistory = this.parseHistoryPayload(obj); - if (parsed.msgs.length === 0) { - return; - } - this.messages = parsed.msgs; - this.chatOffset = parsed.offset; - this.chatHasMore = parsed.hasMore; - this.scrollToBottom(); - } catch (e) { - // ignore history load failure - } - } - - /** - * 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。 - * 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。 - */ - private _loadingOlder: boolean = false; - private async loadOlderChat(): Promise { - if (this._loadingOlder || !this.chatHasMore) { - return; - } - this._loadingOlder = true; - try { - const before: number = this.chatOffset; - if (before <= 0) { - this.chatHasMore = false; - return; - } - const resp = await apiClient.getWithTimeout( - '/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000); - const obj: Record = JSON.parse(resp.body) as Record; - const parsed: ParsedHistory = this.parseHistoryPayload(obj); - if (parsed.msgs.length === 0) { - this.chatHasMore = false; - return; - } - // 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号 - const older: ChatMessage[] = parsed.msgs; - this.messages = older.concat(this.messages); - this.chatOffset = parsed.offset; - this.chatHasMore = parsed.hasMore; - } catch (e) { - // 失败保留 hasMore,允许下次滚动重试 - } finally { - this._loadingOlder = false; - } - } - - /** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */ - private parseHistoryPayload(obj: Record): ParsedHistory { - const rawList: Object | undefined = obj['messages'] as Object | undefined; - if (rawList === undefined || rawList === null) { - return { msgs: [], offset: 0, hasMore: false }; - } - const arr: Object[] = rawList as Object[]; - const msgs: ChatMessage[] = []; - for (let i = 0; i < arr.length; i++) { - const item: Record = arr[i] as Record; - const role: string = item['role'] as string ?? ''; - const content: string = item['content'] as string ?? ''; - const att: ChatAttachment | undefined = parseAttachment(item['attachment']); - // 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理 - if (role.length === 0 || (content.length === 0 && att === undefined)) { - continue; - } - const msg: ChatMessage = { - id: this.allocId(), - role: role, - content: content, - isFinal: true, - }; - if (att !== undefined) { - msg.attachment = att; - } - // 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls, - // 这里必须还原,否则刷新后思考与工具卡就凭空消失。 - const rc: string = item['reasoning_content'] as string ?? ''; - if (rc.length > 0) { - msg.reasoningContent = rc; - } - const tcs: ToolCallInfo[] | undefined = this.parseHistoryToolCalls(item['tool_calls']); - if (tcs !== undefined) { - msg.toolCalls = tcs; - } - // 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息 - const src: string = item['source'] as string ?? ''; - if (src.length > 0) { - msg.source = src; - } - msgs.push(msg); - } - const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0; - const hasMore: boolean = obj['has_more'] === true; - return { msgs: msgs, offset: offset, hasMore: hasMore }; - } - - /** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */ - private parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined { - if (raw === undefined || raw === null) { - return undefined; - } - const arr: Object[] = raw as Object[]; - if (arr.length === 0) { - return undefined; - } - const tcs: ToolCallInfo[] = []; - for (let i = 0; i < arr.length; i++) { - const item: Record = arr[i] as Record; - const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? ''); - if (name.length === 0) { - continue; - } - const tc: ToolCallInfo = { - name: name, - args: this.stringifyField(item['args']), - result: this.stringifyField(item['result']), - status: item['status'] as string ?? undefined, - plugin: item['plugin'] as string ?? undefined, - }; - tcs.push(tc); - } - return tcs.length > 0 ? tcs : undefined; - } - - private handleSSE(ev: SseEvent): void { - try { - // 服务端 data 字段是完整 sdk.Event:{type, source, payload, timestamp} - // 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。 - const frame: Record = JSON.parse(ev.data) as Record; - const inner: Object | undefined = frame['payload']; - const payload: Record = - inner !== undefined && inner !== null ? inner as Record : frame; - const frameType: string = frame['type'] as string ?? ''; - const type: string = ev.event.length > 0 ? ev.event : frameType; - // 诊断只记事件类型(内容可能含隐私,不落盘) - hilog.debug(0x0000, 'HomeAgent', 'sse %{public}s', type); - - if (type === 'agent_output') { - const content: string = payload['content'] as string ?? ''; - // channel_output 携带图片/文件:url/size/output_type 三个字段在 payload 顶层, - // 它是一条独立的附件消息,不能合并进上一条文本气泡。 - const kind: string = payload['kind'] as string ?? ''; - if (kind === 'channel_output') { - const att: ChatAttachment | undefined = attachmentFromChannelOutput( - payload['output_type'] as string ?? '', - payload['url'] as string ?? '', - payload['size'] as number ?? 0); - if (att !== undefined) { - const amsg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: content, - isFinal: true, - source: 'channel', - attachment: att, - }; - this.messages.push(amsg); - this.markNew(amsg.id); - this.chatLoading = false; - this.chatStage = ''; - this.sseActiveForTurn = false; - this.refreshMessages(); - this.scrollToBottom(); - return; - } - } - const last: ChatMessage | null = this.lastMessage(); - if (last !== null && last.role === 'assistant' && !last.isFinal) { - last.content = content; - last.isFinal = true; - last.isStreaming = false; - } else if (last !== null && last.role === 'assistant' && last.isFinal) { - // POST 已经创建了最终消息,仅合并内容(如果有增量) - if (content.length > last.content.length) { - last.content = content; - } - } else { - const msg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: content, - isFinal: true, - }; - this.messages.push(msg); - this.markNew(msg.id); - } - this.chatLoading = false; - this.chatStage = ''; - this.sseActiveForTurn = false; - this.refreshMessages(); - this.scrollToBottom(); - } else if (type === 'reasoning') { - const rc: string = payload['content'] as string ?? ''; - if (rc.length > 0) { - this.chatStage = 'AI 思考中...'; - // 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条), - // 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。 - let last: ChatMessage | null = this.lastMessage(); - if (last === null || last.role !== 'assistant' || last.isFinal === true) { - const msg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: '', - isStreaming: true, - isFinal: false, - }; - this.messages.push(msg); - this.markNew(msg.id); - last = msg; - } - last.reasoningContent = rc; - this.refreshMessages(); - this.scrollToBottom(); - } - } else if (type === 'sync_required') { - // 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件) - this.loadHistory(); - } else if (type === 'agent_error') { - // 后端错误一律转人话,技术细节不上 UI - this.chatLoading = false; - this.chatStage = '本轮处理失败,请重试'; - this.refreshMessages(); - } else if (type === 'content_delta') { - const delta: string = payload['content'] as string ?? ''; - if (delta.length > 0) { - this.sseActiveForTurn = true; - let last: ChatMessage | null = this.lastMessage(); - if (last === null || last.role !== 'assistant' || last.isFinal) { - const msg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: '', - isStreaming: true, - isFinal: false, - }; - this.messages.push(msg); - this.markNew(msg.id); - last = msg; - } - last.content += delta; - this.refreshMessages(); - this.scrollToBottom(); - } - } else if (type === 'reasoning_delta') { - const delta: string = payload['content'] as string ?? ''; - if (delta.length > 0) { - this.sseActiveForTurn = true; - this.chatStage = 'AI 思考中...'; - let last: ChatMessage | null = this.lastMessage(); - if (last === null || last.role !== 'assistant' || last.isFinal) { - const msg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: '', - isStreaming: true, - isFinal: false, - }; - this.messages.push(msg); - this.markNew(msg.id); - last = msg; - } - if (last.reasoningContent === undefined) { - last.reasoningContent = ''; - } - last.reasoningContent += delta; - this.refreshMessages(); - } - } else if (type === 'tool_call') { - const toolName: string = payload['tool'] as string ?? ''; - const toolStatus: string = payload['status'] as string ?? ''; - const toolPlugin: string = payload['plugin'] as string ?? ''; - if (toolName.length > 0) { - this.chatStage = '工具调用: ' + toolName; - const target: ToolCallInfo = this.ensureToolCall(toolName); - if (toolPlugin.length > 0) { - target.plugin = toolPlugin; - } - const argsText: string = this.stringifyField(payload['args']); - if (argsText.length > 0) { - target.args = argsText; - } - if (toolStatus.length > 0) { - // 后端只在工具执行【结束】时发 tool_call(status=ok/denied/interrupted), - // 所以拿到 status 就意味着这次调用已收尾,result 一并落卡。 - target.status = toolStatus; - target.result = this.stringifyField(payload['result']); - } else { - target.status = 'running'; - } - this.refreshMessages(); - this.scrollToBottom(); - } - } else if (type === 'stage') { - const phase: string = payload['phase'] as string ?? ''; - const channel: string = payload['channel'] as string ?? ''; - const stageTool: string = payload['tool'] as string ?? ''; - if (channel !== '_consolidation_') { - if (phase === 'pre_action') { - this.chatStage = 'AI 思考中...'; - } else if (phase === 'before_toolcall') { - this.chatStage = '工具调用: ' + stageTool; - // 关键:tool_call 事件只在执行【结束】后才发,所以"调用中"这一态 - // 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。 - if (stageTool.length > 0) { - const tc: ToolCallInfo = this.ensureToolCall(stageTool); - if (tc.status === undefined) { - tc.status = 'running'; - } - } - } else if (phase === 'before_output') { - this.chatStage = '生成回复中...'; - } - this.refreshMessages(); - } - } - } catch (e) { - // ignore parse errors - } - } - - /** - * 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。 - * 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。 - */ - private ensureToolCall(name: string): ToolCallInfo { - let last: ChatMessage | null = this.lastMessage(); - if (last === null || last.role !== 'assistant' || last.isFinal === true) { - const msg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: '', - isStreaming: true, - isFinal: false, - }; - this.messages.push(msg); - this.markNew(msg.id); - last = msg; - } - if (last.toolCalls === undefined) { - last.toolCalls = []; - } - for (let i = 0; i < last.toolCalls.length; i++) { - const t: ToolCallInfo = last.toolCalls[i]; - // 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时, - // 已完成的那张不能被后来的调用覆盖。 - if (t.name === name && this.tcRunning(t)) { - return t; - } - } - const created: ToolCallInfo = { name: name, args: '' }; - last.toolCalls.push(created); - return created; - } - - /** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */ - private stringifyField(raw: Object | undefined): string { - if (raw === undefined || raw === null) { - return ''; - } - if (typeof raw === 'string') { - return raw as string; - } - try { - return JSON.stringify(raw); - } catch (e) { - return String(raw); - } - } - - private lastMessage(): ChatMessage | null { - if (this.messages.length === 0) { - return null; - } - return this.messages[this.messages.length - 1]; - } - - /** 防抖刷新:合并高频 SSE delta,最多 ~20fps */ - private refreshMessages(): void { - if (this.refreshTimer >= 0) { - return; - } - this.refreshTimer = setTimeout(() => { - this.refreshTimer = -1; - this.messages = this.messages.slice(); - }, 50); - } - - /** 强制立即刷新(用于状态切换等需要即时响应的场景) */ - private forceRefresh(): void { - if (this.refreshTimer >= 0) { - clearTimeout(this.refreshTimer); - this.refreshTimer = -1; - } - this.messages = this.messages.slice(); - } - - /** - * ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的 - * if 分支重新求值。这是 ArkUI V1 渲染模型决定的:ForEach 对相同键 - * 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以 - * 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后 - * 永远不会再次求值——气泡里的这些面板就永远不出现。 - * - * 反过来,content_delta 不进 structSig:正文文本靠 MarkdownView - * 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。 - * 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段 - * 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。 - */ - private structSig(msg: ChatMessage): string { - let s: string = msg.id.toString(); - const rc: string | undefined = msg.reasoningContent; - s += '_r' + (rc !== undefined ? rc.length.toString() : '0'); - s += '_ro' + (msg.reasoningOpen === true ? '1' : '0'); - const tcs: ToolCallInfo[] | undefined = msg.toolCalls; - if (tcs !== undefined) { - s += '_t' + tcs.length.toString(); - for (let i = 0; i < tcs.length; i++) { - const tc: ToolCallInfo = tcs[i]; - s += '_' + (tc.status ?? ''); - s += '_' + (tc.open === true ? 'o' : 'c'); - s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0'); - s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0'); - s += '_' + (tc.plugin ?? ''); - } - } else { - s += '_t0'; - } - s += '_a' + (msg.attachment !== undefined ? '1' : '0'); - s += '_src' + (msg.source ?? ''); - s += '_f' + (msg.isFinal === true ? '1' : '0'); - s += '_s' + (msg.isStreaming === true ? '1' : '0'); - return s; + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; } /** @@ -663,383 +81,6 @@ export struct ChatPage { return pad; } - /** 标记新消息,触发入场动画 */ - private markNew(msgId: number): void { - const arr: number[] = this.newMsgIds.slice(); - arr.push(msgId); - this.newMsgIds = arr; - setTimeout(() => { - const idx: number = this.newMsgIds.indexOf(msgId); - if (idx >= 0) { - const updated: number[] = this.newMsgIds.slice(); - updated.splice(idx, 1); - this.newMsgIds = updated; - this.forceRefresh(); - } - }, 250); - } - - private palette(): ThemePalette { - return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; - } - - /** - * 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。 - * bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar - * 顶部淡出用的是同一手法,保证上下两端的融入观感一致。 - */ - private opaqueBottomBg(): string { - return '#FF' + this.palette().bgPrimary.substring(1); - } - - private transparentBottomBg(): string { - return '#00' + this.palette().bgPrimary.substring(1); - } - - /** 输入框底:高不透明度 + blur,保证背景内容不会透过输入文字 */ - private inputSolidBg(): string { - return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)'; - } - - private scrollToBottom(): void { - this.autoScrolling = true; - setTimeout(() => { - this.scroller.scrollEdge(Edge.Bottom); - }, 50); - setTimeout(() => { - this.autoScrolling = false; - this.navHidden = false; - navBar.setVisible(true); - }, 450); - } - - /** - * 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。 - * - * 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。 - * 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关, - * 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。 - */ - private recomputeMultiLine(text: string): void { - const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP - - INPUT_INNER_PAD * 2; - if (avail <= 0) { - return; - } - let multi: boolean = text.indexOf('\n') >= 0; - if (!multi && text.length > 0) { - const opt: MeasureOptions = { - textContent: text, - fontSize: INPUT_FONT_SIZE, - }; - const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt); - // measureTextSize 返回 px,可用宽度是 vp,换算后再比 - const widthVp: number = this.getUIContext().px2vp(size.width as number); - multi = widthVp > avail; - } - if (multi !== this.inputMultiLine) { - this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => { - this.inputMultiLine = multi; - }); - } - } - - // ===================== 附件:选择与上传 ===================== - - /** 从图库挑一张图 */ - private async pickImage(): Promise { - this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { - this.attachMenuOpen = false; - }); - try { - const options = new picker.PhotoSelectOptions(); - options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; - options.maxSelectNumber = 1; - const photoPicker = new picker.PhotoViewPicker(); - const result = await photoPicker.select(options); - if (result.photoUris.length === 0) { - return; - } - this.stagePickedFile(result.photoUris[0], true); - } catch (e) { - this.chatStage = userMessage('chat.pickImage', e); - this.forceRefresh(); - } - } - - /** 从文件管理器挑一个文件 */ - private async pickFile(): Promise { - this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { - this.attachMenuOpen = false; - }); - try { - const options = new picker.DocumentSelectOptions(); - options.maxSelectNumber = 1; - const docPicker = new picker.DocumentViewPicker(); - const uris: string[] = await docPicker.select(options); - if (uris.length === 0) { - return; - } - this.stagePickedFile(uris[0], false); - } catch (e) { - this.chatStage = userMessage('chat.pickFile', e); - this.forceRefresh(); - } - } - - /** - * 把 picker 给的 URI 复制到应用沙箱。 - * http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径, - * 直接把 picker 的 media:// URI 交过去会读不到内容。 - */ - private stagePickedFile(srcUri: string, isImage: boolean): void { - try { - const ctx = getContext(this) as common.UIAbilityContext; - const name: string = fileNameOf(srcUri); - const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name; - const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY); - const destFile = fileIo.openSync(destPath, - fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC); - fileIo.copyFileSync(srcFile.fd, destFile.fd); - fileIo.closeSync(srcFile); - fileIo.closeSync(destFile); - const stat = fileIo.statSync(destPath); - this.clearPendingFile(); - this.pendingPath = destPath; - this.pendingName = name; - this.pendingSize = stat.size; - this.pendingIsImage = isImage; - this.pendingMime = mimeOf(name, isImage); - } catch (e) { - this.chatStage = userMessage('chat.stageFile', e); - } - this.forceRefresh(); - } - - /** 丢弃待发送附件,并删掉沙箱里的临时副本 */ - private clearPendingFile(): void { - if (this.pendingPath.length > 0) { - try { - fileIo.unlinkSync(this.pendingPath); - } catch (e) { - // 已不存在,忽略 - } - } - this.pendingPath = ''; - this.pendingName = ''; - this.pendingSize = 0; - this.pendingIsImage = false; - this.pendingMime = ''; - } - - /** - * 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。 - * 后端收下后自身会把用户消息与附件写进会话并触发 agent, - * 回复照常从 SSE 过来,所以这里不再走 /chat。 - */ - private async sendWithAttachment(text: string): Promise { - const path: string = this.pendingPath; - const name: string = this.pendingName; - const size: number = this.pendingSize; - const isImage: boolean = this.pendingIsImage; - const mime: string = this.pendingMime; - - const att: ChatAttachment = { - type: isImage ? 'image' : 'file', - // 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL - url: 'file://' + path, - size: size, - name: name, - }; - const userMsg: ChatMessage = { id: this.allocId(), role: 'user', content: text }; - userMsg.attachment = att; - this.messages.push(userMsg); - this.markNew(userMsg.id); - this.inputText = ''; - this.inputMultiLine = false; - this.uploading = true; - this.chatLoading = true; - this.chatStage = '正在上传附件...'; - this.sseActiveForTurn = false; - this.forceRefresh(); - this.scrollToBottom(); - - const parts: http.MultiFormData[] = [ - { name: 'file', contentType: mime, remoteFileName: name, filePath: path }, - { name: 'message', contentType: 'text/plain', data: text }, - { name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) }, - { name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() }, - { name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() }, - ]; - - try { - const resp = await apiClient.postMultipart('/chat/file', parts, 180000); - const obj: Record = JSON.parse(resp.body) as Record; - const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']); - if (uploaded !== undefined) { - userMsg.attachment = uploaded; - } - this.chatStage = '等待 AI 回复...'; - } catch (e) { - this.chatStage = userMessage('chat.upload', e); - this.chatLoading = false; - } - this.uploading = false; - this.clearPendingFile(); - this.forceRefresh(); - this.scrollToBottom(); - } - - private async sendChat(): Promise { - const text: string = this.inputText.trim(); - if (this.chatLoading || this.uploading) { - return; - } - const cur = connStore.getCurrentConnection(); - if (cur === null) { - return; - } - if (this.pendingPath.length > 0) { - await this.sendWithAttachment(text); - return; - } - if (text.length === 0) { - return; - } - this.inputText = ''; - this.inputMultiLine = false; - // 重置 SSE 标记 - this.sseActiveForTurn = false; - - const userMsg: ChatMessage = { id: this.allocId(), role: 'user', content: text }; - this.messages.push(userMsg); - this.markNew(userMsg.id); - this.chatLoading = true; - this.chatStage = '等待 AI 回复...'; - this.forceRefresh(); - this.scrollToBottom(); - - const bodyObj: SendChatBody = { - message: text, - client_msg_id: Date.now().toString(36), - // 必须带设备身份:后端没有 device_id 就把来源编码成 webui, - // agent 会以为消息来自网页端。device_id 非空时后端编码 - // source = "webui/" 并注入设备上下文。 - device_id: connStore.ensureDeviceId(), - device_name: connStore.getDeviceName(), - }; - - // 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染); - // 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。 - try { - const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000); - - // SSE 已经处理了响应,跳过 POST 消息创建 - if (this.sseActiveForTurn) { - this.chatLoading = false; - this.chatStage = ''; - this.forceRefresh(); - this.scrollToBottom(); - return; - } - - const parsed: Record = JSON.parse(resp.body) as Record; - const respText: string = parsed['response'] ?? '(无响应)'; - const reasoning: string = parsed['reasoning_content'] ?? ''; - const last: ChatMessage | null = this.lastMessage(); - if (last !== null && last.role === 'assistant' && !last.isFinal) { - last.content = respText; - last.isFinal = true; - last.isStreaming = false; - if (reasoning.length > 0 && last.reasoningContent === undefined) { - last.reasoningContent = reasoning; - } - } else if (last !== null && last.role === 'assistant' && last.isFinal) { - // 已有最终消息,合并(不应发生,但防御性处理) - if (respText.length > last.content.length) { - last.content = respText; - } - } else { - const msg: ChatMessage = { - id: this.allocId(), - role: 'assistant', - content: respText, - isFinal: true, - }; - if (reasoning.length > 0) { - msg.reasoningContent = reasoning; - } - this.messages.push(msg); - this.markNew(msg.id); - } - this.chatLoading = false; - this.chatStage = ''; - this.forceRefresh(); - this.scrollToBottom(); - } catch (e) { - // 超时通常意味着后端仍在生成,不算失败;其余一律显示人话, - // 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把 - // "Failed to connect to the server."、内网地址直接摆到聊天流里)。 - if (isTimeout(e)) { - this.chatStage = '请求已发送,等待回复...'; - } else { - this.chatStage = userMessage('chat.send', e); - } - this.forceRefresh(); - this.scrollToBottom(); - } - } - - private async interruptChat(): Promise { - try { - await apiClient.post('/chat/interrupt', null); - } catch (e) { - // ignore - } - } - - private toggleReasoning(msg: ChatMessage): void { - // 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation, - // 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。 - this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { - msg.reasoningOpen = !(msg.reasoningOpen === true); - this.forceRefresh(); - }); - } - - /** - * 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar, - * 不在回调里做任何耗时操作。navBar.setVisible 内部已去重, - * 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换, - * 不会引起布局回流——这是滑动流畅的关键。 - */ - private handleScrollDirection(yOffset: number, state: ScrollState): void { - if (this.autoScrolling) { - return; - } - // 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页 - if (yOffset < 60 && this.chatHasMore) { - this.loadOlderChat(); - } - if (state === ScrollState.Idle) { - if (this.navHidden) { - this.navHidden = false; - navBar.setVisible(true); - } - } else { - // Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏 - if (!this.navHidden) { - this.navHidden = true; - navBar.setVisible(false); - } - } - } - - /** 判断消息是否处于入场动画阶段 */ - private isNewMsg(msgId: number): boolean { - return this.newMsgIds.indexOf(msgId) >= 0; - } - // ===================== 二级页面:附件详情 ===================== /** @@ -1063,17 +104,6 @@ export struct ChatPage { this.activeAtt = undefined; } - /** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */ - private latestAttachment(): ChatAttachment | undefined { - for (let i = this.messages.length - 1; i >= 0; i--) { - const a: ChatAttachment | undefined = this.messages[i].attachment; - if (a !== undefined) { - return a; - } - } - return undefined; - } - build() { // 宽屏:左栏聊天流(含底部导航与输入区),右栏附件详情。 // 窄屏:附件详情整屏覆盖,系统返回手势直接作用于 navStack。 @@ -1098,7 +128,7 @@ export struct ChatPage { markSubPageOpen(true); // 右栏不能空白:有附件就展示最近一个,没有则由 SubDestination 显示空态 if (this.navStack.size() === 0) { - const a: ChatAttachment | undefined = this.latestAttachment(); + const a: ChatAttachment | undefined = chatStore.latestAttachment(); this.activeAtt = a; this.activeSub = SUB_ATTACHMENT; this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false); @@ -1156,807 +186,22 @@ export struct ChatPage { @Builder ChatBody() { Stack({ alignContent: Alignment.Bottom }) { - // 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出) - Column() { - // Messages - Scroll(this.scroller) { - Column() { - ForEach(this.messages, (msg: ChatMessage, idx: number) => { - this.MessageBubble(msg) - }, (msg: ChatMessage, idx: number) => this.structSig(msg)) - - if (this.chatLoading) { - Row({ space: 8 }) { - LoadingProgress() - .width(16) - .height(16) - .color(this.palette().accent) - Text(this.chatStage.length > 0 ? this.chatStage : '处理中...') - .fontSize(12) - .fontColor(this.palette().textMuted) - } - .width('100%') - .justifyContent(FlexAlign.Start) - .padding({ left: 52, top: 6, bottom: 6 }) - // if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新); - // 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。 - .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) - } - } - .width('100%') - .padding({ - left: 14, - right: 14, - top: 76, - bottom: this.listBottomPad() - }) - } - .width('100%') - .height('100%') - .scrollBar(BarState.Off) - .edgeEffect(EdgeEffect.Spring) - .align(Alignment.Top) - .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => { - this.handleScrollDirection(yOffset, state); - }) - } - .width('100%') - .height('100%') - - // 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透) - PageTopBar({ title: '聊天' }) - - // 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景, - // 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。 - Column() - .width('100%') - .height(170) - .linearGradient({ - direction: GradientDirection.Bottom, - colors: [ - [this.transparentBottomBg(), 0.0], - [this.opaqueBottomBg(), 0.6], - [this.opaqueBottomBg(), 1.0], - ], - }) - .hitTestBehavior(HitTestMode.None) + // 层1~1.6:消息流 + 顶栏遮罩 + 底部淡出遮罩 + ChatStream({ + bottomPad: this.listBottomPad(), + onOpenAttachment: (att: ChatAttachment) => { + this.openAttachment(att); + }, + }) // 层2:悬浮输入区(导航栏上方,与导航栏左右平齐,各组件独立不共框) - NavFloatOverlay({ tab: 0 }) { - // 加号展开的两个选项(图片 / 文件),点一次收起 - if (this.attachMenuOpen) { - this.AttachMenu() - } - - // 待发送附件预览(选好图片/文件、还没点发送时显示) - if (this.pendingName.length > 0) { - this.PendingAttachmentChip() - } - - // Stack 而不是 Column:加号与发送按钮钉死在底部这一行不动, - // 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。 - Stack({ alignContent: Alignment.Bottom }) { - // 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮 - Row() { - FloatIconButton({ - icon: $r('app.media.ic_plus'), - onTap: () => { - // 菜单展开会同时改变 listBottomPad,用 animateTo 把 - // 列表内边距和菜单进出场拉到同一个时钟上。 - this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { - this.attachMenuOpen = !this.attachMenuOpen; - }); - }, - }) - Blank() - if (this.chatLoading) { - Button() { - Image($r('app.media.ic_stop')) - .width(16) - .height(16) - .fillColor(Color.White) - } - .width(44) - .height(44) - .type(ButtonType.Circle) - .backgroundColor('#77809A') - // 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出 - .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) - .onClick(() => { - this.interruptChat(); - }) - } else { - Button() { - Image($r('app.media.ic_send')) - .width(18) - .height(18) - .fillColor(Color.White) - } - .width(44) - .height(44) - .type(ButtonType.Circle) - .backgroundColor(this.palette().accent) - .enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0) - .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) - .onClick(() => { - this.sendChat(); - }) - } - } - .width('100%') - .height(44) - .alignItems(VerticalAlign.Center) - .onAreaChange((_o: Area, n: Area) => { - // 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。 - const w: number = n.width as number; - if (Math.abs(w - this.inputRowWidth) > 0.5) { - this.inputRowWidth = w; - this.recomputeMultiLine(this.inputText); - } - }) - - // 上层:输入框。 - // 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行; - // 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。 - // - // 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成 - // 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。 - // 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行, - // 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。 - Row() { - TextArea({ - placeholder: '输入消息...', - text: this.inputText, - }) - .layoutWeight(1) - // 不写死高度:单行 44,随文字换行自动增高,最多约 5 行后内部滚动 - .constraintSize({ minHeight: 44, maxHeight: 168 }) - .fontSize(INPUT_FONT_SIZE) - .fontColor(this.palette().textPrimary) - .placeholderFont({ size: 13 }) - .placeholderColor(this.palette().textMuted) - .backgroundColor(this.inputSolidBg()) - .backdropBlur(24) - .borderRadius(22) - .border({ width: 1, color: this.palette().glassBorder }) - .padding({ - left: INPUT_INNER_PAD, - right: INPUT_INNER_PAD, - top: 11, - bottom: 11, - }) - .enterKeyType(EnterKeyType.Send) - .onChange((value: string) => { - this.inputText = value; - this.recomputeMultiLine(value); - }) - .onSubmit(() => { - this.sendChat(); - }) - } - .width('100%') - // 多行时必须显式写 0:给 .padding() 传 undefined 在增量更新时会被当作 - // "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。 - .padding(this.inputMultiLine - ? { left: 0, right: 0 } - : { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP }) - .margin({ bottom: this.inputMultiLine ? 52 : 0 }) - // 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。 - // 不设 None 的话 padding 区域仍属于 Row,会把点击吞掉 —— 发送键点不动。 - // None = 自身不响应、子节点(TextArea)照常响应,触摸落到下层那一行。 - .hitTestBehavior(HitTestMode.None) - .animation({ duration: 260, curve: Curve.Friction }) - } - .width('100%') - } + ChatComposer({ + inputMultiLine: $inputMultiLine, + attachMenuOpen: $attachMenuOpen, + pendingName: $pendingName, + }) } .width('100%') .height('100%') } - - /** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */ - @Builder - AttachMenu() { - // 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐 - Row() { - Row({ space: 8 }) { - this.AttachOption($r('app.media.ic_image'), '图片', () => { - this.pickImage(); - }) - this.AttachOption($r('app.media.ic_file'), '文件', () => { - this.pickFile(); - }) - } - } - .width('100%') - .justifyContent(FlexAlign.Start) - .margin({ bottom: 8 }) - .hitTestBehavior(HitTestMode.Transparent) - // 加号菜单由 if 控制,进出场只能靠 transition;配合下面 toggle 处的 - // animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。 - .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) - } - - @Builder - AttachOption(icon: Resource, label: string, tap: () => void) { - // 按压反馈交给 MotionBase:每个实例自带独立按压态, - // 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。 - MotionBase({ pressEnabled: true, fillWidth: false }) { - Row({ space: 6 }) { - Image(icon) - .width(15) - .height(15) - .fillColor(this.palette().textSecondary) - .draggable(false) - Text(label) - .fontSize(12) - .fontColor(this.palette().textPrimary) - } - .padding({ left: 12, right: 14, top: 8, bottom: 8 }) - .backgroundColor(this.palette().navBarBg) - .borderRadius(RADIUS_PILL) - .border({ width: 1, color: this.palette().navBarBorder }) - .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) - .onClick(tap) - } - } - - /** 待发送附件预览条:缩略信息 + 一个移除按钮 */ - @Builder - PendingAttachmentChip() { - // 按压反馈交给 MotionBase(全宽预览条) - MotionBase({ pressEnabled: true }) { - Row({ space: 8 }) { - Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file')) - .width(15) - .height(15) - .fillColor(this.palette().accent) - .draggable(false) - Column({ space: 1 }) { - Text(this.pendingName) - .fontSize(12) - .fontColor(this.palette().textPrimary) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.uploading ? '上传中...' : formatBytes(this.pendingSize)) - .fontSize(10) - .fontColor(this.palette().textMuted) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - if (this.uploading) { - LoadingProgress() - .width(14) - .height(14) - .color(this.palette().accent) - } else { - Image($r('app.media.ic_close')) - .width(13) - .height(13) - .fillColor(this.palette().textMuted) - .draggable(false) - .onClick(() => { - this.clearPendingFile(); - this.forceRefresh(); - }) - } - } - .width('100%') - .padding({ left: 12, right: 12, top: 8, bottom: 8 }) - .margin({ bottom: 8 }) - .backgroundColor(this.palette().navBarBg) - .borderRadius(RADIUS_MD) - .border({ width: 1, color: this.palette().navBarBorder }) - .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) - } - } - - @Builder - Avatar(role: string) { Text(role === 'user' ? '我' : 'AI') - .fontSize(11) - .fontWeight(FontWeight.Bold) - .fontColor(role === 'user' ? this.palette().msgUserText : this.palette().accent) - .textAlign(TextAlign.Center) - .width(28) - .height(28) - .borderRadius(RADIUS_PILL) - .backgroundColor(role === 'user' ? this.palette().msgUserBg : this.palette().accentBg) - .margin({ top: 2 }) - } - - @Builder - ReasoningCard(msg: ChatMessage) { - // 两层结构,原因见 BubbleBody 的布局说明: - // 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度, - // 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满; - // 内层 holder Column 自身无 padding,所以它的子节点写 width('100%') - // 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。 - Row() { - Column() { - Row({ space: 7 }) { - Image($r('app.media.ic_sparkle')) - .width(12) - .height(12) - .fillColor(this.palette().accent) - Text('思考过程') - .fontSize(11) - .fontWeight(FontWeight.Medium) - .fontColor(this.palette().textPrimary) - // 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡 - if (msg.isFinal !== true && msg.isStreaming === true) { - LoadingProgress() - .width(11) - .height(11) - .color(this.palette().accent) - } - Blank() - Text(this.reasoningLenLabel(msg)) - .fontSize(9.5) - .fontColor(this.palette().textMuted) - Image($r('app.media.ic_chevron_down')) - .width(14) - .height(14) - .fillColor(this.palette().textMuted) - .rotate({ angle: msg.reasoningOpen === true ? 180 : 0 }) - .animation({ duration: 200, curve: Curve.EaseOut }) - } - .width('100%') - .padding({ left: 10, right: 10, top: 6, bottom: 6 }) - .onClick(() => { - this.toggleReasoning(msg); - }) - - if (msg.reasoningOpen === true) { - Text(msg.reasoningContent) - .fontSize(11.5) - .lineHeight(17) - .fontColor(this.palette().textTertiary) - .width('100%') - .padding({ left: 10, right: 10, bottom: 8 }) - .maxLines(24) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .wordBreak(WordBreak.BREAK_ALL) - // 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出 - .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) - } - - // 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep) - if (msg.isFinal !== true && msg.isStreaming === true) { - Stack() { - Row() - .height(2) - .borderRadius(2) - .width('200%') - .linearGradient({ - angle: 90, - colors: [ - ['rgba(255,255,255,0.01)', 0], - [this.palette().accent, 0.35], - ['rgba(255,255,255,0.01)', 0.5], - [this.palette().accent, 0.65], - ['rgba(255,255,255,0.01)', 1], - ], - }) - .opacity(0.7) - .translate({ x: this.isNewMsg(msg.id) ? '0%' : '-50%' }) - .animation({ duration: 1200, curve: Curve.Linear }) - } - .width('100%') - .clip(true) - .height(2) - .margin({ top: 6 }) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .alignItems(VerticalAlign.Top) - .margin({ bottom: 6 }) - .borderRadius(RADIUS_SM) - .backgroundColor(this.palette().bgHover) - .border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed }) - } - - /** 折叠时也要能看出思考在增长:显示字数 */ - private reasoningLenLabel(msg: ChatMessage): string { - const rc: string | undefined = msg.reasoningContent; - if (rc === undefined || rc.length === 0) { - return ''; - } - return rc.length.toString() + ' 字'; - } - - /** - * 是否仍在执行。 - * 判据是 status 而不是 result:后端 status=ok 的工具也可能返回空串, - * 用 result 判断会让这类调用永远显示"调用中"。 - */ - private tcRunning(tc: ToolCallInfo): boolean { - const s: string | undefined = tc.status; - return s === undefined || s.length === 0 || s === 'running'; - } - - private tcError(tc: ToolCallInfo): boolean { - return tc.status === 'denied' || tc.status === 'error'; - } - - private tcLeftColor(tc: ToolCallInfo): string { - if (this.tcError(tc)) { - return '#DB3694'; - } - if (this.tcRunning(tc)) { - return this.palette().accent; - } - return 'rgba(23, 169, 100, 0.8)'; - } - - @Builder - ToolCard(tc: ToolCallInfo) { - // 同 ReasoningCard:外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽 - Row() { - Column() { - Row({ space: 6 }) { - if (this.tcError(tc)) { - Image($r('app.media.ic_error')) - .width(13).height(13) - .fillColor(this.tcIcoColor(tc)) - } else if (this.tcRunning(tc)) { - LoadingProgress() - .width(12).height(12) - .color(this.tcIcoColor(tc)) - } else { - Image($r('app.media.ic_check')) - .width(13).height(13) - .fillColor(this.tcIcoColor(tc)) - } - Text(tc.name) - .fontSize(11) - .fontWeight(FontWeight.Medium) - .fontColor(this.palette().textPrimary) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - // 名字可长可短,必须让它占剩余宽度并可收缩, - // 否则右侧状态文字会被挤出气泡(78% 宽度 + clip 直接切掉) - .layoutWeight(1) - if (tc.plugin !== undefined && tc.plugin.length > 0) { - Text(tc.plugin) - .fontSize(9.5) - .fontColor(this.palette().textMuted) - .maxLines(1) - .flexShrink(0) - } - // 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标") - Text(this.tcStateLabel(tc)) - .fontSize(9.5) - .fontWeight(FontWeight.Medium) - .fontColor(this.tcStateColor(tc)) - .flexShrink(0) - Image($r('app.media.ic_chevron_down')) - .width(13).height(13) - .fillColor(this.palette().textMuted) - .flexShrink(0) - .rotate({ angle: tc.open === true ? 180 : 0 }) - .animation({ duration: 150, curve: Curve.EaseOut }) - } - .width('100%') - .alignItems(VerticalAlign.Center) - .onClick(() => { - this.toggleToolCard(tc); - }) - - if (tc.open === true) { - Column() { - if (tc.args.length > 0 && tc.args !== '{}') { - Text('参数') - .fontSize(9.5).fontWeight(FontWeight.Medium) - .fontColor(this.palette().textMuted) - .margin({ top: 6, bottom: 2 }) - Text(tc.args) - .fontSize(11) - .fontColor(this.palette().preText) - .backgroundColor(this.palette().preBg) - .borderRadius(4) - .padding({ left: 7, right: 7, top: 5, bottom: 5 }) - .width('100%') - .textAlign(TextAlign.Start) - .wordBreak(WordBreak.BREAK_ALL) - } - if (tc.result !== undefined && tc.result.length > 0) { - Text('结果') - .fontSize(9.5).fontWeight(FontWeight.Medium) - .fontColor(this.palette().textMuted) - .margin({ top: 6, bottom: 2 }) - Text(tc.result) - .fontSize(11) - .fontColor(this.palette().preText) - .backgroundColor(this.palette().preBg) - .borderRadius(4) - .padding({ left: 7, right: 7, top: 5, bottom: 5 }) - .width('100%') - .textAlign(TextAlign.Start) - .wordBreak(WordBreak.BREAK_ALL) - .maxLines(8) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .width('100%') - .alignItems(HorizontalAlign.Start) - // 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入 - .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .alignItems(VerticalAlign.Top) - .padding({ left: 10, right: 10, top: 7, bottom: 7 }) - .margin({ bottom: 4 }) - .borderRadius(RADIUS_SM) - .backgroundColor(this.palette().bgHover) - .border({ - width: { left: 3, top: 1, right: 1, bottom: 1 }, - color: { - left: this.tcLeftColor(tc), - top: this.palette().kvBorder, - right: this.palette().kvBorder, - bottom: this.palette().kvBorder, - }, - }) - } - - private tcIcoColor(tc: ToolCallInfo): string { - if (this.tcError(tc)) { - return '#DB3694'; - } - if (this.tcRunning(tc)) { - return this.palette().accent; - } - return 'rgba(23, 169, 100, 0.9)'; - } - - private tcStateLabel(tc: ToolCallInfo): string { - if (tc.status === 'denied') { - return '已拒绝'; - } - if (this.tcRunning(tc)) { - return '调用中'; - } - return '完成'; - } - - private tcStateColor(tc: ToolCallInfo): string { - if (tc.status === 'denied') { - return '#FF9EC6'; - } - if (this.tcRunning(tc)) { - return '#A3B8FF'; - } - return '#6EE7A8'; - } - - private tcStateBg(tc: ToolCallInfo): string { - if (tc.status === 'denied') { - return 'rgba(219, 54, 148, 0.18)'; - } - if (this.tcRunning(tc)) { - return 'rgba(63, 110, 245, 0.18)'; - } - return 'rgba(23, 169, 100, 0.18)'; - } - - /** 工具卡/思考卡在气泡内是独占一行的块,气泡 78% 宽度对它们太窄 —— 见 BubbleBody */ - - private toggleToolCard(tc: ToolCallInfo): void { - this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { - tc.open = !(tc.open === true); - this.forceRefresh(); - }); - } - - /** - * 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。 - * 纯文本 78% 好看;但工具卡/思考卡是"面板",78% 会把里面的状态文字和 - * 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。 - */ - private bubbleMaxWidth(msg: ChatMessage): string { - const hasPanels: boolean = - (msg.toolCalls !== undefined && msg.toolCalls.length > 0) || - (msg.reasoningContent !== undefined && msg.reasoningContent.length > 0); - return hasPanels ? '92%' : '78%'; - } - - /** - * 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项: - * 本机自己发的消息在后端会被写成 webui/,那仍然是"我发的", - * 不能当成渠道消息挂上别人的头像。 - */ - private isChannelMsg(msg: ChatMessage): boolean { - const src: string = msg.source ?? ''; - if (src.length === 0 || src === 'webui') { - return false; - } - return src !== 'webui/' + connStore.ensureDeviceId(); - } - - /** 渠道名展示:webui/ 只显示 ,其余原样。 */ - private chanLabel(src: string): string { - if (src.startsWith('webui/')) { - return src.substring(6); - } - return src; - } - - /** 渠道首字母(大写),用作头像文字。 */ - private chanLetter(src: string): string { - const label: string = this.chanLabel(src); - if (label.length === 0) { - return '?'; - } - return label.substring(0, 1).toUpperCase(); - } - - /** 由渠道名散列出稳定色,避免每次渲染换色。 */ - private chanColor(src: string): string { - const label: string = this.chanLabel(src); - let h: number = 0; - for (let i = 0; i < label.length; i++) { - h = (h * 31 + label.charCodeAt(i)) % 360; - } - return 'hsl(' + h.toString() + ', 52%, 46%)'; - } - - @Builder - ChanAvatar(src: string) { - Text(this.chanLetter(src)) - .fontSize(12) - .fontWeight(FontWeight.Bold) - .fontColor(Color.White) - .textAlign(TextAlign.Center) - .width(28) - .height(28) - .borderRadius(RADIUS_PILL) - .backgroundColor(this.chanColor(src)) - .margin({ top: 2 }) - } - - /** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */ - private isSelfMsg(msg: ChatMessage): boolean { - return msg.role === 'user' && !this.isChannelMsg(msg); - } - - /** - * 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。 - * - * 原来 BubbleBody 直接放进 Row,它的 constraintSize maxWidth 是百分比 - * (78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带 - * 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding, - * 于是 92% 算出来的宽度里包含了这些 padding,再加上 28 的头像和 8 的 - * 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来, - * 分栏后左栏只有 420vp,溢出的十几 vp 直接被栏宽裁掉 —— 表现为 - * 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp - * 被 clip 的问题是同一个成因)。 - * - * 修法同 MarkdownView:用 layoutWeight(1) 拿"剩余空间"而不是百分比。 - * 槽自身无 padding,外框宽 == 内容宽 == 头像与间距之外的真实可用宽度, - * 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。 - */ - @Builder - BubbleSlot(msg: ChatMessage) { - Column() { - this.BubbleBody(msg) - } - .layoutWeight(1) - .alignItems(this.isSelfMsg(msg) ? HorizontalAlign.End : HorizontalAlign.Start) - } - - @Builder - MessageBubble(msg: ChatMessage) { - Row({ space: 8 }) { - if (this.isChannelMsg(msg)) { - this.ChanAvatar(msg.source ?? '') - this.BubbleSlot(msg) - } else if (msg.role === 'user') { - this.BubbleSlot(msg) - this.Avatar(msg.role) - } else { - this.Avatar(msg.role) - this.BubbleSlot(msg) - } - } - .width('100%') - .alignItems(VerticalAlign.Top) - // 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配, - // 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。 - .justifyContent(this.isSelfMsg(msg) ? FlexAlign.End : FlexAlign.Start) - .padding({ left: 8, right: 8, top: 3, bottom: 3 }) - // 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0) - .opacity(this.isNewMsg(msg.id) ? 0 : 1) - .translate({ y: this.isNewMsg(msg.id) ? 8 : 0 }) - .animation({ duration: 180, curve: Curve.EaseOut }) - } - - @Builder - BubbleBody(msg: ChatMessage) { - Column() { - // 渠道来源名(对齐 GUI 的 msg-chan-name):只有别处来的消息才显示 - if (this.isChannelMsg(msg)) { - Text(this.chanLabel(msg.source ?? '')) - .fontSize(10) - .fontWeight(FontWeight.Medium) - .fontColor(this.palette().textMuted) - .margin({ bottom: 4 }) - } - - // Reasoning card (assistant only) - if (msg.role === 'assistant' && msg.reasoningContent !== undefined && msg.reasoningContent.length > 0) { - this.ReasoningCard(msg) - } - - // 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页 - if (msg.attachment !== undefined) { - AttachmentCard({ - att: msg.attachment, - mine: msg.role === 'user', - onTap: () => { - const a: ChatAttachment | undefined = msg.attachment; - if (a !== undefined) { - this.openAttachment(a); - } - }, - }) - } - - // Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn - if (msg.content.length > 0) { - if (msg.role === 'assistant') { - MarkdownView({ - content: msg.content, - isStreaming: msg.isStreaming === true, - isDark: this.isDark, - }) - } else { - Text(msg.content) - .fontSize(15) - .lineHeight(24) - .fontColor(this.palette().msgBubbleText) - .textAlign(TextAlign.Start) - .wordBreak(WordBreak.BREAK_ALL) - .constraintSize({ maxWidth: '100%' }) - .margin({ top: msg.attachment !== undefined ? 8 : 0 }) - } - } - - // Tool cards - if (msg.toolCalls !== undefined && msg.toolCalls.length > 0) { - Column() { - ForEach(msg.toolCalls, (tc: ToolCallInfo, tci: number) => { - this.ToolCard(tc) - }, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name) - } - // 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。 - // 让它自适应,最大宽约束由气泡内容框向下传递,ToolCard 内部用 layoutWeight 取满。 - .alignItems(HorizontalAlign.Start) - .margin({ top: msg.content.length > 0 ? 6 : 0 }) - } - } - .constraintSize({ maxWidth: this.bubbleMaxWidth(msg) }) - .clip(true) - .padding({ left: 12, right: 12, top: 9, bottom: 9 }) - .backgroundColor(msg.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg) - .borderRadius({ - topLeft: RADIUS_MD, - topRight: RADIUS_MD, - bottomLeft: msg.role === 'assistant' ? 4 : RADIUS_MD, - bottomRight: msg.role === 'user' ? 4 : RADIUS_MD, - }) - .border({ width: 1, color: msg.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder }) - .shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 }) - .alignItems(HorizontalAlign.Start) - // bubbleGrow: 气泡入场缩放效果 - .scale({ - x: this.isNewMsg(msg.id) ? 0.95 : 1, - y: this.isNewMsg(msg.id) ? 0.95 : 1, - }) - .animation({ duration: 200, curve: Curve.EaseOut }) - } }