messageReducer.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import type {
  2. ApprovalRequest, ChatMessage, ChatRouteState, ChatUsage, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
  3. } from '@shared/types'
  4. import { createId } from './id'
  5. import { bool, number, record, string } from './normalize'
  6. type MergePosition = 'append' | 'prepend' | 'snapshot'
  7. function messageTime(message: ChatMessage): number {
  8. return message.timestamp < 10_000_000_000 ? message.timestamp * 1000 : message.timestamp
  9. }
  10. /**
  11. * Keep the conversation in its canonical chronological order even when a
  12. * history snapshot races a realtime event. Sequence is preferred when both
  13. * rows carry one; otherwise timestamps retain the order supplied by Hermes.
  14. */
  15. export function orderChatMessages(messages: ChatMessage[]): ChatMessage[] {
  16. return messages
  17. .map((message, index) => ({ message, index }))
  18. .sort((left, right) => {
  19. const leftSequence = left.message.sequence
  20. const rightSequence = right.message.sequence
  21. if (leftSequence !== undefined && rightSequence !== undefined && leftSequence !== rightSequence) {
  22. return leftSequence - rightSequence
  23. }
  24. const timeDelta = messageTime(left.message) - messageTime(right.message)
  25. return timeDelta || left.index - right.index
  26. })
  27. .map(({ message }) => message)
  28. }
  29. function sameMessage(left: ChatMessage, right: ChatMessage): boolean {
  30. if (left.id && right.id && left.id === right.id) return true
  31. return Boolean(left.clientMessageId && right.clientMessageId && left.clientMessageId === right.clientMessageId)
  32. }
  33. function mergeOne(previous: ChatMessage, incoming: ChatMessage): ChatMessage {
  34. const persistedUser = (message: ChatMessage) => message.role === 'user' && message.stage === 'settled'
  35. && message.serverMessageId === message.id && message.id !== message.clientMessageId && !message.id.startsWith('user:')
  36. // A late receipt or a cached optimistic row must not demote persisted history.
  37. if (persistedUser(previous) && !persistedUser(incoming)) [previous, incoming] = [incoming, previous]
  38. const replacesContent = Boolean(incoming.content) || Boolean(incoming.attachments?.length)
  39. return {
  40. ...previous,
  41. ...incoming,
  42. id: incoming.serverMessageId || incoming.stage === 'settled' ? incoming.id : previous.id,
  43. clientMessageId: incoming.clientMessageId ?? previous.clientMessageId,
  44. serverMessageId: incoming.serverMessageId ?? previous.serverMessageId,
  45. content: replacesContent ? incoming.content : previous.content,
  46. reasoning: incoming.reasoning ?? previous.reasoning,
  47. attachments: incoming.attachments?.length ? incoming.attachments : previous.attachments,
  48. toolCalls: incoming.toolCalls?.length ? incoming.toolCalls : previous.toolCalls,
  49. }
  50. }
  51. /** Merge only by server/client identity. Repeated text is always retained. */
  52. export function mergeChatMessages(existing: ChatMessage[], incoming: ChatMessage[], position: MergePosition = 'append'): ChatMessage[] {
  53. const base = position === 'prepend' || position === 'snapshot' ? [...incoming, ...existing] : [...existing, ...incoming]
  54. const result: ChatMessage[] = []
  55. for (const message of base) {
  56. let merged = message
  57. let index: number
  58. let insertionIndex = result.length
  59. // An enriched history row can bridge both an old server row and its local
  60. // echo already in the cache. Consume every identity match, not just the first.
  61. while ((index = result.findIndex(candidate => sameMessage(candidate, merged))) >= 0) {
  62. insertionIndex = Math.min(insertionIndex, index)
  63. merged = mergeOne(result.splice(index, 1)[0]!, merged)
  64. }
  65. result.splice(insertionIndex, 0, merged)
  66. }
  67. return orderChatMessages(result)
  68. }
  69. function payloadRecord(event: RpcEventFrame['params']): Record<string, unknown> {
  70. return record(event.payload)
  71. }
  72. function eventSessionId(event: RpcEventFrame['params'], fallback: string): string {
  73. const payload = payloadRecord(event)
  74. return string(event.session_id ?? payload.session_id ?? payload.sessionId, fallback)
  75. }
  76. function eventMessageId(payload: Record<string, unknown>, fallback: string): string {
  77. return string(payload.message_id ?? payload.messageId ?? payload.id ?? payload.run_id ?? payload.runId, fallback)
  78. }
  79. function lastUserMessage(messages: ChatMessage[]): ChatMessage | undefined {
  80. return [...messages].reverse().find(message => message.role === 'user')
  81. }
  82. function lastStreamingAssistant(messages: ChatMessage[], afterLatestUser = false): ChatMessage | undefined {
  83. let latestUserIndex = -1
  84. if (afterLatestUser) {
  85. for (let index = messages.length - 1; index >= 0; index -= 1) {
  86. if (messages[index].role === 'user') {
  87. latestUserIndex = index
  88. break
  89. }
  90. }
  91. }
  92. return messages.slice(latestUserIndex + 1).reverse()
  93. .find(message => message.role === 'assistant' && message.isStreaming)
  94. }
  95. function settleSupersededStreams(messages: ChatMessage[], current?: ChatMessage): ChatMessage[] {
  96. return messages.map(message => message.role === 'assistant' && message.isStreaming && message !== current
  97. ? { ...message, stage: message.stage === 'streaming' ? 'settled' : message.stage, isStreaming: false }
  98. : message)
  99. }
  100. function streamingSegmentId(state: ChatRouteState): string {
  101. const latestUser = lastUserMessage(state.messages)
  102. return `stream:${state.route.profile}:${state.route.sessionId}:${latestUser?.id ?? 'orphan'}:${createId('segment')}`
  103. }
  104. function sealInterimMessage(state: ChatRouteState, payload: Record<string, unknown>): ChatMessage[] {
  105. const content = string(payload.output ?? payload.text ?? payload.content)
  106. if (!content.trim()) return state.messages
  107. const current = lastStreamingAssistant(state.messages)
  108. const occurredAt = number(payload.timestamp ?? payload.created_at, Date.now() / 1000)
  109. const sealedId = current?.id ?? streamingSegmentId(state)
  110. const sealed: ChatMessage = {
  111. ...(current ?? {}),
  112. id: sealedId,
  113. clientMessageId: current?.clientMessageId ?? (sealedId.startsWith('stream:') ? sealedId : undefined),
  114. sessionId: state.route.sessionId,
  115. profile: state.route.profile,
  116. role: 'assistant',
  117. content,
  118. timestamp: current?.timestamp ?? occurredAt,
  119. stage: 'settled',
  120. isStreaming: false,
  121. raw: payload as JsonValue,
  122. }
  123. const nextId = streamingSegmentId(state)
  124. const next: ChatMessage = {
  125. id: nextId,
  126. clientMessageId: nextId,
  127. sessionId: state.route.sessionId,
  128. profile: state.route.profile,
  129. role: 'assistant',
  130. content: '',
  131. timestamp: occurredAt,
  132. stage: 'streaming',
  133. isStreaming: true,
  134. raw: { stream_interim_continuation: true },
  135. }
  136. return mergeChatMessages(mergeChatMessages(state.messages, [sealed]), [next])
  137. }
  138. function toolFromEvent(payload: Record<string, unknown>, status: ToolCall['status']): ToolCall {
  139. return {
  140. id: string(payload.tool_call_id ?? payload.toolCallId ?? payload.tool_id ?? payload.id, createId('tool')),
  141. name: string(payload.name ?? payload.tool_name, '工具'),
  142. status,
  143. arguments: (payload.arguments ?? payload.args ?? payload.context) as JsonValue | undefined,
  144. result: (payload.result ?? payload.output) as JsonValue | undefined,
  145. preview: string(payload.preview ?? payload.summary ?? payload.args_text) || undefined,
  146. error: string(payload.error) || undefined,
  147. durationSeconds: number(payload.duration_seconds ?? payload.duration_s) || undefined,
  148. }
  149. }
  150. function normalizeUsage(payload: Record<string, unknown>): ChatUsage | undefined {
  151. const raw = record(payload.usage ?? payload)
  152. if (!Object.keys(raw).length) return undefined
  153. return {
  154. inputTokens: number(raw.input_tokens ?? raw.input ?? raw.prompt) || undefined,
  155. outputTokens: number(raw.output_tokens ?? raw.output ?? raw.completion) || undefined,
  156. totalTokens: number(raw.total_tokens ?? raw.total) || undefined,
  157. contextTokens: number(raw.context_tokens ?? raw.context_used ?? raw.current_tokens ?? raw.used) || undefined,
  158. contextLimit: number(raw.context_limit ?? raw.context_max ?? raw.max_tokens ?? raw.limit) || undefined,
  159. percentUsed: number(raw.percent_used ?? raw.context_percent ?? raw.percentage) || undefined,
  160. raw: raw as JsonValue,
  161. }
  162. }
  163. function updateStreamingMessage(
  164. state: ChatRouteState,
  165. payload: Record<string, unknown>,
  166. mode: 'start' | 'delta' | 'reasoning' | 'complete' | 'failed',
  167. ): ChatMessage[] {
  168. // A start event belongs to the turn after the latest user row. An interrupted
  169. // turn may never receive a terminal event, so reusing any older streaming row
  170. // would make the new answer render above the new prompt.
  171. const current = lastStreamingAssistant(state.messages, mode === 'start')
  172. const fallbackId = current?.id ?? streamingSegmentId(state)
  173. const id = eventMessageId(payload, fallbackId)
  174. const existing = state.messages.find(message => message.id === id) ?? current
  175. const delta = string(payload.delta ?? payload.text_delta ?? payload.content_delta)
  176. const full = string(payload.output ?? payload.text ?? payload.content)
  177. const emptyInterimContinuation = mode === 'complete'
  178. && existing === current
  179. && bool(record(current?.raw).stream_interim_continuation)
  180. && !current?.content.trim()
  181. && !current?.reasoning?.trim()
  182. && !current?.toolCalls?.length
  183. if (emptyInterimContinuation && current) {
  184. const currentIndex = state.messages.indexOf(current)
  185. const previous = state.messages.slice(0, currentIndex).reverse()
  186. .find(message => message.role === 'assistant')
  187. const previousContent = previous?.content.trim() ?? ''
  188. const continuesPrevious = Boolean(full && previousContent
  189. && (full === previousContent || full.startsWith(previousContent) || previousContent.startsWith(full)))
  190. if (previous && (!full || bool(payload.response_previewed) || continuesPrevious)) {
  191. return orderChatMessages(state.messages
  192. .filter(message => message !== current)
  193. .map(message => message === previous && full
  194. ? { ...message, content: full, stage: 'settled', isStreaming: false, raw: payload as JsonValue }
  195. : message))
  196. }
  197. }
  198. let content = existing?.content ?? ''
  199. let reasoning = existing?.reasoning
  200. if (mode === 'delta') content += delta || full
  201. if (mode === 'complete') content = bool(payload.authoritative_output) && full ? full : full || content
  202. if (mode === 'reasoning') reasoning = `${reasoning ?? ''}${delta || full}`
  203. const message: ChatMessage = {
  204. id,
  205. // Keep a stable local identity while Hermes promotes a synthetic stream ID
  206. // to its authoritative message/run ID on a later event.
  207. clientMessageId: existing?.clientMessageId
  208. ?? (existing?.id.startsWith('stream:') ? existing.id : id.startsWith('stream:') ? id : undefined),
  209. serverMessageId: id.startsWith('stream:') ? undefined : id,
  210. sessionId: state.route.sessionId,
  211. profile: state.route.profile,
  212. role: 'assistant',
  213. content,
  214. reasoning,
  215. timestamp: number(payload.timestamp ?? payload.created_at, existing?.timestamp ?? Date.now() / 1000),
  216. stage: mode === 'complete' ? 'settled' : mode === 'failed' ? 'failed' : 'streaming',
  217. isStreaming: !['complete', 'failed'].includes(mode),
  218. error: mode === 'failed' ? string(payload.error ?? payload.message, '运行失败') : undefined,
  219. toolCalls: existing?.toolCalls,
  220. raw: payload as JsonValue,
  221. }
  222. const messages = mode === 'start' ? settleSupersededStreams(state.messages, current) : state.messages
  223. return mergeChatMessages(messages, [message])
  224. }
  225. export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['params']): ChatRouteState {
  226. if (event.type === 'gateway.ready') return state
  227. const payload = payloadRecord(event)
  228. if (eventSessionId(event, state.route.sessionId) !== state.route.sessionId) return state
  229. const next: ChatRouteState = { ...state, error: undefined }
  230. switch (event.type) {
  231. case 'run.peer_user_message':
  232. case 'peer.user.message': {
  233. const id=string(payload.message_id),clientMessageId=string(payload.client_message_id ?? payload.queue_id)
  234. const existing=state.messages.find(m=>m.id===id||m.clientMessageId===clientMessageId)
  235. const stage=payload.status==='failed'?'failed':payload.status==='accepted'?'accepted':existing?.stage??'pending'
  236. next.messages=mergeChatMessages(state.messages,[{...existing,id,clientMessageId,sessionId:state.route.sessionId,profile:state.route.profile,
  237. role:'user',content:string(payload.text ?? payload.content),timestamp:existing?.timestamp??number(payload.timestamp,Date.now()/1000),stage}])
  238. break
  239. }
  240. case 'message.start':
  241. case 'run.started':
  242. next.messages = updateStreamingMessage(state, payload, 'start')
  243. next.isStreaming = true
  244. next.isQueued = false
  245. break
  246. case 'message.delta':
  247. case 'content.delta':
  248. case 'assistant.delta':
  249. next.messages = updateStreamingMessage(state, payload, 'delta')
  250. next.isStreaming = true
  251. break
  252. case 'message.interim':
  253. // Hermes emits this after commentary that precedes a tool call. Seal the
  254. // visible text in place and start a fresh segment, otherwise later deltas
  255. // keep rewriting the older bubble until message.complete replaces it.
  256. next.messages = sealInterimMessage(state, payload)
  257. next.isStreaming = true
  258. break
  259. case 'reasoning.delta':
  260. next.messages = updateStreamingMessage(state, payload, 'reasoning')
  261. next.isStreaming = true
  262. break
  263. case 'thinking.delta':
  264. case 'status.update':
  265. next.liveStatus = string(payload.text ?? payload.message ?? payload.status) || undefined
  266. break
  267. case 'message.complete':
  268. case 'run.completed': {
  269. const terminalStatus = string(payload.status).trim().toLowerCase()
  270. const failed = terminalStatus === 'error' || terminalStatus === 'failed' || Boolean(payload.error)
  271. next.messages = updateStreamingMessage(state, payload, failed ? 'failed' : 'complete')
  272. next.isStreaming = false
  273. next.isQueued = false
  274. next.liveStatus = undefined
  275. next.usage = normalizeUsage(payload) ?? next.usage
  276. if (failed) next.error = string(payload.error ?? payload.text ?? payload.message, '运行失败')
  277. break
  278. }
  279. case 'error':
  280. case 'run.failed':
  281. next.messages = updateStreamingMessage(state, payload, 'failed')
  282. next.isStreaming = false
  283. next.isQueued = false
  284. next.error = string(payload.error ?? payload.message, '运行失败')
  285. break
  286. case 'run.queued':
  287. next.isQueued = true
  288. break
  289. case 'run.steered':
  290. case 'submission.acknowledged':
  291. next.isQueued = false
  292. break
  293. case 'session.usage':
  294. case 'usage.update':
  295. case 'context.update':
  296. next.usage = normalizeUsage(payload)
  297. break
  298. case 'tool.start':
  299. case 'tool.progress':
  300. case 'tool.started':
  301. case 'tool.generating': {
  302. const current = lastStreamingAssistant(next.messages)
  303. if (!current) break
  304. const tool = toolFromEvent(payload, 'running')
  305. const tools = [...(current.toolCalls ?? [])]
  306. const index = tools.findIndex(item => item.id === tool.id)
  307. if (index >= 0) tools[index] = { ...tools[index], ...tool }
  308. else tools.push(tool)
  309. next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
  310. break
  311. }
  312. case 'tool.complete':
  313. case 'tool.completed':
  314. case 'tool.failed': {
  315. const current = lastStreamingAssistant(next.messages) ?? [...next.messages].reverse().find(message => message.role === 'assistant')
  316. if (!current) break
  317. const tool = toolFromEvent(payload, event.type === 'tool.failed' || payload.error ? 'failed' : 'completed')
  318. const tools = [...(current.toolCalls ?? [])]
  319. const index = tools.findIndex(item => item.id === tool.id)
  320. if (index >= 0) tools[index] = { ...tools[index], ...tool }
  321. else tools.push(tool)
  322. next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
  323. break
  324. }
  325. case 'approval.request':
  326. case 'approval.requested': {
  327. const id = string(payload.request_id ?? payload.requestId ?? payload.id)
  328. if (id) next.pendingApproval = {
  329. id, sessionId: state.route.sessionId, message: string(payload.message ?? payload.prompt) || undefined,
  330. toolName: string(payload.tool_name ?? payload.tool) || undefined,
  331. choices: Array.isArray(payload.choices) ? payload.choices.map(String) : undefined,
  332. payload: payload as Record<string, JsonValue>,
  333. } satisfies ApprovalRequest
  334. break
  335. }
  336. case 'approval.resolved':
  337. next.pendingApproval = undefined
  338. break
  339. case 'clarify.request':
  340. case 'clarify.requested': {
  341. const id = string(payload.request_id ?? payload.requestId ?? payload.id)
  342. if (id) next.pendingClarification = {
  343. id, sessionId: state.route.sessionId, question: string(payload.question ?? payload.message ?? payload.prompt, '需要补充信息'),
  344. choices: Array.isArray(payload.choices) ? payload.choices.map(String) : undefined,
  345. payload: payload as Record<string, JsonValue>,
  346. } satisfies ClarificationRequest
  347. break
  348. }
  349. case 'clarify.expire':
  350. case 'clarify.resolved':
  351. next.pendingClarification = undefined
  352. break
  353. }
  354. return next
  355. }