messageReducer.ts 15 KB

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