feat(ui): add WebFetch tool widget with comprehensive content display

- Add WebFetchWidget component with URL display, prompt handling, and content preview
- Integrate WebFetch widget into StreamMessage tool rendering pipeline
- Include loading states, error handling, and expandable content functionality
- Support both basic URL fetching and prompted analysis workflows
This commit is contained in:
Mufeed VH
2025-07-02 18:26:57 +05:30
parent a7e17f16ec
commit d431f3286d
2 changed files with 193 additions and 2 deletions

View File

@@ -35,7 +35,8 @@ import {
TaskWidget,
LSResultWidget,
ThinkingWidget,
WebSearchWidget
WebSearchWidget,
WebFetchWidget
} from "./ToolWidgets";
interface StreamMessageProps {
@@ -246,6 +247,12 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
return <WebSearchWidget query={input.query} result={toolResult} />;
}
// WebFetch tool
if (toolName === "webfetch" && input?.url) {
renderedSomething = true;
return <WebFetchWidget url={input.url} prompt={input.prompt} result={toolResult} />;
}
// Default - return null
return null;
};
@@ -361,7 +368,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch'];
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch','webfetch'];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}

View File

@@ -2288,3 +2288,187 @@ export const ThinkingWidget: React.FC<{
</div>
);
};
/**
* Widget for WebFetch tool - displays URL fetching with optional prompts
*/
export const WebFetchWidget: React.FC<{
url: string;
prompt?: string;
result?: any;
}> = ({ url, prompt, result }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [showFullContent, setShowFullContent] = useState(false);
// Extract result content if available
let fetchedContent = '';
let isLoading = !result;
let hasError = false;
if (result) {
if (typeof result.content === 'string') {
fetchedContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
fetchedContent = result.content.text;
} else if (Array.isArray(result.content)) {
fetchedContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
fetchedContent = JSON.stringify(result.content, null, 2);
}
}
// Check if there's an error
hasError = result.is_error ||
fetchedContent.toLowerCase().includes('error') ||
fetchedContent.toLowerCase().includes('failed');
}
// Truncate content for preview
const maxPreviewLength = 500;
const isTruncated = fetchedContent.length > maxPreviewLength;
const previewContent = isTruncated && !showFullContent
? fetchedContent.substring(0, maxPreviewLength) + '...'
: fetchedContent;
// Extract domain from URL for display
const getDomain = (urlString: string) => {
try {
const urlObj = new URL(urlString);
return urlObj.hostname;
} catch {
return urlString;
}
};
const handleUrlClick = async () => {
try {
await open(url);
} catch (error) {
console.error('Failed to open URL:', error);
}
};
return (
<div className="flex flex-col gap-2">
{/* Header with URL and optional prompt */}
<div className="space-y-2">
{/* URL Display */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-500/5 border border-purple-500/10">
<Globe className="h-4 w-4 text-purple-500/70" />
<span className="text-xs font-medium uppercase tracking-wider text-purple-600/70 dark:text-purple-400/70">Fetching</span>
<button
onClick={handleUrlClick}
className="text-sm text-foreground/80 hover:text-foreground flex-1 truncate text-left hover:underline decoration-purple-500/50"
>
{url}
</button>
</div>
{/* Prompt Display */}
{prompt && (
<div className="ml-6 space-y-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
<Info className="h-3 w-3" />
<span>Analysis Prompt</span>
</button>
{isExpanded && (
<div className="rounded-lg border bg-muted/30 p-3 ml-4">
<p className="text-sm text-foreground/90">
{prompt}
</p>
</div>
)}
</div>
)}
</div>
{/* Results */}
{isLoading ? (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
<div className="px-3 py-2 flex items-center gap-2 text-muted-foreground">
<div className="animate-pulse flex items-center gap-1">
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce"></div>
</div>
<span className="text-sm">Fetching content from {getDomain(url)}...</span>
</div>
</div>
) : fetchedContent ? (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
{hasError ? (
<div className="px-3 py-2">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">Failed to fetch content</span>
</div>
<pre className="mt-2 text-xs font-mono text-muted-foreground whitespace-pre-wrap">
{fetchedContent}
</pre>
</div>
) : (
<div className="p-3 space-y-2">
{/* Content Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-3.5 w-3.5" />
<span>Content from {getDomain(url)}</span>
</div>
{isTruncated && (
<button
onClick={() => setShowFullContent(!showFullContent)}
className="text-xs text-purple-500 hover:text-purple-600 transition-colors flex items-center gap-1"
>
{showFullContent ? (
<>
<ChevronUp className="h-3 w-3" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Show full content
</>
)}
</button>
)}
</div>
{/* Fetched Content */}
<div className="relative">
<div className={cn(
"rounded-lg bg-muted/30 p-3 overflow-hidden",
!showFullContent && isTruncated && "max-h-[300px]"
)}>
<pre className="text-sm font-mono text-foreground/90 whitespace-pre-wrap">
{previewContent}
</pre>
{!showFullContent && isTruncated && (
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-muted/30 to-transparent pointer-events-none" />
)}
</div>
</div>
</div>
)}
</div>
) : (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
<div className="px-3 py-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-4 w-4" />
<span className="text-sm">No content returned</span>
</div>
</div>
</div>
)}
</div>
);
};