From a009cae62ac269c1b32addeada943aba2d39c5e2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:15:29 +0530 Subject: [PATCH] refactor: remove link_preview tool and associated components to streamline agent functionality --- .../app/agents/new_chat/__init__.py | 4 +- .../app/agents/new_chat/chat_deepagent.py | 3 +- .../app/agents/new_chat/system_prompt.py | 22 +- .../app/agents/new_chat/tools/__init__.py | 3 - .../app/agents/new_chat/tools/link_preview.py | 465 ------------------ .../app/agents/new_chat/tools/registry.py | 10 +- .../app/services/public_chat_service.py | 2 - .../app/tasks/chat/stream_new_chat.py | 63 --- .../new-chat/[[...chat_id]]/page.tsx | 1 - .../assistant-ui/assistant-message.tsx | 3 - .../components/assistant-ui/thread.tsx | 2 +- .../components/public-chat/public-thread.tsx | 2 - surfsense_web/components/tool-ui/index.ts | 21 - .../components/tool-ui/link-preview.tsx | 250 ---------- .../components/tool-ui/media-card/index.tsx | 354 ------------- surfsense_web/contracts/enums/toolIcons.tsx | 2 - 16 files changed, 5 insertions(+), 1202 deletions(-) delete mode 100644 surfsense_backend/app/agents/new_chat/tools/link_preview.py delete mode 100644 surfsense_web/components/tool-ui/link-preview.tsx delete mode 100644 surfsense_web/components/tool-ui/media-card/index.tsx diff --git a/surfsense_backend/app/agents/new_chat/__init__.py b/surfsense_backend/app/agents/new_chat/__init__.py index 96f4b399b..4238d17de 100644 --- a/surfsense_backend/app/agents/new_chat/__init__.py +++ b/surfsense_backend/app/agents/new_chat/__init__.py @@ -5,7 +5,7 @@ This module provides the SurfSense deep agent with configurable tools for knowledge base search, podcast generation, and more. Directory Structure: -- tools/: All agent tools (knowledge_base, podcast, link_preview, etc.) +- tools/: All agent tools (knowledge_base, podcast, generate_image, etc.) - chat_deepagent.py: Main agent factory - system_prompt.py: System prompts and instructions - context.py: Context schema for the agent @@ -38,7 +38,6 @@ from .tools import ( ToolDefinition, build_tools, create_generate_podcast_tool, - create_link_preview_tool, create_scrape_webpage_tool, create_search_knowledge_base_tool, format_documents_for_context, @@ -63,7 +62,6 @@ __all__ = [ "create_chat_litellm_from_config", # Tool factories "create_generate_podcast_tool", - "create_link_preview_tool", "create_scrape_webpage_tool", "create_search_knowledge_base_tool", # Agent factory diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index e073a24cf..2857be4a7 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -150,7 +150,6 @@ async def create_surfsense_deep_agent( - search_knowledge_base: Search the user's personal knowledge base - generate_podcast: Generate audio podcasts from content - generate_image: Generate images from text descriptions using AI models - - link_preview: Fetch rich previews for URLs - scrape_webpage: Extract content from webpages - save_memory: Store facts/preferences about the user - recall_memory: Retrieve relevant user memories @@ -206,7 +205,7 @@ async def create_surfsense_deep_agent( # Create agent with only specific tools agent = create_surfsense_deep_agent( llm, search_space_id, db_session, ..., - enabled_tools=["search_knowledge_base", "link_preview"] + enabled_tools=["search_knowledge_base", "scrape_webpage"] ) # Create agent without podcast generation diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index 69cb8f40a..77df3acfd 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -184,21 +184,6 @@ _TOOL_INSTRUCTIONS["generate_report"] = """ - AFTER CALLING THIS TOOL: Do NOT repeat, summarize, or reproduce the report content in the chat. The report is already displayed as an interactive card that the user can open, read, copy, and export. Simply confirm that the report was generated (e.g., "I've generated your report on [topic]. You can view the Markdown report now, and export it in various formats from the card."). NEVER write out the report text in the chat. """ -_TOOL_INSTRUCTIONS["link_preview"] = """ -- 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. -""" - _TOOL_INSTRUCTIONS["generate_image"] = """ - generate_image: Generate images from text descriptions using AI image models. - Use this when the user asks you to create, generate, draw, design, or make an image. @@ -215,14 +200,11 @@ _TOOL_INSTRUCTIONS["generate_image"] = """ _TOOL_INSTRUCTIONS["scrape_webpage"] = """ - scrape_webpage: Scrape and extract the main content from a webpage. - Use this when the user wants you to READ and UNDERSTAND the actual content of a webpage. - - IMPORTANT: This is different from link_preview: - * link_preview: Only fetches metadata (title, description, thumbnail) for display - * scrape_webpage: Actually reads the FULL page content so you can analyze/summarize it - CRITICAL — WHEN TO USE (always attempt scraping, never refuse before trying): * When a user asks to "get", "fetch", "pull", "grab", "scrape", or "read" content from a URL * When the user wants live/dynamic data from a specific webpage (e.g., tables, scores, stats, prices) * When a URL was mentioned earlier in the conversation and the user asks for its actual content - * When link_preview or search_knowledge_base returned insufficient data and the user wants more + * When search_knowledge_base returned insufficient data and the user wants more - Trigger scenarios: * "Read this article and summarize it" * "What does this page say about X?" @@ -446,7 +428,6 @@ _TOOL_EXAMPLES["generate_report"] = """ _TOOL_EXAMPLES["scrape_webpage"] = """ - User: "Check out https://dev.to/some-article" - - Call: `link_preview(url="https://dev.to/some-article")` - Call: `scrape_webpage(url="https://dev.to/some-article")` - Then provide your analysis of the content. - User: "Read this article and summarize it for me: https://example.com/blog/ai-trends" @@ -489,7 +470,6 @@ _ALL_TOOL_NAMES_ORDERED = [ "generate_podcast", "generate_video_presentation", "generate_report", - "link_preview", "generate_image", "scrape_webpage", "save_memory", diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py index de84cdfb1..404926d19 100644 --- a/surfsense_backend/app/agents/new_chat/tools/__init__.py +++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py @@ -10,7 +10,6 @@ Available tools: - generate_podcast: Generate audio podcasts from content - generate_video_presentation: Generate video presentations with slides and narration - generate_image: Generate images from text descriptions using AI models -- link_preview: Fetch rich previews for URLs - scrape_webpage: Extract content from webpages - save_memory: Store facts/preferences about the user - recall_memory: Retrieve relevant user memories @@ -25,7 +24,6 @@ from .knowledge_base import ( format_documents_for_context, search_knowledge_base_async, ) -from .link_preview import create_link_preview_tool from .podcast import create_generate_podcast_tool from .registry import ( BUILTIN_TOOLS, @@ -51,7 +49,6 @@ __all__ = [ "create_generate_image_tool", "create_generate_podcast_tool", "create_generate_video_presentation_tool", - "create_link_preview_tool", "create_recall_memory_tool", "create_save_memory_tool", "create_scrape_webpage_tool", diff --git a/surfsense_backend/app/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py deleted file mode 100644 index 81d91d54c..000000000 --- a/surfsense_backend/app/agents/new_chat/tools/link_preview.py +++ /dev/null @@ -1,465 +0,0 @@ -""" -Link preview tool for the SurfSense 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 asyncio -import hashlib -import logging -import re -from typing import Any -from urllib.parse import urlparse - -import httpx -import trafilatura -from fake_useragent import UserAgent -from langchain_core.tools import tool -from playwright.sync_api import sync_playwright - -from app.utils.proxy_config import get_playwright_proxy, get_residential_proxy_url - -logger = logging.getLogger(__name__) - - -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 _unescape_html(text: str) -> str: - """Unescape common HTML entities.""" - return ( - text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", '"') - .replace("'", "'") - .replace("'", "'") - ) - - -def _make_absolute_url(image_url: str, base_url: str) -> str: - """Convert a relative image URL to an absolute URL.""" - if image_url.startswith(("http://", "https://")): - return image_url - if image_url.startswith("//"): - return f"https:{image_url}" - if image_url.startswith("/"): - parsed = urlparse(base_url) - return f"{parsed.scheme}://{parsed.netloc}{image_url}" - return image_url - - -async def fetch_with_chromium(url: str) -> dict[str, Any] | None: - """ - Fetch page content using headless Chromium browser via Playwright. - Used as a fallback when simple HTTP requests are blocked (403, etc.). - - Runs the sync Playwright API in a thread so it works on any event - loop, including Windows ``SelectorEventLoop``. - - Args: - url: URL to fetch - - Returns: - Dict with title, description, image, and raw_html, or None if failed - """ - try: - return await asyncio.to_thread(_fetch_with_chromium_sync, url) - except Exception as e: - logger.error(f"[link_preview] Chromium fallback failed for {url}: {e}") - return None - - -def _fetch_with_chromium_sync(url: str) -> dict[str, Any] | None: - """Synchronous Playwright fetch executed in a worker thread.""" - logger.info(f"[link_preview] Falling back to Chromium for {url}") - - ua = UserAgent() - user_agent = ua.random - - playwright_proxy = get_playwright_proxy() - - with sync_playwright() as p: - launch_kwargs: dict = {"headless": True} - if playwright_proxy: - launch_kwargs["proxy"] = playwright_proxy - browser = p.chromium.launch(**launch_kwargs) - context = browser.new_context(user_agent=user_agent) - page = context.new_page() - - try: - page.goto(url, wait_until="domcontentloaded", timeout=30000) - raw_html = page.content() - finally: - browser.close() - - if not raw_html or len(raw_html.strip()) == 0: - logger.warning(f"[link_preview] Chromium returned empty content for {url}") - return None - - trafilatura_metadata = trafilatura.extract_metadata(raw_html) - - image = extract_image(raw_html) - - result: dict[str, Any] = { - "title": None, - "description": None, - "image": image, - "raw_html": raw_html, - } - - if trafilatura_metadata: - result["title"] = trafilatura_metadata.title - result["description"] = trafilatura_metadata.description - - if not result["title"]: - result["title"] = extract_title(raw_html) - if not result["description"]: - result["description"] = extract_description(raw_html) - - logger.info(f"[link_preview] Successfully fetched {url} via Chromium") - return result - - -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: - # Generate a random User-Agent to avoid bot detection - ua = UserAgent() - user_agent = ua.random - - # Use residential proxy if configured - proxy_url = get_residential_proxy_url() - - # Use a browser-like User-Agent to fetch Open Graph metadata. - # We're only fetching publicly available metadata (title, description, thumbnail) - # that websites intentionally expose via OG tags for link preview purposes. - async with httpx.AsyncClient( - timeout=10.0, - follow_redirects=True, - proxy=proxy_url, - headers={ - "User-Agent": user_agent, - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "no-cache", - "Pragma": "no-cache", - }, - ) 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: - image = _make_absolute_url(image, url) - - # Clean up title and description (unescape HTML entities) - if title: - title = _unescape_html(title) - if description: - description = _unescape_html(description) - # 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: - # Timeout - try Chromium fallback - logger.warning( - f"[link_preview] Timeout for {url}, trying Chromium fallback" - ) - chromium_result = await fetch_with_chromium(url) - if chromium_result: - title = chromium_result.get("title") or domain - description = chromium_result.get("description") - image = chromium_result.get("image") - - # Clean up and truncate - if title: - title = _unescape_html(title) - if description: - description = _unescape_html(description) - if len(description) > 200: - description = description[:197] + "..." - - # Make sure image URL is absolute - if image: - image = _make_absolute_url(image, url) - - return { - "id": preview_id, - "assetId": url, - "kind": "link", - "href": url, - "title": title, - "description": description, - "thumb": image, - "domain": domain, - } - - 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: - status_code = e.response.status_code - - # For 403 (Forbidden) and similar bot-detection errors, try Chromium fallback - if status_code in (403, 401, 406, 429): - logger.warning( - f"[link_preview] HTTP {status_code} for {url}, trying Chromium fallback" - ) - chromium_result = await fetch_with_chromium(url) - if chromium_result: - title = chromium_result.get("title") or domain - description = chromium_result.get("description") - image = chromium_result.get("image") - - # Clean up and truncate - if title: - title = _unescape_html(title) - if description: - description = _unescape_html(description) - if len(description) > 200: - description = description[:197] + "..." - - # Make sure image URL is absolute - if image: - image = _make_absolute_url(image, url) - - return { - "id": preview_id, - "assetId": url, - "kind": "link", - "href": url, - "title": title, - "description": description, - "thumb": image, - "domain": domain, - } - - return { - "id": preview_id, - "assetId": url, - "kind": "link", - "href": url, - "title": domain or "Link", - "domain": domain, - "error": f"HTTP {status_code}", - } - except Exception as e: - error_message = str(e) - logger.error(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/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 29ef75641..7700d47d3 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -77,7 +77,6 @@ from .linear import ( create_delete_linear_issue_tool, create_update_linear_issue_tool, ) -from .link_preview import create_link_preview_tool from .mcp_tool import load_mcp_tools from .notion import ( create_create_notion_page_tool, @@ -186,13 +185,6 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ # are optional — when missing, source_strategy="kb_search" degrades # gracefully to "provided" ), - # Link preview tool - fetches Open Graph metadata for URLs - ToolDefinition( - name="link_preview", - description="Fetch metadata for a URL to display a rich preview card", - factory=lambda deps: create_link_preview_tool(), - requires=[], - ), # Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.) ToolDefinition( name="generate_image", @@ -559,7 +551,7 @@ def build_tools( tools = build_tools(deps) # Use only specific tools - tools = build_tools(deps, enabled_tools=["search_knowledge_base", "link_preview"]) + tools = build_tools(deps, enabled_tools=["search_knowledge_base"]) # Use defaults but disable podcast tools = build_tools(deps, disabled_tools=["generate_podcast"]) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 9f0c76b9c..763ae64c3 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -39,12 +39,10 @@ from app.utils.rbac import check_permission UI_TOOLS = { "generate_image", - "link_preview", "generate_podcast", "generate_report", "generate_video_presentation", "scrape_webpage", - "multi_link_preview", } diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index e683ea106..1f3eaa179 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -335,22 +335,6 @@ async def _stream_agent_events( 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_image": prompt = ( tool_input.get("prompt", "") @@ -504,30 +488,6 @@ async def _stream_agent_events( status="completed", items=completed_items, ) - elif tool_name == "link_preview": - 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_image": if isinstance(tool_output, dict) and not tool_output.get("error"): completed_items = [ @@ -818,29 +778,6 @@ async def _stream_agent_events( f"Presentation generation failed: {error_msg}", "error", ) - elif tool_name == "link_preview": - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - 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 == "generate_image": yield streaming_service.format_tool_output_available( tool_call_id, 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 7ed59c0bf..b3cc4fa6c 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 @@ -131,7 +131,6 @@ const TOOLS_WITH_UI = new Set([ "generate_podcast", "generate_report", "generate_video_presentation", - "link_preview", "display_image", "generate_image", "delete_notion_page", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 0a506d23b..fa3aec45a 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -27,7 +27,6 @@ import { CreateCalendarEventToolUI, DeleteCalendarEventToolUI, UpdateCalendarEve import { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "@/components/tool-ui/google-drive"; import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, UpdateJiraIssueToolUI } from "@/components/tool-ui/jira"; import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, UpdateLinearIssueToolUI } from "@/components/tool-ui/linear"; -import { LinkPreviewToolUI, MultiLinkPreviewToolUI } from "@/components/tool-ui/link-preview"; 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"; @@ -58,8 +57,6 @@ const AssistantMessageInner: FC = () => { generate_report: GenerateReportToolUI, generate_podcast: GeneratePodcastToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - link_preview: LinkPreviewToolUI, - multi_link_preview: MultiLinkPreviewToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, scrape_webpage: ScrapeWebpageToolUI, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 5e8a251d2..f160114ba 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1054,7 +1054,7 @@ interface ToolGroup { const TOOL_GROUPS: ToolGroup[] = [ { label: "Research", - tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"], + tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage"], }, { label: "Generate", diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 20afa8e95..8076188c0 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -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 { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; interface PublicThreadProps { @@ -151,7 +150,6 @@ const PublicAssistantMessage: FC = () => { generate_podcast: GeneratePodcastToolUI, generate_report: GenerateReportToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, - link_preview: LinkPreviewToolUI, display_image: DisplayImageToolUI, generate_image: GenerateImageToolUI, scrape_webpage: ScrapeWebpageToolUI, diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 979489ffe..0f1126847 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -48,27 +48,6 @@ export { DeleteLinearIssueToolUI, UpdateLinearIssueToolUI, } from "./linear"; -export { - type LinkPreviewArgs, - LinkPreviewArgsSchema, - type LinkPreviewResult, - LinkPreviewResultSchema, - LinkPreviewToolUI, - type MultiLinkPreviewArgs, - MultiLinkPreviewArgsSchema, - type MultiLinkPreviewResult, - MultiLinkPreviewResultSchema, - MultiLinkPreviewToolUI, -} from "./link-preview"; -export { - MediaCard, - MediaCardErrorBoundary, - MediaCardLoading, - type MediaCardProps, - MediaCardSkeleton, - parseSerializableMediaCard, - type SerializableMediaCard, -} from "./media-card"; export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion"; export { Plan, diff --git a/surfsense_web/components/tool-ui/link-preview.tsx b/surfsense_web/components/tool-ui/link-preview.tsx deleted file mode 100644 index 7af00c5ba..000000000 --- a/surfsense_web/components/tool-ui/link-preview.tsx +++ /dev/null @@ -1,250 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartProps } from "@assistant-ui/react"; -import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react"; -import { z } from "zod"; -import { - MediaCard, - MediaCardErrorBoundary, - MediaCardLoading, - parseSerializableMediaCard, - type SerializableMediaCard, -} from "@/components/tool-ui/media-card"; - -// ============================================================================ -// Zod Schemas -// ============================================================================ - -/** - * Schema for link_preview tool arguments - */ -const LinkPreviewArgsSchema = z.object({ - url: z.string(), - title: z.string().nullish(), -}); - -/** - * Schema for link_preview tool result - */ -const LinkPreviewResultSchema = z.object({ - id: z.string(), - assetId: z.string(), - kind: z.literal("link"), - href: z.string(), - title: z.string(), - description: z.string().nullish(), - thumb: z.string().nullish(), - domain: z.string().nullish(), - error: z.string().nullish(), -}); - -// ============================================================================ -// Types -// ============================================================================ - -type LinkPreviewArgs = z.infer; -type LinkPreviewResult = z.infer; - -/** - * 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 = ({ args, result, status }: ToolCallMessagePartProps) => { - 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 ( -
- - - -
- ); -}; - -// ============================================================================ -// Multi Link Preview Schemas -// ============================================================================ - -/** - * Schema for multi_link_preview tool arguments - */ -const MultiLinkPreviewArgsSchema = z.object({ - urls: z.array(z.string()), -}); - -/** - * Schema for error items in multi_link_preview result - */ -const MultiLinkPreviewErrorSchema = z.object({ - url: z.string(), - error: z.string(), -}); - -/** - * Schema for multi_link_preview tool result - */ -const MultiLinkPreviewResultSchema = z.object({ - previews: z.array(LinkPreviewResultSchema), - errors: z.array(MultiLinkPreviewErrorSchema).nullish(), -}); - -type MultiLinkPreviewArgs = z.infer; -type MultiLinkPreviewResult = z.infer; - -export const MultiLinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps) => { - 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 { - LinkPreviewArgsSchema, - LinkPreviewResultSchema, - MultiLinkPreviewArgsSchema, - MultiLinkPreviewResultSchema, - type LinkPreviewArgs, - type LinkPreviewResult, - type MultiLinkPreviewArgs, - type MultiLinkPreviewResult, -}; diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx deleted file mode 100644 index c7c8cfdf2..000000000 --- a/surfsense_web/components/tool-ui/media-card/index.tsx +++ /dev/null @@ -1,354 +0,0 @@ -"use client"; - -import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react"; -import Image from "next/image"; -import { Component, type ReactNode } from "react"; -import { z } from "zod"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -/** - * Zod schemas for runtime validation - */ -const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "21:9", "auto"]); -const MediaCardKindSchema = z.enum(["link", "image", "video", "audio"]); - -const ResponseActionSchema = z.object({ - id: z.string(), - label: z.string(), - variant: z.enum(["default", "secondary", "outline", "destructive", "ghost"]).nullish(), - confirmLabel: z.string().nullish(), -}); - -const SerializableMediaCardSchema = z.object({ - id: z.string(), - assetId: z.string(), - kind: MediaCardKindSchema, - href: z.string().nullish(), - src: z.string().nullish(), - title: z.string(), - description: z.string().nullish(), - thumb: z.string().nullish(), - ratio: AspectRatioSchema.nullish(), - domain: z.string().nullish(), -}); - -/** - * Types derived from Zod schemas - */ -type AspectRatio = z.infer; -type MediaCardKind = z.infer; -type ResponseAction = z.infer; -export type SerializableMediaCard = z.infer; - -/** - * 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; -} - -/** - * Parse and validate serializable media card from tool result - */ -export function parseSerializableMediaCard(result: unknown): SerializableMediaCard { - const parsed = SerializableMediaCardSchema.safeParse(result); - - if (!parsed.success) { - console.warn("Invalid media card data:", parsed.error.issues); - throw new Error(`Invalid media card: ${parsed.error.issues.map((i) => i.message).join(", ")}`); - } - - return parsed.data; -} - -/** - * 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 "9:16": - return "aspect-[9/16]"; - 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 && ( -
- {responseActions.map((action) => ( - - - - - {action.confirmLabel && ( - -

{action.confirmLabel}

-
- )} -
- ))} -
- )} -
- - - ); -} - -/** - * MediaCard Loading State - */ -export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) { - return ( - -
- -
- -
-
-
-
-
-
-
-

{title}

- - - ); -} diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx index d2b804e23..90ec7a544 100644 --- a/surfsense_web/contracts/enums/toolIcons.tsx +++ b/surfsense_web/contracts/enums/toolIcons.tsx @@ -5,7 +5,6 @@ import { FileText, Film, Globe, - Link2, type LucideIcon, Podcast, ScanLine, @@ -18,7 +17,6 @@ const TOOL_ICONS: Record = { generate_podcast: Podcast, generate_video_presentation: Film, generate_report: FileText, - link_preview: Link2, generate_image: Sparkles, scrape_webpage: ScanLine, web_search: Globe,