import React from 'react' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/utils/cn' import type { TaskArtifact } from '../../types' import { UrlScriptPreview } from './UrlScriptPreview' import { ShellExecutePreview } from './ShellExecutePreview' import { WebSearchPreview } from './WebSearchPreview' import { extractToolOutputArtifact, normalizeArtifactFileType } from './previewUtils' import { ToolOutputArtifactPreview } from './ToolOutputArtifactPreview' export interface ToolCallPreviewProps { /** 工具名称 */ toolName?: string /** 原始事件参数(event.content.arguments) */ eventArguments?: unknown /** 工具输入参数 */ toolInput?: unknown /** 工具输出结果 */ toolOutput?: unknown /** 自定义类名 */ className?: string /** 主题 */ theme?: string } function getBaseName(path?: string): string { if (!path) return '' const cleanPath = path.split(/[?#]/)[0] const segments = cleanPath.split('/').filter(Boolean) return segments[segments.length - 1] || '' } function extractArrayToolOutputArtifact( toolOutput: unknown, toolName?: string, ): TaskArtifact | null { if (!Array.isArray(toolOutput) || toolOutput.length === 0) { return null } const firstObject = toolOutput.find( item => item && typeof item === 'object' && !Array.isArray(item), ) as Record | undefined if (!firstObject) { return null } const outputPathFields = ['image_path', 'path', 'file_path', 'file_url', 'url', 'download_url'] const outputNameFields = ['file_name', 'name', 'title'] const outputTypeFields = ['file_type', 'type', 'mime_type'] const outputPath = outputPathFields.find( field => typeof firstObject[field] === 'string' && firstObject[field], ) const pathValue = outputPath ? String(firstObject[outputPath]) : '' if (!pathValue) { return null } const outputName = outputNameFields.find( field => typeof firstObject[field] === 'string' && firstObject[field], ) const fileName = outputName ? String(firstObject[outputName]) : getBaseName(pathValue) || `${toolName || 'tool_output'}` const outputType = outputTypeFields.find( field => typeof firstObject[field] === 'string' && firstObject[field], ) const fileType = normalizeArtifactFileType( outputType ? String(firstObject[outputType]) : '', fileName, pathValue, ) return { path: pathValue, file_name: fileName, file_type: fileType, url: /^https?:\/\//.test(pathValue) ? pathValue : undefined, } } /** * 从工具输入/输出中提取代码内容 */ function extractCodeFromData(data: unknown): string | null { if (!data || typeof data !== 'object') { return null } const obj = data as Record // 常见的代码字段 const codeFields = [ 'code', 'script', 'command', 'cmd', 'content', 'source', 'text', 'body', ] for (const field of codeFields) { if (typeof obj[field] === 'string' && obj[field]) { return obj[field] as string } } return null } /** * 从工具输出中智能提取脚本 URL * 优先支持几种常见字段:url / script_url / file_url / download_url */ function extractScriptUrl(data: unknown): string | null { if (!data) return null // 纯字符串且是 URL if (typeof data === 'string') { return /^https?:\/\//.test(data) ? data : null } if (typeof data !== 'object' || data === null) return null const obj = data as Record const urlLikeFields = ['script_url', 'url', 'file_url', 'download_url'] for (const field of urlLikeFields) { const val = obj[field] if (typeof val === 'string' && /^https?:\/\//.test(val)) { return val } } return null } /** * 提取 event.content.arguments[0] 作为搜索词 */ function extractFirstArgument(args: unknown): string | null { if (!args) return null let normalized: unknown[] = [] if (Array.isArray(args)) { normalized = args } else if (typeof args === 'string') { try { const parsed = JSON.parse(args) if (Array.isArray(parsed)) { normalized = parsed } else { normalized = [args] } } catch { normalized = [args] } } else { return null } if (!normalized.length) return null const first = normalized[0] if (typeof first === 'string') return first if (typeof first === 'number' || typeof first === 'boolean') return String(first) return null } /** * 格式化数据为字符串,尝试保持原始格式 */ function formatData(data: unknown, toolName?: string): string { // 如果是字符串,直接返回 if (typeof data === 'string') { return data } // 如果是脚本执行类工具,尝试提取代码 const isCodeExecutionTool = toolName && ( toolName.includes('execute') || toolName.includes('shell') || toolName.includes('code') || toolName.includes('script') || toolName.includes('python') || toolName.includes('javascript') || toolName.includes('node') || toolName.includes('bash') ) if (isCodeExecutionTool) { const code = extractCodeFromData(data) if (code) { return code } } // 默认格式化为 JSON return JSON.stringify(data, null, 2) } /** * 将任意值拍平为 [key, displayValue][] 用于 table 渲染 * - 对象:每个 key-value 一行 * - 其他:单行 value */ function flattenToRows(data: unknown): Array<[string, string]> { if (!data) return [] // 特殊处理:当只有 value 且它是 JSON 字符串时,优先解析并展开内部字段 if ( typeof data === 'object' && data !== null && !Array.isArray(data) && 'value' in (data as Record) ) { const rawValue = (data as Record).value if (typeof rawValue === 'string') { try { const parsed = JSON.parse(rawValue) // 解析成功且是对象/数组时,递归拍平内部结构 if (parsed && typeof parsed === 'object') { return flattenToRows(parsed) } } catch { // 解析失败则继续按下面的逻辑处理 } } } // 普通对象:按 key → value 展开 if (typeof data === 'object' && data !== null && !Array.isArray(data)) { return Object.entries(data as Record).map(([k, v]) => [ k, typeof v === 'string' ? v : JSON.stringify(v, null, 2), ]) } // 根就是 JSON 字符串:尝试解析并展开 if (typeof data === 'string') { try { const parsed = JSON.parse(data) if (parsed && typeof parsed === 'object') { return flattenToRows(parsed) } } catch { // 不是合法 JSON,则按原始字符串展示 } } const str = typeof data === 'string' ? data : JSON.stringify(data, null, 2) return [['value', str]] } function ToolLabel({ label, desc }: { label: string, desc: string }) { return

