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

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

View file

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

View file

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

View file

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

View file

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