diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets index 165ae1c..ab52fe4 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets @@ -16,6 +16,15 @@ export interface ParsedHistory { offset: number; /** 服务端是否还有更早的历史 */ hasMore: boolean; + /** + * 服务端下发的增量游标(响应里的 last_seq)。 + * + * 为什么必须带回来:/chat/history?after= 只回 seq 更大的消息, + * 客户端存下游标下次带上,才能只拿增量而不重新拉整页 + * (jianf 说的“暴露数据查询 api,前端轮询后 patch 视图”那条路)。 + * 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。 + */ + lastSeq: number; } /** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */ @@ -23,7 +32,7 @@ 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 }; + return { msgs: [], offset: 0, hasMore: false, lastSeq: 0 }; } const arr: Object[] = rawList as Object[]; const msgs: ChatMessage[] = []; @@ -42,6 +51,12 @@ export function parseHistoryPayload( content: content, isFinal: true, }; + // seq:服务端单调递增序号,增量游标与 keyed 对账的定位符。 + // 缺失(旧后端/本地乐观消息)时保持 undefined,不编造。 + const seqVal: Object | undefined = item['seq']; + if (typeof seqVal === 'number' && (seqVal as number) > 0) { + msg.seq = seqVal as number; + } if (att !== undefined) { msg.attachment = att; } @@ -64,7 +79,15 @@ export function parseHistoryPayload( } 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 }; + // last_seq:增量游标。缺失时回退到本页最大 seq,保证游标不会倒退。 + let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0; + for (let i = 0; i < msgs.length; i++) { + const sq: number | undefined = msgs[i].seq; + if (sq !== undefined && sq > lastSeq) { + lastSeq = sq; + } + } + return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq }; } /** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */ diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets index f5691ed..af0d574 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets @@ -34,35 +34,114 @@ export const K_CHAT_STAGE: string = 'chatStageText'; export const K_CHAT_CONNECTED: string = 'chatSseUp'; const SSE_RECONNECT_MS: number = 5000; +/** 增量轮询间隔:与 WebUI 的 chatTicker 一致(3s)。 */ +const CHAT_POLL_MS: number = 3000; /** - * 把服务端历史与本地消息合并:历史是权威,但本地刚发出、尚未被服务端 - * 回显的消息不能丢。 + * 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的 + * applyServerMessages 同口径)。 * - * 保留两类本地消息,追加在历史末尾: - * - user 且 source 为空(本地乐观消息),且内容未出现在历史里; - * - assistant 且 !isFinal(仍在流式输出)。 + * 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把 + * 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把 + * 其中一条误判成重复而丢弃(“App 发出的消息不显示”就是这个表现)。 + * seq 是服务端分配的唯一序号,才是可靠的定位符。 + * + * 规则: + * - 服务端消息带 seq:本地已有同 seq → 原地更新(工具卡/最终文本是 + * 原地改的,不产生新 seq,只靠 after 拿不到,必须靠尾部探测更新); + * 本地没有 → 追加。 + * - 服务端消息无 seq(旧后端):退化为「本地末尾同角色同内容则认领」。 + * - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq), + * 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。 + * + * tailOnly:只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新), + * 避免把历史中间的消息插进来造成顺序错乱。 */ -function mergeHistoryWithLocal(history: ChatMessage[], local: ChatMessage[]): ChatMessage[] { - const merged: ChatMessage[] = history.slice(); - for (let i = 0; i < local.length; i++) { - const m: ChatMessage = local[i]; - if (m.role === 'user' && (m.source ?? '').length === 0) { - let dup: boolean = false; - for (let j = 0; j < history.length; j++) { - if (history[j].role === 'user' && history[j].content === m.content) { - dup = true; +function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[], + tailOnly: boolean, alloc: () => number): ChatMessage[] { + const out: ChatMessage[] = local.slice(); + for (let i = 0; i < incoming.length; i++) { + const sm: ChatMessage = incoming[i]; + const sq: number | undefined = sm.seq; + let found: number = -1; + if (sq !== undefined) { + // 从尾部往前找:新消息总在尾部,省掉全表扫描 + for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) { + if (out[j].seq === sq) { + found = j; break; } } - if (!dup) { - merged.push(m); - } - } else if (m.role === 'assistant' && m.isFinal !== true) { - merged.push(m); } + if (found >= 0) { + // 原地更新:保留本地 id(组件按 id 复用,不重建气泡), + // 只覆盖服务端权威字段。 + const prev: ChatMessage = out[found]; + if (prev.content !== sm.content) { + prev.content = sm.content; + } + if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) { + prev.reasoningContent = sm.reasoningContent; + } + if (sm.toolCalls !== undefined) { + prev.toolCalls = sm.toolCalls; + } + if (sm.attachment !== undefined) { + prev.attachment = sm.attachment; + } + if (sm.source !== undefined) { + prev.source = sm.source; + } + prev.isFinal = true; + prev.isStreaming = false; + continue; + } + if (tailOnly) { + // 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间) + let maxLocalSeq: number = 0; + for (let j = 0; j < out.length; j++) { + const ls: number | undefined = out[j].seq; + if (ls !== undefined && ls > maxLocalSeq) { + maxLocalSeq = ls; + } + } + if (sq !== undefined && sq > maxLocalSeq) { + sm.id = alloc(); + out.push(sm); + } + continue; + } + // 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。 + if (sq !== undefined) { + let claimed: number = -1; + for (let j = out.length - 1; j >= 0; j--) { + const lm: ChatMessage = out[j]; + if (lm.seq !== undefined) { + break; + } + if (lm.role === sm.role) { + claimed = j; + break; + } + } + if (claimed >= 0) { + const prev: ChatMessage = out[claimed]; + prev.seq = sq; + prev.isFinal = true; + prev.isStreaming = false; + if (sm.source !== undefined) { + prev.source = sm.source; + } + if (sm.attachment !== undefined) { + prev.attachment = sm.attachment; + } + continue; + } + } + sm.id = alloc(); + out.push(sm); } - return merged; + return out; } class ChatStore implements ChatStreamSink { @@ -79,6 +158,11 @@ class ChatStore implements ChatStreamSink { private newIds: number[] = []; // SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息 private sseActiveForTurn: boolean = false; + /** 增量游标:本地已知的最大服务端 seq(对应 WebUI 的 state.chatLastSeq) */ + private lastSeq: number = 0; + /** 增量轮询中进行中,避免重入 */ + private polling: boolean = false; + private pollTimer: number = -1; private sse: SseClient = new SseClient(); init(): void { @@ -314,25 +398,112 @@ class ChatStore implements ChatStreamSink { 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; + // 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退, + // 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。 + if (this.msgs.length === 0) { + this.msgs = parsed.msgs; + } else { + // 与本地未回显的消息按 seq 对账,而不是整表替换。 + // + // 为什么:sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争; + // 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失 + // (“客户端侧发出的消息不显示”)。 + this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false, + () => this.allocId()); } - // 与本地未回显的消息合并,而不是整表替换。 - // - // 为什么:sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争; - // 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失 - // (“客户端侧发出的消息不显示”)。同理,仍在流的助消息也不能被 - // 快照截断。 - this.msgs = mergeHistoryWithLocal(parsed.msgs, this.msgs); this.offset = parsed.offset; this.hasEarlier = parsed.hasMore; + // 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。 + if (parsed.lastSeq > this.lastSeq) { + this.lastSeq = parsed.lastSeq; + } this.forceRefresh(); this.requestScroll(); + this.startPolling(); } catch (e) { // ignore history load failure } } + /** + * 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。 + * + * 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供 + * after= 游标(commit 9711177),WebUI 前端据此 3s 轮询增量并 patch + * 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息 + * 永远进不来**(页面不会加载新的聊天信息)。 + * + * 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录, + * 不会产生新 seq,单靠 after 拿不到这些更新。 + */ + async pollIncremental(): Promise { + if (this.polling) { + return; + } + this.polling = true; + try { + if (this.lastSeq <= 0) { + // 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。 + this.polling = false; + await this.loadHistory(); + return; + } + const resp = await apiClient.getWithTimeout( + '/chat/history?after=' + this.lastSeq, 8000); + const obj: Record = JSON.parse(resp.body) as Record; + const parsed: ParsedHistory = this.parseHistory(obj); + let changed: boolean = false; + if (parsed.msgs.length > 0) { + this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false, + () => this.allocId()); + changed = true; + } + if (parsed.lastSeq > this.lastSeq) { + this.lastSeq = parsed.lastSeq; + } + // 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。 + try { + const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000); + const tailObj: Record = JSON.parse(tailResp.body) as Record; + const tail: ParsedHistory = this.parseHistory(tailObj); + if (tail.msgs.length > 0) { + const before: number = this.msgs.length; + this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true, + () => this.allocId()); + if (this.msgs.length !== before) { + changed = true; + } + } + } catch (e) { + // 尾部探测失败不影响增量结果 + } + if (changed) { + this.forceRefresh(); + } + } catch (e) { + // 轮询失败静默:下一拍会重试(SSE 仍在负责流式渲染) + } finally { + this.polling = false; + } + } + + /** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */ + startPolling(): void { + if (this.pollTimer >= 0) { + return; + } + this.pollTimer = setInterval(() => { + this.pollIncremental(); + }, CHAT_POLL_MS); + } + + stopPolling(): void { + if (this.pollTimer >= 0) { + clearInterval(this.pollTimer); + this.pollTimer = -1; + } + } + /** * 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。 * 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。 @@ -380,6 +551,11 @@ class ChatStore implements ChatStreamSink { if (cur === null) { return; } + // 增量轮询与 SSE 同时拉起:SSE 负责 token 级流式观感, + // 轮询负责「界面最终状态」——两者是两条腿,缺一不可 + // (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。 + // 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。 + this.startPolling(); this.sse.close(); this.sse.connect(cur, '/chat/events', (ev: SseEvent) => { @@ -417,6 +593,7 @@ class ChatStore implements ChatStreamSink { /** 页面消失:断线、停表,避免后台空转 */ disconnect(): void { this.cancelReconnect(); + this.stopPolling(); this.sse.close(); this.cancelRefresh(); } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets index 3f16a04..a43f2fe 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets @@ -36,10 +36,22 @@ export struct ChatStream { private scroller: Scroller = new Scroller(); private autoScrolling: boolean = false; + /** 滚动世代号:scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */ + private scrollGen: number = 0; private navHidden: boolean = false; aboutToAppear(): void { this.messages = chatStore.messages(); + // 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。 + // + // 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。 + // ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream + // 构造之前就完成了,requestScroll 递增的 chatScrollRev 就成了“挂载前 + // 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是 + // “消息加载好了却停在顶部/中间,不滚到最新”。 + if (this.messages.length > 0) { + this.scrollToBottom(); + } } private onChatRev(): void { @@ -69,20 +81,30 @@ export struct ChatStream { private scrollToBottom(): void { this.autoScrolling = true; - // 滚两次:内容高度是在消息数组更新后的若干帧内才逐步确定的。 - // 长历史/长思考卡布局慢,只滚一次会落在“当时”的底部 —— 实测初次加载 - // 历史时最后一条被输入区挡住,再手动上滑还能露出更多内容。 - setTimeout(() => { - this.scroller.scrollEdge(Edge.Bottom); - }, 50); - setTimeout(() => { - this.scroller.scrollEdge(Edge.Bottom); - }, 260); + // 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的, + // 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms, + // 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。 + // 用递增间隔重试到 ~1s,让后几帧的布局增长也跟得上。 + // + // scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。 + const gen: number = ++this.scrollGen; + const delays: number[] = [50, 120, 220, 360, 550, 800]; + for (let i = 0; i < delays.length; i++) { + setTimeout(() => { + if (gen !== this.scrollGen) { + return; + } + this.scroller.scrollEdge(Edge.Bottom); + }, delays[i]); + } setTimeout(() => { + if (gen !== this.scrollGen) { + return; + } this.autoScrolling = false; this.navHidden = false; navBar.setVisible(true); - }, 480); + }, 900); } /** diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets index d12dad6..7e14bd1 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets @@ -26,6 +26,14 @@ export interface ChatMessage { toolCalls?: ToolCallInfo[]; /** 消息来源通道:'webui' | 'channel' | 'webui/' 等;用于区分设备/渠道消息 */ source?: string; + /** + * 服务端单调递增序号(后端 ChatMsg.seq)。 + * + * 它是与后端增量查询(/chat/history?after=)对账的唯一定位符: + * 本地乐观消息没有 seq,服务端回显后靠 seq 认领并去重。 + * 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。 + */ + seq?: number; /** 图片/文件附件(后端 ChatMsg.attachment) */ attachment?: ChatAttachment; }