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

212 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

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