diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py
index 1c97a05ad..23db4f813 100644
--- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py
+++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py
@@ -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)
diff --git a/surfsense_backend/app/agents/new_chat/link_preview.py b/surfsense_backend/app/agents/new_chat/link_preview.py
new file mode 100644
index 000000000..388a6c14e
--- /dev/null
+++ b/surfsense_backend/app/agents/new_chat/link_preview.py
@@ -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']+property=["\']og:{property_name}["\'][^>]+content=["\']([^"\']+)["\']'
+ match = re.search(pattern, html, re.IGNORECASE)
+ if match:
+ return match.group(1)
+
+ # Try content before property
+ pattern = rf']+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']+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']'
+ match = re.search(pattern, html, re.IGNORECASE)
+ if match:
+ return match.group(1)
+
+ # Try content before name
+ pattern = rf']+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']+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']'
+ match = re.search(pattern, html, re.IGNORECASE)
+ if match:
+ return match.group(1)
+
+ # Try content before name
+ pattern = r']+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
tag
+ pattern = r"]*>([^<]+)"
+ 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("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", '"')
+ .replace("'", "'")
+ .replace("'", "'")
+ )
+ if description:
+ description = (
+ description.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", '"')
+ .replace("'", "'")
+ .replace("'", "'")
+ )
+ # 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
+
diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py
index f725be684..32e649e5d 100644
--- a/surfsense_backend/app/agents/new_chat/system_prompt.py
+++ b/surfsense_backend/app/agents/new_chat/system_prompt.py
@@ -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.
- 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")`
{citation_section}
"""
diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py
index cb40a82a8..853fb3392 100644
--- a/surfsense_backend/app/tasks/chat/stream_new_chat.py
+++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py
@@ -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"):
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index 51a2f9875..4d45db118 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -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 (
+
diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts
index 3cddfba21..aebe400d5 100644
--- a/surfsense_web/components/tool-ui/index.ts
+++ b/surfsense_web/components/tool-ui/index.ts
@@ -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";
diff --git a/surfsense_web/components/tool-ui/link-preview.tsx b/surfsense_web/components/tool-ui/link-preview.tsx
new file mode 100644
index 000000000..c97b6820b
--- /dev/null
+++ b/surfsense_web/components/tool-ui/link-preview.tsx
@@ -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 (
+
+
+
+
+
Failed to load preview
+
{url}
+
{error}
+
+
+
+ );
+}
+
+/**
+ * Cancelled state component
+ */
+function LinkPreviewCancelledState({ url }: { url: string }) {
+ return (
+
+ );
+}
+
+/**
+ * Parsed MediaCard component with error handling
+ */
+function ParsedMediaCard({ result }: { result: unknown }) {
+ const card = parseSerializableMediaCard(result);
+
+ return (
+ {
+ 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 (
+
+
+
+ );
+ }
+
+ // Incomplete/cancelled state
+ if (status.type === "incomplete") {
+ if (status.reason === "cancelled") {
+ return ;
+ }
+ if (status.reason === "error") {
+ return (
+
+ );
+ }
+ }
+
+ // No result yet
+ if (!result) {
+ return (
+
+
+
+ );
+ }
+
+ // Error result from the tool
+ if (result.error) {
+ return ;
+ }
+
+ // Success - render the media card
+ return (
+
+ );
+ },
+});
+
+/**
+ * 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 (
+
+ {urls.slice(0, 4).map((url, index) => (
+
+ ))}
+
+ );
+ }
+
+ // Incomplete state
+ if (status.type === "incomplete") {
+ return (
+
+
+
+ Link previews cancelled
+
+
+ );
+ }
+
+ // No result
+ if (!result || !result.previews) {
+ return null;
+ }
+
+ // Render grid of previews
+ return (
+
+ {result.previews.map((preview) => (
+
+
+
+ ))}
+ {result.errors?.map((err) => (
+
+ ))}
+
+ );
+ },
+});
+
+export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
+
diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx
new file mode 100644
index 000000000..47c28e49b
--- /dev/null
+++ b/surfsense_web/components/tool-ui/media-card/index.tsx
@@ -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;
+
+ // 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 ;
+ case "image":
+ return ;
+ case "video":
+ case "audio":
+ return ;
+ default:
+ return ;
+ }
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
Failed to load preview
+
+ {this.state.error?.message || "An error occurred"}
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+/**
+ * Loading skeleton for MediaCard
+ */
+export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {
+ if (href && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ handleCardClick();
+ }
+ }}
+ >
+ {/* Thumbnail */}
+ {thumb && (
+
+
{
+ // Hide broken images
+ e.currentTarget.style.display = "none";
+ }}
+ />
+ {/* Gradient overlay */}
+
+
+ )}
+
+ {/* Fallback when no thumbnail */}
+ {!thumb && (
+
+
+ {getKindIcon(kind)}
+ {kind === "link" ? "Link Preview" : kind}
+
+
+ )}
+
+ {/* Content */}
+
+
+ {/* Domain favicon placeholder */}
+
+
+
+
+
+ {/* Domain badge */}
+ {displayDomain && (
+
+
+ {displayDomain}
+
+ {href && (
+
+ )}
+
+ )}
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ {/* Response Actions */}
+ {responseActions && responseActions.length > 0 && (
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {responseActions.map((action) => (
+
+
+
+
+ {action.confirmLabel && (
+
+ {action.confirmLabel}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * MediaCard Loading State
+ */
+export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) {
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ );
+}
+