mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
- 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 的守卫顺序均与原实现逐条比对过, 守卫从"页面方法内"移到输入区组件时保持了原有先后(连接判定先于清空输入)。
208 lines
7.1 KiB
Plaintext
208 lines
7.1 KiB
Plaintext
import { connStore } from '../common/ConnStore';
|
||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||
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 { AttachmentDetailContent } from '../components/Attachment';
|
||
import { chatStore } from '../common/ChatStore';
|
||
|
||
/** 二级页面标识:附件(图片/文件)详情 */
|
||
const SUB_NONE: string = '';
|
||
const SUB_ATTACHMENT: string = 'attachment';
|
||
|
||
/**
|
||
* 聊天页:只做"页面壳"。
|
||
*
|
||
* 拆分后的分工(拆分前这里是 1962 行的单文件):
|
||
* - 消息流状态机(SSE / 历史 / 新消息动画)→ common/ChatStore.ets
|
||
* - SSE 事件翻译 → common/ChatSse.ets
|
||
* - 发送与中断 → common/ChatSession.ets
|
||
* - 消息列表 + 滚动 → components/ChatStream.ets
|
||
* - 气泡(含思考卡/工具卡) → components/ChatBubble.ets
|
||
* - 悬浮输入区 + 附件选择/上传 → components/ChatComposer.ets
|
||
* 本文件保留:导航栈与附件详情二级页、以及"列表底部留白"这个
|
||
* 输入区与列表之间的耦合点(所以三个相关标志位用 @Link 与输入区共享)。
|
||
*/
|
||
@Component
|
||
export struct ChatPage {
|
||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||
/** 宽屏:左边聊天流(含底部导航栏),右边附件详情 */
|
||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||
/** 当前在右栏/二级页展示的附件;未打开时为 undefined */
|
||
@State activeAtt: ChatAttachment | undefined = undefined;
|
||
@State activeSub: string = SUB_NONE;
|
||
/** 以下三项影响列表底部留白,与输入区共享(输入区负责改) */
|
||
@State inputMultiLine: boolean = false;
|
||
@State attachMenuOpen: boolean = false;
|
||
@State pendingName: string = '';
|
||
private navStack: NavPathStack = new NavPathStack();
|
||
|
||
aboutToAppear(): void {
|
||
// 把本页导航栈登记给 Index:返回手势/三键返回按当前 Tab 精确派发到这里
|
||
registerNavStack(0, this.navStack, () => {
|
||
this.activeSub = SUB_NONE;
|
||
this.activeAtt = undefined;
|
||
});
|
||
chatStore.init();
|
||
const cur = connStore.getCurrentConnection();
|
||
if (cur !== null) {
|
||
chatStore.loadHistory();
|
||
chatStore.connect();
|
||
}
|
||
}
|
||
|
||
aboutToDisappear(): void {
|
||
unregisterNavStack(0);
|
||
chatStore.disconnect();
|
||
}
|
||
|
||
private palette(): ThemePalette {
|
||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||
}
|
||
|
||
/**
|
||
* 消息列表底部留白:悬浮输入区(导航栏上方 94 + 输入行 44 ≈ 138)
|
||
* 之上再留 ~72 的安全间距,保证最后一条气泡的思考卡/工具卡/附件
|
||
* 不被遮挡;加号菜单与待发送附件预览展开时再加高。
|
||
*/
|
||
private listBottomPad(): number {
|
||
let pad: number = 210;
|
||
if (this.inputMultiLine) {
|
||
pad += 60;
|
||
}
|
||
if (this.attachMenuOpen) {
|
||
pad += 70;
|
||
}
|
||
if (this.pendingName.length > 0) {
|
||
pad += 46;
|
||
}
|
||
return pad;
|
||
}
|
||
|
||
// ===================== 二级页面:附件详情 =====================
|
||
|
||
/**
|
||
* 打开附件详情。
|
||
* WebGUI 点图片是新开浏览器标签、点文件是直接下载;
|
||
* 移动端没有"标签页",改成一个二级页面:大图预览 + 元信息 + 保存。
|
||
*/
|
||
private openAttachment(att: ChatAttachment): void {
|
||
this.activeAtt = att;
|
||
this.activeSub = SUB_ATTACHMENT;
|
||
if (this.isWide && this.navStack.size() > 0) {
|
||
this.navStack.replacePathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
|
||
} else {
|
||
this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), true);
|
||
}
|
||
}
|
||
|
||
private closeAttachment(): void {
|
||
this.navStack.pop();
|
||
this.activeSub = SUB_NONE;
|
||
this.activeAtt = undefined;
|
||
}
|
||
|
||
build() {
|
||
// 宽屏:左栏聊天流(含底部导航与输入区),右栏附件详情。
|
||
// 窄屏:附件详情整屏覆盖,系统返回手势直接作用于 navStack。
|
||
Navigation(this.navStack) {
|
||
this.ChatBody()
|
||
}
|
||
.navDestination(this.SubDestination)
|
||
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
|
||
.navBarPosition(NavBarPosition.Start)
|
||
.navBarWidth(WIDE_NAV_BAR_WIDTH)
|
||
.minContentWidth(WIDE_MIN_CONTENT)
|
||
.hideTitleBar(true)
|
||
.hideToolBar(true)
|
||
.hideBackButton(true)
|
||
.width('100%')
|
||
.height('100%')
|
||
.onNavBarStateChange((isVisible: boolean) => {
|
||
markSubPageOpen(this.isWide || !isVisible);
|
||
})
|
||
.onNavigationModeChange((mode: NavigationMode) => {
|
||
if (mode === NavigationMode.Split) {
|
||
markSubPageOpen(true);
|
||
// 右栏不能空白:有附件就展示最近一个,没有则由 SubDestination 显示空态
|
||
if (this.navStack.size() === 0) {
|
||
const a: ChatAttachment | undefined = chatStore.latestAttachment();
|
||
this.activeAtt = a;
|
||
this.activeSub = SUB_ATTACHMENT;
|
||
this.navStack.pushPathByName(SUB_ATTACHMENT, subPageParam(SUB_ATTACHMENT), false);
|
||
}
|
||
} else {
|
||
this.navStack.clear(false);
|
||
this.activeSub = SUB_NONE;
|
||
markSubPageOpen(false);
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 附件详情二级页面 */
|
||
@Builder
|
||
SubDestination(name: string, param: object) {
|
||
NavDestination() {
|
||
if (name === SUB_ATTACHMENT) {
|
||
SubPageLayer({
|
||
title: '附件详情',
|
||
tab: 0,
|
||
onBack: () => {
|
||
this.closeAttachment();
|
||
},
|
||
}) {
|
||
this.AttachmentPane()
|
||
}
|
||
}
|
||
}
|
||
.hideTitleBar(true)
|
||
.backgroundColor(Color.Transparent)
|
||
}
|
||
|
||
@Builder
|
||
AttachmentPane() {
|
||
if (this.activeAtt !== undefined) {
|
||
AttachmentDetailContent({ att: this.activeAtt })
|
||
} else {
|
||
// 宽屏右栏空态:聊天里还没有任何图片/文件
|
||
Column({ space: 8 }) {
|
||
Image($r('app.media.ic_file'))
|
||
.width(38)
|
||
.height(38)
|
||
.fillColor(this.palette().textMuted)
|
||
.draggable(false)
|
||
Text('选择聊天中的图片或文件查看详情')
|
||
.fontSize(13)
|
||
.fontColor(this.palette().textMuted)
|
||
}
|
||
.width('100%')
|
||
.padding({ top: 60 })
|
||
.alignItems(HorizontalAlign.Center)
|
||
}
|
||
}
|
||
|
||
@Builder
|
||
ChatBody() {
|
||
Stack({ alignContent: Alignment.Bottom }) {
|
||
// 层1~1.6:消息流 + 顶栏遮罩 + 底部淡出遮罩
|
||
ChatStream({
|
||
bottomPad: this.listBottomPad(),
|
||
onOpenAttachment: (att: ChatAttachment) => {
|
||
this.openAttachment(att);
|
||
},
|
||
})
|
||
|
||
// 层2:悬浮输入区(导航栏上方,与导航栏左右平齐,各组件独立不共框)
|
||
ChatComposer({
|
||
inputMultiLine: $inputMultiLine,
|
||
attachMenuOpen: $attachMenuOpen,
|
||
pendingName: $pendingName,
|
||
})
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
}
|
||
}
|