mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-24 02:48:04 +00:00
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:
@ -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 可能是对象也可能是字符串。 */
|
||||
|
||||
@ -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 9711177),WebUI 前端据此 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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user