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"<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("&", "&") + .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 ( +
+

+ + Preview: {url} +

+
+ ); +} + +/** + * 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 && ( +
+ {alt { + // 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}

+ + + ); +} +