初始化模版工程

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,347 @@
import React, { useMemo, useState } from 'react'
import { ChevronRight, Folder, FolderOpen, X } from 'lucide-react'
import { cn } from '@/utils/cn'
import type { TaskArtifact } from '../types'
import { getFileIconConfig } from '../utils/fileIcons'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface ArtifactListProps {
artifacts: TaskArtifact[]
onClick?: (artifact: TaskArtifact) => void
onClose?: () => void
selected?: TaskArtifact | null
className?: string
}
function getFileTypeLabel(artifact: TaskArtifact) {
const raw =
artifact.file_type ||
artifact.file_name.split('.').pop() ||
artifact.path?.split('.').pop() ||
''
const ext = raw.toLowerCase()
if (!ext) return '文件'
switch (ext) {
case 'py':
return 'Python 脚本'
case 'html':
return 'HTML 文档'
case 'md':
case 'markdown':
return 'Markdown 文件'
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'webp':
return '图片文件'
case 'pdf':
return 'PDF 文件'
case 'csv':
return 'CSV 文件'
case 'ppt':
case 'pptx':
return '演示文稿'
default:
return `${ext.toUpperCase()} 文件`
}
}
interface TreeNode {
name: string
path: string
isFolder: boolean
fileCount: number
children?: TreeNode[]
artifact?: TaskArtifact
}
type BuildNode = {
name: string
path: string
isFolder: boolean
fileCount: number
children?: Record<string, BuildNode>
artifact?: TaskArtifact
}
// 只要是 http/https 链接,一律视为「网页」,不在文件列表中展示
function isHttpUrlArtifact(artifact: TaskArtifact): boolean {
const rawPath = artifact.path || artifact.file_name || ''
return /^https?:\/\//.test(rawPath)
}
function buildFileTree(artifacts: TaskArtifact[]): TreeNode[] {
const root: Record<string, BuildNode> = {}
artifacts.forEach(artifact => {
const fullPath = artifact.path || artifact.file_name
// 对于 http/https URL去掉协议和域名部分只使用路径来构建「目录」
let normalizedPath = fullPath
if (fullPath.startsWith('http://') || fullPath.startsWith('https://')) {
try {
const url = new URL(fullPath)
normalizedPath = url.pathname && url.pathname !== '/' ? url.pathname : url.hostname
} catch {
// 如果 URL 解析失败,退回原始 fullPath
normalizedPath = fullPath
}
}
const parts = normalizedPath.split('/').filter(Boolean)
let cur = root
let curPath = ''
parts.forEach((part, i) => {
curPath = curPath ? `${curPath}/${part}` : part
const isLast = i === parts.length - 1
if (!cur[part]) {
cur[part] = {
name: part,
path: curPath,
isFolder: !isLast,
fileCount: 0,
children: isLast ? undefined : {},
artifact: isLast ? artifact : undefined,
}
} else if (isLast) {
cur[part].artifact = artifact
}
if (!isLast && cur[part].children) {
cur = cur[part].children!
}
})
})
const toArr = (obj: Record<string, BuildNode>): TreeNode[] =>
Object.values(obj)
.map((n) => {
const children = n.children ? toArr(n.children) : undefined
const fileCount = n.isFolder
? (children || []).reduce((sum, child) => sum + child.fileCount, 0)
: 1
return {
name: n.name,
path: n.path,
isFolder: n.isFolder,
fileCount,
artifact: n.artifact,
children,
}
})
.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1
return a.name.localeCompare(b.name)
})
return toArr(root)
}
function isSelectedArtifact(node: TreeNode, selected?: TaskArtifact | null) {
return !node.isFolder && !!node.artifact && selected?.path === node.artifact.path
}
interface TreeItemProps {
node: TreeNode
depth?: number
collapsedPaths: Set<string>
selected?: TaskArtifact | null
onToggle: (path: string) => void
onSelect?: (artifact: TaskArtifact) => void
}
function TreeItem({
node,
depth = 0,
collapsedPaths,
selected,
onToggle,
onSelect,
}: TreeItemProps) {
const isFolder = node.isFolder
const isExpanded = isFolder && !collapsedPaths.has(node.path)
const selectedState = isSelectedArtifact(node, selected)
const { icon: FileIcon, color } = isFolder
? { icon: isExpanded ? FolderOpen : Folder, color: 'text-primary' }
: getFileIconConfig(
node.artifact?.file_type || node.artifact?.file_name.split('.').pop() || '',
)
return (
<div>
<button
type="button"
onClick={() => {
if (isFolder) {
onToggle(node.path)
return
}
if (node.artifact) {
onSelect?.(node.artifact)
}
}}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-4 text-left transition-colors',
'hover:bg-accent/70',
selectedState && 'bg-primary/10 text-primary dark:bg-primary/18 dark:text-primary-foreground',
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{isFolder && (<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<ChevronRight className={cn('h-4 w-4 text-muted-foreground transition-transform', isExpanded && 'rotate-90')} />
</span>
)}
<FileIcon className={cn('h-4 w-4 shrink-0', color)} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{isFolder
? `${node.fileCount}`
: node.artifact
? getFileTypeLabel(node.artifact)
: '文件'}
</span>
</button>
{isFolder && isExpanded && node.children?.length ? (
<div className="ml-4 border-l border-border pl-2">
{node.children.map(child => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
collapsedPaths={collapsedPaths}
selected={selected}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
function InnerArtifactList({ artifacts, onClick, onClose, selected, className }: ArtifactListProps) {
const files = useMemo(
() =>
artifacts.filter(
(a) =>
a.event_type !== 'tool_call' &&
!isHttpUrlArtifact(a),
),
[artifacts],
)
const [collapsedPaths, setCollapsedPaths] = useState<Set<string>>(() => new Set())
const tree = useMemo(
() => buildFileTree(files),
[files],
)
const fileCount = files.length
if (fileCount === 0) {
return (
<div className={cn('flex items-center justify-center h-full text-sm text-muted-foreground', className)}>
</div>
)
}
return (
<div
className={cn(
'flex h-full bg-card overflow-hidden border border-border',
className,
)}
>
<main className="flex-1 flex min-w-0 flex-col">
<header className="h-12 md:h-14 border-b border-border flex items-center justify-between px-4 md:px-6 bg-card">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-foreground">
<FolderTreeIcon />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
<div className="flex items-center gap-2 md:gap-3">
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-accent transition-all"
onClick={onClose}
title="关闭面板"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
</header>
<ScrollArea className="flex-1">
<div className="px-2 py-4">
{tree.map(node => (
<TreeItem
key={node.path}
node={node}
collapsedPaths={collapsedPaths}
selected={selected}
onToggle={(path) => {
setCollapsedPaths(prev => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}}
onSelect={onClick}
/>
))}
</div>
</ScrollArea>
<footer className="h-9 md:h-10 border-t border-border flex items-center px-4 md:px-6 text-[11px] text-muted-foreground bg-card">
<span>{fileCount} </span>
</footer>
</main>
</div>
)
}
function FolderTreeIcon() {
return (
<div className="relative h-4 w-4">
<div className="absolute left-0 top-0 h-1.5 w-1.5 rounded-sm bg-current opacity-70" />
<div className="absolute left-0 top-2.5 h-1.5 w-1.5 rounded-sm bg-current opacity-70" />
<div className="absolute left-2.5 top-1.25 h-1.5 w-1.5 rounded-sm bg-current" />
<div className="absolute left-[3px] top-[3px] h-px w-[7px] bg-current opacity-50" />
<div className="absolute left-[3px] top-[11px] h-px w-[7px] bg-current opacity-50" />
<div className="absolute left-[9px] top-[6px] h-[5px] w-px bg-current opacity-50" />
</div>
)
}
export const ArtifactList = React.memo(InnerArtifactList)
export default ArtifactList

View File

@@ -0,0 +1,364 @@
import React, { useState } from 'react'
import { ChevronLeft, Download, X } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import type { TaskArtifact, ImageAttachment } from '../types'
import { isImageFile } from './utils'
import { useNovaKit } from '../context/useNovaKit'
import { MarkdownPreview, MarkdownContent, CsvPreview, ToolCallPreview, ShellExecutePreview, UrlScriptPreview, VirtualPdfPreview } from './Preview'
import { detectToolType, ToolType, extractShellOutput, extractScriptCode, getDisplayTitle, isScriptLikeFile } from './Preview/previewUtils'
import { ImageEditor } from '@/components/image-editor'
import { TaskArtifactHtml } from '@/components/html-editor'
import PptPreview from '@/components/ppt-editor'
import { ImageAttachmentItem } from '../message-list/message-item/ImageAttachmentItem'
export interface ArtifactPreviewProps {
/** 当前展示的文件 */
artifact: TaskArtifact | null
/** 所有图片(用于切换) */
images?: TaskArtifact[]
/** 获取文件 URL 的函数 */
getUrl?: (artifact: TaskArtifact) => string | Promise<string>
/** 返回按钮点击回调 */
onBack?: () => void
/** 是否从文件列表点击进入(用于决定是否显示悬浮工具栏) */
fromFileList?: boolean
/** 下载按钮点击回调 */
onDownload?: (artifact: TaskArtifact) => void
/** 关闭面板回调 */
onClose?: () => void
/** 自定义类名 */
className?: string
}
const PREVIEW_MIME_TYPES = ['xlsx', 'xls', 'doc', 'docx']
/**
* 文件预览组件
*/
function FilePreview({ artifact }: { artifact: TaskArtifact }) {
const { api,conversationId } = useNovaKit()
const [url, setUrl] = React.useState<string>('')
const [isUrlLoading, setIsUrlLoading] = React.useState(false)
// 检查是否是工具调用
const isToolCall =
artifact.event_type?.toLowerCase() === 'tool_call' ||
artifact.file_type?.toLowerCase() === 'tool_call' ||
artifact.file_type?.toLowerCase() === 'tool' ||
!!artifact.tool_name
// Skill Loader按 Markdown 文档渲染
const isSkillLoader = artifact.action_type === 'skill_loader'
// 检测工具类型
const toolType = isToolCall ? detectToolType(artifact) : ToolType.OTHER
const isMarkdown = artifact.file_type?.toLowerCase() === 'md' ||
artifact.file_name?.toLowerCase().endsWith('.md')
// 仅当文件 path 变化时才重新获取 URL避免流式推送时对象引用变化引起重复请求
React.useEffect(() => {
if (artifact.path && artifact.event_type !== 'tool_call') {
setIsUrlLoading(true)
setUrl('')
api
.getArtifactUrl?.(
artifact,
PREVIEW_MIME_TYPES.includes(artifact.file_type)
? {
'x-oss-process': 'doc/preview,print_1,copy_1,export_1',
}
: undefined,
)
.then(res => {
const originUrl = typeof res?.data === 'string' ? res.data : ''
if (PREVIEW_MIME_TYPES.includes(artifact.file_type)) {
// Office 文件:走文档预览服务并切到 betteryeah 域名
const shortUrl = originUrl.replace(
'oss-cn-hangzhou.aliyuncs.com',
'betteryeah.com',
)
setUrl(
shortUrl
? `${shortUrl}&x-oss-process=doc%2Fpreview%2Cprint_1%2Ccopy_1%2Cexport_1`
: '',
)
} else {
// 其他类型:直接使用后端返回的 URL
setUrl(originUrl)
}
setIsUrlLoading(false)
})
.catch(() => {
setIsUrlLoading(false)
})
} else {
setIsUrlLoading(false)
}
// 用 artifact.path 而非整个 artifact 对象api 不加入依赖Context 每次渲染都会返回新引用)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifact.path, toolType])
// Skill Loader直接将 tool_output 作为 Markdown 内容展示
if (isSkillLoader) {
const output = artifact.tool_output ?? artifact.content ?? ''
const content =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
return (
<div className="flex-1 h-full overflow-hidden">
<div className="h-full overflow-y-auto p-6">
<MarkdownContent content={content} />
</div>
</div>
)
}
// 脚本执行:使用自定义 SHELL_EXECUTE 预览样式组件
if (toolType === ToolType.SHELL_EXECUTE) {
const outputText = extractShellOutput(artifact.tool_output || '')
return <ShellExecutePreview output={outputText} />
}
if (toolType === ToolType.SCRIPT_FILE) {
const code = extractScriptCode(artifact.tool_input)
const displayTitle = getDisplayTitle(artifact)
// 这里复用 ShellExecutePreview 的终端风格,而不是普通 ScriptPreview
return <ShellExecutePreview output={code} toolLabel={displayTitle} />
}
// 其他工具调用:使用 ToolCallPreview
if (isToolCall) {
return (
<ToolCallPreview
toolName={artifact.tool_name}
eventArguments={artifact.event_arguments}
toolInput={artifact.tool_input}
toolOutput={artifact.tool_output}
/>
)
}
// Markdown用 URL fetch 内容后渲染
if (isMarkdown && url) {
return <MarkdownPreview url={url} />
}
// PPT如果是 PPT 文件且有 slideList使用 PPT 预览
const isPpt = artifact.file_type?.toLowerCase() === 'ppt' ||
artifact.file_type?.toLowerCase() === 'pptx' ||
artifact.file_name?.toLowerCase().endsWith('.ppt') ||
artifact.file_name?.toLowerCase().endsWith('.pptx')
if (isPpt && url) {
return <PptPreview url={url} artifact={artifact} taskId={conversationId || ''} />
}
// CSVfetch 内容后渲染为表格
const isCsv = artifact.file_type?.toLowerCase() === 'csv' ||
artifact.file_name?.toLowerCase().endsWith('.csv')
if (isCsv && url) {
return <CsvPreview url={url} />
}
const isPdf = artifact.file_type?.toLowerCase() === 'pdf' ||
artifact.file_name?.toLowerCase().endsWith('.pdf')
if (isPdf && url) {
return <VirtualPdfPreview url={url} />
}
const isScriptFileByExt = isScriptLikeFile(artifact)
if (isScriptFileByExt && url) {
return <UrlScriptPreview url={url} title={artifact.file_name} />
}
if (url) {
return (
<iframe
src={url}
className="w-full h-full border-0"
title={artifact.file_name}
/>
)
}
// URL 解析中:避免先闪出“不支持预览”
if (isUrlLoading) {
return (
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground h-full">
<div className="text-sm">...</div>
</div>
)
}
// 不支持预览的文件类型
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
<div className="text-6xl mb-4">📄</div>
<div className="text-lg font-medium text-foreground mb-2">{artifact.file_name}</div>
<div className="text-sm mb-4">
{artifact.file_type?.toUpperCase() || '未知'}
</div>
<p className="text-sm"></p>
</div>
)
}
/**
* 文件预览组件
*/
function InnerArtifactPreview({
artifact,
onBack,
fromFileList,
onDownload,
onClose,
className,
}: ArtifactPreviewProps) {
const [currentArtifact, setCurrentArtifact] = useState<TaskArtifact | null>(artifact)
const { conversationId, panelMode } = useNovaKit()
// 仅当 artifact 的“身份”变化时才同步,避免对象引用变化引起不必要的预览刷新
React.useEffect(() => {
const incomingIdentity = artifact
? `${artifact.path}|${artifact.file_name}|${artifact.file_type}|${artifact.tool_name}|${artifact.event_type}`
: ''
const currentIdentity = currentArtifact
? `${currentArtifact.path}|${currentArtifact.file_name}|${currentArtifact.file_type}|${currentArtifact.tool_name}|${currentArtifact.event_type}`
: ''
if (incomingIdentity !== currentIdentity) {
setCurrentArtifact(artifact)
}
// currentArtifact 不加入依赖,避免 setCurrentArtifact 触发自身
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifact])
if (!currentArtifact) {
return (
<div className={cn('flex items-center justify-center h-full text-muted-foreground', className)}>
</div>
)
}
// 判断是否是图片:检查 path、file_name 和 file_type
const isImage =
isImageFile(currentArtifact.path) ||
isImageFile(currentArtifact.file_name) ||
['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(currentArtifact.file_type?.toLowerCase() || '')
// html预览
const isHtml =
currentArtifact.file_type?.toLowerCase() === 'html' ||
currentArtifact.file_name?.toLowerCase().endsWith('.html');
// 只用稳定标识避免同一文件流式更新时重复触发入场动画
const previewIdentity = currentArtifact.path || currentArtifact.file_name
return (
<Card className={cn('flex flex-col h-full border-0 rounded-none relative', className)}>
{/* 悬浮工具栏 */}
{(panelMode === 'sidebar' || fromFileList) && <div className="absolute left-4 top-4 z-30 flex items-center gap-1.5 rounded-lg border border-border/50 bg-background/70 p-1 backdrop-blur-xl animate-in fade-in-0 slide-in-from-top-4 duration-300">
{onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8 rounded-md transition-all hover:bg-primary hover:text-white"
>
<ChevronLeft className="w-4 h-4 transition-transform hover:-translate-x-0.5" />
</Button>
)}
<span className="text-sm font-medium truncate max-w-[200px] px-2 text-foreground/80 select-none">
{currentArtifact.file_name}
</span>
<div className="flex items-center gap-1 pl-1.5 border-l border-border/50">
{onDownload && (
<Button
variant="ghost"
size="icon"
onClick={() => onDownload(currentArtifact)}
title="下载"
className="h-8 w-8 rounded-md transition-all hover:bg-primary/20"
>
<Download className="w-4 h-4 transition-transform hover:translate-y-0.5" />
</Button>
)}
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
title="关闭面板"
className="h-8 w-8 rounded-md transition-all hover:bg-destructive/20 hover:text-destructive text-muted-foreground"
>
<X className="w-4 h-4 transition-transform hover:rotate-90" />
</Button>
)}
</div>
</div>}
{/* 预览内容 */}
<div className="flex-1 overflow-hidden">
{isImage ? (
panelMode === 'dialog' ? (
<ImageAttachmentItem
assetsType={currentArtifact.from!}
image={
{
url: currentArtifact.url,
path: currentArtifact.path,
file_name: currentArtifact.file_name,
file_url: (currentArtifact as unknown as { file_url?: string }).file_url,
file_type: currentArtifact.file_type,
} as ImageAttachment
}
/>
) : (
<ImageEditor
taskId={conversationId || ''}
currentArtifact={currentArtifact}
readOnly={false}
onBack={() => {}}
onClose={() => {}}
expand={false}
onToggleExpand={() => {}}
/>
)
) : isHtml ? (
<TaskArtifactHtml
taskId={conversationId || ''}
taskArtifact={currentArtifact}
editable={true}
type="web"
onStateChange={(state) => {
console.log(state)
}}
/>
) : (
<div
key={previewIdentity}
className="animate-in fade-in-0 zoom-in-95 duration-300 h-full"
>
<FilePreview artifact={currentArtifact} />
</div>
)}
</div>
</Card>
)
}
export const ArtifactPreview = React.memo(InnerArtifactPreview)
export default ArtifactPreview

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useState } from 'react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
export interface CsvPreviewProps {
/** CSV 文件的远程 URL */
url?: string
/** 直接传入的 CSV 内容 */
content?: string
}
function parseCsv(text: string): string[][] {
const lines = text.split(/\r?\n/)
return lines
.filter(line => line.trim() !== '')
.map(line => {
const row: string[] = []
let inQuotes = false
let cell = ''
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
cell += '"'
i++
} else {
inQuotes = !inQuotes
}
} else if (ch === ',' && !inQuotes) {
row.push(cell)
cell = ''
} else {
cell += ch
}
}
row.push(cell)
return row
})
}
export function CsvPreview({ url, content }: CsvPreviewProps) {
const [rows, setRows] = useState<string[][]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
// 在异步回调里更新本地 state避免在 effect 体内同步 setState
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
setError(null)
})
if (content != null) {
queueMicrotask(() => {
if (cancelled) return
setRows(parseCsv(content))
setLoading(false)
})
return () => {
cancelled = true
}
}
if (!url) {
queueMicrotask(() => {
if (cancelled) return
setRows([])
setLoading(false)
})
return () => {
cancelled = true
}
}
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.text()
})
.then(text => {
if (cancelled) return
setRows(parseCsv(text))
})
.catch(err => {
if (cancelled) return
setError(err.message || '加载失败')
})
.finally(() => {
if (cancelled) return
setLoading(false)
})
return () => {
cancelled = true
}
}, [content, url])
if (loading) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="w-6 h-6 border-2 border-muted border-t-primary rounded-full animate-spin mr-2" />
<span className="text-sm">...</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-full text-destructive text-sm">
{error}
</div>
)
}
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
</div>
)
}
const [header, ...body] = rows
const colCount = Math.max(...rows.map(r => r.length))
const paddedHeader = header.concat(Array(colCount - header.length).fill(''))
return (
<ScrollArea className="w-full h-full">
<div className="p-4">
<Table>
<TableHeader>
<TableRow>
{paddedHeader.map((col, i) => (
<TableHead key={i} className="whitespace-nowrap font-medium text-foreground">
{col || `${i + 1}`}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{body.map((row, ri) => {
const paddedRow = row.concat(Array(colCount - row.length).fill(''))
return (
<TableRow key={ri}>
{paddedRow.map((cell, ci) => (
<TableCell key={ci} className="whitespace-nowrap text-sm">
{cell}
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View File

@@ -0,0 +1,64 @@
import React, { createContext, useEffect, useState } from 'react'
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
export const HighlighterContext = createContext<HighlighterCore | null>(null)
export interface HighlighterProviderProps {
children: React.ReactNode
}
/**
* Shiki Highlighter Provider - 提供全局的代码高亮器实例
*/
export function HighlighterProvider({ children }: HighlighterProviderProps) {
const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null)
useEffect(() => {
let mounted = true
createHighlighterCore({
themes: [
import('@shikijs/themes/github-light'),
import('@shikijs/themes/github-dark-default'),
import('@shikijs/themes/vitesse-dark'),
import('@shikijs/themes/one-light'),
import('@shikijs/themes/snazzy-light'),
import('@shikijs/themes/everforest-light'),
],
langs: [
import('@shikijs/langs/css'),
import('@shikijs/langs/javascript'),
import('@shikijs/langs/tsx'),
import('@shikijs/langs/jsx'),
import('@shikijs/langs/xml'),
import('@shikijs/langs/html'),
import('@shikijs/langs/python'),
import('@shikijs/langs/sh'),
import('@shikijs/langs/json'),
import('@shikijs/langs/sql'),
import('@shikijs/langs/nginx'),
import('@shikijs/langs/mermaid'),
import('@shikijs/langs/markdown'),
],
engine: createOnigurumaEngine(import('shiki/wasm')),
}).then(h => {
if (mounted) {
setHighlighter(h)
}
})
return () => {
mounted = false
highlighter?.dispose()
}
}, [])
return (
<HighlighterContext.Provider value={highlighter}>
{children}
</HighlighterContext.Provider>
)
}
export default HighlighterProvider

View File

@@ -0,0 +1,65 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import remarkGfm from 'remark-gfm'
import { cn } from '@/utils/cn'
export interface MarkdownPreviewProps {
/** Markdown 文件的 URL */
url: string
}
export interface MarkdownContentProps {
/** 直接传入 Markdown 字符串 */
content: string
className?: string
}
/**
* 内联 Markdown 渲染组件 - 接收字符串内容直接渲染
*/
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={cn('prose prose-sm max-w-none dark:prose-invert break-words', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
)
}
/**
* Markdown 预览组件 - 接收 URLfetch 内容后渲染
*/
export function MarkdownPreview({ url }: MarkdownPreviewProps) {
const [content, setContent] = React.useState<string>('')
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
if (!url) return
setLoading(true)
fetch(url)
.then(res => res.text())
.then(text => setContent(text))
.catch(() => setContent('加载失败'))
.finally(() => setLoading(false))
}, [url])
if (loading) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
<span className="text-sm mt-2">...</span>
</div>
)
}
return (
<ScrollArea className="h-full">
<div className="p-6 pt-14 prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</ScrollArea>
)
}
export default MarkdownPreview

View File

@@ -0,0 +1,211 @@
import React, { useState, useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useSize } from '../../hooks/useSize'
export interface SlideItem {
content: string
[key: string]: unknown
}
export interface PptPreviewProps {
/** PPT 文件的 URL */
url: string
}
/**
* PPT 预览组件
*/
export function PptPreview({ url }: PptPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0)
const [slideList, setSlideList] = useState<SlideItem[]>([])
const [loading, setLoading] = useState(true)
React.useEffect(() => {
if (!url) return
setLoading(true)
fetch(url)
.then(res => res.json())
.then(data => {
const slides = data.slide_list || []
setSlideList(slides)
})
.catch(() => setSlideList([]))
.finally(() => setLoading(false))
}, [url])
if (loading) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
<span className="text-sm mt-2">...</span>
</div>
)
}
if (!slideList || slideList.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
<div className="text-6xl mb-4">📊</div>
<p className="text-sm"></p>
</div>
)
}
return <PptSlideViewer slideList={slideList} currentIndex={currentIndex} setCurrentIndex={setCurrentIndex} />
}
function PptSlideViewer({
slideList,
currentIndex,
setCurrentIndex
}: {
slideList: SlideItem[]
currentIndex: number
setCurrentIndex: (index: number) => void
}) {
const containerRef = useRef<HTMLDivElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
const size = useSize(containerRef)
const [iframeHeight, setIframeHeight] = useState(720)
const [loadState, setLoadState] = useState<'loading' | 'loaded' | 'error'>('loading')
const currentSlide = slideList[currentIndex]
const scale = size ? size.width / 1280 : 1
const handleIframeLoad = (event: React.SyntheticEvent<HTMLIFrameElement>) => {
const iframe = event.currentTarget
try {
const actualHeight = iframe.contentDocument?.documentElement.scrollHeight
if (actualHeight && actualHeight > 0) {
setIframeHeight(actualHeight)
}
setLoadState('loaded')
} catch (error) {
console.warn('Cannot access iframe content:', error)
setLoadState('loaded')
}
}
const handleIframeError = () => {
setLoadState('error')
}
// 切换幻灯片时重置加载状态
React.useEffect(() => {
setLoadState('loading')
setIframeHeight(720)
}, [currentIndex])
return (
<div className="flex flex-col h-full">
{/* 主预览区 */}
<div className="flex flex-1 flex-col items-center overflow-hidden p-4">
<ScrollArea className="flex-1 w-full">
<div className="flex min-h-full w-full justify-center">
<div
ref={containerRef}
className="relative w-full flex-none overflow-hidden rounded-lg border border-solid border-border bg-card"
style={{
height: scale ? `${iframeHeight * scale}px` : '720px',
}}
>
{loadState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-card/92 backdrop-blur-sm">
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm mt-2">...</p>
</div>
)}
<iframe
ref={iframeRef}
srcDoc={currentSlide.content}
className={cn(
'w-[1280px] border-0 origin-top-left transition-opacity duration-300 ease-in-out',
loadState === 'loading' ? 'opacity-0' : 'opacity-100'
)}
title={`Slide ${currentIndex + 1}`}
sandbox="allow-same-origin allow-scripts"
onLoad={handleIframeLoad}
onError={handleIframeError}
style={{
height: `${iframeHeight}px`,
transform: `scale(${scale})`,
}}
/>
</div>
</div>
</ScrollArea>
{/* 页码和导航 */}
<div className="mt-4 flex shrink-0 items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
disabled={currentIndex === 0}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium text-foreground">
{currentIndex + 1} / {slideList.length}
</span>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentIndex(Math.min(slideList.length - 1, currentIndex + 1))}
disabled={currentIndex === slideList.length - 1}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* 缩略图列表 */}
{slideList.length > 1 && (
<div className="shrink-0 border-t border-border/80 bg-secondary/35 p-3">
<ScrollArea className="w-full">
<div className="flex gap-2 pb-1 w-max">
{slideList.map((slide, index) => (
<button
key={index}
type="button"
className={cn(
'relative aspect-video w-48 shrink-0 overflow-hidden rounded-lg border-2 bg-card transition-all',
currentIndex === index
? 'border-primary ring-2 ring-primary/20'
: 'border-transparent hover:border-muted'
)}
onClick={() => setCurrentIndex(index)}
>
<div className="w-full h-full overflow-hidden">
<iframe
srcDoc={slide.content}
className="w-full h-full border-0 pointer-events-none origin-top-left"
title={`Thumbnail ${index + 1}`}
sandbox="allow-same-origin"
style={{
transform: 'scale(1)',
}}
/>
</div>
<div className="absolute inset-0 bg-transparent pointer-events-none" />
<div className="pointer-events-none absolute bottom-0 left-0 right-0 bg-black/55 py-1 text-center text-xs text-white">
{index + 1}
</div>
</button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)}
</div>
)
}
export default PptPreview

View File

@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import type { Element } from 'hast'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/utils/cn'
import { useHighlighter } from './useHighlighter'
export interface ScriptPreviewProps {
/** 脚本代码 */
code: string
/** 执行结果 */
output?: string
/** 语言类型 */
language?: string
/** 脚本名称/标题 */
title?: string
/** 自定义类名 */
className?: string
/** 主题 */
theme?: string
}
/**
* 根据工具名称或内容检测语言
*/
function detectScriptLanguage(code: string, hint?: string): string {
// 优先使用提示
if (hint) {
const lowerHint = hint.toLowerCase()
if (lowerHint.includes('python') || lowerHint.includes('py')) return 'python'
if (lowerHint.includes('javascript') || lowerHint.includes('js')) return 'javascript'
if (lowerHint.includes('typescript') || lowerHint.includes('ts')) return 'typescript'
if (lowerHint.includes('shell') || lowerHint.includes('bash') || lowerHint.includes('sh')) return 'bash'
if (lowerHint.includes('sql')) return 'sql'
}
const trimmed = code.trim()
// Python
if (
/^(def|class|import|from|if __name__|async def|@\w+)\s/.test(trimmed) ||
/\bprint\s*\(/.test(trimmed)
) {
return 'python'
}
// Bash/Shell
if (
/^(#!\/bin\/(bash|sh)|curl|wget|npm|yarn|cd|ls|echo|sudo)\s/.test(trimmed) ||
/^\$\s/.test(trimmed)
) {
return 'bash'
}
// JavaScript/Node
if (
/^(const|let|var|function|async|import|export)\s/.test(trimmed) ||
/console\.(log|error|warn)/.test(trimmed)
) {
return 'javascript'
}
// SQL
if (/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/i.test(trimmed)) {
return 'sql'
}
return 'plaintext'
}
/**
* 代码块组件
*/
function CodeBlock({
code,
lang,
theme = 'one-light',
}: {
code: string
lang: string
theme?: string
title?: string
}) {
const highlighter = useHighlighter()
const [highlightedHtml, setHighlightedHtml] = useState('')
useEffect(() => {
const generateHighlightedHtml = async () => {
if (!highlighter || !code) {
return ''
}
return highlighter.codeToHtml(code, {
lang,
theme,
transformers: [
{
code(node: Element) {
const className = node.properties.className
if (Array.isArray(className)) {
className.push('whitespace-pre-wrap', 'break-all')
} else {
node.properties.className = ['whitespace-pre-wrap', 'break-all']
}
},
pre(node: Element) {
node.tagName = 'div'
const className = node.properties.className
if (Array.isArray(className)) {
className.push('overflow-auto')
} else {
node.properties.className = ['overflow-auto']
}
// 移除背景色
delete node.properties.style
},
},
],
})
}
generateHighlightedHtml().then(html => {
setHighlightedHtml(html)
})
}, [code, lang, theme, highlighter])
if (!highlightedHtml) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground bg-transparent rounded-lg">
<div className="w-6 h-6 rounded-full animate-spin" />
</div>
)
}
return (
<div className="rounded-lg overflow-hidden bg-transparent">
<div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
</div>
)
}
/**
* 脚本预览组件 - 专门用于显示脚本代码
*/
export function ScriptPreview({
code,
language,
title = '脚本代码',
className,
theme = 'one-light',
}: Omit<ScriptPreviewProps, 'output'>) {
const detectedLang = language || detectScriptLanguage(code, title)
return (
<div className={cn('flex flex-col h-full', className)}>
<ScrollArea className="flex-1">
<CodeBlock code={code} lang={detectedLang} theme={theme} />
</ScrollArea>
</div>
)
}
export default ScriptPreview

View File

@@ -0,0 +1,110 @@
import { Loader2 } from 'lucide-react'
import ScriptPreview from './ScriptPreview'
export interface ShellExecutePreviewProps {
/** shell_execute 的输出文本(已做 ANSI 处理) */
output: string
/** 顶部和终端中展示的工具名称,默认 shell_execute */
toolLabel?: string
/** 是否处于加载中(统一在终端里展示 loading */
loading?: boolean
}
export function ShellExecutePreview({
output,
toolLabel = 'shell_execute',
loading = false,
}: ShellExecutePreviewProps) {
const safeOutput = output || 'No output'
if (loading) {
return (
<div className="flex items-center justify-center py-10 h-full">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
return (
<div
className={`flex w-full bg-background text-foreground ${
!loading ? 'animate-in fade-in-0 slide-in-from-bottom-2 duration-300' : ''
}`}
style={{ height: 'calc(100% + 20px)' }}
>
{/* 右侧主体区域 */}
<div className="flex-1 flex flex-col h-full">
{/* 内容区域:一张终端卡片 */}
<main className="flex-1 overflow-y-auto custom-scrollbar px-3 md:px-6 py-4 md:py-6 flex items-center justify-center">
<div className="max-w-4xl mx-auto space-y-4 md:space-y-6 min-w-[60%]">
{/* 终端卡片,主进场:从下浮现 + 略微缩放 */}
<div
className={`min-h-[200px] overflow-hidden rounded-xl border ${
!loading
? 'animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-4 duration-500'
: ''
}`}
style={{
backgroundColor: 'var(--terminal-background)',
borderColor: 'var(--terminal-border)',
...( !loading ? { animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' } : {}),
}}
>
<div
className="flex items-center justify-between border-b px-3 py-2 md:px-4"
style={{
backgroundColor: 'var(--terminal-surface)',
borderColor: 'var(--terminal-border)',
}}
>
<div
className={`flex gap-1.5 ${
!loading ? 'animate-in fade-in zoom-in-75 duration-700 fill-mode-both' : ''
}`}
>
<div className="h-2.5 w-2.5 rounded-full transition-colors" style={{ backgroundColor: 'color-mix(in srgb, var(--destructive) 72%, transparent)' }} />
<div className="h-2.5 w-2.5 rounded-full transition-colors" style={{ backgroundColor: 'color-mix(in srgb, var(--warning) 72%, transparent)' }} />
<div className="h-2.5 w-2.5 rounded-full transition-colors" style={{ backgroundColor: 'color-mix(in srgb, var(--success) 72%, transparent)' }} />
</div>
<span
className={`text-[10px] font-mono ${
!loading ? 'animate-in fade-in slide-in-from-top-1 duration-700 fill-mode-both' : ''
}`}
style={{ color: 'var(--terminal-text-muted)' }}
>
bash {toolLabel}
</span>
</div>
<div className="relative space-y-2 py-3 font-mono text-[11px] md:py-4 md:text-xs" style={{ color: 'var(--terminal-text)' }}>
<div
className={`flex gap-2 mb-1 px-3 md:px-4 ${
!loading ? 'animate-in fade-in-0 slide-in-from-left-2 duration-500' : ''
}`}
>
<span style={{ color: 'var(--terminal-prompt)' }}>$</span>
<span className="truncate" style={{ color: 'var(--terminal-text)' }}>{toolLabel}</span>
</div>
<div
className={`mt-2 max-h-[420px] overflow-auto whitespace-pre-wrap border-t px-3 pt-2 custom-scrollbar md:px-4 ${
!loading ? 'animate-in fade-in-0 slide-in-from-bottom-2 duration-500' : ''
}`}
style={{
borderColor: 'var(--terminal-border)',
color: 'var(--terminal-text)',
}}
>
<ScriptPreview
code={safeOutput}
language="bash"
title={toolLabel}
theme="github-dark-default"
/>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,420 @@
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

View File

@@ -0,0 +1,233 @@
import React from 'react'
import { cn } from '@/utils/cn'
import type { ImageAttachment, TaskArtifact } from '../../types'
import { useNovaKit } from '../../context/useNovaKit'
import { isImageFile } from '../utils'
import { TaskArtifactHtml } from '@/components/html-editor'
import { Html } from '@/components/html-editor/components/html-render/task-html'
import PptPreview from '@/components/ppt-editor'
import { ImageAttachmentItem } from '../../message-list/message-item/ImageAttachmentItem'
import { UrlScriptPreview } from './UrlScriptPreview'
import { ShellExecutePreview } from './ShellExecutePreview'
import { MarkdownContent, MarkdownPreview } from './MarkdownPreview'
import { CsvPreview } from './CsvPreview'
import { VirtualPdfPreview } from './VirtualPdfPreview'
import { isScriptLikeFile, normalizeArtifactFileType } from './previewUtils'
export interface ToolOutputArtifactPreviewProps {
artifact: TaskArtifact
className?: string
}
const PREVIEW_FILE_TYPES = ['xlsx', 'xls', 'doc', 'docx']
const TEXT_LIKE_FILE_TYPES = ['txt', 'text', 'json', 'log', 'xml', 'yaml', 'yml']
export function ToolOutputArtifactPreview({
artifact,
className,
}: ToolOutputArtifactPreviewProps) {
const { api, conversationId, mode } = useNovaKit()
const [url, setUrl] = React.useState('')
const [isUrlLoading, setIsUrlLoading] = React.useState(false)
const editable = mode === 'chat'
const normalizedFileType = normalizeArtifactFileType(
artifact.file_type,
artifact.file_name,
artifact.path,
)
React.useEffect(() => {
const directUrl = artifact.url || (/^https?:\/\//.test(artifact.path) ? artifact.path : '')
if (directUrl) {
setUrl(directUrl)
setIsUrlLoading(false)
return
}
if (artifact.path) {
setIsUrlLoading(true)
setUrl('')
api
.getArtifactUrl?.(
artifact,
PREVIEW_FILE_TYPES.includes(normalizedFileType)
? {
'x-oss-process': 'doc/preview,print_1,copy_1,export_1',
}
: undefined,
)
.then(res => {
const originUrl = typeof res?.data === 'string' ? res.data : ''
if (PREVIEW_FILE_TYPES.includes(normalizedFileType)) {
const shortUrl = originUrl.replace(
'oss-cn-hangzhou.aliyuncs.com',
'betteryeah.com',
)
setUrl(
shortUrl
? `${shortUrl}&x-oss-process=doc%2Fpreview%2Cprint_1%2Ccopy_1%2Cexport_1`
: '',
)
} else {
setUrl(originUrl)
}
setIsUrlLoading(false)
})
.catch(() => {
setIsUrlLoading(false)
})
return
}
setIsUrlLoading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artifact.path, artifact.url, normalizedFileType])
const isImage =
isImageFile(artifact.path) ||
isImageFile(artifact.file_name) ||
['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(normalizedFileType)
if (isImage) {
const imageAttachment: ImageAttachment = {
url: artifact.url || '',
path: artifact.path,
file_name: artifact.file_name,
file_url: artifact.url,
}
return (
<div className={cn('flex h-full items-center justify-center p-6 bg-muted/10', className)}>
<ImageAttachmentItem image={imageAttachment} />
</div>
)
}
const isHtml =
normalizedFileType === 'html' ||
artifact.file_name?.toLowerCase().endsWith('.html')
if (isHtml && artifact.content && !artifact.path) {
return <Html className={cn('h-full', className)} content={artifact.content} />
}
if (isHtml) {
return (
<div className={cn('h-full', className)}>
<TaskArtifactHtml
taskId={conversationId || ''}
taskArtifact={artifact}
editable={editable}
type="web"
/>
</div>
)
}
const isMarkdown =
normalizedFileType === 'md' ||
normalizedFileType === 'markdown' ||
artifact.file_name?.toLowerCase().endsWith('.md')
if (isMarkdown && url) {
return <div className={cn('h-full', className)}><MarkdownPreview url={url} /></div>
}
if (isMarkdown && artifact.content) {
return (
<div className={cn('h-full overflow-y-auto p-6', className)}>
<MarkdownContent content={artifact.content} />
</div>
)
}
const isPpt =
normalizedFileType === 'ppt' ||
normalizedFileType === 'pptx' ||
artifact.file_name?.toLowerCase().endsWith('.ppt') ||
artifact.file_name?.toLowerCase().endsWith('.pptx')
if (isPpt && url) {
return (
<div className={cn('h-full', className)}>
<PptPreview
url={url}
artifact={artifact}
taskId={conversationId || ''}
editable={editable}
/>
</div>
)
}
const isCsv =
normalizedFileType === 'csv' ||
artifact.file_name?.toLowerCase().endsWith('.csv')
if (isCsv && artifact.content) {
return <div className={cn('h-full', className)}><CsvPreview content={artifact.content} /></div>
}
if (isCsv && url) {
return <div className={cn('h-full', className)}><CsvPreview url={url} /></div>
}
const isPdf =
normalizedFileType === 'pdf' ||
artifact.file_name?.toLowerCase().endsWith('.pdf')
if (isPdf && url) {
return <div className={cn('h-full', className)}><VirtualPdfPreview url={url} /></div>
}
const isScript = isScriptLikeFile(artifact)
if (isScript && artifact.content) {
return (
<div className={cn('h-full', className)}>
<ShellExecutePreview output={artifact.content} toolLabel={artifact.file_name} />
</div>
)
}
const isTextLike = TEXT_LIKE_FILE_TYPES.includes(normalizedFileType)
if (isTextLike && artifact.content) {
return (
<div className={cn('h-full', className)}>
<ShellExecutePreview output={artifact.content} toolLabel={artifact.file_name} />
</div>
)
}
if ((isScript || isTextLike) && url) {
return <div className={cn('h-full', className)}><UrlScriptPreview url={url} title={artifact.file_name} /></div>
}
if (url) {
return (
<iframe
src={url}
className={cn('w-full h-full border-0', className)}
title={artifact.file_name}
/>
)
}
if (isUrlLoading) {
return (
<div className={cn('flex h-full items-center justify-center p-8 text-muted-foreground', className)}>
<div className="text-sm">...</div>
</div>
)
}
return (
<div className={cn('flex h-full items-center justify-center p-8 text-muted-foreground', className)}>
<div className="text-sm"></div>
</div>
)
}
export default ToolOutputArtifactPreview

View File

@@ -0,0 +1,70 @@
import React from 'react'
import { ShellExecutePreview } from './ShellExecutePreview'
export interface UrlScriptPreviewProps {
url: string
title?: string
}
/**
* 基于 URL 加载脚本内容并用 ScriptPreview 渲染
* - 保持 nova-sdk 自包含,不依赖宿主应用的 Preview 组件
* - 由外部控制传入的 url通常来自后端签名地址
*/
export function UrlScriptPreview({ url, title }: UrlScriptPreviewProps) {
const [code, setCode] = React.useState('')
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState(false)
React.useEffect(() => {
let cancelled = false
setLoading(true)
setError(false)
fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`Failed to fetch script file: ${res.status}`)
}
return res.text()
})
.then(text => {
if (!cancelled) {
setCode(text)
}
})
.catch(() => {
if (!cancelled) {
setError(true)
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [url])
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
<span className="text-sm"></span>
</div>
)
}
return (
<ShellExecutePreview
output={code}
toolLabel={title || 'script_file'}
loading={loading}
/>
)
}
export default UrlScriptPreview

View File

@@ -0,0 +1,217 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useDebounce, useSize } from 'ahooks'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { pdfjs as PdfJsType } from 'react-pdf'
import 'react-pdf/dist/Page/TextLayer.css'
import 'react-pdf/dist/Page/AnnotationLayer.css'
type ReactPdfModule = typeof import('react-pdf')
type PdfComponents = {
Document: ReactPdfModule['Document'] | null
Page: ReactPdfModule['Page'] | null
pdfjs: ReactPdfModule['pdfjs'] | null
}
type PdfLike = {
numPages: number
getPage: (pageNumber: number) => Promise<{ view: number[] }>
}
function useReactPdf() {
const [components, setComponents] = useState<PdfComponents>({
Document: null,
Page: null,
pdfjs: null,
})
useEffect(() => {
// 仅在浏览器环境下加载 react-pdf避免 Node.js 中触发 pdf.js 的 DOM 依赖
if (typeof window === 'undefined') return
let cancelled = false
;(async () => {
try {
const mod: ReactPdfModule = await import('react-pdf')
if (cancelled) return
const { Document, Page, pdfjs } = mod
// 配置 pdf.js worker
;(pdfjs as typeof PdfJsType).GlobalWorkerOptions.workerSrc =
`https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
setComponents({
Document,
Page,
pdfjs,
})
} catch (error) {
console.error('Failed to load react-pdf:', error)
}
})()
return () => {
cancelled = true
}
}, [])
return components
}
export function VirtualPdfPreview({ url }: { url: string }) {
const [numPages, setNumPages] = useState<number | null>(null)
const [renderedPages, setRenderedPages] = useState<number[]>([])
const [pageHeight, setPageHeight] = useState(0)
const [aspectRatio, setAspectRatio] = useState(0)
const [errorText, setErrorText] = useState<string | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const parentRef = useRef<HTMLDivElement>(null)
const oldPageHeight = useRef(0)
const size = useSize(wrapperRef)
const containerWidth = useDebounce(size?.width, { wait: 200 })
const { Document, Page } = useReactPdf()
const virtualizer = useVirtualizer({
count: numPages || 0,
getScrollElement: () => parentRef.current,
estimateSize: () => (pageHeight || 800) + 10,
overscan: 4,
enabled: !!pageHeight,
})
useEffect(() => {
setRenderedPages([])
setErrorText(null)
}, [containerWidth, url])
useEffect(() => {
if (containerWidth && aspectRatio) {
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false
const newHeight = !aspectRatio || !containerWidth ? 800 : containerWidth / aspectRatio
const lastPageIndex = oldPageHeight.current
? Number(virtualizer.scrollOffset ?? 0) / oldPageHeight.current
: 0
setPageHeight(newHeight)
oldPageHeight.current = newHeight
virtualizer.measure()
if (parentRef.current) {
setTimeout(() => {
parentRef.current?.scrollTo({
top: lastPageIndex * newHeight,
behavior: 'auto',
})
}, 100)
}
}
}, [containerWidth, aspectRatio, virtualizer])
const onDocumentLoadSuccess = async (pdf: PdfLike) => {
setErrorText(null)
setNumPages(pdf.numPages)
const pageObj = await pdf.getPage(1)
const pageWidth = pageObj.view[2]
const firstPageHeight = pageObj.view[3]
const ratio = Number((pageWidth / firstPageHeight).toFixed(2))
setAspectRatio(ratio)
setRenderedPages([])
}
const handlePageRenderSuccess = (pageNumber: number) => {
setRenderedPages(prev => (prev.includes(pageNumber) ? prev : [...prev, pageNumber]))
}
if (errorText) {
return (
<div className="h-full w-full flex items-center justify-center text-sm text-destructive px-4 text-center">
PDF {errorText}
</div>
)
}
// 浏览器端尚未加载到 react-pdf给一个轻量的占位
if (!Document || !Page) {
return (
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground px-4 text-center">
PDF ...
</div>
)
}
return (
<div ref={wrapperRef} className="w-full h-full mt-[80px]">
<div
ref={parentRef}
className="h-full overflow-scroll [&_.react-pdf__message.react-pdf__message--loading]:h-full [&::-webkit-scrollbar]:hidden scroll-smooth [-webkit-overflow-scrolling:touch]"
>
<Document
file={url}
loading={
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground">
PDF ...
</div>
}
onLoadError={(error) => {
console.error('react-pdf load failed:', error)
setErrorText(error instanceof Error ? error.message : '未知错误')
}}
onLoadSuccess={onDocumentLoadSuccess}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
className="w-full flex justify-center duration-200 transition-transform"
>
{!renderedPages.includes(virtualRow.index + 1) && (
<div
className="absolute top-0 left-0 right-0 z-1 flex items-center justify-center bg-card/94 text-sm text-muted-foreground duration-200 transition-transform backdrop-blur-sm"
style={{
height: `${pageHeight}px`,
}}
>
PDF ...
</div>
)}
<Page
loading={
<div className="h-full w-full flex items-center justify-center text-sm text-muted-foreground">
PDF ...
</div>
}
pageNumber={virtualRow.index + 1}
width={containerWidth}
onRenderSuccess={() => {
handlePageRenderSuccess(virtualRow.index + 1)
}}
/>
</div>
))}
</div>
</Document>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import {
Clock3,
Globe,
Search,
Sparkles,
} from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/utils/cn'
export interface WebSearchItem {
url?: string
title?: string
snippet?: string
date?: string
position?: number
[key: string]: unknown
}
export interface WebSearchPreviewProps {
/** 搜索结果列表(通常来自 info_search_web 的 tool_output */
results?: unknown
/** 查询词,优先来自 tool_input.arguments[0] */
searchQuery?: string | null
className?: string
}
function normalizeResults(results: unknown): WebSearchItem[] {
if (!Array.isArray(results)) return []
return results
.filter(item => item && typeof item === 'object')
.map(item => item as WebSearchItem)
.filter(item => typeof item.url === 'string' && !!item.url)
}
function getHostText(url?: string): string {
if (!url) return 'Unknown source'
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return 'Unknown source'
}
}
function getFaviconUrl(url?: string): string | null {
if (!url) return null
try {
const host = new URL(url).hostname
return `https://www.google.com/s2/favicons?domain=${host}&sz=64`
} catch {
return null
}
}
function formatDateLabel(date?: string): string {
if (!date) return ''
const normalized = date.trim()
if (!normalized) return ''
// 常见相对时间(如 "3 days ago")直接保留
if (/\b(ago|yesterday|today)\b/i.test(normalized)) {
return normalized
}
const parsed = new Date(normalized)
if (Number.isNaN(parsed.getTime())) {
return normalized
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(parsed)
}
export function WebSearchPreview({ results, searchQuery, className }: WebSearchPreviewProps) {
const items = normalizeResults(results)
const queryText = searchQuery?.trim() || items[0]?.title || 'Search for premium insights...'
if (!items.length) {
return (
<div className={cn('flex-1 h-full flex items-center justify-center p-8 text-muted-foreground', className)}>
<div className="flex items-center gap-2 text-sm">
<Search className="h-4 w-4" />
</div>
</div>
)
}
return (
<div
className={cn(
'flex h-full flex-col bg-background pt-[56px] text-foreground',
className,
)}
>
<ScrollArea className="flex-1">
<div className="min-h-full">
<header className="sticky top-0 z-10 border-b border-border/80 bg-background/92 backdrop-blur-sm">
<div className="px-4 md:px-8 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 flex-1">
<div className="flex shrink-0 items-center gap-2 text-primary">
<Sparkles className="h-6 w-6" />
<h2 className="hidden md:block text-lg font-bold tracking-tight">Search</h2>
</div>
<div className="relative group flex-1">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
<input
readOnly
value={queryText}
className="w-full rounded-full border-2 border-primary/30 bg-card/76 py-2.5 pl-11 pr-4 text-sm text-foreground outline-none backdrop-blur-sm placeholder:text-muted-foreground focus-visible:outline-none md:text-base"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/20 bg-primary/10 text-xs font-semibold text-primary">
AI
</div>
</div>
</div>
</div>
</header>
<main className="px-4 md:px-8 py-4">
<div className="mb-6 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
About {items.length.toLocaleString()} results
</p>
</div>
<div className="space-y-9">
{items.map((item, idx) => {
const favicon = getFaviconUrl(item.url)
const host = getHostText(item.url)
return (
<article key={`${item.url}-${idx}`} className="group max-w-3xl">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-md border border-border/80 bg-card">
{favicon ? (
<img src={favicon} alt={`${host} favicon`} className="w-full h-full object-contain" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
<div className="flex flex-col min-w-0">
<span className="truncate text-xs font-medium text-foreground/78">
{host}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{item.url}
</span>
</div>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="mb-2 inline-block text-lg font-semibold leading-tight text-primary underline-offset-4 hover:underline md:text-xl wrap-break-word"
>
{item.title || item.url}
</a>
{item.snippet && (
<p className="line-clamp-3 text-sm leading-relaxed text-foreground/74 md:text-base">
{item.snippet}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
{item.date && (
<span className="flex items-center gap-1">
<Clock3 className="h-3.5 w-3.5" />
{formatDateLabel(item.date)}
</span>
)}
{typeof item.position === 'number' && item.position <= 3 && (
<span className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wider text-primary">
Top Result
</span>
)}
</div>
</article>
)
})}
</div>
</main>
<footer className="sticky bottom-0 mt-auto border-t border-border/80 bg-secondary/55 px-4 py-6 backdrop-blur-sm md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-5">
<div className="flex items-center gap-5 text-sm text-muted-foreground">
<span className="cursor-default transition-colors hover:text-foreground">Help Center</span>
<span className="cursor-default transition-colors hover:text-foreground">Privacy</span>
<span className="cursor-default transition-colors hover:text-foreground">Terms</span>
</div>
<div className="text-xs text-muted-foreground">
Powered by Web Search Tool
</div>
</div>
</footer>
</div>
</ScrollArea>
</div>
)
}
export default WebSearchPreview

View File

@@ -0,0 +1,31 @@
export { ToolCallPreview } from './ToolCallPreview'
export type { ToolCallPreviewProps } from './ToolCallPreview'
export { ScriptPreview } from './ScriptPreview'
export type { ScriptPreviewProps } from './ScriptPreview'
export { UrlScriptPreview } from './UrlScriptPreview'
export type { UrlScriptPreviewProps } from './UrlScriptPreview'
export { ShellExecutePreview } from './ShellExecutePreview'
export type { ShellExecutePreviewProps } from './ShellExecutePreview'
export { MarkdownPreview, MarkdownContent } from './MarkdownPreview'
export type { MarkdownPreviewProps, MarkdownContentProps } from './MarkdownPreview'
export { PptPreview } from './PptPreview'
export type { PptPreviewProps, SlideItem } from './PptPreview'
export { CsvPreview } from './CsvPreview'
export type { CsvPreviewProps } from './CsvPreview'
export { VirtualPdfPreview } from './VirtualPdfPreview'
export { HighlighterProvider, HighlighterContext } from './HighlighterProvider'
export type { HighlighterProviderProps } from './HighlighterProvider'
export { useHighlighter } from './useHighlighter'
export * from './previewUtils'
export { WebSearchPreview } from './WebSearchPreview'
export type { WebSearchPreviewProps, WebSearchItem } from './WebSearchPreview'

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { TaskArtifact } from '../../types'
import { extractToolOutputArtifact, normalizeArtifactFileType } from './previewUtils'
describe('normalizeArtifactFileType', () => {
it('normalizes common mime types', () => {
expect(normalizeArtifactFileType('text/markdown')).toBe('md')
expect(normalizeArtifactFileType('image/svg+xml')).toBe('svg')
expect(
normalizeArtifactFileType(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
),
).toBe('pptx')
})
it('falls back to file extension', () => {
expect(normalizeArtifactFileType('', 'report.csv')).toBe('csv')
expect(normalizeArtifactFileType(undefined, undefined, '/tmp/demo.py')).toBe('py')
})
})
describe('extractToolOutputArtifact', () => {
const baseArtifact: TaskArtifact = {
path: 'tool-call-1',
file_name: '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
tool_name: 'file_create',
}
it('extracts remote file artifacts from tool_output', () => {
const artifact = extractToolOutputArtifact({
...baseArtifact,
tool_output: {
path: '/upload/result/index.html',
file_name: 'index.html',
file_type: 'text/html',
},
})
expect(artifact).toEqual({
path: '/upload/result/index.html',
file_name: 'index.html',
file_type: 'html',
content: undefined,
url: undefined,
task_id: undefined,
})
})
it('extracts inline preview content when only file_type and content exist', () => {
const artifact = extractToolOutputArtifact({
...baseArtifact,
tool_output: {
file_name: 'notes.md',
file_type: 'text/markdown',
content: '# Title',
},
})
expect(artifact).toEqual({
path: '',
file_name: 'notes.md',
file_type: 'md',
content: '# Title',
url: undefined,
task_id: undefined,
})
})
it('ignores non-file tool outputs', () => {
expect(
extractToolOutputArtifact({
...baseArtifact,
tool_output: {
status: 'ok',
message: 'done',
},
}),
).toBeNull()
})
})

View File

@@ -0,0 +1,392 @@
import type { TaskArtifact } from '../../types'
/**
* 工具类型枚举
*/
export enum ToolType {
SHELL_EXECUTE = 'shell_execute',
SCRIPT_FILE = 'script_file',
OTHER = 'other',
}
/**
* 脚本文件扩展名
*/
const SCRIPT_EXTENSIONS = ['py', 'js', 'ts', 'sh', 'bash', 'zsh', 'fish', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'swift']
const INLINE_PREVIEW_EXTENSIONS = [
'md',
'markdown',
'txt',
'html',
'csv',
'json',
...SCRIPT_EXTENSIONS,
]
const MIME_TYPE_ALIASES: Record<string, string> = {
'text/markdown': 'md',
'text/x-markdown': 'md',
'application/markdown': 'md',
'text/html': 'html',
'application/pdf': 'pdf',
'text/csv': 'csv',
'application/csv': 'csv',
'application/json': 'json',
'text/plain': 'txt',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
}
const TOOL_OUTPUT_FILE_TYPE_FIELDS = ['file_type', 'type', 'mime_type']
const TOOL_OUTPUT_FILE_NAME_FIELDS = ['file_name', 'name', 'title']
const TOOL_OUTPUT_PATH_FIELDS = ['path', 'file_path', 'file_url', 'url', 'download_url']
const TOOL_OUTPUT_CONTENT_FIELDS = ['content', 'text', 'body', 'source', 'code', 'file_content']
function toObject(data: unknown): Record<string, unknown> | null {
if (!data) return null
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
return null
}
}
if (typeof data === 'object' && !Array.isArray(data)) {
return data as Record<string, unknown>
}
return null
}
function getCandidateObjects(data: Record<string, unknown>): Record<string, unknown>[] {
const candidates = [data]
for (const key of ['result', 'data', 'file']) {
const nested = data[key]
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
candidates.push(nested as Record<string, unknown>)
}
}
return candidates
}
function pickFirstString(
objects: Record<string, unknown>[],
fields: string[],
): string {
for (const obj of objects) {
for (const field of fields) {
const value = obj[field]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
}
return ''
}
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 isInlinePreviewFileType(fileType: string): boolean {
return INLINE_PREVIEW_EXTENSIONS.includes(fileType)
}
function isAbsoluteHttpUrl(value?: string): boolean {
return !!value && /^https?:\/\//.test(value)
}
/**
* 从路径中提取文件扩展名
*/
export function getFileExtension(path?: string): string {
if (!path) return ''
const baseName = getBaseName(path) || path
const match = baseName.match(/\.([^.]+)$/)
return match ? match[1].toLowerCase() : ''
}
/**
* 统一文件类型,兼容扩展名与 MIME type
*/
export function normalizeArtifactFileType(
fileType?: string,
fileName?: string,
path?: string,
): string {
const normalizedFileType = (fileType || '')
.trim()
.toLowerCase()
.split(';')[0]
if (normalizedFileType) {
if (MIME_TYPE_ALIASES[normalizedFileType]) {
return MIME_TYPE_ALIASES[normalizedFileType]
}
if (normalizedFileType.includes('/')) {
const subtype = normalizedFileType.split('/').pop() || ''
if (subtype === 'svg+xml') return 'svg'
if (subtype.includes('markdown')) return 'md'
if (subtype.includes('presentationml.presentation')) return 'pptx'
if (subtype.includes('ms-powerpoint')) return 'ppt'
if (subtype.includes('spreadsheetml.sheet')) return 'xlsx'
if (subtype.includes('ms-excel')) return 'xls'
if (subtype.includes('wordprocessingml.document')) return 'docx'
if (subtype.includes('msword')) return 'doc'
return subtype
}
return normalizedFileType
}
return getFileExtension(fileName) || getFileExtension(path)
}
/**
* 从 tool_input 中提取 file_path
*/
export function getFilePathFromInput(input: unknown): string {
try {
let obj: Record<string, unknown> | null = null
if (typeof input === 'string') {
obj = JSON.parse(input)
} else if (input && typeof input === 'object') {
obj = input as Record<string, unknown>
}
if (obj && typeof obj.file_path === 'string') {
return obj.file_path
}
} catch {
// ignore
}
return ''
}
/**
* 判断普通文件是否是脚本类型(根据扩展名)
*/
export function isScriptLikeFile(artifact: TaskArtifact): boolean {
const extFromPath = getFileExtension(artifact.path)
const extFromName = getFileExtension(artifact.file_name)
const extFromType = normalizeArtifactFileType(
artifact.file_type,
artifact.file_name,
artifact.path,
)
const ext = extFromPath || extFromName || extFromType
if (!ext) return false
return SCRIPT_EXTENSIONS.includes(ext)
}
/**
* 从 tool_output 中提取可直接预览的文件 artifact
*/
export function extractToolOutputArtifact(
artifact: TaskArtifact,
): TaskArtifact | null {
const rawOutput = toObject(artifact.tool_output)
if (!rawOutput) return null
const candidates = getCandidateObjects(rawOutput)
const outputPath = pickFirstString(candidates, TOOL_OUTPUT_PATH_FIELDS)
const outputName = pickFirstString(candidates, TOOL_OUTPUT_FILE_NAME_FIELDS)
const outputType = pickFirstString(candidates, TOOL_OUTPUT_FILE_TYPE_FIELDS)
const normalizedType = normalizeArtifactFileType(outputType, outputName, outputPath)
const outputContent = pickFirstString(candidates, TOOL_OUTPUT_CONTENT_FIELDS)
if (!normalizedType && !outputPath && !outputName) {
return null
}
const canPreviewInline = !!outputContent && isInlinePreviewFileType(normalizedType)
if (!outputPath && !canPreviewInline) {
return null
}
const fallbackBaseName = artifact.tool_name || artifact.file_name || 'tool_output'
const fallbackFileName = normalizedType
? `${fallbackBaseName}.${normalizedType}`
: fallbackBaseName
const fileName = outputName || getBaseName(outputPath) || fallbackFileName
return {
path: outputPath || '',
file_name: fileName,
file_type: normalizedType || getFileExtension(fileName),
content: canPreviewInline ? outputContent : undefined,
url: isAbsoluteHttpUrl(outputPath) ? outputPath : undefined,
task_id: artifact.task_id,
}
}
/**
* 判断工具类型
*/
export function detectToolType(artifact: TaskArtifact): ToolType {
const toolName = artifact.tool_name
// 1. shell_execute 特殊处理
if (toolName === 'shell_execute') {
return ToolType.SHELL_EXECUTE
}
// 2. 检查文件扩展名
const filePath = getFilePathFromInput(artifact.tool_input)
const fileExt =
getFileExtension(filePath) ||
getFileExtension(artifact.path) ||
getFileExtension(artifact.file_name) ||
normalizeArtifactFileType(artifact.file_type, artifact.file_name, artifact.path)
if (SCRIPT_EXTENSIONS.includes(fileExt)) {
return ToolType.SCRIPT_FILE
}
// 3. 检查工具名称关键字
if (toolName && (
toolName.toLowerCase().includes('execute') ||
toolName.toLowerCase().includes('shell') ||
toolName.toLowerCase().includes('code') ||
toolName.toLowerCase().includes('script') ||
toolName.toLowerCase().includes('python') ||
toolName.toLowerCase().includes('javascript') ||
toolName.toLowerCase().includes('node') ||
toolName.toLowerCase().includes('bash') ||
toolName.toLowerCase().includes('cmd')
)) {
return ToolType.SCRIPT_FILE
}
return ToolType.OTHER
}
/**
* 移除 ANSI 转义序列
*/
export function removeAnsiCodes(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '')
}
/**
* 从数据中提取字符串
*/
function extractFromObject(data: unknown, fields: string[]): string | null {
let obj: Record<string, unknown> | null = null
// 解析 JSON 字符串
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (parsed && typeof parsed === 'object') {
obj = parsed
} else if (typeof parsed === 'string') {
return parsed
}
} catch {
return data
}
} else if (data && typeof data === 'object') {
obj = data as Record<string, unknown>
}
// 从对象中提取字段
if (obj) {
for (const field of fields) {
const value = obj[field]
if (typeof value === 'string' && value) {
return value
}
}
}
return null
}
/**
* 提取 shell_execute 输出
*/
export function extractShellOutput(output: unknown): string {
// 处理数组
if (Array.isArray(output)) {
if (output.length === 1 && typeof output[0] === 'string') {
return removeAnsiCodes(output[0])
}
return removeAnsiCodes(output.filter(item => typeof item === 'string').join('\n'))
}
// 解析 JSON
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output)
if (Array.isArray(parsed)) {
if (parsed.length === 1 && typeof parsed[0] === 'string') {
return removeAnsiCodes(parsed[0])
}
return removeAnsiCodes(parsed.filter(item => typeof item === 'string').join('\n'))
}
} catch {
return removeAnsiCodes(output)
}
}
// 从对象中提取
const result = extractFromObject(output, ['output', 'result', 'stdout'])
if (result) {
return removeAnsiCodes(result)
}
return JSON.stringify(output, null, 2)
}
/**
* 提取脚本代码
*/
export function extractScriptCode(input: unknown): string {
const codeFields = [
'file_content',
'content',
'code',
'script',
'command',
'cmd',
'source',
'text',
'body',
]
const result = extractFromObject(input, codeFields)
return result || JSON.stringify(input, null, 2)
}
/**
* 获取显示标题
*/
export function getDisplayTitle(artifact: TaskArtifact): string {
const filePath = getFilePathFromInput(artifact.tool_input)
return filePath || artifact.file_name || artifact.tool_name || '未命名'
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
export function useHighlighter(_highlighter?: HighlighterCore | null) {
const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null)
useEffect(() => {
if (_highlighter) {
return
}
createHighlighterCore({
themes: [
import('@shikijs/themes/one-light'),
import('@shikijs/themes/vitesse-dark'),
import('@shikijs/themes/snazzy-light'),
import('@shikijs/themes/everforest-light'),
import('@shikijs/themes/github-dark-default'),
],
langs: [
import('@shikijs/langs/css'),
import('@shikijs/langs/javascript'),
import('@shikijs/langs/tsx'),
import('@shikijs/langs/jsx'),
import('@shikijs/langs/xml'),
import('@shikijs/langs/html'),
import('@shikijs/langs/python'),
import('@shikijs/langs/sh'),
import('@shikijs/langs/json'),
import('@shikijs/langs/sql'),
import('@shikijs/langs/nginx'),
import('@shikijs/langs/mermaid'),
import('@shikijs/langs/markdown'),
],
engine: createOnigurumaEngine(import('shiki/wasm')),
}).then(highlighter => {
setHighlighter(highlighter)
})
}, [_highlighter])
useEffect(() => {
return () => {
highlighter?.dispose()
}
}, [highlighter])
return _highlighter || highlighter
}

View File

@@ -0,0 +1,257 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { cn } from '@/utils/cn'
import type { TaskArtifact } from '../types'
import { ArtifactList } from './ArtifactList'
import { isImageFile } from './utils'
import { ArtifactPreview } from './ArtifactPreview'
import { useEventStore } from '../store/useEventStore'
import { extractToolOutputArtifact } from './Preview/previewUtils'
import { useNovaKit } from '../context/useNovaKit'
import { Dialog, DialogContent } from '@/components/ui/dialog'
export interface TaskPanelProps {
/** 文件列表 */
artifacts: TaskArtifact[]
/** 是否可见 */
visible?: boolean
/** 面板宽度 */
width?: number | string
/** 获取文件 URL 的函数 */
getUrl?: (artifact: TaskArtifact) => string | Promise<string>
/** 下载文件回调 */
onDownload?: (artifact: TaskArtifact) => void
/** 关闭面板回调 */
onClose?: () => void
/** 自定义类名 */
className?: string
/** 初始选中的文件 */
initialSelected?: TaskArtifact | null
}
/**
* 任务面板组件 - 展示图片和文件
*/
function InnerTaskPanel({
artifacts: artifactsProp,
visible = true,
width = '50%',
getUrl,
onDownload,
onClose,
className,
initialSelected,
}: TaskPanelProps) {
const { panelMode } = useNovaKit()
const [selectedArtifact, setSelectedArtifact] = useState<TaskArtifact | null>(initialSelected || null)
const [fromFileList, setFromFileList] = useState(false)
// 记录用户是否已主动选择过文件,避免流式消息到来时强制刷新预览
const userSelectedRef = useRef(false)
// 从 store 获取 events 和 artifacts
const events = useEventStore((state) => state.events)
const artifactsFromStore = useEventStore((state) => state.artifacts)
// 将 tool_call 类型的 events 转换为 artifacts
const toolCallArtifacts = useMemo((): TaskArtifact[] => {
return events
.filter((event) => event.event_type === 'tool_call')
.map((event) => {
const actionType = (event.content?.action_type as string | undefined) || undefined
const toolName = event.content?.tool_name as string | undefined
const actionName = event.content?.action_name as string | undefined
const isSkillLoader =
actionType === 'skill_loader' ||
actionName === 'skill_loader' ||
toolName === 'skill_loader'
const base: TaskArtifact = {
path: event.event_id,
file_name: toolName || '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
action_type: isSkillLoader
? 'skill_loader'
: actionType || actionName || toolName,
tool_name: toolName,
event_arguments: event.content?.arguments,
tool_input: event.content?.tool_input,
tool_output: event.content?.tool_output,
}
// Skill Loader按照 remix 中的逻辑,作为 Markdown 文件渲染
if (isSkillLoader) {
const metaToolName = (event.content?.metadata as Record<string, unknown>)?.tool_name as string | undefined
const output = event.content?.tool_output
const content =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
return {
...base,
file_type: 'md',
file_name: metaToolName || base.file_name || 'Skill 文档',
content,
}
}
const outputArtifact = extractToolOutputArtifact(base)
if (outputArtifact) {
return outputArtifact
}
return base
})
}, [events])
// 合并所有 artifacts优先使用 store 中的,然后是 props 传入的,最后是 tool_call
const allArtifacts = useMemo(() => {
// 如果 store 中有数据,优先使用 store
const baseArtifacts = artifactsFromStore.length > 0 ? artifactsFromStore : artifactsProp
const merged = [...baseArtifacts, ...toolCallArtifacts]
// 过滤掉 http(s) URL 中,看起来不像「文件」的条目(避免普通网页链接出现在文件列表)
return merged.filter((artifact) => {
const path = artifact.path || artifact.file_name || ''
// 不是 http(s) 链接的,一律保留
if (!/^https?:\/\//.test(path)) return true
try {
const url = new URL(path)
const pathname = url.pathname || ''
// 没有路径(如 https://example.com——按非文件处理过滤掉
if (!pathname || pathname === '/') return false
// 根据路径末尾扩展名判断是否是「文件」
const lastSegment = pathname.split('/').filter(Boolean).pop() || ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
if (!match) return false
const ext = match[1].toLowerCase()
const fileLikeExts = [
'pdf',
'png',
'jpg',
'jpeg',
'gif',
'webp',
'csv',
'ppt',
'pptx',
'doc',
'docx',
'xls',
'xlsx',
'md',
'markdown',
'txt',
'json',
]
// 只有在扩展名属于常见文件类型时,才当作文件保留;否则视为网页链接,过滤掉
return fileLikeExts.includes(ext)
} catch {
// URL 解析失败时保留,避免误删正常路径
return true
}
})
}, [artifactsFromStore, artifactsProp, toolCallArtifacts])
// 仅在 initialSelected 实际变化时同步(不依赖 allArtifacts避免流推时反复触发
useEffect(() => {
if (initialSelected) {
userSelectedRef.current = false
setSelectedArtifact(initialSelected)
setFromFileList(false)
}
}, [initialSelected])
// 只有「当前没有选中」时才自动选中单一文件,防止流式推送中途重置预览
useEffect(() => {
if (!initialSelected && allArtifacts.length === 1) {
setSelectedArtifact(prev => prev ?? allArtifacts[0])
setFromFileList(false)
}
// 用 length 而非整个 allArtifacts避免每次新事件导致数组引用变化而触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allArtifacts.length, initialSelected])
// 筛选出所有图片
const images = useMemo(() => {
return allArtifacts.filter(a => isImageFile(a.path))
}, [allArtifacts])
// 选择文件
const handleSelect = useCallback((artifact: TaskArtifact) => {
userSelectedRef.current = true
setSelectedArtifact(artifact)
setFromFileList(true)
}, [])
// 返回列表
const handleBack = useCallback(() => {
setSelectedArtifact(null)
setFromFileList(false)
}, [])
const panel = (
<div
className={cn(
'h-full flex flex-col rounded-none border-l border-border',
'transition-all duration-300 ease-in-out',
className
)}
style={{ width: panelMode === 'dialog' ? '100%' : width }}
>
{/* 内容区 */}
<div className="flex-1 overflow-hidden relative">
{selectedArtifact || (allArtifacts.length === 1 && allArtifacts[0]) ? (
<div className="absolute inset-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
<ArtifactPreview
artifact={selectedArtifact || allArtifacts[0]}
fromFileList={fromFileList}
images={images}
getUrl={getUrl}
onBack={allArtifacts.length > 1 ? handleBack : undefined}
onDownload={onDownload}
onClose={onClose}
/>
</div>
) : (
<div className="absolute inset-0 animate-in fade-in-0 slide-in-from-left-4 duration-300">
<ArtifactList
artifacts={allArtifacts}
onClick={handleSelect}
selected={selectedArtifact}
className="h-full"
/>
</div>
)}
</div>
</div>
)
if (panelMode === 'dialog') {
return (
<Dialog open={visible} onOpenChange={(open) => { if (!open) onClose?.() }}>
<DialogContent className="max-w-5xl w-[90vw] h-[80vh] p-0 overflow-hidden">
{panel}
</DialogContent>
</Dialog>
)
}
return panel
}
export const TaskPanel = React.memo(InnerTaskPanel)
export default TaskPanel
// 导出子组件
export { ArtifactList } from './ArtifactList'
export { ArtifactPreview } from './ArtifactPreview'

View File

@@ -0,0 +1,24 @@
/**
* 获取文件扩展名
*/
function getFileExtension(path: string): string {
const parts = path.split('.')
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''
}
/**
* 判断是否是图片文件
*/
export function isImageFile(path: string): boolean {
const ext = getFileExtension(path.replace(/\?.*$/, ''))
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)
}
export function safeParse(val: string) {
try {
return JSON.parse(val)
} catch {
return {}
}
}