Files
OpenOPC/opc/plugins/office_ui/frontend_src/chat/ChatStore.ts
T
2026-07-14 20:31:10 +08:00

654 lines
25 KiB
TypeScript

import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import type { ChatChannel, ChatMessage } from '../types/chat'
import { stableMessageTimelineKey } from '../lib/messageTimelineIdentity'
function uid(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
const DUPLICATE_WINDOW_MS = 2000
const RESULT_SURFACE_PRIORITY: Record<string, number> = {
child_task_result: 80,
child_task_result_retry: 79,
company_role_result: 75,
company_role_result_retry: 74,
child_result: 70,
runtime_v2_assistant: 60,
runtime_v2_company_assistant: 20,
top_level_reply: 40,
worker_notification: 10,
}
function messageMetadata(message: ChatMessage): Record<string, unknown> {
return (message.metadata ?? {}) as Record<string, unknown>
}
function normalizeMessageFormatting(content: string): string {
return String(content ?? '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.map(line => line.trimEnd())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function normalizeMessageContent(content: string): string {
const normalized = normalizeMessageFormatting(content)
const titleStripped = stripNarrativeTitlePrefix(normalized)
const paragraphs = titleStripped.split(/\n{2,}/).map(part => part.trim()).filter(Boolean)
if (paragraphs.length > 1 && /^Verification:\s/i.test(paragraphs[paragraphs.length - 1])) {
return paragraphs.slice(0, -1).join('\n\n').trim()
}
return titleStripped
}
function stripNarrativeTitlePrefix(content: string): string {
let trimmed = String(content || '').trim()
// Only an explicit, first-line "**Title**: body" wrapper is removable.
// Searching for an arbitrary `: ` in the first N characters is destructive:
// ordinary Markdown such as "**Work Item 1: ...**" and list fields such as
// "- ID: ..." would be peeled one layer at a time on repeated syncs.
for (;;) {
const markdownTitle = trimmed.match(/^\*\*([^\r\n]{8,160}?)\*\*:(?:[ \t]+|\r?\n+)([\s\S]+)$/)
if (!markdownTitle) return trimmed
const body = markdownTitle[2].trim()
if (body.length < 80 || body === trimmed) return trimmed
trimmed = body
}
}
function losslessIdentityContent(
existing: ChatMessage,
candidate: ChatMessage,
preferred: ChatMessage,
sharesConcreteIdentity: boolean,
): string {
if (!sharesConcreteIdentity) return preferred.content
const existingComparable = normalizeMessageFormatting(existing.content)
const candidateComparable = normalizeMessageFormatting(candidate.content)
if (!existingComparable || !candidateComparable || existingComparable === candidateComparable) {
return preferred.content
}
// A cache/detail replay can contain a prefix-truncated copy of the same
// persistent message. Preserve the lossless source regardless of arrival
// order; unrelated edits still follow the normal preference rules.
if (existingComparable.endsWith(candidateComparable)) return existing.content
if (candidateComparable.endsWith(existingComparable)) return candidate.content
return preferred.content
}
function messageIdentityKeys(message: ChatMessage): Set<string> {
const metadata = messageMetadata(message)
const keys = new Set<string>()
const checkpointType = typeof metadata.checkpoint_type === 'string' ? metadata.checkpoint_type.trim() : ''
const checkpointId = typeof metadata.checkpoint_id === 'string' ? metadata.checkpoint_id.trim() : ''
for (const value of [
message.id,
typeof metadata.ui_message_id === 'string' ? metadata.ui_message_id : '',
typeof metadata.result_delivery_id === 'string' && metadata.result_delivery_id.trim()
? `delivery:${metadata.result_delivery_id.trim()}`
: '',
checkpointType && checkpointId ? `checkpoint:${checkpointType}:${checkpointId}` : '',
]) {
const normalized = String(value ?? '').trim()
if (normalized) keys.add(normalized)
}
return keys
}
function scopedMessageIdentityKeys(message: ChatMessage): Set<string> {
return new Set(
[...messageIdentityKeys(message)].map(key => `${message.channelId}\u0000${key}`),
)
}
function isDerivedIdentityKey(value: string): boolean {
return value.startsWith('checkpoint:') || value.startsWith('delivery:')
}
function messageTimestamp(message: ChatMessage): number {
return typeof message.timestamp === 'number' ? message.timestamp : 0
}
function isOptimisticMessage(message: ChatMessage): boolean {
return String(message.id ?? '').startsWith('msg-')
}
function isPersistentMessage(message: ChatMessage): boolean {
// sendMessage creates client-only optimistic rows with this prefix. Once the
// backend acknowledges one, message deduplication replaces its identity with
// the persistent ui_message_id / backend message id.
return !isOptimisticMessage(message)
}
function latestPersistentMessageTimestamps(messages: ChatMessage[]): Record<string, number> {
const latest: Record<string, number> = {}
for (const message of messages) {
if (!isPersistentMessage(message)) continue
const timestamp = messageTimestamp(message)
if (timestamp > (latest[message.channelId] ?? 0)) {
latest[message.channelId] = timestamp
}
}
return latest
}
function advanceReadTimestamp(
state: Record<string, number>,
channelId: string,
timestamp: number,
): Record<string, number> {
if (timestamp <= (state[channelId] ?? 0)) return state
return { ...state, [channelId]: timestamp }
}
function unreadMessageCounts(
messages: ChatMessage[],
readTimestamps: Record<string, number>,
): Record<string, number> {
const counts: Record<string, number> = {}
for (const message of messages) {
if (!isPersistentMessage(message) || message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (messageTimestamp(message) <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
const sender = String(message.sender ?? '').trim().toLowerCase()
const metadata = messageMetadata(message)
const role = typeof metadata.role === 'string' ? metadata.role.trim().toLowerCase() : ''
if (sender === 'user' || role === 'user') return 'user'
return 'assistant'
}
function messagePreferenceScore(message: ChatMessage): number {
const metadata = messageMetadata(message)
const sender = String(message.sender ?? '').trim().toLowerCase()
let score = 0
const resultPriority = resultSurfacePriority(message)
if (resultPriority) score += 1000 + resultPriority
if (metadata.source === 'engine') score += 100
if (sender && sender !== 'system') score += 20
if (sender && !['assistant', 'system', 'user'].includes(sender)) score += 5
if (message.replyToId) score += 2
score += Math.min(Object.keys(metadata).length, 10)
return score
}
function messageHasEngineSource(message: ChatMessage): boolean {
return String(messageMetadata(message).source ?? '').trim().toLowerCase() === 'engine'
}
function resultSurfacePriority(message: ChatMessage): number {
const metadata = messageMetadata(message)
const transcriptKind = String(metadata.transcript_kind ?? '').trim()
if (transcriptKind) return RESULT_SURFACE_PRIORITY[transcriptKind] ?? 0
const kind = String(metadata.kind ?? '').trim()
return RESULT_SURFACE_PRIORITY[kind] ?? 0
}
function isResultSurface(message: ChatMessage): boolean {
return resultSurfacePriority(message) > 0
}
function messagesShareIdentity(existing: ChatMessage, candidate: ChatMessage): boolean {
if (existing.channelId !== candidate.channelId) return false
const existingIds = messageIdentityKeys(existing)
const candidateIds = messageIdentityKeys(candidate)
for (const id of existingIds) {
if (candidateIds.has(id)) return true
}
return false
}
function messagesSemanticallyMatch(existing: ChatMessage, candidate: ChatMessage): boolean {
if (messagesShareIdentity(existing, candidate)) return true
if (existing.channelId !== candidate.channelId) return false
if (messageRoleBucket(existing) !== messageRoleBucket(candidate)) return false
if (normalizeMessageContent(existing.content) !== normalizeMessageContent(candidate.content)) return false
const bothResultSurfaces = isResultSurface(existing) && isResultSurface(candidate)
if (!bothResultSurfaces && String(existing.replyToId ?? '') !== String(candidate.replyToId ?? '')) return false
if (!(messageHasEngineSource(existing) || messageHasEngineSource(candidate))) return false
const existingTs = messageTimestamp(existing)
const candidateTs = messageTimestamp(candidate)
if (!bothResultSurfaces && existingTs && candidateTs && Math.abs(existingTs - candidateTs) > DUPLICATE_WINDOW_MS) {
return false
}
return true
}
function mergeDuplicateMessages(
existing: ChatMessage,
candidate: ChatMessage,
preferCandidate = false,
): ChatMessage {
let preferred = existing
let secondary = candidate
const existingOptimistic = isOptimisticMessage(existing)
const candidateOptimistic = isOptimisticMessage(candidate)
if (existingOptimistic !== candidateOptimistic) {
preferred = existingOptimistic ? candidate : existing
secondary = existingOptimistic ? existing : candidate
} else if (preferCandidate) {
preferred = candidate
secondary = existing
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
preferred = candidate
secondary = existing
}
const mentions: string[] = []
for (const values of [secondary.mentions, preferred.mentions]) {
for (const value of values ?? []) {
if (!mentions.includes(value)) mentions.push(value)
}
}
const existingIds = messageIdentityKeys(existing)
const candidateIds = messageIdentityKeys(candidate)
let sharesConcreteIdentity = false
let canonicalId = ''
for (const id of existingIds) {
if (!candidateIds.has(id)) continue
if (!isDerivedIdentityKey(id)) {
sharesConcreteIdentity = true
if (!canonicalId) canonicalId = id
}
}
const content = losslessIdentityContent(existing, candidate, preferred, sharesConcreteIdentity)
const existingCheckpointId = String(messageMetadata(existing).checkpoint_id ?? '').trim()
const candidateCheckpointId = String(messageMetadata(candidate).checkpoint_id ?? '').trim()
const preservesCheckpointPosition = !!existingCheckpointId && existingCheckpointId === candidateCheckpointId
const replacesResultSurface = isResultSurface(existing) && isResultSurface(candidate)
const retainedTimelineId = replacesResultSurface
? String(
messageMetadata(existing).ui_timeline_id
?? messageMetadata(candidate).ui_timeline_id
?? stableMessageTimelineKey(existing),
).trim()
: ''
const mergedMetadata = { ...messageMetadata(secondary), ...messageMetadata(preferred) }
if (retainedTimelineId) mergedMetadata.ui_timeline_id = retainedTimelineId
return {
...secondary,
...preferred,
...(canonicalId ? { id: canonicalId } : {}),
content,
metadata: mergedMetadata,
mentions,
timestamp: preservesCheckpointPosition || replacesResultSurface
? messageTimestamp(existing) || messageTimestamp(candidate)
: messageTimestamp(preferred) || messageTimestamp(secondary),
}
}
function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
const deduped: ChatMessage[] = []
// Map from identity key → index in deduped for O(1) identity lookups
const identityKeyToIdx = new Map<string, number>()
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
const candidateIds = scopedMessageIdentityKeys(message)
let matchIndex = -1
let preferCandidate = false
// O(1) identity lookup via Map instead of O(n) backward scan
for (const id of candidateIds) {
const idx = identityKeyToIdx.get(id)
if (idx !== undefined) {
matchIndex = idx
preferCandidate = deduped[idx].id === message.id
break
}
}
// Semantic match: scan backwards with early-exit when only short-window matches remain.
if (matchIndex === -1) {
const candidateTs = messageTimestamp(message)
const candidateIsResultSurface = isResultSurface(message)
for (let i = deduped.length - 1; i >= 0; i--) {
const existingTs = messageTimestamp(deduped[i])
if (
!candidateIsResultSurface
&& candidateTs > 0
&& existingTs > 0
&& candidateTs - existingTs > DUPLICATE_WINDOW_MS
) break
if (messagesSemanticallyMatch(deduped[i], message)) {
matchIndex = i
break
}
}
}
const insertIdx = matchIndex === -1 ? deduped.length : matchIndex
if (matchIndex === -1) {
deduped.push(message)
} else {
deduped[matchIndex] = mergeDuplicateMessages(deduped[matchIndex], message, preferCandidate)
}
// Register all identity keys for the merged/inserted message for fast future lookups
for (const id of scopedMessageIdentityKeys(deduped[insertIdx])) {
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
}
}
return deduped
}
function mergeMessagesIntoExisting(
state: ChatMessage[],
incoming: ChatMessage[],
): ChatMessage[] {
const merged = [...state]
const identityKeyToIdx = new Map<string, number>()
merged.forEach((message, index) => {
for (const key of scopedMessageIdentityKeys(message)) identityKeyToIdx.set(key, index)
})
for (const candidate of [...incoming].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
let matchIndex = -1
for (const key of scopedMessageIdentityKeys(candidate)) {
const index = identityKeyToIdx.get(key)
if (index !== undefined) {
matchIndex = index
break
}
}
if (matchIndex < 0) {
for (let index = merged.length - 1; index >= 0; index -= 1) {
if (messagesSemanticallyMatch(merged[index], candidate)) {
matchIndex = index
break
}
}
}
if (matchIndex < 0) {
matchIndex = merged.length
merged.push(candidate)
} else {
const mounted = merged[matchIndex]
merged[matchIndex] = mergeDuplicateMessages(
mounted,
candidate,
mounted.id === candidate.id,
)
}
for (const key of scopedMessageIdentityKeys(candidate)) identityKeyToIdx.set(key, matchIndex)
for (const key of scopedMessageIdentityKeys(merged[matchIndex])) identityKeyToIdx.set(key, matchIndex)
}
return merged.sort((a, b) => (
messageTimestamp(a) === messageTimestamp(b)
? a.id.localeCompare(b.id)
: messageTimestamp(a) - messageTimestamp(b)
))
}
export const __chatStoreTestUtils = {
advanceReadTimestamp,
dedupeMessages,
latestPersistentMessageTimestamps,
mergeMessagesIntoExisting,
unreadMessageCounts,
}
type ChannelAction =
| { type: 'SET'; channels: ChatChannel[] }
| { type: 'ADD'; channel: ChatChannel }
| { type: 'REMOVE'; channelId: string }
| { type: 'REMOVE_PARTICIPANT'; agentId: string }
| { type: 'CLEAR' }
function channelReducer(state: ChatChannel[], action: ChannelAction): ChatChannel[] {
switch (action.type) {
case 'SET': return action.channels
case 'CLEAR': return []
case 'ADD': return state.some(ch => ch.id === action.channel.id) ? state : [...state, action.channel]
case 'REMOVE': return state.filter(ch => ch.id !== action.channelId)
case 'REMOVE_PARTICIPANT': return state.map(ch => ({
...ch,
participants: ch.participants.filter(p => p !== action.agentId),
}))
default: return state
}
}
type MessageAction =
| { type: 'SET'; messages: ChatMessage[] }
| { type: 'ADD'; message: ChatMessage }
| { type: 'MERGE'; messages: ChatMessage[] }
| { type: 'MARK_SENDER_DELETED'; senderId: string }
| { type: 'REMOVE_BY_CHANNEL'; channelId: string }
| { type: 'REMOVE_BY_TASK_ID'; taskId: string }
| { type: 'CLEAR' }
function messageReducer(state: ChatMessage[], action: MessageAction): ChatMessage[] {
switch (action.type) {
case 'SET': {
// Backend snapshots (collab_sync / collab_sync_push) arrive frequently
// while agents are running. A naive replace drops any client-side
// optimistic message (id prefixed `msg-` from sendMessage) that the
// backend has not round-tripped yet, which causes the composer's sent
// text to flicker in and out and user input lines to disappear for
// a beat. Preserve those local-only messages here until the backend
// snapshot catches up.
const incoming = dedupeMessages(action.messages)
if (state.length === 0) return incoming
const localOnly = state.filter(existing =>
typeof existing.id === 'string' &&
existing.id.startsWith('msg-') &&
!incoming.some(inc => messagesSemanticallyMatch(existing, inc))
)
if (localOnly.length === 0) return incoming
return dedupeMessages([...incoming, ...localOnly])
}
case 'CLEAR':
return []
case 'ADD': {
// Fast path: only scan recent messages within the dedup window — avoids O(n²) full dedup
// for the common case of a single new message arriving from the WebSocket.
// Identity matches (same message_id) are checked across the full list so that
// metadata-only updates (e.g. checkpoint_status changes) always merge in-place
// regardless of how old the original message is.
const candidateTs = messageTimestamp(action.message)
const candidateIds = messageIdentityKeys(action.message)
let pastWindow = false
for (let i = state.length - 1; i >= 0; i--) {
const existingTs = messageTimestamp(state[i])
if (!pastWindow && candidateTs > 0 && existingTs > 0 && candidateTs - existingTs > DUPLICATE_WINDOW_MS) {
pastWindow = true
}
if (messagesShareIdentity(state[i], action.message)) {
const updated = [...state]
updated[i] = mergeDuplicateMessages(state[i], action.message, state[i].id === action.message.id)
return updated
}
if ((isResultSurface(action.message) || !pastWindow) && messagesSemanticallyMatch(state[i], action.message)) {
const updated = [...state]
updated[i] = mergeDuplicateMessages(state[i], action.message)
return updated
}
}
return [...state, action.message]
}
case 'MERGE': {
if (action.messages.length === 0) return state
return mergeMessagesIntoExisting(state, action.messages)
}
case 'MARK_SENDER_DELETED': return state.map(m =>
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
)
case 'REMOVE_BY_CHANNEL': return state.filter(m => m.channelId !== action.channelId)
case 'REMOVE_BY_TASK_ID': return state.filter(m =>
m.channelId !== `session:${action.taskId}` &&
!((m.metadata as Record<string, unknown>)?.task_id === action.taskId)
)
default: return state
}
}
export interface ChatStoreState {
scopeProjectId: string
channels: ChatChannel[]
messages: ChatMessage[]
sendMessage: (opts: { channelId: string; sender: string; senderName: string; content: string; replyToId?: string; metadata?: ChatMessage['metadata'] }) => ChatMessage
getChannelMessages: (channelId: string) => ChatMessage[]
getUnreadCount: (channelId: string) => number
markRead: (channelId: string) => void
markSenderDeleted: (agentId: string) => void
removeParticipant: (agentId: string) => void
removeSessionData: (taskId: string) => void
clear: () => void
initFromBackend: (projectId: string, channels: ChatChannel[], messages: ChatMessage[]) => void
addMessageFromBackend: (msg: ChatMessage) => void
mergeMessagesFromBackend: (messages: ChatMessage[]) => void
addChannelFromBackend: (ch: ChatChannel) => void
}
export function useChatStore(): ChatStoreState {
const [channels, dispatchCh] = useReducer(channelReducer, [])
const [messages, dispatchMsg] = useReducer(messageReducer, [])
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
const readBaselineProjectRef = useRef<string | null>(null)
const latestPersistentTimestamps = useMemo(
() => latestPersistentMessageTimestamps(messages),
[messages],
)
const latestPersistentTimestampsRef = useRef(latestPersistentTimestamps)
latestPersistentTimestampsRef.current = latestPersistentTimestamps
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
const buckets: Record<string, ChatMessage[]> = {}
for (const message of messages) {
if (!buckets[message.channelId]) buckets[message.channelId] = []
buckets[message.channelId].push(message)
}
return buckets
}, [messages])
const unreadCounts = useMemo(
() => unreadMessageCounts(messages, readTimestamps),
[messages, readTimestamps],
)
const sendMessage = useCallback((opts: {
channelId: string; sender: string; senderName: string; content: string;
replyToId?: string; metadata?: ChatMessage['metadata']
}) => {
const msg: ChatMessage = {
id: `msg-${uid()}`,
channelId: opts.channelId,
sender: opts.sender,
senderName: opts.senderName,
content: opts.content,
timestamp: Date.now(),
replyToId: opts.replyToId,
mentions: [],
metadata: opts.metadata,
}
dispatchMsg({ type: 'ADD', message: msg })
return msg
}, [])
const getChannelMessages = useCallback((channelId: string) => {
return messagesByChannel[channelId] ?? []
}, [messagesByChannel])
const getUnreadCount = useCallback((channelId: string) => {
return unreadCounts[channelId] ?? 0
}, [unreadCounts])
const markRead = useCallback((channelId: string) => {
const latestTimestamp = latestPersistentTimestampsRef.current[channelId] ?? 0
if (latestTimestamp <= 0) return
setReadTimestamps(prev => advanceReadTimestamp(prev, channelId, latestTimestamp))
}, [])
const markSenderDeleted = useCallback((agentId: string) => {
dispatchMsg({ type: 'MARK_SENDER_DELETED', senderId: agentId })
}, [])
const removeParticipant = useCallback((agentId: string) => {
dispatchCh({ type: 'REMOVE_PARTICIPANT', agentId })
}, [])
const removeSessionData = useCallback((taskId: string) => {
dispatchCh({ type: 'REMOVE', channelId: `session:${taskId}` })
dispatchMsg({ type: 'REMOVE_BY_TASK_ID', taskId })
}, [])
const clear = useCallback(() => {
dispatchCh({ type: 'CLEAR' })
dispatchMsg({ type: 'CLEAR' })
readBaselineProjectRef.current = null
setReadTimestamps({})
}, [])
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
const nextProjectId = projectId || 'default'
const projectChanged = nextProjectId !== scopeProjectId
const shouldResetReadBaseline = readBaselineProjectRef.current !== nextProjectId
readBaselineProjectRef.current = nextProjectId
setScopeProjectId(nextProjectId)
dispatchCh({ type: 'SET', channels: chs })
// Backend `collab_sync` / `collab_sync_push` payloads carry the
// "current window" of messages, not the full history. Dispatching
// SET here would drop any older messages that were loaded earlier
// via `session_detail` (limit: 200) — the very first user-typed
// project-goal message sits at the top of the channel and is the
// first to fall out of this window. Every subsequent push would
// then wipe it, and the next `session_detail` refresh would merge
// it back, producing a ~1s flicker cycle on the top of the list.
// MERGE instead so the snapshot is additive, not destructive.
if (projectChanged) {
dispatchMsg({ type: 'SET', messages: msgs })
} else {
dispatchMsg({ type: 'MERGE', messages: msgs })
}
// Establish one read baseline when entering a project. Repeated full-sync
// payloads must not advance it behind the viewport controller's back.
if (shouldResetReadBaseline) {
setReadTimestamps(latestPersistentMessageTimestamps(msgs))
}
}, [scopeProjectId])
const addMessageFromBackend = useCallback((msg: ChatMessage) => {
dispatchMsg({ type: 'ADD', message: msg })
}, [])
const mergeMessagesFromBackend = useCallback((msgs: ChatMessage[]) => {
dispatchMsg({ type: 'MERGE', messages: msgs })
}, [])
const addChannelFromBackend = useCallback((ch: ChatChannel) => {
dispatchCh({ type: 'ADD', channel: ch })
}, [])
return useMemo(() => ({
scopeProjectId, channels, messages,
sendMessage, getChannelMessages, getUnreadCount, markRead,
markSenderDeleted, removeParticipant, removeSessionData, clear, initFromBackend,
addMessageFromBackend, mergeMessagesFromBackend, addChannelFromBackend,
}), [scopeProjectId, channels, messages, sendMessage, getChannelMessages, getUnreadCount, markRead,
markSenderDeleted, removeParticipant, removeSessionData, clear, initFromBackend,
addMessageFromBackend, mergeMessagesFromBackend, addChannelFromBackend])
}