初始化模版工程
This commit is contained in:
347
components/nova-sdk/task-panel/ArtifactList.tsx
Normal file
347
components/nova-sdk/task-panel/ArtifactList.tsx
Normal 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
|
||||
364
components/nova-sdk/task-panel/ArtifactPreview.tsx
Normal file
364
components/nova-sdk/task-panel/ArtifactPreview.tsx
Normal 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 || ''} />
|
||||
}
|
||||
|
||||
// CSV:fetch 内容后渲染为表格
|
||||
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
|
||||
171
components/nova-sdk/task-panel/Preview/CsvPreview.tsx
Normal file
171
components/nova-sdk/task-panel/Preview/CsvPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
65
components/nova-sdk/task-panel/Preview/MarkdownPreview.tsx
Normal file
65
components/nova-sdk/task-panel/Preview/MarkdownPreview.tsx
Normal 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 预览组件 - 接收 URL,fetch 内容后渲染
|
||||
*/
|
||||
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
|
||||
211
components/nova-sdk/task-panel/Preview/PptPreview.tsx
Normal file
211
components/nova-sdk/task-panel/Preview/PptPreview.tsx
Normal 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
|
||||
162
components/nova-sdk/task-panel/Preview/ScriptPreview.tsx
Normal file
162
components/nova-sdk/task-panel/Preview/ScriptPreview.tsx
Normal 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
|
||||
110
components/nova-sdk/task-panel/Preview/ShellExecutePreview.tsx
Normal file
110
components/nova-sdk/task-panel/Preview/ShellExecutePreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
420
components/nova-sdk/task-panel/Preview/ToolCallPreview.tsx
Normal file
420
components/nova-sdk/task-panel/Preview/ToolCallPreview.tsx
Normal 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
|
||||
@@ -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
|
||||
70
components/nova-sdk/task-panel/Preview/UrlScriptPreview.tsx
Normal file
70
components/nova-sdk/task-panel/Preview/UrlScriptPreview.tsx
Normal 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
|
||||
|
||||
217
components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx
Normal file
217
components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
components/nova-sdk/task-panel/Preview/WebSearchPreview.tsx
Normal file
211
components/nova-sdk/task-panel/Preview/WebSearchPreview.tsx
Normal 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
|
||||
31
components/nova-sdk/task-panel/Preview/index.ts
Normal file
31
components/nova-sdk/task-panel/Preview/index.ts
Normal 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'
|
||||
82
components/nova-sdk/task-panel/Preview/previewUtils.test.ts
Normal file
82
components/nova-sdk/task-panel/Preview/previewUtils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
392
components/nova-sdk/task-panel/Preview/previewUtils.ts
Normal file
392
components/nova-sdk/task-panel/Preview/previewUtils.ts
Normal 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 || '未命名'
|
||||
}
|
||||
49
components/nova-sdk/task-panel/Preview/useHighlighter.ts
Normal file
49
components/nova-sdk/task-panel/Preview/useHighlighter.ts
Normal 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
|
||||
}
|
||||
257
components/nova-sdk/task-panel/index.tsx
Normal file
257
components/nova-sdk/task-panel/index.tsx
Normal 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'
|
||||
24
components/nova-sdk/task-panel/utils.ts
Normal file
24
components/nova-sdk/task-panel/utils.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user