fix(ohos): 聊天改用 seq/after 增量轮询,修「不滚动/己方消息不显示/新消息不加载」

jianf 报的三个现象其实同一个根因:WebUI 前端在 commit 9711177 已改为
「暴露数据查询 API + 前端轮询 patch 视图」,聊天记录走 /chat/history?after=<seq>
增量游标 + seq 对账;鸿蒙端一直只做**首屏全量加载**,没跟上这套口径。

1) 页面不加载新的聊天信息(根因)
   鸿蒙只在 aboutToAppear 拉一次 /chat/history?limit=40,之后除了 SSE 就没有
   任何拉取。SSE 只在「本端发起的那一轮」推事件,其他端/其他渠道(QQ、
   WebUI 浏览器)发来的消息永远不会出现在鸿蒙页面上。线上实测:鸿蒙
   IP(61.54.104.206, auth=api-key)最后一次请求停在 9/17 22:34,此后
   只有浏览器 session 在轮询。

2) App 发出的消息不显示
   原来靠 mergeHistoryWithLocal 按「正文内容」去重:本地乐观 user 消息
   与服务端回显内容一致就被判重复丢弃;同一句话发两次同样误删。
   改为按 seq 对账(新增 reconcileServerMsgs,对齐 WebUI applyServerMessages):
   服务端带 seq → 有则原地更新、无则认领本地无 seq 的同类乐观消息;
   旧后端无 seq 才退化为内容比对。

3) 加载完不滚动、停在最新消息处
   @Watch 只在值**变化**时触发,不触发初始值。ChatPage.aboutToAppear 里
   loadHistory() 是异步的,若它在 ChatStream 构造之前就完成,
   requestScroll 递增的 chatScrollRev 就成了「挂载前已发生的变化」——
   onScrollReq 永不被调,于是停在顶部/中间。
   修:ChatStream.aboutToAppear 见已有消息就自己滚一次;scrollToBottom
   的重试从 50/260ms 扩到 50..800ms(长历史布局慢,两次不够),
   并用世代号作废旧一轮定时器,避免与新滚动打架。

配套:
- Model.ChatMessage 加 seq;ChatHistory 解析 seq 与 last_seq(游标缺失时
  回退本页最大 seq,保证不倒退)。
- 新增 pollIncremental():after 增量 + limit=1 尾部探测(工具卡/最终文本是
  原地改写已有 seq,不产生新 seq,只靠 after 拿不到),3s 节奏与 WebUI 一致。
- startPolling 挂在 connect() 而非 loadHistory 末尾:首屏失败也能自愈。
- disconnect() 停轮询。

验证:hvigor assembleHap BUILD SUCCESSFUL;make check-client-versions 一致;
go build/vet/test 全量零失败。
This commit is contained in:
JianFeeeee
2026-09-18 10:49:30 +08:00
parent 1f5af1dccf
commit 831bd2290b
4 changed files with 271 additions and 41 deletions

View File

@ -16,6 +16,15 @@ export interface ParsedHistory {
offset: number;
/** 服务端是否还有更早的历史 */
hasMore: boolean;
/**
* 服务端下发的增量游标(响应里的 last_seq
*
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
* jianf 说的“暴露数据查询 api前端轮询后 patch 视图”那条路)。
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
*/
lastSeq: number;
}
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
@ -23,7 +32,7 @@ export function parseHistoryPayload(
obj: Record<string, Object>, 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 可能是对象也可能是字符串。 */

View File

@ -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<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
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=<seq> 游标commit 9711177WebUI 前端据此 3s 轮询增量并 patch
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
* 永远进不来**(页面不会加载新的聊天信息)。
*
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
* 不会产生新 seq单靠 after 拿不到这些更新。
*/
async pollIncremental(): Promise<void> {
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<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
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<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
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();
}

View File

@ -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);
}
/**

View File

@ -26,6 +26,14 @@ export interface ChatMessage {
toolCalls?: ToolCallInfo[];
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
source?: string;
/**
* 服务端单调递增序号(后端 ChatMsg.seq
*
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
* 本地乐观消息没有 seq服务端回显后靠 seq 认领并去重。
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
*/
seq?: number;
/** 图片/文件附件(后端 ChatMsg.attachment */
attachment?: ChatAttachment;
}