212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
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
|