421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
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<string, unknown> | 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<string, unknown>
|
||
|
||
// 常见的代码字段
|
||
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<string, unknown>
|
||
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<string, unknown>)
|
||
) {
|
||
const rawValue = (data as Record<string, unknown>).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<string, unknown>).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 <div className="flex items-baseline justify-between mb-4">
|
||
<h2 className="text-[14px] font-bold text-muted-foreground uppercase tracking-[0.2em]">
|
||
{label}
|
||
</h2>
|
||
<div className="mx-4 h-px flex-1 bg-border/80" />
|
||
<span className="text-[11px] font-mono text-muted-foreground">
|
||
{desc}
|
||
</span>
|
||
</div>
|
||
}
|
||
|
||
/**
|
||
* 输入参数详情面板(参考设计稿样式)
|
||
*/
|
||
function InputPanel({ data }: { data: unknown }) {
|
||
const rows = flattenToRows(data)
|
||
if (!rows.length) return null
|
||
|
||
const [first, ...rest] = rows
|
||
const pairs: Array<Array<[string, string]>> = []
|
||
|
||
for (let i = 0; i < rest.length; i += 2) {
|
||
pairs.push(rest.slice(i, i + 2))
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 顶部标题行 */}
|
||
<ToolLabel label="输入参数" desc={`${rows.length} keys`} />
|
||
|
||
<div className="grid grid-cols-1 gap-y-6">
|
||
{/* 首行:做成醒目的大块 */}
|
||
{first && (
|
||
<div className="group">
|
||
<label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-primary">
|
||
{first[0]}
|
||
</label>
|
||
<div className="rounded-lg border border-border bg-card/84 px-3 py-2.5 font-mono text-sm text-foreground transition-all group-hover:border-primary/40">
|
||
{first[1]}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 后续参数,两列栅格,自动铺排 */}
|
||
{pairs.map((rowGroup, idx) => (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8" key={idx}>
|
||
{rowGroup.map(([key, val]) => (
|
||
<div className="group" key={key}>
|
||
<label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-primary">
|
||
{key}
|
||
</label>
|
||
<div className="break-all font-mono text-sm text-foreground">
|
||
{val}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 工具调用预览组件
|
||
* - 输入参数: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 (
|
||
<WebSearchPreview
|
||
results={toolOutput}
|
||
searchQuery={searchQuery}
|
||
className={className}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className={cn('h-full overflow-hidden', className)}>
|
||
<ToolOutputArtifactPreview artifact={outputArtifact} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 对脚本类工具,优先尝试用 UrlScriptPreview(复用 ShellExecutePreview 风格)
|
||
const isScriptTool = !!toolName && toolName.toLowerCase().includes('script')
|
||
const scriptUrl = isScriptTool ? extractScriptUrl(toolOutput) : null
|
||
|
||
return (
|
||
<div className={cn('flex flex-col h-full', className)}>
|
||
<ScrollArea className="flex-1">
|
||
<div className="p-6 space-y-8 mt-[50px]">
|
||
{/* 工具名称 */}
|
||
{toolName && (
|
||
<div className="mb-6">
|
||
<ToolLabel label="工具调用" desc={toolName} />
|
||
<div className="inline-flex items-center rounded-md bg-primary/10 px-3 py-1.5 font-mono text-sm text-primary">
|
||
{toolName}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入参数 - Table */}
|
||
{toolInput != null && (
|
||
<div>
|
||
<InputPanel data={toolInput} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 输出结果 */}
|
||
{toolOutput != null && (
|
||
<div>
|
||
<ToolLabel label="输出结果" desc="200 ok" />
|
||
|
||
{/* 脚本工具 + 有 URL:用 UrlScriptPreview(内部再用 ShellExecutePreview) */}
|
||
{!outputArtifact && scriptUrl && (
|
||
<div className="rounded-lg border border-border overflow-hidden">
|
||
<UrlScriptPreview url={scriptUrl} title={toolName} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 其他情况:走原来的浅色代码高亮 */}
|
||
{!scriptUrl && (
|
||
<ShellExecutePreview output={outputCode} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ToolCallPreview
|