refactor: remove frontend of scrape_webpage tool

This commit is contained in:
Anish Sarkar 2026-03-24 18:55:06 +05:30
parent a009cae62a
commit 3f4e1a7dfd
9 changed files with 118 additions and 655 deletions

View file

@ -29,7 +29,6 @@ import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, UpdateJiraIssueToolUI } f
import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, UpdateLinearIssueToolUI } from "@/components/tool-ui/linear";
import { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "@/components/tool-ui/notion";
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
@ -59,7 +58,6 @@ const AssistantMessageInner: FC = () => {
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: DisplayImageToolUI,
generate_image: GenerateImageToolUI,
scrape_webpage: ScrapeWebpageToolUI,
save_memory: SaveMemoryToolUI,
recall_memory: RecallMemoryToolUI,
execute: SandboxExecuteToolUI,

View file

@ -1,8 +1,14 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getToolIcon } from "@/contracts/enums/toolIcons";
function formatToolName(name: string): string {
return name
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export const ToolFallback: ToolCallMessagePartComponent = ({
toolName,
@ -10,66 +16,127 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
result,
status,
}) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
const isError = status?.type === "incomplete" && status.reason === "error";
const isRunning = status?.type === "running" || status?.type === "requires-action";
const cancelledReason =
isCancelled && status.error
? typeof status.error === "string"
? status.error
: JSON.stringify(status.error)
: null;
const errorReason =
isError && status.error
? typeof status.error === "string"
? status.error
: JSON.stringify(status.error)
: null;
const Icon = getToolIcon(toolName);
const displayName = formatToolName(toolName);
return (
<div
className={cn(
"aui-tool-fallback-root mb-4 flex w-full flex-col gap-3 rounded-lg border py-3",
isCancelled && "border-muted-foreground/30 bg-muted/30"
"my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none",
isCancelled && "opacity-60",
isError && "border-destructive/20 bg-destructive/5",
)}
>
<div className="aui-tool-fallback-header flex items-center gap-2 px-4">
{isCancelled ? (
<XCircleIcon className="aui-tool-fallback-icon size-4 text-muted-foreground" />
) : (
<CheckIcon className="aui-tool-fallback-icon size-4" />
)}
<p
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
>
<div
className={cn(
"aui-tool-fallback-title grow",
isCancelled && "text-muted-foreground line-through"
"flex size-8 shrink-0 items-center justify-center rounded-lg",
isError
? "bg-destructive/10"
: isCancelled
? "bg-muted"
: "bg-primary/10",
)}
>
{isCancelled ? "Cancelled tool: " : "Used tool: "}
<b>{toolName}</b>
</p>
<Button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
</Button>
</div>
{!isCollapsed && (
<div className="aui-tool-fallback-content flex flex-col gap-2 border-t pt-2">
{cancelledReason && (
<div className="aui-tool-fallback-cancelled-root px-4">
<p className="aui-tool-fallback-cancelled-header font-semibold text-muted-foreground">
Cancelled reason:
</p>
<p className="aui-tool-fallback-cancelled-reason text-muted-foreground">
{cancelledReason}
</p>
</div>
)}
<div className={cn("aui-tool-fallback-args-root px-4", isCancelled && "opacity-60")}>
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">{argsText}</pre>
</div>
{!isCancelled && result !== undefined && (
<div className="aui-tool-fallback-result-root border-t border-dashed px-4 pt-2">
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
{isError ? (
<XCircleIcon className="size-4 text-destructive" />
) : isCancelled ? (
<XCircleIcon className="size-4 text-muted-foreground" />
) : isRunning ? (
<Icon className="size-4 text-primary animate-pulse" />
) : (
<CheckIcon className="size-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-semibold",
isError
? "text-destructive"
: isCancelled
? "text-muted-foreground line-through"
: "text-foreground",
)}
>
{isRunning
? displayName
: isCancelled
? `Cancelled: ${displayName}`
: isError
? `Failed: ${displayName}`
: displayName}
</p>
{isRunning && (
<p className="text-xs text-muted-foreground mt-0.5">Running...</p>
)}
{cancelledReason && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{cancelledReason}</p>
)}
{errorReason && (
<p className="text-xs text-destructive/80 mt-0.5 truncate">{errorReason}</p>
)}
</div>
{!isRunning && (
<div className="shrink-0 text-muted-foreground">
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronUpIcon className="size-4" />
)}
</div>
)}
</button>
{isExpanded && !isRunning && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3 space-y-3">
{argsText && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Arguments</p>
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
{argsText}
</pre>
</div>
)}
{!isCancelled && result !== undefined && (
<>
<div className="h-px bg-border/30" />
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Result</p>
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
</>
)}
</div>
</>
)}
</div>
);

View file

@ -17,7 +17,6 @@ import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
interface PublicThreadProps {
footer?: ReactNode;
@ -152,7 +151,6 @@ const PublicAssistantMessage: FC = () => {
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: DisplayImageToolUI,
generate_image: GenerateImageToolUI,
scrape_webpage: ScrapeWebpageToolUI,
},
Fallback: ToolFallback,
},

View file

@ -1,425 +0,0 @@
"use client";
import {
AlertCircleIcon,
BookOpenIcon,
CalendarIcon,
ExternalLinkIcon,
FileTextIcon,
UserIcon,
} from "lucide-react";
import Image from "next/image";
import { Component, type ReactNode, useCallback, useState } from "react";
import { z } from "zod";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* Zod schema for serializable article data (from backend)
*/
const SerializableArticleSchema = z.object({
id: z.string().default("article-unknown"),
assetId: z.string().nullish(),
kind: z.literal("article").nullish(),
title: z.string().default("Untitled Article"),
description: z.string().nullish(),
content: z.string().nullish(),
href: z.string().url().nullish(),
domain: z.string().nullish(),
author: z.string().nullish(),
date: z.string().nullish(),
word_count: z.number().nullish(),
wordCount: z.number().nullish(),
was_truncated: z.boolean().nullish(),
wasTruncated: z.boolean().nullish(),
error: z.string().nullish(),
});
/**
* Serializable article data type (from backend)
*/
export type SerializableArticle = z.infer<typeof SerializableArticleSchema>;
/**
* Article component props
*/
export interface ArticleProps {
/** Unique identifier for the article */
id: string;
/** Asset identifier (usually the URL) */
assetId?: string;
/** Article title */
title: string;
/** Brief description or excerpt */
description?: string;
/** Full content of the article (markdown) */
content?: string;
/** URL to the original article */
href?: string;
/** Domain of the article source */
domain?: string;
/** Author name */
author?: string;
/** Publication date */
date?: string;
/** Word count */
wordCount?: number;
/** Whether content was truncated */
wasTruncated?: boolean;
/** Optional max width */
maxWidth?: string;
/** Optional error message */
error?: string;
/** Optional className */
className?: string;
/** Response actions */
responseActions?: Array<{
id: string;
label: string;
variant?: "default" | "outline";
}>;
/** Response action handler */
onResponseAction?: (actionId: string) => void;
}
/**
* Parse and validate serializable article data to ArticleProps
*/
export function parseSerializableArticle(data: unknown): ArticleProps {
const result = SerializableArticleSchema.safeParse(data);
if (!result.success) {
console.warn("Invalid article data:", result.error.issues);
// Return fallback with basic info
const obj = (data && typeof data === "object" ? data : {}) as Record<string, unknown>;
return {
id: String(obj.id || "article-unknown"),
title: String(obj.title || "Untitled Article"),
error: "Failed to parse article data",
};
}
const parsed = result.data;
return {
id: parsed.id,
assetId: parsed.assetId,
title: parsed.title,
description: parsed.description,
content: parsed.content,
href: parsed.href,
domain: parsed.domain,
author: parsed.author,
date: parsed.date,
wordCount: parsed.word_count ?? parsed.wordCount,
wasTruncated: parsed.was_truncated ?? parsed.wasTruncated,
error: parsed.error,
};
}
/**
* Format word count for display
*/
function formatWordCount(count: number): string {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k words`;
}
return `${count} words`;
}
/**
* Favicon component that fetches the site icon via Google's favicon service,
* falling back to BookOpenIcon on error.
*/
function SiteFavicon({ domain }: { domain: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <BookOpenIcon className="size-5 text-primary" />;
}
return (
<Image
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`}
alt={`${domain} favicon`}
width={28}
height={28}
className="size-5 sm:size-7 rounded-sm"
onError={() => setFailed(true)}
unoptimized
/>
);
}
/**
* Article card component for displaying scraped webpage content
*/
export function Article({
id,
title,
description,
content,
href,
domain,
author,
date,
wordCount,
wasTruncated,
maxWidth = "100%",
error,
className,
responseActions,
onResponseAction,
}: ArticleProps) {
const handleCardClick = useCallback(() => {
if (href) {
window.open(href, "_blank", "noopener,noreferrer");
}
}, [href]);
// Error state
if (error) {
return (
<Card
id={id}
className={cn("overflow-hidden border-destructive/20 bg-destructive/5", className)}
style={{ maxWidth }}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircleIcon className="size-5 text-destructive" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-destructive text-sm">Failed to scrape webpage</p>
{href && <p className="text-muted-foreground text-xs mt-0.5 truncate">{href}</p>}
<p className="text-muted-foreground text-xs mt-1">{error}</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<TooltipProvider>
<Card
id={id}
className={cn(
"group relative overflow-hidden transition-all duration-200",
"hover:shadow-lg hover:border-primary/20",
href && "cursor-pointer",
className
)}
style={{ maxWidth }}
onClick={href ? handleCardClick : undefined}
role={href ? "link" : undefined}
tabIndex={href ? 0 : undefined}
onKeyDown={(e) => {
if (href && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleCardClick();
}
}}
>
{/* Header */}
<CardContent className="p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
{/* Favicon / Icon */}
{domain ? (
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center">
<SiteFavicon domain={domain} />
</div>
) : (
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<BookOpenIcon className="size-4 sm:size-5 text-primary" />
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h3 className="font-semibold text-xs sm:text-sm line-clamp-2 group-hover:text-primary transition-colors">
{title}
</h3>
{/* Description */}
{description && (
<p className="text-muted-foreground text-[10px] sm:text-xs mt-1 line-clamp-2">
{description}
</p>
)}
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-muted-foreground">
{domain && (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1">
<ExternalLinkIcon className="size-3" />
<span className="truncate max-w-[120px]">{domain}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Source: {domain}</p>
</TooltipContent>
</Tooltip>
)}
{author && (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1">
<UserIcon className="size-3" />
<span className="truncate max-w-[100px]">{author}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Author: {author}</p>
</TooltipContent>
</Tooltip>
)}
{date && (
<span className="flex items-center gap-1">
<CalendarIcon className="size-3" />
<span>{date}</span>
</span>
)}
{wordCount && (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1">
<FileTextIcon className="size-3" />
<span>{formatWordCount(wordCount)}</span>
{wasTruncated && <span className="text-warning">(truncated)</span>}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{wasTruncated
? "Content was truncated due to length"
: "Full article content available"}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
{/* Response actions */}
{responseActions && responseActions.length > 0 && (
<div className="flex gap-2 mt-3 pt-3 border-t">
{responseActions.map((action) => (
<button
key={action.id}
type="button"
onClick={(e) => {
e.stopPropagation();
onResponseAction?.(action.id);
}}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
action.variant === "outline"
? "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{action.label}
</button>
))}
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
);
}
/**
* Loading state for article component
*/
export function ArticleLoading({ title = "Loading article..." }: { title?: string }) {
return (
<Card className="overflow-hidden animate-pulse">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="size-10 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
<p className="text-xs text-muted-foreground mt-3">{title}</p>
</CardContent>
</Card>
);
}
/**
* Skeleton for article component
*/
export function ArticleSkeleton() {
return (
<Card className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start gap-3 animate-pulse">
<div className="size-10 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-2/3" />
</div>
</div>
</CardContent>
</Card>
);
}
/**
* Error boundary props
*/
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
/**
* Error boundary for article component
*/
export class ArticleErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<Card className="overflow-hidden border-destructive/20 bg-destructive/5">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<AlertCircleIcon className="size-5 text-destructive" />
<p className="text-sm text-destructive">Failed to render article</p>
</div>
</CardContent>
</Card>
)
);
}
return this.props.children;
}
}

View file

@ -6,15 +6,6 @@
* rich UI when specific tools are called by the agent.
*/
export {
Article,
ArticleErrorBoundary,
ArticleLoading,
type ArticleProps,
ArticleSkeleton,
parseSerializableArticle,
type SerializableArticle,
} from "./article";
export { Audio } from "./audio";
export {
type DisplayImageArgs,
@ -65,13 +56,6 @@ export {
ExecuteResultSchema,
SandboxExecuteToolUI,
} from "./sandbox-execute";
export {
type ScrapeWebpageArgs,
ScrapeWebpageArgsSchema,
type ScrapeWebpageResult,
ScrapeWebpageResultSchema,
ScrapeWebpageToolUI,
} from "./scrape-webpage";
export {
type MemoryItem,
type RecallMemoryArgs,

View file

@ -1,163 +0,0 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { AlertCircleIcon, FileTextIcon } from "lucide-react";
import { z } from "zod";
import {
Article,
ArticleErrorBoundary,
ArticleLoading,
parseSerializableArticle,
} from "@/components/tool-ui/article";
// ============================================================================
// Zod Schemas
// ============================================================================
/**
* Schema for scrape_webpage tool arguments
*/
const ScrapeWebpageArgsSchema = z.object({
url: z.string(),
max_length: z.number().nullish(),
});
/**
* Schema for scrape_webpage tool result
*/
const ScrapeWebpageResultSchema = z.object({
id: z.string(),
assetId: z.string(),
kind: z.literal("article"),
href: z.string(),
title: z.string(),
description: z.string().nullish(),
content: z.string().nullish(),
domain: z.string().nullish(),
author: z.string().nullish(),
date: z.string().nullish(),
word_count: z.number().nullish(),
was_truncated: z.boolean().nullish(),
crawler_type: z.string().nullish(),
error: z.string().nullish(),
});
// ============================================================================
// Types
// ============================================================================
type ScrapeWebpageArgs = z.infer<typeof ScrapeWebpageArgsSchema>;
type ScrapeWebpageResult = z.infer<typeof ScrapeWebpageResultSchema>;
/**
* Error state component shown when webpage scraping fails
*/
function ScrapeErrorState({ url, error }: { url: string; error: string }) {
return (
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 max-w-md">
<div className="flex items-center gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircleIcon className="size-6 text-destructive" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-destructive text-sm">Failed to scrape webpage</p>
<p className="text-muted-foreground text-xs mt-0.5 truncate">{url}</p>
<p className="text-muted-foreground text-xs mt-1">{error}</p>
</div>
</div>
</div>
);
}
/**
* Cancelled state component
*/
function ScrapeCancelledState({ url }: { url: string }) {
return (
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground max-w-md">
<p className="flex items-center gap-2">
<FileTextIcon className="size-4" />
<span className="line-through truncate">Scraping: {url}</span>
</p>
</div>
);
}
/**
* Parsed Article component with error handling
*/
function ParsedArticle({ result }: { result: unknown }) {
const { description, ...article } = parseSerializableArticle(result);
return <Article {...article} maxWidth="480px" />;
}
/**
* Scrape Webpage Tool UI Component
*
* This component is registered with assistant-ui to render an article card
* when the scrape_webpage tool is called by the agent.
*
* It displays scraped webpage content including:
* - Title and description
* - Author and date (if available)
* - Word count
* - Link to original source
*/
export const ScrapeWebpageToolUI = ({ args, result, status }: ToolCallMessagePartProps<ScrapeWebpageArgs, ScrapeWebpageResult>) => {
const url = args.url || "Unknown URL";
// Loading state - tool is still running
if (status.type === "running" || status.type === "requires-action") {
return (
<div className="my-4">
<ArticleLoading title={`Scraping ${url}...`} />
</div>
);
}
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return <ScrapeCancelledState url={url} />;
}
if (status.reason === "error") {
return (
<ScrapeErrorState
url={url}
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
// No result yet
if (!result) {
return (
<div className="my-4">
<ArticleLoading title={`Extracting content from ${url}...`} />
</div>
);
}
// Error result from the tool
if (result.error) {
return <ScrapeErrorState url={url} error={result.error} />;
}
// Success - render the article card
return (
<div className="my-4">
<ArticleErrorBoundary>
<ParsedArticle result={result} />
</ArticleErrorBoundary>
</div>
);
};
export {
ScrapeWebpageArgsSchema,
ScrapeWebpageResultSchema,
type ScrapeWebpageArgs,
type ScrapeWebpageResult,
};