初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View 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])
}