feat: add link preview tool for enhanced URL metadata display in chat

This commit is contained in:
Anish Sarkar 2025-12-23 00:58:27 +05:30
parent 28985e6af4
commit 4b69fdf214
8 changed files with 1035 additions and 3 deletions

View file

@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.context import SurfSenseContextSchema
from app.agents.new_chat.knowledge_base import create_search_knowledge_base_tool
from app.agents.new_chat.link_preview import create_link_preview_tool
from app.agents.new_chat.podcast import create_generate_podcast_tool
from app.agents.new_chat.system_prompt import build_surfsense_system_prompt
from app.services.connector_service import ConnectorService
@ -34,6 +35,7 @@ def create_surfsense_deep_agent(
user_instructions: str | None = None,
enable_citations: bool = True,
enable_podcast: bool = True,
enable_link_preview: bool = True,
additional_tools: Sequence[BaseTool] | None = None,
):
"""
@ -53,6 +55,8 @@ def create_surfsense_deep_agent(
When False, the agent will not be instructed to add citations to responses.
enable_podcast: Whether to include the podcast generation tool (default: True).
When True and user_id is provided, the agent can generate podcasts.
enable_link_preview: Whether to include the link preview tool (default: True).
When True, the agent can fetch and display rich link previews.
additional_tools: Optional sequence of additional tools to inject into the agent.
The search_knowledge_base tool will always be included.
@ -78,6 +82,11 @@ def create_surfsense_deep_agent(
)
tools.append(podcast_tool)
# Add link preview tool if enabled
if enable_link_preview:
link_preview_tool = create_link_preview_tool()
tools.append(link_preview_tool)
if additional_tools:
tools.extend(additional_tools)

View file

@ -0,0 +1,292 @@
"""
Link preview tool for the new chat agent.
This module provides a tool for fetching URL metadata (title, description,
Open Graph image, etc.) to display rich link previews in the chat UI.
"""
import hashlib
import re
from typing import Any
from urllib.parse import urlparse
import httpx
from langchain_core.tools import tool
def extract_domain(url: str) -> str:
"""Extract the domain from a URL."""
try:
parsed = urlparse(url)
domain = parsed.netloc
# Remove 'www.' prefix if present
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""
def extract_og_content(html: str, property_name: str) -> str | None:
"""Extract Open Graph meta content from HTML."""
# Try og:property first
pattern = rf'<meta[^>]+property=["\']og:{property_name}["\'][^>]+content=["\']([^"\']+)["\']'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
# Try content before property
pattern = rf'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:{property_name}["\']'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
return None
def extract_twitter_content(html: str, name: str) -> str | None:
"""Extract Twitter Card meta content from HTML."""
pattern = rf'<meta[^>]+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
# Try content before name
pattern = rf'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:{name}["\']'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
return None
def extract_meta_description(html: str) -> str | None:
"""Extract meta description from HTML."""
pattern = r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
# Try content before name
pattern = r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']description["\']'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
return None
def extract_title(html: str) -> str | None:
"""Extract title from HTML."""
# Try og:title first
og_title = extract_og_content(html, "title")
if og_title:
return og_title
# Try twitter:title
twitter_title = extract_twitter_content(html, "title")
if twitter_title:
return twitter_title
# Fall back to <title> tag
pattern = r"<title[^>]*>([^<]+)</title>"
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1).strip()
return None
def extract_description(html: str) -> str | None:
"""Extract description from HTML."""
# Try og:description first
og_desc = extract_og_content(html, "description")
if og_desc:
return og_desc
# Try twitter:description
twitter_desc = extract_twitter_content(html, "description")
if twitter_desc:
return twitter_desc
# Fall back to meta description
return extract_meta_description(html)
def extract_image(html: str) -> str | None:
"""Extract image URL from HTML."""
# Try og:image first
og_image = extract_og_content(html, "image")
if og_image:
return og_image
# Try twitter:image
twitter_image = extract_twitter_content(html, "image")
if twitter_image:
return twitter_image
return None
def generate_preview_id(url: str) -> str:
"""Generate a unique ID for a link preview."""
hash_val = hashlib.md5(url.encode()).hexdigest()[:12]
return f"link-preview-{hash_val}"
def create_link_preview_tool():
"""
Factory function to create the link_preview tool.
Returns:
A configured tool function for fetching link previews.
"""
@tool
async def link_preview(url: str) -> dict[str, Any]:
"""
Fetch metadata for a URL to display a rich link preview.
Use this tool when the user shares a URL or asks about a specific webpage.
This tool fetches the page's Open Graph metadata (title, description, image)
to display a nice preview card in the chat.
Common triggers include:
- User shares a URL in the chat
- User asks "What's this link about?" or similar
- User says "Show me a preview of this page"
- User wants to preview an article or webpage
Args:
url: The URL to fetch metadata for. Must be a valid HTTP/HTTPS URL.
Returns:
A dictionary containing:
- id: Unique identifier for this preview
- assetId: The URL itself (for deduplication)
- kind: "link" (type of media card)
- href: The URL to open when clicked
- title: Page title
- description: Page description (if available)
- thumb: Thumbnail/preview image URL (if available)
- domain: The domain name
- error: Error message (if fetch failed)
"""
preview_id = generate_preview_id(url)
domain = extract_domain(url)
# Validate URL
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
try:
async with httpx.AsyncClient(
timeout=10.0,
follow_redirects=True,
headers={
"User-Agent": "Mozilla/5.0 (compatible; SurfSenseBot/1.0; +https://surfsense.net)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
},
) as client:
response = await client.get(url)
response.raise_for_status()
# Get content type to ensure it's HTML
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
# Not an HTML page, return basic info
return {
"id": preview_id,
"assetId": url,
"kind": "link",
"href": url,
"title": url.split("/")[-1] or domain,
"description": f"File from {domain}",
"domain": domain,
}
html = response.text
# Extract metadata
title = extract_title(html) or domain
description = extract_description(html)
image = extract_image(html)
# Make sure image URL is absolute
if image and not image.startswith(("http://", "https://")):
if image.startswith("//"):
image = f"https:{image}"
elif image.startswith("/"):
parsed = urlparse(url)
image = f"{parsed.scheme}://{parsed.netloc}{image}"
# Clean up title and description (unescape HTML entities)
if title:
title = (
title.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&#39;", "'")
.replace("&apos;", "'")
)
if description:
description = (
description.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&#39;", "'")
.replace("&apos;", "'")
)
# Truncate long descriptions
if len(description) > 200:
description = description[:197] + "..."
return {
"id": preview_id,
"assetId": url,
"kind": "link",
"href": url,
"title": title,
"description": description,
"thumb": image,
"domain": domain,
}
except httpx.TimeoutException:
return {
"id": preview_id,
"assetId": url,
"kind": "link",
"href": url,
"title": domain or "Link",
"domain": domain,
"error": "Request timed out",
}
except httpx.HTTPStatusError as e:
return {
"id": preview_id,
"assetId": url,
"kind": "link",
"href": url,
"title": domain or "Link",
"domain": domain,
"error": f"HTTP {e.response.status_code}",
}
except Exception as e:
error_message = str(e)
print(f"[link_preview] Error fetching {url}: {error_message}")
return {
"id": preview_id,
"assetId": url,
"kind": "link",
"href": url,
"title": domain or "Link",
"domain": domain,
"error": f"Failed to fetch: {error_message[:50]}",
}
return link_preview

View file

@ -145,6 +145,19 @@ You have access to the following tools:
- Returns: A task_id for tracking. The podcast will be generated in the background.
- IMPORTANT: Only one podcast can be generated at a time. If a podcast is already being generated, the tool will return status "already_generating".
- After calling this tool, inform the user that podcast generation has started and they will see the player when it's ready (takes 3-5 minutes).
3. link_preview: Fetch metadata for a URL to display a rich preview card.
- IMPORTANT: Use this tool WHENEVER the user shares or mentions a URL/link in their message.
- This fetches the page's Open Graph metadata (title, description, thumbnail) to show a preview card.
- NOTE: This tool only fetches metadata, NOT the full page content. It cannot read the article text.
- Trigger scenarios:
* User shares a URL (e.g., "Check out https://example.com")
* User pastes a link in their message
* User asks about a URL or link
- Args:
- url: The URL to fetch metadata for (must be a valid HTTP/HTTPS URL)
- Returns: A rich preview card with title, description, thumbnail, and domain
- The preview card will automatically be displayed in the chat.
</tools>
<tool_call_examples>
- User: "Fetch all my notes and what's in them?"
@ -162,6 +175,15 @@ You have access to the following tools:
- User: "Make a podcast about quantum computing"
- First search: `search_knowledge_base(query="quantum computing")`
- Then: `generate_podcast(source_content="Key insights about quantum computing from the knowledge base:\n\n[Comprehensive summary of all relevant search results with key facts, concepts, and findings]", podcast_title="Quantum Computing Explained")`
- User: "Check out https://dev.to/some-article"
- Call: `link_preview(url="https://dev.to/some-article")`
- User: "What's this blog post about? https://example.com/blog/post"
- Call: `link_preview(url="https://example.com/blog/post")`
- User: "https://github.com/some/repo"
- Call: `link_preview(url="https://github.com/some/repo")`
</tool_call_examples>{citation_section}
"""

