messageReducer.ts 19 KB

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