{label}

{desc}
} /** * 输入参数详情面板(参考设计稿样式) */ function InputPanel({ data }: { data: unknown }) { const rows = flattenToRows(data) if (!rows.length) return null const [first, ...rest] = rows const pairs: Array> = [] for (let i = 0; i < rest.length; i += 2) { pairs.push(rest.slice(i, i + 2)) } return (
{/* 顶部标题行 */}
{/* 首行:做成醒目的大块 */} {first && (
{first[1]}
)} {/* 后续参数,两列栅格,自动铺排 */} {pairs.map((rowGroup, idx) => (
{rowGroup.map(([key, val]) => (
{val}
))}
))}
) } /** * 工具调用预览组件 * - 输入参数:table 展示 * - 输出结果:浅色主题代码块 */ export function ToolCallPreview({ toolName, eventArguments, toolInput, toolOutput, className, }: ToolCallPreviewProps) { // info_search_web:复刻 Remix 的 WebSearch 展示逻辑(列表 + 可点击链接) if (toolName === 'info_search_web') { const searchQuery = extractFirstArgument(eventArguments) return ( ) } const outputCode = formatData(toolOutput, toolName) const outputArtifact = extractToolOutputArtifact({ path: '', file_name: toolName || 'tool_output', file_type: 'tool_call', event_type: 'tool_call', tool_name: toolName, event_arguments: eventArguments, tool_input: toolInput, tool_output: toolOutput, }) ?? extractArrayToolOutputArtifact(toolOutput, toolName) if (outputArtifact) { return (
) } // 对脚本类工具,优先尝试用 UrlScriptPreview(复用 ShellExecutePreview 风格) const isScriptTool = !!toolName && toolName.toLowerCase().includes('script') const scriptUrl = isScriptTool ? extractScriptUrl(toolOutput) : null return (
{/* 工具名称 */} {toolName && (
{toolName}
)} {/* 输入参数 - Table */} {toolInput != null && (
)} {/* 输出结果 */} {toolOutput != null && (
{/* 脚本工具 + 有 URL:用 UrlScriptPreview(内部再用 ShellExecutePreview) */} {!outputArtifact && scriptUrl && (
)} {/* 其他情况:走原来的浅色代码高亮 */} {!scriptUrl && ( )}
)}
) } export default ToolCallPreview