初始化模版工程
This commit is contained in:
323
components/nova-sdk/hooks/useEventProcessor.ts
Normal file
323
components/nova-sdk/hooks/useEventProcessor.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user