初始化模版工程
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user