View file

@ -284,6 +284,20 @@ async def stream_new_chat(
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "link_preview":
url = (
tool_input.get("url", "")
if isinstance(tool_input, dict)
else str(tool_input)
)
last_active_step_title = "Fetching link preview"
last_active_step_items = [f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"]
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Fetching link preview",
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "generate_podcast":
podcast_title = (
tool_input.get("podcast_title", "SurfSense Podcast")
@ -343,6 +357,16 @@ async def stream_new_chat(
f"Searching knowledge base: {query[:100]}{'...' if len(query) > 100 else ''}",
"info",
)
elif tool_name == "link_preview":
url = (
tool_input.get("url", "")
if isinstance(tool_input, dict)
else str(tool_input)
)
yield streaming_service.format_terminal_info(
f"Fetching link preview: {url[:80]}{'...' if len(url) > 80 else ''}",
"info",
)
elif tool_name == "generate_podcast":
title = (
tool_input.get("podcast_title", "SurfSense Podcast")
@ -404,6 +428,31 @@ async def stream_new_chat(
status="completed",
items=completed_items,
)
elif tool_name == "link_preview":
# Build completion items based on link preview result
if isinstance(tool_output, dict):
title = tool_output.get("title", "Link")
domain = tool_output.get("domain", "")
has_error = "error" in tool_output
if has_error:
completed_items = [
*last_active_step_items,
f"Error: {tool_output.get('error', 'Failed to fetch')}",
]
else:
completed_items = [
*last_active_step_items,
f"Title: {title[:60]}{'...' if len(title) > 60 else ''}",
f"Domain: {domain}" if domain else "Preview loaded",
]
else:
completed_items = [*last_active_step_items, "Preview loaded"]
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Fetching link preview",
status="completed",
items=completed_items,
)
elif tool_name == "generate_podcast":
# Build detailed completion items based on podcast status
podcast_status = (
@ -492,8 +541,33 @@ async def stream_new_chat(
f"Podcast generation failed: {error_msg}",
"error",
)
else:
# Don't stream the full output for other tools (can be very large), just acknowledge
elif tool_name == "link_preview":
# Stream the full link preview result so frontend can render the MediaCard
yield streaming_service.format_tool_output_available(
tool_call_id,
tool_output
if isinstance(tool_output, dict)
else {"result": tool_output},
)
# Send appropriate terminal message
if isinstance(tool_output, dict) and "error" not in tool_output:
title = tool_output.get("title", "Link")
yield streaming_service.format_terminal_info(
f"Link preview loaded: {title[:50]}{'...' if len(title) > 50 else ''}",
"success",
)
else:
error_msg = (
tool_output.get("error", "Failed to fetch")
if isinstance(tool_output, dict)
else "Failed to fetch"
)
yield streaming_service.format_terminal_info(
f"Link preview failed: {error_msg}",
"error",
)
elif tool_name == "search_knowledge_base":
# Don't stream the full output for search (can be very large), just acknowledge
yield streaming_service.format_tool_output_available(
tool_call_id,
{"status": "completed", "result_length": len(str(tool_output))},
@ -501,6 +575,15 @@ async def stream_new_chat(
yield streaming_service.format_terminal_info(
"Knowledge base search completed", "success"
)
else:
# Default handling for other tools
yield streaming_service.format_tool_output_available(
tool_call_id,
{"status": "completed", "result_length": len(str(tool_output))},
)
yield streaming_service.format_terminal_info(
f"Tool {tool_name} completed", "success"
)
# Handle chain/agent end to close any open text blocks
elif event_type in ("on_chain_end", "on_agent_end"):

View file

@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Thread } from "@/components/assistant-ui/thread";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -79,7 +80,7 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
/**
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set(["generate_podcast"]);
const TOOLS_WITH_UI = new Set(["generate_podcast", "link_preview"]);
/**
* Type for thinking step data from the backend
@ -589,6 +590,7 @@ export default function NewChatPage() {
return (
<AssistantRuntimeProvider runtime={runtime}>
<GeneratePodcastToolUI />
<LinkPreviewToolUI />
<div className="h-[calc(100vh-64px)] max-h-[calc(100vh-64px)] overflow-hidden">
<Thread messageThinkingSteps={messageThinkingSteps} />
</div>

View file

@ -15,3 +15,20 @@ export {
type DeepAgentThinkingArgs,
type DeepAgentThinkingResult,
} from "./deepagent-thinking";
export {
LinkPreviewToolUI,
MultiLinkPreviewToolUI,
type LinkPreviewArgs,
type LinkPreviewResult,
type MultiLinkPreviewArgs,
type MultiLinkPreviewResult,
} from "./link-preview";
export {
MediaCard,
MediaCardErrorBoundary,
MediaCardLoading,
MediaCardSkeleton,
parseSerializableMediaCard,
type MediaCardProps,
type SerializableMediaCard,
} from "./media-card";

View file

@ -0,0 +1,226 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react";
import {
MediaCard,
MediaCardErrorBoundary,
MediaCardLoading,
parseSerializableMediaCard,
type SerializableMediaCard,
} from "@/components/tool-ui/media-card";
/**
* Type definitions for the link_preview tool
*/
interface LinkPreviewArgs {
url: string;
title?: string;
}
interface LinkPreviewResult {
id: string;
assetId: string;
kind: "link";
href: string;
title: string;
description?: string;
thumb?: string;
domain?: string;
error?: string;
}
/**
* Error state component shown when link preview fails
*/
function LinkPreviewErrorState({ 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 load preview</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 LinkPreviewCancelledState({ 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">
<LinkIcon className="size-4" />
<span className="line-through truncate">Preview: {url}</span>
</p>
</div>
);
}
/**
* Parsed MediaCard component with error handling
*/
function ParsedMediaCard({ result }: { result: unknown }) {
const card = parseSerializableMediaCard(result);
return (
<MediaCard
{...card}
maxWidth="420px"
responseActions={[
{ id: "open", label: "Open", variant: "default" },
]}
onResponseAction={(id) => {
if (id === "open" && card.href) {
window.open(card.href, "_blank", "noopener,noreferrer");
}
}}
/>
);
}
/**
* Link Preview Tool UI Component
*
* This component is registered with assistant-ui to render a rich
* link preview card when the link_preview tool is called by the agent.
*
* It displays website metadata including:
* - Title and description
* - Thumbnail/Open Graph image
* - Domain name
* - Clickable link to open in new tab
*/
export const LinkPreviewToolUI = makeAssistantToolUI<
LinkPreviewArgs,
LinkPreviewResult
>({
toolName: "link_preview",
render: function LinkPreviewUI({ args, result, status }) {
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">
<MediaCardLoading title={`Loading preview for ${url}...`} />
</div>
);
}
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return <LinkPreviewCancelledState url={url} />;
}
if (status.reason === "error") {
return (
<LinkPreviewErrorState
url={url}
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
// No result yet
if (!result) {
return (
<div className="my-4">
<MediaCardLoading title={`Fetching metadata for ${url}...`} />
</div>
);
}
// Error result from the tool
if (result.error) {
return <LinkPreviewErrorState url={url} error={result.error} />;
}
// Success - render the media card
return (
<div className="my-4">
<MediaCardErrorBoundary>
<ParsedMediaCard result={result} />
</MediaCardErrorBoundary>
</div>
);
},
});
/**
* Multiple Link Previews Tool UI Component
*
* This component handles cases where multiple links need to be previewed.
* It renders a grid of link preview cards.
*/
interface MultiLinkPreviewArgs {
urls: string[];
}
interface MultiLinkPreviewResult {
previews: LinkPreviewResult[];
errors?: { url: string; error: string }[];
}
export const MultiLinkPreviewToolUI = makeAssistantToolUI<
MultiLinkPreviewArgs,
MultiLinkPreviewResult
>({
toolName: "multi_link_preview",
render: function MultiLinkPreviewUI({ args, result, status }) {
const urls = args.urls || [];
// Loading state
if (status.type === "running" || status.type === "requires-action") {
return (
<div className="my-4 grid gap-4 sm:grid-cols-2">
{urls.slice(0, 4).map((url, index) => (
<MediaCardLoading key={`loading-${url}-${index}`} title="Loading..." />
))}
</div>
);
}
// Incomplete state
if (status.type === "incomplete") {
return (
<div className="my-4 text-muted-foreground text-sm">
<p className="flex items-center gap-2">
<LinkIcon className="size-4" />
<span>Link previews cancelled</span>
</p>
</div>
);
}
// No result
if (!result || !result.previews) {
return null;
}
// Render grid of previews
return (
<div className="my-4 grid gap-4 sm:grid-cols-2">
{result.previews.map((preview) => (
<MediaCardErrorBoundary key={preview.id}>
<ParsedMediaCard result={preview} />
</MediaCardErrorBoundary>
))}
{result.errors?.map((err) => (
<LinkPreviewErrorState key={err.url} url={err.url} error={err.error} />
))}
</div>
);
},
});
export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };

View file

@ -0,0 +1,381 @@
"use client";
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react";
import Image from "next/image";
import { Component, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* Aspect ratio options for media cards
*/
type AspectRatio = "1:1" | "4:3" | "16:9" | "21:9" | "auto";
/**
* MediaCard kind - determines the display style
*/
type MediaCardKind = "link" | "image" | "video" | "audio";
/**
* Response action configuration
*/
interface ResponseAction {
id: string;
label: string;
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost";
confirmLabel?: string;
}
/**
* Props for the MediaCard component
*/
export interface MediaCardProps {
id: string;
assetId: string;
kind: MediaCardKind;
href?: string;
src?: string;
title: string;
description?: string;
thumb?: string;
ratio?: AspectRatio;
domain?: string;
maxWidth?: string;
alt?: string;
className?: string;
responseActions?: ResponseAction[];
onResponseAction?: (id: string) => void;
}
/**
* Serializable schema for MediaCard props (for tool results)
*/
export interface SerializableMediaCard {
id: string;
assetId: string;
kind: MediaCardKind;
href?: string;
src?: string;
title: string;
description?: string;
thumb?: string;
ratio?: AspectRatio;
domain?: string;
}
/**
* Parse and validate serializable media card from tool result
*/
export function parseSerializableMediaCard(result: unknown): SerializableMediaCard {
if (typeof result !== "object" || result === null) {
throw new Error("Invalid media card result: expected object");
}
const obj = result as Record<string, unknown>;
// Validate required fields
if (typeof obj.id !== "string") {
throw new Error("Invalid media card: missing id");
}
if (typeof obj.assetId !== "string") {
throw new Error("Invalid media card: missing assetId");
}
if (typeof obj.kind !== "string") {
throw new Error("Invalid media card: missing kind");
}
if (typeof obj.title !== "string") {
throw new Error("Invalid media card: missing title");
}
return {
id: obj.id,
assetId: obj.assetId,
kind: obj.kind as MediaCardKind,
href: typeof obj.href === "string" ? obj.href : undefined,
src: typeof obj.src === "string" ? obj.src : undefined,
title: obj.title,
description: typeof obj.description === "string" ? obj.description : undefined,
thumb: typeof obj.thumb === "string" ? obj.thumb : undefined,
ratio: typeof obj.ratio === "string" ? (obj.ratio as AspectRatio) : undefined,
domain: typeof obj.domain === "string" ? obj.domain : undefined,
};
}
/**
* Get aspect ratio class based on ratio prop
*/
function getAspectRatioClass(ratio?: AspectRatio): string {
switch (ratio) {
case "1:1":
return "aspect-square";
case "4:3":
return "aspect-[4/3]";
case "16:9":
return "aspect-video";
case "21:9":
return "aspect-[21/9]";
case "auto":
default:
return "aspect-[2/1]";
}
}
/**
* Get icon based on media card kind
*/
function getKindIcon(kind: MediaCardKind) {
switch (kind) {
case "link":
return <LinkIcon className="size-5" />;
case "image":
return <ImageIcon className="size-5" />;
case "video":
case "audio":
return <Globe className="size-5" />;
default:
return <LinkIcon className="size-5" />;
}
}
/**
* Error boundary for MediaCard
*/
interface MediaCardErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class MediaCardErrorBoundary extends Component<
{ children: ReactNode },
MediaCardErrorBoundaryState
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): MediaCardErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<Card className="w-full max-w-md border-destructive/20 bg-destructive/5">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
<LinkIcon className="size-5 text-destructive" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-destructive text-sm">Failed to load preview</p>
<p className="text-muted-foreground text-xs truncate">
{this.state.error?.message || "An error occurred"}
</p>
</div>
</CardContent>
</Card>
);
}
return this.props.children;
}
}
/**
* Loading skeleton for MediaCard
*/
export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
return (
<Card
className="w-full overflow-hidden animate-pulse"
style={{ maxWidth }}
>
<div className="aspect-[2/1] bg-muted" />
<CardContent className="p-4">
<div className="h-4 w-3/4 rounded bg-muted" />
<div className="mt-2 h-3 w-full rounded bg-muted" />
<div className="mt-1 h-3 w-2/3 rounded bg-muted" />
</CardContent>
</Card>
);
}
/**
* MediaCard Component
*
* A rich media card for displaying link previews, images, and other media
* in AI chat applications. Supports thumbnails, descriptions, and actions.
*/
export function MediaCard({
id,
kind,
href,
title,
description,
thumb,
ratio = "auto",
domain,
maxWidth = "420px",
alt,
className,
responseActions,
onResponseAction,
}: MediaCardProps) {
const aspectRatioClass = getAspectRatioClass(ratio);
const displayDomain = domain || (href ? new URL(href).hostname.replace("www.", "") : undefined);
const handleCardClick = () => {
if (href) {
window.open(href, "_blank", "noopener,noreferrer");
}
};
return (
<TooltipProvider>
<Card
id={id}
className={cn(
"group relative w-full 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();
}
}}
>
{/* Thumbnail */}
{thumb && (
<div className={cn("relative w-full overflow-hidden bg-muted", aspectRatioClass)}>
<Image
src={thumb}
alt={alt || title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
unoptimized
onError={(e) => {
// Hide broken images
e.currentTarget.style.display = "none";
}}
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</div>
)}
{/* Fallback when no thumbnail */}
{!thumb && (
<div
className={cn(
"relative flex w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50",
aspectRatioClass
)}
>
<div className="flex flex-col items-center gap-2 text-muted-foreground">
{getKindIcon(kind)}
<span className="text-xs">{kind === "link" ? "Link Preview" : kind}</span>
</div>
</div>
)}
{/* Content */}
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Domain favicon placeholder */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Globe className="size-5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
{/* Domain badge */}
{displayDomain && (
<div className="mb-1.5 flex items-center gap-1.5">
<Badge variant="secondary" className="text-xs font-normal">
{displayDomain}
</Badge>
{href && (
<ExternalLinkIcon className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
)}
{/* Title */}
<h3 className="font-semibold text-foreground text-sm leading-tight line-clamp-2 group-hover:text-primary transition-colors">
{title}
</h3>
{/* Description */}
{description && (
<p className="mt-1.5 text-muted-foreground text-xs leading-relaxed line-clamp-2">
{description}
</p>
)}
</div>
</div>
{/* Response Actions */}
{responseActions && responseActions.length > 0 && (
<div
className="mt-4 flex items-center justify-end gap-2 border-t pt-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{responseActions.map((action) => (
<Tooltip key={action.id}>
<TooltipTrigger asChild>
<Button
variant={action.variant || "secondary"}
size="sm"
onClick={() => onResponseAction?.(action.id)}
>
{action.label}
</Button>
</TooltipTrigger>
{action.confirmLabel && (
<TooltipContent>
<p>{action.confirmLabel}</p>
</TooltipContent>
)}
</Tooltip>
))}
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
);
}
/**
* MediaCard Loading State
*/
export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) {
return (
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[2/1] bg-muted animate-pulse flex items-center justify-center">
<Loader2 className="size-8 text-muted-foreground animate-spin" />
</div>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-muted animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-muted animate-pulse" />
<div className="h-3 w-1/2 rounded bg-muted animate-pulse" />
</div>
</div>
<p className="mt-3 text-center text-muted-foreground text-sm">{title}</p>
</CardContent>
</Card>
);
}