import { useMemo, useCallback } from 'react' import type { ApiEvent, ExtendedEvent } from '../types' import { processSlideEvents, attachSlideMetadata } from '../utils/slideEventHelpers' import { getEventRenderProps } from '../utils/event-render-props' type TodoListItem = { id?: string title?: string status?: string [key: string]: unknown } function getEventTaskId(event: ApiEvent | ExtendedEvent): string | undefined { const directKeys = ['task_id', 'plan_id', 'conversation_id'] as const for (const key of directKeys) { if (key in event) { const value = event[key] if (value !== undefined && value !== null) { return String(value) } } } const metadataTaskId = event.metadata && typeof event.metadata === 'object' && (('task_id' in event.metadata && event.metadata.task_id) || ('plan_id' in event.metadata && event.metadata.plan_id) || ('conversation_id' in event.metadata && event.metadata.conversation_id)) ? ((event.metadata.task_id ?? event.metadata.plan_id ?? event.metadata.conversation_id) as string | number | undefined | null) : undefined return metadataTaskId !== undefined && metadataTaskId !== null ? String(metadataTaskId) : undefined } function isTodoListEvent(event: ExtendedEvent): boolean { return ( event.event_type === 'task_update' && !!event.renderProps?.taskTodoConfig?.list?.length ) } function getTodoItemKey(item: TodoListItem, index: number): string { if (item.id !== undefined && item.id !== null) { return `id:${String(item.id)}` } if (typeof item.title === 'string' && item.title.trim()) { return `title:${item.title.trim()}` } return `index:${index}` } function mergeTodoListItems(group: ExtendedEvent[]): TodoListItem[] { const itemsByKey = new Map() const firstSeenKeys: string[] = [] for (const event of group) { const items = (event.renderProps?.taskTodoConfig?.list || []) as TodoListItem[] items.forEach((item, index) => { const key = getTodoItemKey(item, index) if (!itemsByKey.has(key)) { firstSeenKeys.push(key) } itemsByKey.set(key, { ...(itemsByKey.get(key) || {}), ...item, }) }) } const latestItems = (group[group.length - 1]?.renderProps?.taskTodoConfig?.list || []) as TodoListItem[] const orderedKeys = Array.from( new Set([ ...latestItems.map((item, index) => getTodoItemKey(item, index)), ...firstSeenKeys, ]), ) return orderedKeys .map(key => itemsByKey.get(key)) .filter((item): item is TodoListItem => !!item) } function mergeTodoListGroup(group: ExtendedEvent[]): ExtendedEvent { const lastEvent = group[group.length - 1] return { ...lastEvent, renderProps: lastEvent.renderProps ? { ...lastEvent.renderProps, taskTodoConfig: { list: mergeTodoListItems(group), }, } : lastEvent.renderProps, } } /** * 将 ApiEvent 转换为 ExtendedEvent,并填充 renderProps */ export function useEventConverter() { return useCallback((event: ApiEvent): ExtendedEvent => { const isUserInput = event.event_type === 'user_input' || event.role === 'user' const extEvent: ExtendedEvent = { event_id: event.event_id, event_type: event.event_type || 'message', timestamp: event.created_at ? new Date(event.created_at).getTime() : typeof event.timestamp === 'number' ? event.timestamp : event.timestamp ? new Date(event.timestamp).getTime() : Date.now(), content: { text: (event.content?.text || event.content?.content || '') as string, ...event.content, }, metadata: { ...event.metadata, isUserInput, task_id: getEventTaskId(event), role: event.role, }, event_status: event.event_status, stream: event.stream, plan_step_state: event.plan_step_state, // 填充 renderProps(核心:将原始事件结构化为渲染所需属性) renderProps: getEventRenderProps(event), } return extEvent }, []) } /** * 处理原始事件数据,去重、合并流式事件,并生成 renderProps */ export function useEventProcessor( rawEvents: ApiEvent[], ) { const convertEvent = useEventConverter() return useMemo(() => { if (!rawEvents || rawEvents.length === 0) { return [[] as ExtendedEvent[]] } // 按 event_id 去重,后来的替换先来的(流式事件也遵循此规则) const eventsMap = new Map() for (const event of rawEvents) { eventsMap.set(event.event_id, event) } // 处理 slide 流式数据 const slideData = processSlideEvents(rawEvents) // 转换、过滤、附加 slide 元数据 const displayEvents = Array.from(eventsMap.values()) .filter(event => event.is_display !== false) // 对应 Remix task-event.ts notEmpty:过滤 text 类型但内容为空的流式中间状态 .filter(event => { if (event.event_type !== 'text') return true const content = event.content?.content return !(!content || (typeof content === 'string' && !content.trim())) }) .map(event => { const extEvent = convertEvent(event) const slideMetadata = attachSlideMetadata(event, slideData) if (slideMetadata) { extEvent.metadata = { ...extEvent.metadata, slide: slideMetadata, } } return extEvent }) // 按时间戳升序排列 displayEvents.sort( (a: ExtendedEvent, b: ExtendedEvent) => (a.timestamp || 0) - (b.timestamp || 0), ) // ───────────────────────────────────────────── // browser_use 事件合并: // - 按 tool_call_id 维度将同一次浏览器操作的多条事件合并为一条 // - 保留「最后一个事件」作为展示主体 // - 如果最后一个没有截图,则从整组中向前查找最近一个带截图的 tool_output 补上 // 这样从一开始就只渲染一条 BrowserUseAction,不会出现多条拆散的浏览器卡片 // ───────────────────────────────────────────── const browserGroups = new Map() const nonBrowserEvents: ExtendedEvent[] = [] for (const event of displayEvents) { const actionType = event.renderProps?.action?.action_type if (actionType === 'browser_use') { const toolCallId = (event.content as any)?.tool_call_id || (event.renderProps?.action?.tool_input as any)?.tool_call_id || event.event_id const group = browserGroups.get(toolCallId) if (group) { group.push(event) } else { browserGroups.set(toolCallId, [event]) } } else { nonBrowserEvents.push(event) } } const mergedBrowserEvents: ExtendedEvent[] = [] for (const group of browserGroups.values()) { if (group.length === 0) continue const lastEvent = group[group.length - 1] const lastAction = lastEvent.renderProps?.action if (lastAction) { const lastToolOutput = lastAction.tool_output as | { result?: { clean_screenshot_path?: string; screenshot_path?: string } } | undefined const hasScreenshot = lastToolOutput?.result?.clean_screenshot_path || lastToolOutput?.result?.screenshot_path if (!hasScreenshot) { // 从该组中找最近一个有截图的 tool_output for (let j = group.length - 2; j >= 0; j--) { const candidateOutput = group[j].renderProps?.action?.tool_output as | { result?: { clean_screenshot_path?: string; screenshot_path?: string } } | undefined if ( candidateOutput?.result?.clean_screenshot_path || candidateOutput?.result?.screenshot_path ) { lastEvent.renderProps!.action = { ...lastEvent.renderProps!.action!, tool_output: candidateOutput, } break } } } } mergedBrowserEvents.push(lastEvent) } const mergedEvents = [...nonBrowserEvents, ...mergedBrowserEvents] // 合并后再次按时间排序,保证整体对话时间线正确 mergedEvents.sort( (a: ExtendedEvent, b: ExtendedEvent) => (a.timestamp || 0) - (b.timestamp || 0), ) // ───────────────────────────────────────────── // TodoList(task_update) 事件合并: // - 有 task_id 时,按 task_id 聚合同一条任务线的多次 task_update // - 没有 task_id 时,回退到当前用户轮次,避免无 task_id 的卡片被全局误并 // - 保留最后一个事件作为展示主体 // - Todo 列表按 id/title 增量合并,兼容后续事件只返回局部步骤的情况 // 这样消息列表中只展示一张最新的 Todo 卡片,不会被中间更新刷出多条 // ───────────────────────────────────────────── const todoGroups = new Map() const nonTodoEvents: ExtendedEvent[] = [] let currentTurnKey = 'conversation-start' for (const event of mergedEvents) { if (event.metadata?.isUserInput) { currentTurnKey = event.event_id } if (isTodoListEvent(event)) { const taskKey = getEventTaskId(event) const groupKey = taskKey ? `task:${taskKey}` : `${currentTurnKey}:no-task` const group = todoGroups.get(groupKey) if (group) { group.push(event) } else { todoGroups.set(groupKey, [event]) } } else { nonTodoEvents.push(event) } } const mergedTodoEvents = Array.from(todoGroups.entries()).map( ([, group]) => mergeTodoListGroup(group), ) const timelineEvents = [...nonTodoEvents, ...mergedTodoEvents] timelineEvents.sort( (a: ExtendedEvent, b: ExtendedEvent) => (a.timestamp || 0) - (b.timestamp || 0), ) return [timelineEvents] }, [rawEvents, convertEvent]) }