suna/frontend/src/components/thread/tool-views/WebSearchToolView.tsx

382 lines
17 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Search,
CheckCircle,
AlertTriangle,
ExternalLink,
Image as ImageIcon,
Loader2,
Globe,
FileText,
Clock,
BookOpen,
CalendarDays,
ChevronDown,
ChevronUp,
Check
} from 'lucide-react';
import { ToolViewProps } from './types';
import {
extractSearchQuery,
extractSearchResults,
cleanUrl,
formatTimestamp,
getToolTitle,
} from './utils';
import { cn } from '@/lib/utils';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
function truncateText(text: string, maxLength: number = 70) {
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
}
export function WebSearchToolView({
name = 'web-search',
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
}: ToolViewProps) {
const { resolvedTheme } = useTheme();
const isDarkTheme = resolvedTheme === 'dark';
const [progress, setProgress] = useState(0);
const [expandedResults, setExpandedResults] = useState<Record<number, boolean>>({});
const query = extractSearchQuery(assistantContent);
const searchResults = extractSearchResults(toolContent);
const toolTitle = getToolTitle(name);
// Extract additional data from response
const [answer, setAnswer] = useState<string | null>(null);
const [images, setImages] = useState<string[]>([]);
// Toggle result expansion
const toggleExpand = (idx: number) => {
setExpandedResults(prev => ({
...prev,
[idx]: !prev[idx]
}));
};
// Simulate progress when streaming
useEffect(() => {
if (isStreaming) {
const timer = setInterval(() => {
setProgress((prevProgress) => {
if (prevProgress >= 95) {
clearInterval(timer);
return prevProgress;
}
return prevProgress + 5;
});
}, 300);
return () => clearInterval(timer);
} else {
setProgress(100);
}
}, [isStreaming]);
useEffect(() => {
if (toolContent) {
try {
const parsedContent = JSON.parse(toolContent);
// Check if it's the response format with answer
if (parsedContent.answer && typeof parsedContent.answer === 'string') {
setAnswer(parsedContent.answer);
}
// Check for images array
if (parsedContent.images && Array.isArray(parsedContent.images)) {
setImages(parsedContent.images);
}
} catch (e) {
// Silently fail - the view will work without these extras
}
}
}, [toolContent]);
// Helper to determine favicon
const getFavicon = (url: string) => {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
} catch (e) {
return null;
}
};
const getResultType = (result: any) => {
const { url, title } = result;
if (url.includes('news') || url.includes('article') || title.includes('News')) {
return { icon: FileText, label: 'Article' };
} else if (url.includes('wiki')) {
return { icon: BookOpen, label: 'Wiki' };
} else if (url.includes('blog')) {
return { icon: CalendarDays, label: 'Blog' };
} else {
return { icon: Globe, label: 'Website' };
}
};
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-white dark:bg-zinc-950">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20">
<Search className="w-5 h-5 text-blue-500 dark:text-blue-400" />
</div>
<div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
className={
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
{isSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{isSuccess ? 'Search completed successfully' : 'Search failed'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{isStreaming ? (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="text-center w-full max-w-xs">
<div className="w-16 h-16 rounded-full mx-auto mb-6 flex items-center justify-center bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500 dark:text-blue-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2">
Searching the web
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
<span className="font-mono text-xs break-all">{query}</span>
</p>
<Progress value={progress} className="w-full h-2" />
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
</div>
</div>
) : searchResults.length > 0 ? (
<ScrollArea className="h-full w-full">
<div className="p-4 py-0 my-4">
{answer && (
<div className="mb-6 bg-blue-50/70 dark:bg-blue-950/30 p-4 rounded-lg border border-blue-100 dark:border-blue-900/50 shadow-sm">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2 flex items-center">
<Search className="h-4 w-4 mr-2 opacity-70" />
Answer
</h3>
<p className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap font-sans">
{answer}
</p>
</div>
)}
{images.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3 flex items-center">
<ImageIcon className="h-4 w-4 mr-2 opacity-70" />
Images
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-1">
{images.slice(0, 6).map((image, idx) => (
<a
key={idx}
href={image}
target="_blank"
rel="noopener noreferrer"
className="group relative overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-100 dark:bg-zinc-900 hover:border-blue-300 dark:hover:border-blue-700 transition-colors shadow-sm hover:shadow-md"
>
<img
src={image}
alt={`Search result ${idx + 1}`}
className="object-cover w-full h-32 group-hover:opacity-90 transition-opacity"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'%3E%3C/circle%3E%3Cpolyline points='21 15 16 10 5 21'%3E%3C/polyline%3E%3C/svg%3E";
target.classList.add("p-4");
}}
/>
<div className="absolute top-0 right-0 p-1">
<Badge variant="secondary" className="bg-black/60 hover:bg-black/70 text-white border-none shadow-md">
<ExternalLink className="h-3 w-3" />
</Badge>
</div>
</a>
))}
</div>
{images.length > 6 && (
<Button variant="outline" size="sm" className="mt-2 text-xs">
View {images.length - 6} more images
</Button>
)}
</div>
)}
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 mb-4 flex items-center justify-between">
<span>Search Results ({searchResults.length})</span>
<Badge variant="outline" className="text-xs font-normal">
<Clock className="h-3 w-3 mr-1.5 opacity-70" />
{new Date().toLocaleDateString()}
</Badge>
</div>
<div className="space-y-4">
{searchResults.map((result, idx) => {
const { icon: ResultTypeIcon, label: resultTypeLabel } = getResultType(result);
const isExpanded = expandedResults[idx] || false;
const favicon = getFavicon(result.url);
return (
<div
key={idx}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-sm hover:shadow transition-shadow overflow-hidden"
>
<div className="p-4">
<div className="flex items-start gap-3 mb-2">
{favicon && (
<img
src={favicon}
alt=""
className="w-5 h-5 mt-1 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs px-2 py-0 h-5 font-normal bg-zinc-50 dark:bg-zinc-800">
<ResultTypeIcon className="h-3 w-3 mr-1 opacity-70" />
{resultTypeLabel}
</Badge>
</div>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-md font-medium text-blue-600 dark:text-blue-400 hover:underline line-clamp-1 mb-1"
>
{result.title}
</a>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mb-2 flex items-center">
<Globe className="h-3 w-3 mr-1.5 flex-shrink-0 opacity-70" />
{truncateText(cleanUrl(result.url))}
</div>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 rounded-full"
onClick={() => toggleExpand(idx)}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isExpanded ? 'Show less' : 'Show more'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{result.snippet && (
<p className={cn(
"text-sm text-zinc-600 dark:text-zinc-400",
isExpanded ? "" : "line-clamp-2"
)}>
{result.snippet}
</p>
)}
</div>
{isExpanded && (
<div className="bg-zinc-50 px-4 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 p-3 flex justify-between items-center">
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Source: {cleanUrl(result.url)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs bg-white dark:bg-zinc-900"
asChild
>
<a href={result.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
Visit Site
</a>
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
</ScrollArea>
) : (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-gradient-to-b from-zinc-100 to-zinc-50 shadow-inner dark:from-zinc-800/40 dark:to-zinc-900/60">
<Search className="h-10 w-10 text-zinc-400 dark:text-zinc-600" />
</div>
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
No Results Found
</h3>
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{query || 'Unknown query'}
</code>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Try refining your search query for better results
</p>
</div>
)}
</CardContent>
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
{!isStreaming && searchResults.length > 0 && (
<Badge className="h-6 py-0.5">
<Globe className="h-3 w-3" />
{searchResults.length} results
</Badge>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{toolTimestamp && !isStreaming
? formatTimestamp(toolTimestamp)
: assistantTimestamp
? formatTimestamp(assistantTimestamp)
: ''}
</div>
</div>
</Card>
);
}