Files
test1/components/nova-sdk/task-panel/Preview/ToolCallPreview.tsx
2026-03-20 07:33:46 +00:00

421 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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