mateclaw/mateclaw-ui/src/utils/messageReconcile.ts

273 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 消息 reconcile 工具 — 防止 poorer DB 快照覆盖 local rich message。
*
* 问题场景:流结束后 onStreamEnd → refreshCurrentConversationMessages() 从 DB 拉消息,
* 但此时后端 API 返回的 metadata 可能因 JSON 编码问题丢失 segments
* 整表替换会把本地包含完整 segments 的 rich message 覆盖为 poorer 版本。
*
* 解决:逐条按 id 比较"丰富度",只接受更完整的版本。
*/
import type { Message } from '@/types'
/** 安全 parse metadata处理 string / double-encoded / object / null */
function safeParseMeta(metadata: any): Record<string, any> {
if (!metadata) return {}
if (typeof metadata === 'object') return metadata
if (typeof metadata === 'string') {
try {
let parsed = JSON.parse(metadata)
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
}
return typeof parsed === 'object' && parsed !== null ? parsed : {}
} catch {
return {}
}
}
return {}
}
/**
* 计算消息丰富度分数。分数越高,消息包含的展示信息越完整。
* 只对 assistant 消息有意义。
*/
export function messageRichness(msg: Message): number {
if (msg.role !== 'assistant') return 0
let score = 0
const meta = safeParseMeta(msg.metadata)
// segments 是分段渲染的权威数据源,权重最高
const segs = Array.isArray(meta.segments) ? meta.segments : []
score += segs.length * 120
// toolCalls
const tcs = Array.isArray(meta.toolCalls) ? meta.toolCalls : []
score += tcs.length * 40
// contentParts 中的 thinking / tool_call
const parts = Array.isArray(msg.contentParts) ? msg.contentParts : []
if (parts.some(p => p.type === 'thinking')) score += 120
score += parts.filter(p => p.type === 'tool_call').length * 40
// content 文本长度只作为弱信号,避免“最终总结更长”把 richer timeline 冲掉
score += Math.min((msg.content?.length || 0), 80)
return score
}
function partKey(part: any): string {
if (!part || !part.type) return ''
return [
part.type,
part.text || '',
part.fileUrl || '',
part.fileName || '',
part.path || '',
].join('::')
}
function mergeContentParts(localParts: any[], fetchedParts: any[]): any[] {
if (!localParts.length) return fetchedParts
if (!fetchedParts.length) return localParts
const merged = [...fetchedParts]
const seen = new Set(merged.map(partKey))
for (const part of localParts) {
const key = partKey(part)
if (!key || seen.has(key)) continue
merged.push(part)
seen.add(key)
}
const order: Record<string, number> = {
thinking: 0,
tool_call: 1,
text: 2,
image: 3,
file: 4,
audio: 5,
video: 6,
}
return merged.sort((a, b) => (order[a.type] ?? 99) - (order[b.type] ?? 99))
}
/** 审批的已决状态(前端通过 SSE 实时更新,比后端持久化更新) */
const RESOLVED_APPROVAL = new Set(['expired', 'approved', 'denied'])
function mergeMetadata(localMetaRaw: any, fetchedMetaRaw: any): Record<string, any> {
const localMeta = safeParseMeta(localMetaRaw)
const fetchedMeta = safeParseMeta(fetchedMetaRaw)
const merged: Record<string, any> = { ...localMeta, ...fetchedMeta }
const localSegs = Array.isArray(localMeta.segments) ? localMeta.segments : []
const fetchedSegs = Array.isArray(fetchedMeta.segments) ? fetchedMeta.segments : []
if (localSegs.length > fetchedSegs.length) {
merged.segments = localSegs
} else if (fetchedSegs.length > 0) {
merged.segments = fetchedSegs
}
const localToolCalls = Array.isArray(localMeta.toolCalls) ? localMeta.toolCalls : []
const fetchedToolCalls = Array.isArray(fetchedMeta.toolCalls) ? fetchedMeta.toolCalls : []
if (localToolCalls.length > fetchedToolCalls.length) {
merged.toolCalls = localToolCalls
} else if (fetchedToolCalls.length > 0) {
merged.toolCalls = fetchedToolCalls
}
// pendingApproval前端已决/过期状态不被后端的 pending 状态回退
const localApprovalStatus = localMeta.pendingApproval?.status
if (RESOLVED_APPROVAL.has(localApprovalStatus)) {
merged.pendingApproval = localMeta.pendingApproval
// 清除已过期审批关联的 phase 字段,防止 UI 残留
if (localApprovalStatus === 'expired') {
delete merged.currentPhase
delete merged.runningToolName
}
} else if (!merged.pendingApproval && localMeta.pendingApproval) {
merged.pendingApproval = localMeta.pendingApproval
}
return merged
}
function mergeAssistantMessages(localMsg: Message, fetchedMsg: Message): Message {
const localRichness = messageRichness(localMsg)
const fetchedRichness = messageRichness(fetchedMsg)
const richer = localRichness >= fetchedRichness ? localMsg : fetchedMsg
const poorer = richer === localMsg ? fetchedMsg : localMsg
// 取 richer 的 contentParts 而非合并两边,避免 tool_call 因序列化差异导致重复
const localParts = Array.isArray(localMsg.contentParts) ? localMsg.contentParts : []
const fetchedParts = Array.isArray(fetchedMsg.contentParts) ? fetchedMsg.contentParts : []
const contentParts = localParts.length >= fetchedParts.length ? localParts : fetchedParts
return {
...poorer,
...richer,
// 用 fetched 的持久化字段覆盖id、status、tokens 等)
id: fetchedMsg.id || localMsg.id,
content: (fetchedMsg.content?.length || 0) >= (localMsg.content?.length || 0)
? fetchedMsg.content
: localMsg.content,
contentParts,
metadata: mergeMetadata(localMsg.metadata, fetchedMsg.metadata),
// 不要把已终结的状态failed/completed/stopped回退为 awaiting_approval
status: (fetchedMsg.status === 'awaiting_approval' && localMsg.status && localMsg.status !== 'awaiting_approval')
? localMsg.status
: (fetchedMsg.status || localMsg.status),
promptTokens: fetchedMsg.promptTokens ?? localMsg.promptTokens,
completionTokens: fetchedMsg.completionTokens ?? localMsg.completionTokens,
createTime: fetchedMsg.createTime || localMsg.createTime,
conversationId: fetchedMsg.conversationId || localMsg.conversationId,
thinkingExpanded: localMsg.thinkingExpanded ?? fetchedMsg.thinkingExpanded,
}
}
/**
* 逐条 reconcile对每条消息按 id 匹配,只接受更丰富的版本。
*
* - 新消息fetched 有但 local 没有):接受 fetched
* - 非 assistant 消息:直接用 fetched
* - assistant 消息:比较 richness取更高分的版本
* - 本地有但 fetched 没有的 assistant 消息:保留(防止 lagging snapshot 丢消息)
*/
/**
* 判断是否为"客户端临时 id"UUID / 带字母的非数字 id
* 服务端 id 是雪花算法生成的 Long全数字字符串
*/
function isClientId(id: any): boolean {
if (id === null || id === undefined) return true
const s = String(id)
return s === '' || !/^\d+$/.test(s)
}
export function reconcileMessages(local: Message[], fetched: Message[]): Message[] {
if (!local.length) return fetched
if (!fetched.length) return local
const localMap = new Map<string, Message>()
for (const m of local) {
localMap.set(String(m.id), m)
}
const matchedLocalIds = new Set<string>()
const result: Message[] = []
// 收集未被 id 匹配过的本地 assistant通常是流式产生的 client-uuid placeholder
// 供 fetched 端新 assistant"认领"它们的 timeline避免两条并排。
const unclaimedLocalAssistants: Message[] = []
for (const lm of local) {
if (lm.role === 'assistant' && isClientId(lm.id)) {
unclaimedLocalAssistants.push(lm)
}
}
for (const fm of fetched) {
const fid = String(fm.id)
const lm = localMap.get(fid)
if (lm) {
if (fm.role !== 'assistant') {
result.push(fm)
} else {
// assistant 消息:不要整条覆盖,合并 fetched 的持久化字段与 local 的 richer timeline
result.push(mergeAssistantMessages(lm, fm))
}
matchedLocalIds.add(fid)
continue
}
// fetched 里这条本地没有
if (fm.role === 'assistant' && unclaimedLocalAssistants.length > 0) {
// 尝试"认领"本地一个 client-uuid placeholder
// 取队首(最早的未认领),合并 richness 后采用 fetched 的持久化 id/时间。
// 这消除了"本地流式气泡"+"刚落库的 DB assistant"同时存在的重复。
const claimed = unclaimedLocalAssistants.shift()!
matchedLocalIds.add(String(claimed.id))
result.push(mergeAssistantMessages(claimed, fm))
} else {
result.push(fm)
}
}
// 保留 fetched 中不存在的本地消息user + assistant防止 lagging snapshot 丢弃刚发送的消息
const fetchedConversationId = fetched.length > 0 ? (fetched[0] as any).conversationId : ''
for (const lm of local) {
const lid = String(lm.id)
if (!matchedLocalIds.has(lid)) {
// 跳过不属于当前对话的本地消息,防止跨对话污染
const lmConvId = (lm as any).conversationId
if (!lmConvId || (fetchedConversationId && lmConvId !== fetchedConversationId)) {
continue
}
// 只保留在 fetched 末尾之后的消息(刚发送/刚完成DB 还没返回)
const lastFetchedTime = result.length > 0 ? result[result.length - 1].createTime : ''
if (!lastFetchedTime || (lm.createTime && lm.createTime >= lastFetchedTime)) {
result.push(lm)
}
}
}
return result
}
/**
* 统一解析 API 响应中的消息列表。
* 后端有两种返回模式:
* - 不传 limitR.ok(List<MessageVO>) → res.data 是数组
* - 传 limitR.ok({messages, hasMore}) → res.data 是对象
*/
export function extractMessages(res: any): { messages: any[], hasMore: boolean } {
const data = res?.data
if (Array.isArray(data)) {
return { messages: data, hasMore: false }
}
if (data && Array.isArray(data.messages)) {
return { messages: data.messages, hasMore: !!data.hasMore }
}
return { messages: [], hasMore: false }
}