324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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<string, TodoListItem>()
|
||
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<string, ApiEvent>()
|
||
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<string, ExtendedEvent[]>()
|
||
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<string, ExtendedEvent[]>()
|
||
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])
|
||
}
|