Files
test1/components/nova-sdk/hooks/useEventProcessor.ts
2026-03-20 07:33:46 +00:00

324 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}