修复历史边界与流式消息去重并更新发布包

This commit is contained in:
shiyue
2026-09-21 00:38:51 +08:00
parent db04e088bc
commit f8f5ef3a0b
14 changed files with 1733 additions and 423 deletions

View File

@@ -1811,13 +1811,46 @@
|| message.clientMessageId
|| message.replyToRequestId
|| message.crossConversation?.replyToRequestId
|| message.crossConversation?.replyRequestId
|| message.codexAppTurnKey
|| message.nativeTurnKey;
if (explicit) return String(explicit);
const turnId = message.turnId || message.codexAppTurnId;
if (turnId) return `turn:${message.role || ''}:${turnId}`;
const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || '');
return `message:${stableMessageHash(`${message.role || ''}\u001f${message.timestamp || ''}\u001f${content}`)}`;
}
function getMessageRenderFingerprint(message) {
// 工具结果、附件和跨会话来源变化也必须刷新气泡,不能只比较正文。
return stableMessageHash(JSON.stringify(message || {}));
}
function mergeIndexedSessionMessages(snapshot, messages, incomingBase = 0) {
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const entries = new Map();
const positions = snapshot.historyMessagePositions || {};
const upsert = (message, index) => {
const key = getStableMessageKey(message);
const previous = entries.get(key);
entries.set(key, { message, index, order: previous?.order ?? entries.size });
};
existingMessages.forEach((message, index) => {
const storedIndex = positions[getStableMessageKey(message)];
upsert(message, Number.isFinite(storedIndex) ? storedIndex : existingBase + index);
});
messages.forEach((message, index) => upsert(message, incomingBase + index));
// 下标只决定位置。同下标的不同 ID 均保留,同 ID 则更新消息及位置。
const sorted = Array.from(entries.values()).sort((a, b) => a.index - b.index || a.order - b.order);
snapshot.messages = sorted.map((entry) => entry.message);
snapshot.historyMessagePositions = Object.fromEntries(sorted.map((entry) => [getStableMessageKey(entry.message), entry.index]));
snapshot.historyBaseIndex = sorted[0]?.index ?? incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
}
function findRenderedSessionMessageByKey(key) {
const normalizedKey = String(key || '').trim();
if (!normalizedKey) return null;
@@ -2493,10 +2526,10 @@
messageEl.dataset.sessionMessage = 'true';
messageEl.dataset.messageIndex = String(messageIndex);
if (message) {
messageEl.__sessionMessage = message;
const key = getStableMessageKey(message);
if (key) messageEl.dataset.messageKey = key;
const rawContent = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || '');
messageEl.dataset.messageFingerprint = stableMessageHash(`${message.role || ''}\u001f${rawContent}`);
messageEl.dataset.messageFingerprint = getMessageRenderFingerprint(message);
}
if (messageEl.classList.contains('assistant')) {
syncAssistantBranchButton(messageEl, messageIndex);
@@ -2673,6 +2706,7 @@
|| payload.taskTracking?.enabled === true,
historyTotal,
historyBaseIndex,
historyMessagePositions: payload.historyMessagePositions ? deepClone(payload.historyMessagePositions) : {},
historyBuffered,
historyCursor,
historyTruncated,
@@ -6755,6 +6789,7 @@
// session_history_chunk 的 prepend 并发,造成消息视觉顺序错乱。
immediate: !!options.immediate || !!snapshot.historyPending,
baseIndex: snapshot.historyBaseIndex || 0,
messagePositions: snapshot.historyMessagePositions,
});
scheduleAdvancedSearchJump();
if (snapshot.isRunning && snapshot.sessionId === currentSessionId) {
@@ -6765,6 +6800,7 @@
// 仅保留 streaming-msg 会导致用户气泡在第一次切换时消失。
reconcileRenderedSessionMessages(snapshot.messages || [], {
baseIndex: snapshot.historyBaseIndex || 0,
messagePositions: snapshot.historyMessagePositions,
preserveScroll: true,
});
generatingSessionId = snapshot.sessionId;
@@ -7047,36 +7083,8 @@
const bufferedSnapshot = getSessionHistoryBuffer(sessionId);
const snapshot = cachedSnapshot || bufferedSnapshot;
if (!snapshot) return;
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const stableKey = (message) => typeof getStableMessageKey === 'function'
? getStableMessageKey(message)
: String(message?.id || message?.messageId || '').trim();
const indexed = new Map();
const keyed = new Map();
existingMessages.forEach((message, index) => {
const position = existingBase + index;
indexed.set(position, message);
const key = stableKey(message);
if (key) keyed.set(key, position);
});
messages.forEach((message, index) => {
const key = stableKey(message);
if (key && keyed.has(key)) {
indexed.set(keyed.get(key), message);
return;
}
const position = incomingBase + index;
indexed.set(position, message);
if (key) keyed.set(key, position);
});
const indexes = Array.from(indexed.keys()).sort((a, b) => a - b);
snapshot.messages = indexes.map((index) => indexed.get(index));
snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
mergeIndexedSessionMessages(snapshot, messages, incomingBase);
if (Number.isFinite(Number(options.total))) snapshot.historyTotal = Math.max(0, Number(options.total));
if (Number.isFinite(Number(options.cursor))) snapshot.historyCursor = Math.max(0, Number(options.cursor));
snapshot.historyTruncated = snapshot.historyCursor > 0 || snapshot.historyBaseIndex > 0;
@@ -7094,36 +7102,8 @@
function mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, options = {}) {
if (!snapshot || !Array.isArray(messages) || messages.length === 0) return;
const existingMessages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
const existingBase = Number.isFinite(Number(snapshot.historyBaseIndex))
? Number(snapshot.historyBaseIndex)
: Math.max(0, Number(snapshot.historyTotal || existingMessages.length) - existingMessages.length);
const incomingBase = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const stableKey = (message) => typeof getStableMessageKey === 'function'
? getStableMessageKey(message)
: String(message?.id || message?.messageId || '').trim();
const indexed = new Map();
const keyed = new Map();
existingMessages.forEach((message, index) => {
const position = existingBase + index;
indexed.set(position, message);
const key = stableKey(message);
if (key) keyed.set(key, position);
});
messages.forEach((message, index) => {
const key = stableKey(message);
if (key && keyed.has(key)) {
indexed.set(keyed.get(key), message);
return;
}
const position = incomingBase + index;
indexed.set(position, message);
if (key) keyed.set(key, position);
});
const indexes = Array.from(indexed.keys()).sort((a, b) => a - b);
snapshot.messages = indexes.map((index) => indexed.get(index));
snapshot.historyBaseIndex = indexes.length > 0 ? indexes[0] : incomingBase;
snapshot.historyBuffered = snapshot.messages.length;
mergeIndexedSessionMessages(snapshot, messages, incomingBase);
if (Number.isFinite(Number(options.total))) {
snapshot.historyTotal = Math.max(0, Number(options.total));
}
@@ -8379,10 +8359,7 @@
? findRenderedSessionMessageByKey(getStableMessageKey(msg.message))
: null;
if (existingElement) {
const rawContent = typeof msg.message.content === 'string'
? msg.message.content
: JSON.stringify(msg.message.content || '');
const fingerprint = stableMessageHash(`${msg.message.role || ''}\u001f${rawContent}`);
const fingerprint = getMessageRenderFingerprint(msg.message);
if (existingElement.dataset.messageFingerprint !== fingerprint) {
const messageIndex = Number(existingElement.dataset.messageIndex);
const replacement = buildMsgElement(
@@ -8533,7 +8510,11 @@
}
break;
}
finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true });
finishGenerating(msg.sessionId, {
keepRunning: msg.goalActive === true,
assistantMessage: msg.assistantMessage,
assistantMessageIndex: msg.assistantMessageIndex,
});
break;
case 'system_message':
@@ -8625,6 +8606,7 @@
const toolsDiv = document.querySelector('#streaming-msg .msg-tools');
if (toolsDiv) toolsDiv.innerHTML = '';
}
bindStreamingAssistantMessage(msg.assistantMessageId, msg.turnId);
pendingText = msg.text || '';
flushRender();
if (msg.toolCalls && msg.toolCalls.length > 0) {
@@ -8650,19 +8632,22 @@
if (!isCurrentSessionEvent(msg)) break;
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
if (msg.sessionId === currentSessionId && Array.isArray(msg.messages) && msg.messages.length > 0) {
const renderedMessages = messagesDiv.querySelectorAll('[data-session-message="true"]').length;
const historyTotal = Number.isFinite(Number(msg.historyTotal))
? Math.max(0, Number(msg.historyTotal))
: msg.messages.length;
if (renderedMessages < historyTotal || msg.messages.some((message) => !findRenderedSessionMessageByKey(getStableMessageKey(message)))) {
reconcileRenderedSessionMessages(msg.messages, {
preserveScroll: false,
baseIndex: Number.isFinite(Number(msg.historyBaseIndex))
? Number(msg.historyBaseIndex)
: Math.max(0, historyTotal - msg.messages.length),
});
loadedHistorySessionId = msg.sessionId;
}
const historyBaseIndex = Number.isFinite(Number(msg.historyBaseIndex))
? Number(msg.historyBaseIndex)
: Math.max(0, historyTotal - msg.messages.length);
mergeHistoryChunkIntoCachedSnapshot(msg.sessionId, msg.messages, {
baseIndex: historyBaseIndex,
total: historyTotal,
});
// 重连时同 ID 的正文、工具及来源仍可能更新,数量相同也需要对账。
reconcileRenderedSessionMessages(msg.messages, {
preserveScroll: false,
baseIndex: historyBaseIndex,
});
loadedHistorySessionId = msg.sessionId;
}
setCurrentSessionRunningState(!!msg.isRunning);
if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) {
@@ -8896,6 +8881,44 @@
return true;
}
function bindStreamingAssistantMessage(messageId, turnId) {
const stream = document.getElementById('streaming-msg');
if (!stream) return;
const key = messageId || (turnId ? getStableMessageKey({ role: 'assistant', turnId }) : '');
if (!key) return;
stream.dataset.messageId = String(key);
stream.dataset.messageKey = String(key);
if (turnId) stream.dataset.turnId = String(turnId);
const existing = findRenderedSessionMessageByKey(key);
if (existing && existing !== stream) {
// 快照与运行态属于同一条助手消息,保留正在接收工具和正文的节点。
stream.__sessionMessage = existing.__sessionMessage;
stream.dataset.messageIndex = existing.dataset.messageIndex;
existing.remove();
updateUserOutlinePanel();
}
}
function commitCompletedAssistantMessage(message, requestedIndex) {
if (!message || message.role !== 'assistant') return false;
const stream = document.getElementById('streaming-msg');
const existing = findRenderedSessionMessageByKey(getStableMessageKey(message));
const knownIndex = existing?.dataset.messageIndex ?? stream?.dataset.messageIndex;
const messageIndex = Number.isFinite(requestedIndex)
? requestedIndex
: knownIndex !== undefined && Number.isFinite(Number(knownIndex))
? Number(knownIndex)
: currentSessionMessageCount;
stream?.remove();
reconcileRenderedSessionMessages([message], { baseIndex: messageIndex, preserveScroll: false });
currentSessionMessageCount = Math.max(currentSessionMessageCount, messageIndex + 1);
mergeHistoryChunkIntoCachedSnapshot(currentSessionId, [message], {
baseIndex: messageIndex,
total: currentSessionMessageCount,
});
return true;
}
function finishGenerating(sessionId, options = {}) {
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
const keepRunning = options.keepRunning === true;
@@ -8919,7 +8942,11 @@
if (typing) typing.remove();
const streamEl = document.getElementById('streaming-msg');
if (streamEl) {
const committedAssistant = commitCompletedAssistantMessage(
options.assistantMessage || streamEl?.__sessionMessage,
options.assistantMessageIndex,
);
if (streamEl && !committedAssistant) {
// 若本轮出现过父目录,把末尾散落的 .tool-call 也一并收入同一父节点
if (hasGrouped) {
const toolsDiv = streamEl.querySelector('.msg-tools');
@@ -9290,7 +9317,10 @@
|| (hasGiteaPrompt ? { source: 'gitea', label: 'Gitea', repository: '', resourceKind: 'issue', number: null, title: '' } : null);
const isGiteaMessage = role === 'user' && !!giteaSource;
const visibleContent = isGiteaMessage ? getGiteaVisibleMessageContent(content) : content;
const resolvedMessageId = meta?.messageId || meta?.id || getStableMessageKey(meta) || createLocalId('message');
const stableKey = Object.keys(meta).length > 0
? getStableMessageKey({ ...meta, role, content, attachments })
: '';
const resolvedMessageId = meta?.messageId || meta?.id || stableKey || createLocalId('message');
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}${isGiteaMessage ? ' gitea-message' : ''}`;
if (role === 'user') {
div.id = `hapi-message-${resolvedMessageId}`;
@@ -9299,9 +9329,8 @@
}
if (role !== 'system') {
div.dataset.messageId = resolvedMessageId;
div.dataset.messageKey = getStableMessageKey(meta) || resolvedMessageId;
const rawContent = typeof content === 'string' ? content : JSON.stringify(content || '');
div.dataset.messageFingerprint = stableMessageHash(`${role}\u001f${rawContent}`);
div.dataset.messageKey = stableKey || resolvedMessageId;
div.dataset.messageFingerprint = getMessageRenderFingerprint({ ...meta, role, content, attachments });
}
if (role === 'system') {
@@ -10916,6 +10945,10 @@
renderEpoch++;
const epoch = renderEpoch;
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const messageIndexAt = (index) => {
const storedIndex = options.messagePositions?.[getStableMessageKey(messages[index])];
return Number.isFinite(storedIndex) ? storedIndex : baseIndex + index;
};
closedCollabAgentIds = collectClosedCollabAgentIds(messages);
collabAgentStateCache = new Map();
collabAgentIdsByToolUseId = new Map();
@@ -10932,7 +10965,7 @@
}
if (options.immediate) {
const frag = document.createDocumentFragment();
messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, messageIndexAt(index))));
messagesDiv.appendChild(frag);
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
@@ -10955,7 +10988,7 @@
// Render first batch immediately
const frag0 = document.createDocumentFragment();
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i));
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], messageIndexAt(i)));
messagesDiv.appendChild(frag0);
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
@@ -10969,57 +11002,22 @@
delay += 16;
setTimeout(() => {
if (renderEpoch !== epoch) return; // session switched, abort stale render
const prevHeight = messagesDiv.scrollHeight;
const prevScrollTop = messagesDiv.scrollTop;
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i));
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
// Compensate scrollTop so visible area stays unchanged
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
updateScrollbar();
const pending = [];
const messagePositions = {};
for (let i = start; i < end; i++) {
const key = getStableMessageKey(messages[i]);
// 延迟批次只补缺失气泡,不能覆盖重连期间已收到的更新。
if (findRenderedSessionMessageByKey(key)) continue;
pending.push(messages[i]);
messagePositions[key] = messageIndexAt(i);
}
prependHistoryMessages(pending, { messagePositions, preserveScroll: true });
}, delay);
}
}
function prependHistoryMessages(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) return;
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const existingIndexes = new Set(Array.from(
messagesDiv.querySelectorAll('[data-session-message="true"][data-message-index]'),
).map((element) => Number(element.dataset.messageIndex)));
const existingKeys = new Set(Array.from(
messagesDiv.querySelectorAll('[data-session-message="true"][data-message-key]'),
).map((element) => String(element.dataset.messageKey || '').trim()).filter(Boolean));
const uniqueMessages = [];
messages.forEach((message, index) => {
const messageIndex = baseIndex + index;
const key = getStableMessageKey(message);
if (existingIndexes.has(messageIndex) || (key && existingKeys.has(key))) return;
existingIndexes.add(messageIndex);
if (key) existingKeys.add(key);
uniqueMessages.push({ message, index });
});
if (uniqueMessages.length === 0) return;
collectClosedCollabAgentIds(uniqueMessages.map((entry) => entry.message)).forEach((id) => closedCollabAgentIds.add(id));
const preserveScroll = options.preserveScroll !== false;
const skipScrollbar = options.skipScrollbar === true;
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
const frag = document.createDocumentFragment();
uniqueMessages.forEach(({ message, index }) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
if (!preserveScroll) {
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
if (!skipScrollbar) updateScrollbar();
return;
}
const prevHeight = messagesDiv.scrollHeight;
const prevScrollTop = messagesDiv.scrollTop;
messagesDiv.insertBefore(frag, historyLoadMore?.nextSibling || null);
updateUserOutlinePanel();
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
if (!skipScrollbar) updateScrollbar();
reconcileRenderedSessionMessages(messages, options);
}
function normalizeAskUserInput(input) {
@@ -11709,49 +11707,72 @@
function reconcileRenderedSessionMessages(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) return;
const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
const existing = new Map();
const existingByKey = new Map();
messagesDiv.querySelectorAll('[data-session-message="true"][data-message-index]').forEach((element) => {
const index = Number(element.dataset.messageIndex);
if (Number.isFinite(index)) existing.set(index, element);
const key = String(element.dataset.messageKey || '').trim();
if (key) existingByKey.set(key, element);
});
const beforeHeight = messagesDiv.scrollHeight;
const beforeScrollTop = messagesDiv.scrollTop;
const stream = document.getElementById('streaming-msg');
let inserted = 0;
const records = [];
const existingByKey = new Map();
let changed = false;
messagesDiv.querySelectorAll('[data-session-message="true"]').forEach((element) => {
if (element === stream) return;
const key = String(element.dataset.messageKey || element.dataset.messageId || '').trim();
if (key && key === stream?.dataset.messageKey) {
stream.__sessionMessage = element.__sessionMessage;
stream.dataset.messageIndex = element.dataset.messageIndex;
element.remove();
changed = true;
return;
}
if (key && existingByKey.has(key)) {
element.remove();
changed = true;
return;
}
const record = { element, index: Number(element.dataset.messageIndex), order: records.length };
records.push(record);
if (key) existingByKey.set(key, record);
});
const anchor = stream || records[records.length - 1]?.element.nextSibling || null;
collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id));
messages.forEach((message, offset) => {
const messageIndex = baseIndex + offset;
const messageKey = getStableMessageKey(message);
const keyed = existingByKey.get(messageKey);
if (keyed) {
const rawContent = typeof message.content === 'string' ? message.content : JSON.stringify(message.content || '');
const fingerprint = stableMessageHash(`${message.role || ''}\u001f${rawContent}`);
if (keyed.dataset.messageFingerprint !== fingerprint) {
const storedIndex = options.messagePositions?.[messageKey];
const messageIndex = Number.isFinite(storedIndex) ? storedIndex : baseIndex + offset;
if (messageKey && messageKey === stream?.dataset.messageKey) {
stream.__sessionMessage = message;
stream.dataset.messageIndex = String(messageIndex);
return;
}
let record = existingByKey.get(messageKey);
if (record) {
if (record.element.dataset.messageFingerprint !== getMessageRenderFingerprint(message)) {
const replacement = buildMsgElement(message, messageIndex);
keyed.replaceWith(replacement);
existingByKey.set(messageKey, replacement);
existing.set(messageIndex, replacement);
record.element.replaceWith(replacement);
record.element = replacement;
changed = true;
}
if (record.index !== messageIndex) {
markSessionMessageElement(record.element, messageIndex, message);
record.index = messageIndex;
changed = true;
}
return;
}
if (existing.has(messageIndex)) return;
const element = buildMsgElement(message, messageIndex);
const next = Array.from(existing.entries())
.filter(([index]) => index > messageIndex)
.sort(([left], [right]) => left - right)[0]?.[1];
messagesDiv.insertBefore(element, next || stream || null);
existing.set(messageIndex, element);
if (messageKey) existingByKey.set(messageKey, element);
inserted += 1;
record = { element: buildMsgElement(message, messageIndex), index: messageIndex, order: records.length };
records.push(record);
if (messageKey) existingByKey.set(messageKey, record);
changed = true;
});
if (inserted === 0) return;
if (!changed) return;
messagesDiv.querySelector('.welcome-msg')?.remove();
// 重新排序仅移动历史节点,保留正在生成的 DOM 与流式工具状态。
records.sort((a, b) => a.index - b.index || a.order - b.order);
records.forEach((record) => messagesDiv.insertBefore(record.element, anchor));
updateUserOutlinePanel();
if (options.preserveScroll !== false) {
messagesDiv.scrollTop = beforeScrollTop + (messagesDiv.scrollHeight - beforeHeight);
}
updateScrollbar();
if (!options.skipScrollbar) updateScrollbar();
}
// --- Custom Scrollbar ---