421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
/**
|
||
* 事件渲染属性生成器
|
||
*
|
||
* 完整复刻 remix next-agent 的 getEventRenderProps 逻辑:
|
||
* event-types.ts → event-transformers.ts → event-processors.ts → event-handlers.ts
|
||
*
|
||
* 用于在 useEventProcessor 中将 ApiEvent 转换为 ExtendedEvent 时填充 renderProps。
|
||
*/
|
||
import type {
|
||
ApiEvent,
|
||
MessageItemProps,
|
||
ActionInfo,
|
||
McpContent,
|
||
PlanConfig,
|
||
TaskTodoConfig,
|
||
Operation,
|
||
UserInteraction,
|
||
Attachment,
|
||
ImageAttachment,
|
||
BaseEvent,
|
||
MessageContent,
|
||
} from '../types'
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 事件类型 / 动作类型常量(对应 next-agent-chat.type.ts)
|
||
// ─────────────────────────────────────────────
|
||
|
||
const ET = {
|
||
UserInput: 'user_input',
|
||
Text: 'text',
|
||
ToolCall: 'tool_call',
|
||
TaskUpdate: 'task_update',
|
||
TaskEnd: 'task_end',
|
||
UserInteraction: 'user_interaction',
|
||
MessageAskUser: 'message_ask_user',
|
||
AskUserReply: 'ask_user_reply',
|
||
StepSummary: 'step_summary',
|
||
Summary: 'summary',
|
||
} as const
|
||
|
||
const EA = {
|
||
TextJson: 'text_json',
|
||
Mcp: 'mcp',
|
||
McpTool: 'mcp_tool',
|
||
BrowserUseTakeover: 'browser_use_takeover',
|
||
SlideInit: 'slide_init',
|
||
MessageNotifyUser: 'message_notify_user',
|
||
StepSummary: 'step_summary',
|
||
Summary: 'summary',
|
||
AgentScheduleTask: 'agent_schedule_task',
|
||
} as const
|
||
|
||
const ES = {
|
||
RUNNING: 'running',
|
||
SUCCESS: 'success',
|
||
FAILED: 'failed',
|
||
} as const
|
||
|
||
/** task_update 等无 content 的事件类型集合 */
|
||
const CONTENTLESS_EVENTS = new Set<string>([ET.TaskUpdate])
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 事件类型判断(对应 event-types.ts)
|
||
// ─────────────────────────────────────────────
|
||
|
||
function isUserInputEvent(e: ApiEvent) {
|
||
return e.event_type === ET.UserInput
|
||
}
|
||
|
||
function isToolCallEvent(e: ApiEvent) {
|
||
return e.event_type === ET.ToolCall
|
||
}
|
||
|
||
function isPlanEvent(e: ApiEvent) {
|
||
return e.event_type === ET.Text && e.content?.action_type === EA.TextJson
|
||
}
|
||
|
||
function isMcpEvent(e: ApiEvent) {
|
||
return (
|
||
isToolCallEvent(e) &&
|
||
(e.content?.action_type === EA.Mcp || e.content?.action_type === EA.McpTool)
|
||
)
|
||
}
|
||
|
||
function isBrowserUseTakeoverEvent(e: ApiEvent) {
|
||
return e.content?.action_type === EA.BrowserUseTakeover
|
||
}
|
||
|
||
function isSlideOutlineEvent(e: ApiEvent) {
|
||
return isToolCallEvent(e) && e.content?.action_type === EA.SlideInit
|
||
}
|
||
|
||
function isAgentScheduleTaskEvent(e: ApiEvent) {
|
||
return e.content?.action_type === EA.AgentScheduleTask
|
||
}
|
||
|
||
function isMessageNotifyUserEvent(e: ApiEvent) {
|
||
return isToolCallEvent(e) && e.content?.action_type === EA.MessageNotifyUser
|
||
}
|
||
|
||
function isStepSummaryEvent(e: ApiEvent) {
|
||
return (
|
||
e.event_type === ET.StepSummary && e.content?.action_type === EA.StepSummary
|
||
)
|
||
}
|
||
|
||
function isStepSummaryLegacy(e: ApiEvent) {
|
||
return (
|
||
e.event_type === ET.Summary && e.content?.action_type === EA.Summary
|
||
)
|
||
}
|
||
|
||
/** 任务结束带文件(text 事件 + attachment_files) */
|
||
function isTaskEndWithFiles(e: ApiEvent) {
|
||
return (
|
||
e.event_type === ET.Text && !!e.content?.attachment_files?.length
|
||
)
|
||
}
|
||
|
||
function isImageFile(fileType?: string) {
|
||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(
|
||
(fileType || '').toLowerCase(),
|
||
)
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 数据转换工具(对应 event-transformers.ts)
|
||
// ─────────────────────────────────────────────
|
||
|
||
/** 规范化动作类型:优先取 tool_name,fallback action_type */
|
||
function normalizeActionType(e: ApiEvent): string {
|
||
return (e.content?.tool_name || e.content?.action_type || '') as string
|
||
}
|
||
|
||
/** 创建 base 属性(对应 createBaseEventProps) */
|
||
function createBaseProps(e: ApiEvent): { base: BaseEvent } {
|
||
const normalizedTimestamp =
|
||
typeof e.timestamp === 'number'
|
||
? e.timestamp
|
||
: typeof e.content?.timestamp === 'number'
|
||
? e.content.timestamp
|
||
: undefined
|
||
|
||
return {
|
||
base: {
|
||
event_id: e.event_id,
|
||
event_type: e.event_type,
|
||
event_status: e.event_status,
|
||
stream: e.stream,
|
||
plan_step_state: e.plan_step_state,
|
||
action_type: normalizeActionType(e),
|
||
timestamp: normalizedTimestamp,
|
||
metadata: {
|
||
...(e.metadata as Record<string, unknown>),
|
||
...(e.content?.metadata as Record<string, unknown>),
|
||
isUserInput: isUserInputEvent(e),
|
||
isScheduleTask: isAgentScheduleTaskEvent(e),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
/** 创建 action 属性(对应 createActionProps) */
|
||
function createAction(e: ApiEvent): ActionInfo {
|
||
return {
|
||
name: (e.content?.action_name as string) || '',
|
||
arguments: e.content?.arguments as string[] | undefined,
|
||
action_type: normalizeActionType(e),
|
||
block: isPlanEvent(e) || isSlideOutlineEvent(e),
|
||
tool_input: e.content?.tool_input,
|
||
tool_output: e.content?.tool_output,
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 附件处理(对应 event-processors.ts processEventAttachments)
|
||
// ─────────────────────────────────────────────
|
||
|
||
function processAttachmentFiles(
|
||
files: NonNullable<ApiEvent['content']>['attachment_files'],
|
||
): Attachment[] {
|
||
if (!files?.length) return []
|
||
return files
|
||
.filter(f => f.file_name && f.file_type && f.path)
|
||
.map(f => ({
|
||
file_name: f.file_name!,
|
||
file_type: f.file_type!,
|
||
file_url: f.path!,
|
||
}))
|
||
}
|
||
|
||
function processEventAttachments(e: ApiEvent): [Attachment[], ImageAttachment[]] {
|
||
const attachments: Attachment[] = []
|
||
const imageAttachments: ImageAttachment[] = []
|
||
const toolOutput = e.content?.tool_output
|
||
|
||
// tool_output 中的单文件附件
|
||
if (
|
||
toolOutput &&
|
||
typeof toolOutput === 'object' &&
|
||
!Array.isArray(toolOutput)
|
||
) {
|
||
const to = toolOutput as Record<string, unknown>
|
||
if (to.path && to.file_type && to.file_name) {
|
||
attachments.push({
|
||
file_name: to.file_name as string,
|
||
file_type: to.file_type as string,
|
||
file_url: to.path as string,
|
||
})
|
||
}
|
||
}
|
||
|
||
// user_input / message_notify_user / step_summary 的 attachment_files
|
||
if (
|
||
(isUserInputEvent(e) ||
|
||
isMessageNotifyUserEvent(e) ||
|
||
isStepSummaryEvent(e) ||
|
||
isStepSummaryLegacy(e)) &&
|
||
e.content?.attachment_files?.length
|
||
) {
|
||
attachments.push(...processAttachmentFiles(e.content.attachment_files))
|
||
}
|
||
|
||
// 任务结束带文件(text + attachment_files)
|
||
if (isTaskEndWithFiles(e) && e.content?.attachment_files?.length) {
|
||
const all = processAttachmentFiles(e.content.attachment_files)
|
||
|
||
const imageFiles = all.filter(a => isImageFile(a.file_type))
|
||
imageAttachments.push(
|
||
...imageFiles.map(a => ({
|
||
url: a.file_url,
|
||
path: a.file_url,
|
||
file_name: a.file_name,
|
||
})),
|
||
)
|
||
const rest = all.filter(a => !isImageFile(a.file_type))
|
||
if (rest.length > 0) attachments.push(...rest.slice(0, 3))
|
||
}
|
||
|
||
return [attachments, imageAttachments]
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 通用事件属性(对应 event-processors.ts createCommonEventProps)
|
||
// ─────────────────────────────────────────────
|
||
|
||
function tryParseObject(str: string): Record<string, unknown> {
|
||
try {
|
||
const parsed = JSON.parse(str)
|
||
if (typeof parsed === 'object' && parsed !== null) return parsed
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return {}
|
||
}
|
||
|
||
function createCommonProps(e: ApiEvent): Partial<MessageItemProps> {
|
||
const base = createBaseProps(e)
|
||
const [attachments, imageAttachments] = processEventAttachments(e)
|
||
|
||
// 计划事件(text + text_json)
|
||
if (isPlanEvent(e)) {
|
||
return {
|
||
...base,
|
||
planConfig: {
|
||
...tryParseObject(
|
||
typeof e.content?.content === 'string' ? e.content.content : '{}',
|
||
),
|
||
fast_mode: e.content?.fast_mode,
|
||
} as PlanConfig,
|
||
}
|
||
}
|
||
|
||
const result: Partial<MessageItemProps> = { ...base }
|
||
|
||
if (attachments.length > 0) result.attachment = attachments
|
||
if (imageAttachments.length > 0) result.imageAttachment = imageAttachments
|
||
|
||
// 文本内容
|
||
if (
|
||
!CONTENTLESS_EVENTS.has(e.event_type) &&
|
||
e.content?.content &&
|
||
typeof e.content.content === 'string'
|
||
) {
|
||
result.content = {
|
||
content: e.content.content,
|
||
refer_content: e.content.refer_content,
|
||
} as MessageContent
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 各事件类型处理器(对应 event-handlers.ts)
|
||
// ─────────────────────────────────────────────
|
||
|
||
function handleDefault(e: ApiEvent): MessageItemProps {
|
||
return {
|
||
action: createAction(e),
|
||
...createCommonProps(e),
|
||
}
|
||
}
|
||
|
||
function handleToolCall(e: ApiEvent): MessageItemProps {
|
||
// 规范化 action_type
|
||
if (e.content) {
|
||
(e.content as Record<string, unknown>).action_type = normalizeActionType(e)
|
||
}
|
||
|
||
const baseAction = { action: createAction(e) }
|
||
|
||
// MCP 工具事件
|
||
if (isMcpEvent(e)) {
|
||
const meta = e.content?.metadata as Record<string, unknown> | undefined
|
||
return {
|
||
...createBaseProps(e),
|
||
...baseAction,
|
||
mcpContent: {
|
||
metadata: {
|
||
...meta,
|
||
name: (meta?.name || meta?.mcp_name) as string | undefined,
|
||
},
|
||
status:
|
||
!(meta?.is_auto_executed) &&
|
||
e.event_status !== ES.SUCCESS &&
|
||
e.event_status !== ES.FAILED
|
||
? 'ready'
|
||
: e.event_status,
|
||
tool_call_id: e.content?.tool_call_id,
|
||
action_type: e.content?.action_type,
|
||
tool_input: e.content?.tool_input,
|
||
tool_output: e.content?.tool_output,
|
||
} as McpContent,
|
||
}
|
||
}
|
||
|
||
// 浏览器接管事件
|
||
if (isBrowserUseTakeoverEvent(e)) {
|
||
return {
|
||
...createBaseProps(e),
|
||
...baseAction,
|
||
content: { content: '' },
|
||
operation: {
|
||
operation_type: e.content?.action_type,
|
||
content:
|
||
typeof e.content?.content === 'string' ? e.content.content : '',
|
||
} as Operation,
|
||
}
|
||
}
|
||
|
||
return { ...baseAction, ...createCommonProps(e) }
|
||
}
|
||
|
||
function handleTaskUpdate(e: ApiEvent): MessageItemProps {
|
||
const rawContent = e.content?.content
|
||
const list = Array.isArray(rawContent)
|
||
? rawContent
|
||
: rawContent &&
|
||
typeof rawContent === 'object' &&
|
||
Array.isArray((rawContent as { list?: unknown }).list)
|
||
? ((rawContent as { list: Record<string, unknown>[] }).list ?? [])
|
||
: []
|
||
|
||
return {
|
||
action: createAction(e),
|
||
...createCommonProps(e),
|
||
taskTodoConfig: {
|
||
list,
|
||
} as TaskTodoConfig,
|
||
}
|
||
}
|
||
|
||
function handleUserInteraction(e: ApiEvent): MessageItemProps {
|
||
if (e.content) {
|
||
(e.content as Record<string, unknown>).action_type = normalizeActionType(e)
|
||
}
|
||
const interactionContent = (
|
||
(e.content?.content as Record<string, unknown>) || {}
|
||
)
|
||
const { text, ...rest } = interactionContent
|
||
return {
|
||
action: createAction(e),
|
||
...createBaseProps(e),
|
||
userInteraction: { content: text as string | undefined, ...rest } as UserInteraction,
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 主入口(对应 event-handlers.ts getEventRenderProps)
|
||
// ─────────────────────────────────────────────
|
||
|
||
export function getEventRenderProps(event: ApiEvent): MessageItemProps {
|
||
switch (event.event_type) {
|
||
case ET.UserInput:
|
||
case ET.Text:
|
||
case ET.MessageAskUser:
|
||
case ET.AskUserReply:
|
||
return handleDefault(event)
|
||
|
||
case ET.ToolCall:
|
||
return handleToolCall(event)
|
||
|
||
case ET.TaskUpdate:
|
||
return handleTaskUpdate(event)
|
||
|
||
case ET.UserInteraction:
|
||
return handleUserInteraction(event)
|
||
|
||
default:
|
||
return {
|
||
action: {
|
||
action_type: normalizeActionType(event),
|
||
name: (event.content?.action_name as string) || '',
|
||
block: isSlideOutlineEvent(event),
|
||
},
|
||
...createCommonProps(event),
|
||
}
|
||
}
|
||
}
|