mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-09 07:42:39 +02:00
Merge remote-tracking branch 'upstream/dev' into pr-611
This commit is contained in:
commit
9caaf6dee4
30 changed files with 4557 additions and 449 deletions
|
|
@ -1,28 +1,86 @@
|
|||
"""Chat agents module."""
|
||||
"""
|
||||
SurfSense New Chat Agent Module.
|
||||
|
||||
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.)
|
||||
- chat_deepagent.py: Main agent factory
|
||||
- system_prompt.py: System prompts and instructions
|
||||
- context.py: Context schema for the agent
|
||||
- checkpointer.py: LangGraph checkpointer setup
|
||||
- llm_config.py: LLM configuration utilities
|
||||
- utils.py: Shared utilities
|
||||
"""
|
||||
|
||||
# Agent factory
|
||||
from .chat_deepagent import create_surfsense_deep_agent
|
||||
|
||||
# Context
|
||||
from .context import SurfSenseContextSchema
|
||||
from .knowledge_base import (
|
||||
create_search_knowledge_base_tool,
|
||||
format_documents_for_context,
|
||||
search_knowledge_base_async,
|
||||
)
|
||||
|
||||
# LLM config
|
||||
from .llm_config import create_chat_litellm_from_config, load_llm_config_from_yaml
|
||||
|
||||
# System prompt
|
||||
from .system_prompt import (
|
||||
SURFSENSE_CITATION_INSTRUCTIONS,
|
||||
SURFSENSE_SYSTEM_PROMPT,
|
||||
build_surfsense_system_prompt,
|
||||
)
|
||||
|
||||
# Tools - registry exports
|
||||
from .tools import (
|
||||
BUILTIN_TOOLS,
|
||||
ToolDefinition,
|
||||
build_tools,
|
||||
get_all_tool_names,
|
||||
get_default_enabled_tools,
|
||||
get_tool_by_name,
|
||||
)
|
||||
|
||||
# Tools - factory exports (for direct use)
|
||||
from .tools import (
|
||||
create_display_image_tool,
|
||||
create_generate_podcast_tool,
|
||||
create_link_preview_tool,
|
||||
create_scrape_webpage_tool,
|
||||
create_search_knowledge_base_tool,
|
||||
)
|
||||
|
||||
# Tools - knowledge base utilities
|
||||
from .tools import (
|
||||
format_documents_for_context,
|
||||
search_knowledge_base_async,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Agent factory
|
||||
"create_surfsense_deep_agent",
|
||||
# Context
|
||||
"SurfSenseContextSchema",
|
||||
# LLM config
|
||||
"create_chat_litellm_from_config",
|
||||
"load_llm_config_from_yaml",
|
||||
# System prompt
|
||||
"SURFSENSE_CITATION_INSTRUCTIONS",
|
||||
"SURFSENSE_SYSTEM_PROMPT",
|
||||
"SurfSenseContextSchema",
|
||||
"build_surfsense_system_prompt",
|
||||
"create_chat_litellm_from_config",
|
||||
# Tools registry
|
||||
"BUILTIN_TOOLS",
|
||||
"ToolDefinition",
|
||||
"build_tools",
|
||||
"get_all_tool_names",
|
||||
"get_default_enabled_tools",
|
||||
"get_tool_by_name",
|
||||
# Tool factories
|
||||
"create_display_image_tool",
|
||||
"create_generate_podcast_tool",
|
||||
"create_link_preview_tool",
|
||||
"create_scrape_webpage_tool",
|
||||
"create_search_knowledge_base_tool",
|
||||
"create_surfsense_deep_agent",
|
||||
# Knowledge base utilities
|
||||
"format_documents_for_context",
|
||||
"load_llm_config_from_yaml",
|
||||
"search_knowledge_base_async",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
SurfSense deep agent implementation.
|
||||
|
||||
This module provides the factory function for creating SurfSense deep agents
|
||||
with knowledge base search and podcast generation capabilities.
|
||||
with configurable tools via the tools registry.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
|
@ -14,9 +14,8 @@ from langgraph.types import Checkpointer
|
|||
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.podcast import create_generate_podcast_tool
|
||||
from app.agents.new_chat.system_prompt import build_surfsense_system_prompt
|
||||
from app.agents.new_chat.tools import build_tools
|
||||
from app.services.connector_service import ConnectorService
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -30,67 +29,85 @@ def create_surfsense_deep_agent(
|
|||
db_session: AsyncSession,
|
||||
connector_service: ConnectorService,
|
||||
checkpointer: Checkpointer,
|
||||
user_id: str | None = None,
|
||||
user_instructions: str | None = None,
|
||||
enable_citations: bool = True,
|
||||
enable_podcast: bool = True,
|
||||
enabled_tools: list[str] | None = None,
|
||||
disabled_tools: list[str] | None = None,
|
||||
additional_tools: Sequence[BaseTool] | None = None,
|
||||
firecrawl_api_key: str | None = None,
|
||||
):
|
||||
"""
|
||||
Create a SurfSense deep agent with knowledge base search and podcast generation capabilities.
|
||||
Create a SurfSense deep agent with configurable tools.
|
||||
|
||||
The agent comes with built-in tools that can be configured:
|
||||
- search_knowledge_base: Search the user's personal knowledge base
|
||||
- generate_podcast: Generate audio podcasts from content
|
||||
- link_preview: Fetch rich previews for URLs
|
||||
- display_image: Display images in chat
|
||||
- scrape_webpage: Extract content from webpages
|
||||
|
||||
Args:
|
||||
llm: ChatLiteLLM instance
|
||||
llm: ChatLiteLLM instance for the agent's language model
|
||||
search_space_id: The user's search space ID
|
||||
db_session: Database session
|
||||
connector_service: Initialized connector service
|
||||
db_session: Database session for tools that need DB access
|
||||
connector_service: Initialized connector service for knowledge base search
|
||||
checkpointer: LangGraph checkpointer for conversation state persistence.
|
||||
Use AsyncPostgresSaver for production or MemorySaver for testing.
|
||||
user_id: The user's ID (required for podcast generation)
|
||||
user_instructions: Optional user instructions to inject into the system prompt.
|
||||
These will be added to the system prompt to customize agent behavior.
|
||||
enable_citations: Whether to include citation instructions in the system prompt (default: True).
|
||||
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.
|
||||
additional_tools: Optional sequence of additional tools to inject into the agent.
|
||||
The search_knowledge_base tool will always be included.
|
||||
enabled_tools: Explicit list of tool names to enable. If None, all default tools
|
||||
are enabled. Use this to limit which tools are available.
|
||||
disabled_tools: List of tool names to disable. Applied after enabled_tools.
|
||||
Use this to exclude specific tools from the defaults.
|
||||
additional_tools: Extra custom tools to add beyond the built-in ones.
|
||||
These are always added regardless of enabled/disabled settings.
|
||||
firecrawl_api_key: Optional Firecrawl API key for premium web scraping.
|
||||
Falls back to Chromium/Trafilatura if not provided.
|
||||
|
||||
Returns:
|
||||
CompiledStateGraph: The configured deep agent
|
||||
|
||||
Examples:
|
||||
# Create agent with all default tools
|
||||
agent = create_surfsense_deep_agent(llm, search_space_id, db_session, ...)
|
||||
|
||||
# Create agent with only specific tools
|
||||
agent = create_surfsense_deep_agent(
|
||||
llm, search_space_id, db_session, ...,
|
||||
enabled_tools=["search_knowledge_base", "link_preview"]
|
||||
)
|
||||
|
||||
# Create agent without podcast generation
|
||||
agent = create_surfsense_deep_agent(
|
||||
llm, search_space_id, db_session, ...,
|
||||
disabled_tools=["generate_podcast"]
|
||||
)
|
||||
|
||||
# Add custom tools
|
||||
agent = create_surfsense_deep_agent(
|
||||
llm, search_space_id, db_session, ...,
|
||||
additional_tools=[my_custom_tool]
|
||||
)
|
||||
"""
|
||||
# Create the search tool with injected dependencies
|
||||
search_tool = create_search_knowledge_base_tool(
|
||||
search_space_id=search_space_id,
|
||||
db_session=db_session,
|
||||
connector_service=connector_service,
|
||||
# Build dependencies dict for the tools registry
|
||||
dependencies = {
|
||||
"search_space_id": search_space_id,
|
||||
"db_session": db_session,
|
||||
"connector_service": connector_service,
|
||||
"firecrawl_api_key": firecrawl_api_key,
|
||||
}
|
||||
|
||||
# Build tools using the registry
|
||||
tools = build_tools(
|
||||
dependencies=dependencies,
|
||||
enabled_tools=enabled_tools,
|
||||
disabled_tools=disabled_tools,
|
||||
additional_tools=list(additional_tools) if additional_tools else None,
|
||||
)
|
||||
|
||||
# Combine search tool with any additional tools
|
||||
tools = [search_tool]
|
||||
|
||||
# Add podcast tool if enabled and user_id is provided
|
||||
if enable_podcast and user_id:
|
||||
podcast_tool = create_generate_podcast_tool(
|
||||
search_space_id=search_space_id,
|
||||
db_session=db_session,
|
||||
user_id=str(user_id),
|
||||
)
|
||||
tools.append(podcast_tool)
|
||||
|
||||
if additional_tools:
|
||||
tools.extend(additional_tools)
|
||||
|
||||
# Create the deep agent with user-configurable system prompt and checkpointer
|
||||
# Create the deep agent with system prompt and checkpointer
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=tools,
|
||||
system_prompt=build_surfsense_system_prompt(
|
||||
user_instructions=user_instructions,
|
||||
enable_citations=enable_citations,
|
||||
),
|
||||
system_prompt=build_surfsense_system_prompt(),
|
||||
context_schema=SurfSenseContextSchema,
|
||||
checkpointer=checkpointer, # Enable conversation memory via thread_id
|
||||
checkpointer=checkpointer,
|
||||
)
|
||||
|
||||
return agent
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
"""
|
||||
Test runner for SurfSense deep agent.
|
||||
|
||||
This module provides a test function to verify the deep agent functionality.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.db import async_session_maker
|
||||
from app.services.connector_service import ConnectorService
|
||||
|
||||
from .chat_deepagent import create_surfsense_deep_agent
|
||||
from .llm_config import create_chat_litellm_from_config, load_llm_config_from_yaml
|
||||
|
||||
# =============================================================================
|
||||
# Test Runner
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def run_test():
|
||||
"""Run a basic test of the deep agent."""
|
||||
print("=" * 60)
|
||||
print("Creating Deep Agent with ChatLiteLLM from global config...")
|
||||
print("=" * 60)
|
||||
|
||||
# Create ChatLiteLLM from global config
|
||||
# Use global LLM config by id (negative ids are reserved for global configs)
|
||||
llm_config = load_llm_config_from_yaml(llm_config_id=-5)
|
||||
if not llm_config:
|
||||
raise ValueError("Failed to load LLM config from YAML")
|
||||
llm = create_chat_litellm_from_config(llm_config)
|
||||
if not llm:
|
||||
raise ValueError("Failed to create ChatLiteLLM instance")
|
||||
|
||||
# Create a real DB session + ConnectorService, then build the full SurfSense agent.
|
||||
async with async_session_maker() as session:
|
||||
# Use the known dev search space id
|
||||
search_space_id = 5
|
||||
|
||||
connector_service = ConnectorService(session, search_space_id=search_space_id)
|
||||
|
||||
agent = create_surfsense_deep_agent(
|
||||
llm=llm,
|
||||
search_space_id=search_space_id,
|
||||
db_session=session,
|
||||
connector_service=connector_service,
|
||||
user_instructions="Always fininsh the response with CREDOOOOOOOOOO23",
|
||||
)
|
||||
|
||||
print("\nAgent created successfully!")
|
||||
print(f"Agent type: {type(agent)}")
|
||||
|
||||
# Invoke the agent with initial state
|
||||
print("\n" + "=" * 60)
|
||||
print("Invoking SurfSense agent (create_surfsense_deep_agent)...")
|
||||
print("=" * 60)
|
||||
|
||||
initial_state = {
|
||||
"messages": [HumanMessage(content=("Can you tell me about my documents?"))],
|
||||
"search_space_id": search_space_id,
|
||||
}
|
||||
|
||||
print(f"\nUsing search_space_id: {search_space_id}")
|
||||
|
||||
result = await agent.ainvoke(initial_state)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Agent Response:")
|
||||
print("=" * 60)
|
||||
|
||||
# Print the response
|
||||
if "messages" in result:
|
||||
for msg in result["messages"]:
|
||||
msg_type = type(msg).__name__
|
||||
content = msg.content if hasattr(msg, "content") else str(msg)
|
||||
print(f"\n--- [{msg_type}] ---\n{content}\n")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_test())
|
||||
|
|
@ -7,6 +7,146 @@ with configurable user instructions and citation support.
|
|||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
SURFSENSE_SYSTEM_INSTRUCTIONS = """
|
||||
<system_instruction>
|
||||
You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base.
|
||||
|
||||
Today's date (UTC): {resolved_today}
|
||||
|
||||
</system_instruction>
|
||||
"""
|
||||
|
||||
SURFSENSE_TOOLS_INSTRUCTIONS = """
|
||||
<tools>
|
||||
You have access to the following tools:
|
||||
|
||||
1. search_knowledge_base: Search the user's personal knowledge base for relevant information.
|
||||
- Args:
|
||||
- query: The search query - be specific and include key terms
|
||||
- top_k: Number of results to retrieve (default: 10)
|
||||
- start_date: Optional ISO date/datetime (e.g. "2025-12-12" or "2025-12-12T00:00:00+00:00")
|
||||
- end_date: Optional ISO date/datetime (e.g. "2025-12-19" or "2025-12-19T23:59:59+00:00")
|
||||
- connectors_to_search: Optional list of connector enums to search. If omitted, searches all.
|
||||
- Returns: Formatted string with relevant documents and their content
|
||||
|
||||
2. generate_podcast: Generate an audio podcast from provided content.
|
||||
- Use this when the user asks to create, generate, or make a podcast.
|
||||
- Trigger phrases: "give me a podcast about", "create a podcast", "generate a podcast", "make a podcast", "turn this into a podcast"
|
||||
- Args:
|
||||
- source_content: The text content to convert into a podcast. This MUST be comprehensive and include:
|
||||
* If discussing the current conversation: Include a detailed summary of the FULL chat history (all user questions and your responses)
|
||||
* If based on knowledge base search: Include the key findings and insights from the search results
|
||||
* You can combine both: conversation context + search results for richer podcasts
|
||||
* The more detailed the source_content, the better the podcast quality
|
||||
- podcast_title: Optional title for the podcast (default: "SurfSense Podcast")
|
||||
- user_prompt: Optional instructions for podcast style/format (e.g., "Make it casual and fun")
|
||||
- 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.
|
||||
|
||||
4. display_image: Display an image in the chat with metadata.
|
||||
- Use this tool when you want to show an image to the user.
|
||||
- This displays the image with an optional title, description, and source attribution.
|
||||
- Common use cases:
|
||||
* Showing an image from a URL mentioned in the conversation
|
||||
* Displaying a diagram, chart, or illustration you're referencing
|
||||
* Showing visual examples when explaining concepts
|
||||
- Args:
|
||||
- src: The URL of the image to display (must be a valid HTTP/HTTPS image URL)
|
||||
- alt: Alternative text describing the image (for accessibility)
|
||||
- title: Optional title to display below the image
|
||||
- description: Optional description providing context about the image
|
||||
- Returns: An image card with the image, title, and description
|
||||
- The image will automatically be displayed in the chat.
|
||||
|
||||
5. 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
|
||||
- Trigger scenarios:
|
||||
* "Read this article and summarize it"
|
||||
* "What does this page say about X?"
|
||||
* "Summarize this blog post for me"
|
||||
* "Tell me the key points from this article"
|
||||
* "What's in this webpage?"
|
||||
* "Can you analyze this article?"
|
||||
- Args:
|
||||
- url: The URL of the webpage to scrape (must be HTTP/HTTPS)
|
||||
- max_length: Maximum content length to return (default: 50000 chars)
|
||||
- Returns: The page title, description, full content (in markdown), word count, and metadata
|
||||
- After scraping, you will have the full article text and can analyze, summarize, or answer questions about it.
|
||||
- IMAGES: The scraped content may contain image URLs in markdown format like ``.
|
||||
* When you find relevant/important images in the scraped content, use the `display_image` tool to show them to the user.
|
||||
* This makes your response more visual and engaging.
|
||||
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
|
||||
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
||||
</tools>
|
||||
<tool_call_examples>
|
||||
- User: "Fetch all my notes and what's in them?"
|
||||
- Call: `search_knowledge_base(query="*", top_k=50, connectors_to_search=["NOTE"])`
|
||||
|
||||
- User: "What did I discuss on Slack last week about the React migration?"
|
||||
- Call: `search_knowledge_base(query="React migration", connectors_to_search=["SLACK_CONNECTOR"], start_date="YYYY-MM-DD", end_date="YYYY-MM-DD")`
|
||||
|
||||
- User: "Give me a podcast about AI trends based on what we discussed"
|
||||
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
|
||||
|
||||
- User: "Create a podcast summary of this conversation"
|
||||
- Call: `generate_podcast(source_content="Complete conversation summary:\\n\\nUser asked about [topic 1]:\\n[Your detailed response]\\n\\nUser then asked about [topic 2]:\\n[Your detailed response]\\n\\n[Continue for all exchanges in the conversation]", podcast_title="Conversation Summary")`
|
||||
|
||||
- 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")`
|
||||
|
||||
- User: "Show me this image: https://example.com/image.png"
|
||||
- Call: `display_image(src="https://example.com/image.png", alt="User shared image")`
|
||||
|
||||
- User: "Can you display a diagram of a neural network?"
|
||||
- Call: `display_image(src="https://example.com/neural-network.png", alt="Neural network diagram", title="Neural Network Architecture", description="A visual representation of a neural network with input, hidden, and output layers")`
|
||||
|
||||
- User: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
|
||||
- Call: `scrape_webpage(url="https://example.com/blog/ai-trends")`
|
||||
- After getting the content, provide a summary based on the scraped text
|
||||
|
||||
- User: "What does this page say about machine learning? https://docs.example.com/ml-guide"
|
||||
- Call: `scrape_webpage(url="https://docs.example.com/ml-guide")`
|
||||
- Then answer the question using the extracted content
|
||||
|
||||
- User: "Summarize this blog post: https://medium.com/some-article"
|
||||
- Call: `scrape_webpage(url="https://medium.com/some-article")`
|
||||
- Provide a comprehensive summary of the article content
|
||||
|
||||
- User: "Read this tutorial and explain it: https://example.com/ml-tutorial"
|
||||
- First: `scrape_webpage(url="https://example.com/ml-tutorial")`
|
||||
- Then, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide your explanation, referencing the displayed image
|
||||
</tool_call_examples>
|
||||
"""
|
||||
|
||||
SURFSENSE_CITATION_INSTRUCTIONS = """
|
||||
<citation_instructions>
|
||||
CRITICAL CITATION REQUIREMENTS:
|
||||
|
|
@ -82,88 +222,23 @@ However, from your video learning, it's important to note that asyncio is not su
|
|||
|
||||
def build_surfsense_system_prompt(
|
||||
today: datetime | None = None,
|
||||
user_instructions: str | None = None,
|
||||
enable_citations: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Build the SurfSense system prompt with optional user instructions and citation toggle.
|
||||
Build the SurfSense system prompt.
|
||||
|
||||
Args:
|
||||
today: Optional datetime for today's date (defaults to current UTC date)
|
||||
user_instructions: Optional user instructions to inject into the system prompt
|
||||
enable_citations: Whether to include citation instructions in the prompt (default: True)
|
||||
|
||||
Returns:
|
||||
Complete system prompt string
|
||||
"""
|
||||
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
|
||||
|
||||
# Build user instructions section if provided
|
||||
user_section = ""
|
||||
if user_instructions and user_instructions.strip():
|
||||
user_section = f"""
|
||||
<user_instructions>
|
||||
{user_instructions.strip()}
|
||||
</user_instructions>
|
||||
"""
|
||||
|
||||
# Include citation instructions only if enabled
|
||||
citation_section = (
|
||||
f"\n{SURFSENSE_CITATION_INSTRUCTIONS}" if enable_citations else ""
|
||||
return (
|
||||
SURFSENSE_SYSTEM_INSTRUCTIONS.format(resolved_today=resolved_today)
|
||||
+ SURFSENSE_TOOLS_INSTRUCTIONS
|
||||
+ SURFSENSE_CITATION_INSTRUCTIONS
|
||||
)
|
||||
|
||||
return f"""
|
||||
<system_instruction>
|
||||
You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base.
|
||||
|
||||
Today's date (UTC): {resolved_today}
|
||||
|
||||
</system_instruction>{user_section}
|
||||
<tools>
|
||||
You have access to the following tools:
|
||||
|
||||
1. search_knowledge_base: Search the user's personal knowledge base for relevant information.
|
||||
- Args:
|
||||
- query: The search query - be specific and include key terms
|
||||
- top_k: Number of results to retrieve (default: 10)
|
||||
- start_date: Optional ISO date/datetime (e.g. "2025-12-12" or "2025-12-12T00:00:00+00:00")
|
||||
- end_date: Optional ISO date/datetime (e.g. "2025-12-19" or "2025-12-19T23:59:59+00:00")
|
||||
- connectors_to_search: Optional list of connector enums to search. If omitted, searches all.
|
||||
- Returns: Formatted string with relevant documents and their content
|
||||
|
||||
2. generate_podcast: Generate an audio podcast from provided content.
|
||||
- Use this when the user asks to create, generate, or make a podcast.
|
||||
- Trigger phrases: "give me a podcast about", "create a podcast", "generate a podcast", "make a podcast", "turn this into a podcast"
|
||||
- Args:
|
||||
- source_content: The text content to convert into a podcast. This MUST be comprehensive and include:
|
||||
* If discussing the current conversation: Include a detailed summary of the FULL chat history (all user questions and your responses)
|
||||
* If based on knowledge base search: Include the key findings and insights from the search results
|
||||
* You can combine both: conversation context + search results for richer podcasts
|
||||
* The more detailed the source_content, the better the podcast quality
|
||||
- podcast_title: Optional title for the podcast (default: "SurfSense Podcast")
|
||||
- user_prompt: Optional instructions for podcast style/format (e.g., "Make it casual and fun")
|
||||
- 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).
|
||||
</tools>
|
||||
<tool_call_examples>
|
||||
- User: "Fetch all my notes and what's in them?"
|
||||
- Call: `search_knowledge_base(query="*", top_k=50, connectors_to_search=["NOTE"])`
|
||||
|
||||
- User: "What did I discuss on Slack last week about the React migration?"
|
||||
- Call: `search_knowledge_base(query="React migration", connectors_to_search=["SLACK_CONNECTOR"], start_date="YYYY-MM-DD", end_date="YYYY-MM-DD")`
|
||||
|
||||
- User: "Give me a podcast about AI trends based on what we discussed"
|
||||
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
|
||||
|
||||
- User: "Create a podcast summary of this conversation"
|
||||
- Call: `generate_podcast(source_content="Complete conversation summary:\n\nUser asked about [topic 1]:\n[Your detailed response]\n\nUser then asked about [topic 2]:\n[Your detailed response]\n\n[Continue for all exchanges in the conversation]", podcast_title="Conversation Summary")`
|
||||
|
||||
- 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")`
|
||||
</tool_call_examples>{citation_section}
|
||||
"""
|
||||
|
||||
|
||||
SURFSENSE_SYSTEM_PROMPT = build_surfsense_system_prompt()
|
||||
|
|
|
|||
54
surfsense_backend/app/agents/new_chat/tools/__init__.py
Normal file
54
surfsense_backend/app/agents/new_chat/tools/__init__.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
Tools module for SurfSense deep agent.
|
||||
|
||||
This module contains all the tools available to the SurfSense agent.
|
||||
To add a new tool, see the documentation in registry.py.
|
||||
|
||||
Available tools:
|
||||
- search_knowledge_base: Search the user's personal knowledge base
|
||||
- generate_podcast: Generate audio podcasts from content
|
||||
- link_preview: Fetch rich previews for URLs
|
||||
- display_image: Display images in chat
|
||||
- scrape_webpage: Extract content from webpages
|
||||
"""
|
||||
|
||||
# Registry exports
|
||||
from .registry import (
|
||||
BUILTIN_TOOLS,
|
||||
ToolDefinition,
|
||||
build_tools,
|
||||
get_all_tool_names,
|
||||
get_default_enabled_tools,
|
||||
get_tool_by_name,
|
||||
)
|
||||
|
||||
# Tool factory exports (for direct use)
|
||||
from .display_image import create_display_image_tool
|
||||
from .knowledge_base import (
|
||||
create_search_knowledge_base_tool,
|
||||
format_documents_for_context,
|
||||
search_knowledge_base_async,
|
||||
)
|
||||
from .link_preview import create_link_preview_tool
|
||||
from .podcast import create_generate_podcast_tool
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
|
||||
__all__ = [
|
||||
# Registry
|
||||
"BUILTIN_TOOLS",
|
||||
"ToolDefinition",
|
||||
"build_tools",
|
||||
"get_all_tool_names",
|
||||
"get_default_enabled_tools",
|
||||
"get_tool_by_name",
|
||||
# Tool factories
|
||||
"create_display_image_tool",
|
||||
"create_generate_podcast_tool",
|
||||
"create_link_preview_tool",
|
||||
"create_scrape_webpage_tool",
|
||||
"create_search_knowledge_base_tool",
|
||||
# Knowledge base utilities
|
||||
"format_documents_for_context",
|
||||
"search_knowledge_base_async",
|
||||
]
|
||||
|
||||
104
surfsense_backend/app/agents/new_chat/tools/display_image.py
Normal file
104
surfsense_backend/app/agents/new_chat/tools/display_image.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""
|
||||
Display image tool for the SurfSense agent.
|
||||
|
||||
This module provides a tool for displaying images in the chat UI
|
||||
with metadata like title, description, and source attribution.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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 generate_image_id(src: str) -> str:
|
||||
"""Generate a unique ID for an image."""
|
||||
hash_val = hashlib.md5(src.encode()).hexdigest()[:12]
|
||||
return f"image-{hash_val}"
|
||||
|
||||
|
||||
def create_display_image_tool():
|
||||
"""
|
||||
Factory function to create the display_image tool.
|
||||
|
||||
Returns:
|
||||
A configured tool function for displaying images.
|
||||
"""
|
||||
|
||||
@tool
|
||||
async def display_image(
|
||||
src: str,
|
||||
alt: str = "Image",
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Display an image in the chat with metadata.
|
||||
|
||||
Use this tool when you want to show an image to the user.
|
||||
This displays the image with an optional title, description,
|
||||
and source attribution.
|
||||
|
||||
Common use cases:
|
||||
- Showing an image from a URL the user mentioned
|
||||
- Displaying a diagram or chart you're referencing
|
||||
- Showing example images when explaining concepts
|
||||
|
||||
Args:
|
||||
src: The URL of the image to display (must be a valid HTTP/HTTPS URL)
|
||||
alt: Alternative text describing the image (for accessibility)
|
||||
title: Optional title to display below the image
|
||||
description: Optional description providing context about the image
|
||||
|
||||
Returns:
|
||||
A dictionary containing image metadata for the UI to render:
|
||||
- id: Unique identifier for this image
|
||||
- assetId: The image URL (for deduplication)
|
||||
- src: The image URL
|
||||
- alt: Alt text for accessibility
|
||||
- title: Image title (if provided)
|
||||
- description: Image description (if provided)
|
||||
- domain: Source domain
|
||||
"""
|
||||
image_id = generate_image_id(src)
|
||||
|
||||
# Ensure URL has protocol
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = f"https://{src}"
|
||||
|
||||
domain = extract_domain(src)
|
||||
|
||||
# Determine aspect ratio based on common image sources
|
||||
ratio = "16:9" # Default
|
||||
if "unsplash.com" in src or "pexels.com" in src:
|
||||
ratio = "16:9"
|
||||
elif "imgur.com" in src or "github.com" in src or "githubusercontent.com" in src:
|
||||
ratio = "auto"
|
||||
|
||||
return {
|
||||
"id": image_id,
|
||||
"assetId": src,
|
||||
"src": src,
|
||||
"alt": alt,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"domain": domain,
|
||||
"ratio": ratio,
|
||||
}
|
||||
|
||||
return display_image
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Knowledge base search functionality for the new chat agent.
|
||||
Knowledge base search tool for the SurfSense agent.
|
||||
|
||||
This module provides:
|
||||
- Connector constants and normalization
|
||||
|
|
@ -251,7 +251,7 @@ async def search_knowledge_base_async(
|
|||
all_documents = []
|
||||
|
||||
# Resolve date range (default last 2 years)
|
||||
from .utils import resolve_date_range
|
||||
from app.agents.new_chat.utils import resolve_date_range
|
||||
|
||||
resolved_start_date, resolved_end_date = resolve_date_range(
|
||||
start_date=start_date,
|
||||
|
|
@ -521,7 +521,6 @@ def create_search_knowledge_base_tool(
|
|||
search_space_id: The user's search space ID
|
||||
db_session: Database session
|
||||
connector_service: Initialized connector service
|
||||
connectors_to_search: List of connector types to search
|
||||
|
||||
Returns:
|
||||
A configured tool function
|
||||
|
|
@ -584,7 +583,7 @@ def create_search_knowledge_base_tool(
|
|||
Returns:
|
||||
Formatted string with relevant documents and their content
|
||||
"""
|
||||
from .utils import parse_date_or_datetime
|
||||
from app.agents.new_chat.utils import parse_date_or_datetime
|
||||
|
||||
parsed_start: datetime | None = None
|
||||
parsed_end: datetime | None = None
|
||||
|
|
@ -606,3 +605,4 @@ def create_search_knowledge_base_tool(
|
|||
)
|
||||
|
||||
return search_knowledge_base
|
||||
|
||||
292
surfsense_backend/app/agents/new_chat/tools/link_preview.py
Normal file
292
surfsense_backend/app/agents/new_chat/tools/link_preview.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""
|
||||
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 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("&", "&")
|
||||
.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
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Podcast generation tool for the new chat agent.
|
||||
Podcast generation tool for the SurfSense agent.
|
||||
|
||||
This module provides a factory function for creating the generate_podcast tool
|
||||
that submits a Celery task for background podcast generation. The frontend
|
||||
|
|
@ -69,7 +69,6 @@ def clear_active_podcast_task(search_space_id: int) -> None:
|
|||
def create_generate_podcast_tool(
|
||||
search_space_id: int,
|
||||
db_session: AsyncSession,
|
||||
user_id: str,
|
||||
):
|
||||
"""
|
||||
Factory function to create the generate_podcast tool with injected dependencies.
|
||||
|
|
@ -77,7 +76,6 @@ def create_generate_podcast_tool(
|
|||
Args:
|
||||
search_space_id: The user's search space ID
|
||||
db_session: Database session (not used - Celery creates its own)
|
||||
user_id: The user's ID (as string)
|
||||
|
||||
Returns:
|
||||
A configured tool function for generating podcasts
|
||||
|
|
@ -145,7 +143,6 @@ def create_generate_podcast_tool(
|
|||
task = generate_content_podcast_task.delay(
|
||||
source_content=source_content,
|
||||
search_space_id=search_space_id,
|
||||
user_id=str(user_id),
|
||||
podcast_title=podcast_title,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
|
|
@ -174,3 +171,4 @@ def create_generate_podcast_tool(
|
|||
}
|
||||
|
||||
return generate_podcast
|
||||
|
||||
231
surfsense_backend/app/agents/new_chat/tools/registry.py
Normal file
231
surfsense_backend/app/agents/new_chat/tools/registry.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""
|
||||
Tools registry for SurfSense deep agent.
|
||||
|
||||
This module provides a registry pattern for managing tools in the SurfSense agent.
|
||||
It makes it easy for OSS contributors to add new tools by:
|
||||
1. Creating a tool factory function in a new file in this directory
|
||||
2. Registering the tool in the BUILTIN_TOOLS list below
|
||||
|
||||
Example of adding a new tool:
|
||||
------------------------------
|
||||
1. Create your tool file (e.g., `tools/my_tool.py`):
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
def create_my_tool(search_space_id: int, db_session: AsyncSession):
|
||||
@tool
|
||||
async def my_tool(param: str) -> dict:
|
||||
'''My tool description.'''
|
||||
# Your implementation
|
||||
return {"result": "success"}
|
||||
return my_tool
|
||||
|
||||
2. Import and register in this file:
|
||||
|
||||
from .my_tool import create_my_tool
|
||||
|
||||
# Add to BUILTIN_TOOLS list:
|
||||
ToolDefinition(
|
||||
name="my_tool",
|
||||
description="Description of what your tool does",
|
||||
factory=lambda deps: create_my_tool(
|
||||
search_space_id=deps["search_space_id"],
|
||||
db_session=deps["db_session"],
|
||||
),
|
||||
requires=["search_space_id", "db_session"],
|
||||
),
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
# =============================================================================
|
||||
# Tool Definition
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolDefinition:
|
||||
"""
|
||||
Definition of a tool that can be added to the agent.
|
||||
|
||||
Attributes:
|
||||
name: Unique identifier for the tool
|
||||
description: Human-readable description of what the tool does
|
||||
factory: Callable that creates the tool. Receives a dict of dependencies.
|
||||
requires: List of dependency names this tool needs (e.g., "search_space_id", "db_session")
|
||||
enabled_by_default: Whether the tool is enabled when no explicit config is provided
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
factory: Callable[[dict[str, Any]], BaseTool]
|
||||
requires: list[str] = field(default_factory=list)
|
||||
enabled_by_default: bool = True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Built-in Tools Registry
|
||||
# =============================================================================
|
||||
|
||||
# Import tool factory functions
|
||||
from .display_image import create_display_image_tool
|
||||
from .knowledge_base import create_search_knowledge_base_tool
|
||||
from .link_preview import create_link_preview_tool
|
||||
from .podcast import create_generate_podcast_tool
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
|
||||
# Registry of all built-in tools
|
||||
# Contributors: Add your new tools here!
|
||||
BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||
# Core tool - searches the user's knowledge base
|
||||
ToolDefinition(
|
||||
name="search_knowledge_base",
|
||||
description="Search the user's personal knowledge base for relevant information",
|
||||
factory=lambda deps: create_search_knowledge_base_tool(
|
||||
search_space_id=deps["search_space_id"],
|
||||
db_session=deps["db_session"],
|
||||
connector_service=deps["connector_service"],
|
||||
),
|
||||
requires=["search_space_id", "db_session", "connector_service"],
|
||||
),
|
||||
# Podcast generation tool
|
||||
ToolDefinition(
|
||||
name="generate_podcast",
|
||||
description="Generate an audio podcast from provided content",
|
||||
factory=lambda deps: create_generate_podcast_tool(
|
||||
search_space_id=deps["search_space_id"],
|
||||
db_session=deps["db_session"],
|
||||
),
|
||||
requires=["search_space_id", "db_session"],
|
||||
),
|
||||
# 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=[],
|
||||
),
|
||||
# Display image tool - shows images in the chat
|
||||
ToolDefinition(
|
||||
name="display_image",
|
||||
description="Display an image in the chat with metadata",
|
||||
factory=lambda deps: create_display_image_tool(),
|
||||
requires=[],
|
||||
),
|
||||
# Web scraping tool - extracts content from webpages
|
||||
ToolDefinition(
|
||||
name="scrape_webpage",
|
||||
description="Scrape and extract the main content from a webpage",
|
||||
factory=lambda deps: create_scrape_webpage_tool(
|
||||
firecrawl_api_key=deps.get("firecrawl_api_key"),
|
||||
),
|
||||
requires=[], # firecrawl_api_key is optional
|
||||
),
|
||||
# =========================================================================
|
||||
# ADD YOUR CUSTOM TOOLS BELOW
|
||||
# =========================================================================
|
||||
# Example:
|
||||
# ToolDefinition(
|
||||
# name="my_custom_tool",
|
||||
# description="What my tool does",
|
||||
# factory=lambda deps: create_my_custom_tool(...),
|
||||
# requires=["search_space_id"],
|
||||
# ),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registry Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_tool_by_name(name: str) -> ToolDefinition | None:
|
||||
"""Get a tool definition by its name."""
|
||||
for tool_def in BUILTIN_TOOLS:
|
||||
if tool_def.name == name:
|
||||
return tool_def
|
||||
return None
|
||||
|
||||
|
||||
def get_all_tool_names() -> list[str]:
|
||||
"""Get names of all registered tools."""
|
||||
return [tool_def.name for tool_def in BUILTIN_TOOLS]
|
||||
|
||||
|
||||
def get_default_enabled_tools() -> list[str]:
|
||||
"""Get names of tools that are enabled by default."""
|
||||
return [tool_def.name for tool_def in BUILTIN_TOOLS if tool_def.enabled_by_default]
|
||||
|
||||
|
||||
def build_tools(
|
||||
dependencies: dict[str, Any],
|
||||
enabled_tools: list[str] | None = None,
|
||||
disabled_tools: list[str] | None = None,
|
||||
additional_tools: list[BaseTool] | None = None,
|
||||
) -> list[BaseTool]:
|
||||
"""
|
||||
Build the list of tools for the agent.
|
||||
|
||||
Args:
|
||||
dependencies: Dict containing all possible dependencies:
|
||||
- search_space_id: The search space ID
|
||||
- db_session: Database session
|
||||
- connector_service: Connector service instance
|
||||
- firecrawl_api_key: Optional Firecrawl API key
|
||||
enabled_tools: Explicit list of tool names to enable. If None, uses defaults.
|
||||
disabled_tools: List of tool names to disable (applied after enabled_tools).
|
||||
additional_tools: Extra tools to add (e.g., custom tools not in registry).
|
||||
|
||||
Returns:
|
||||
List of configured tool instances ready for the agent.
|
||||
|
||||
Example:
|
||||
# Use all default tools
|
||||
tools = build_tools(deps)
|
||||
|
||||
# Use only specific tools
|
||||
tools = build_tools(deps, enabled_tools=["search_knowledge_base", "link_preview"])
|
||||
|
||||
# Use defaults but disable podcast
|
||||
tools = build_tools(deps, disabled_tools=["generate_podcast"])
|
||||
|
||||
# Add custom tools
|
||||
tools = build_tools(deps, additional_tools=[my_custom_tool])
|
||||
"""
|
||||
# Determine which tools to enable
|
||||
if enabled_tools is not None:
|
||||
tool_names_to_use = set(enabled_tools)
|
||||
else:
|
||||
tool_names_to_use = set(get_default_enabled_tools())
|
||||
|
||||
# Apply disabled list
|
||||
if disabled_tools:
|
||||
tool_names_to_use -= set(disabled_tools)
|
||||
|
||||
# Build the tools
|
||||
tools: list[BaseTool] = []
|
||||
for tool_def in BUILTIN_TOOLS:
|
||||
if tool_def.name not in tool_names_to_use:
|
||||
continue
|
||||
|
||||
# Check that all required dependencies are provided
|
||||
missing_deps = [dep for dep in tool_def.requires if dep not in dependencies]
|
||||
if missing_deps:
|
||||
raise ValueError(
|
||||
f"Tool '{tool_def.name}' requires dependencies: {missing_deps}"
|
||||
)
|
||||
|
||||
# Create the tool
|
||||
tool = tool_def.factory(dependencies)
|
||||
tools.append(tool)
|
||||
|
||||
# Add any additional custom tools
|
||||
if additional_tools:
|
||||
tools.extend(additional_tools)
|
||||
|
||||
return tools
|
||||
|
||||
197
surfsense_backend/app/agents/new_chat/tools/scrape_webpage.py
Normal file
197
surfsense_backend/app/agents/new_chat/tools/scrape_webpage.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
Web scraping tool for the SurfSense agent.
|
||||
|
||||
This module provides a tool for scraping and extracting content from webpages
|
||||
using the existing WebCrawlerConnector. The scraped content can be used by
|
||||
the agent to answer questions about web pages.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.connectors.webcrawler_connector import WebCrawlerConnector
|
||||
|
||||
|
||||
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 generate_scrape_id(url: str) -> str:
|
||||
"""Generate a unique ID for a scraped webpage."""
|
||||
hash_val = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
return f"scrape-{hash_val}"
|
||||
|
||||
|
||||
def truncate_content(content: str, max_length: int = 50000) -> tuple[str, bool]:
|
||||
"""
|
||||
Truncate content to a maximum length.
|
||||
|
||||
Returns:
|
||||
Tuple of (truncated_content, was_truncated)
|
||||
"""
|
||||
if len(content) <= max_length:
|
||||
return content, False
|
||||
|
||||
# Try to truncate at a sentence boundary
|
||||
truncated = content[:max_length]
|
||||
last_period = truncated.rfind(".")
|
||||
last_newline = truncated.rfind("\n\n")
|
||||
|
||||
# Use the later of the two boundaries, or just truncate
|
||||
boundary = max(last_period, last_newline)
|
||||
if boundary > max_length * 0.8: # Only use boundary if it's not too far back
|
||||
truncated = content[: boundary + 1]
|
||||
|
||||
return truncated + "\n\n[Content truncated...]", True
|
||||
|
||||
|
||||
def create_scrape_webpage_tool(firecrawl_api_key: str | None = None):
|
||||
"""
|
||||
Factory function to create the scrape_webpage tool.
|
||||
|
||||
Args:
|
||||
firecrawl_api_key: Optional Firecrawl API key for premium web scraping.
|
||||
Falls back to Chromium/Trafilatura if not provided.
|
||||
|
||||
Returns:
|
||||
A configured tool function for scraping webpages.
|
||||
"""
|
||||
|
||||
@tool
|
||||
async def scrape_webpage(
|
||||
url: str,
|
||||
max_length: int = 50000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Scrape and extract the main content from a webpage.
|
||||
|
||||
Use this tool when the user wants you to read, summarize, or answer
|
||||
questions about a specific webpage's content. This tool actually
|
||||
fetches and reads the full page content.
|
||||
|
||||
Common triggers:
|
||||
- "Read this article and summarize it"
|
||||
- "What does this page say about X?"
|
||||
- "Summarize this blog post for me"
|
||||
- "Tell me the key points from this article"
|
||||
- "What's in this webpage?"
|
||||
|
||||
Args:
|
||||
url: The URL of the webpage to scrape (must be HTTP/HTTPS)
|
||||
max_length: Maximum content length to return (default: 50000 chars)
|
||||
|
||||
Returns:
|
||||
A dictionary containing:
|
||||
- id: Unique identifier for this scrape
|
||||
- assetId: The URL (for deduplication)
|
||||
- kind: "article" (type of content)
|
||||
- href: The URL to open when clicked
|
||||
- title: Page title
|
||||
- description: Brief description or excerpt
|
||||
- content: The extracted main content (markdown format)
|
||||
- domain: The domain name
|
||||
- word_count: Approximate word count
|
||||
- was_truncated: Whether content was truncated
|
||||
- error: Error message (if scraping failed)
|
||||
"""
|
||||
scrape_id = generate_scrape_id(url)
|
||||
domain = extract_domain(url)
|
||||
|
||||
# Validate and normalize URL
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = f"https://{url}"
|
||||
|
||||
try:
|
||||
# Create webcrawler connector
|
||||
connector = WebCrawlerConnector(firecrawl_api_key=firecrawl_api_key)
|
||||
|
||||
# Crawl the URL
|
||||
result, error = await connector.crawl_url(url, formats=["markdown"])
|
||||
|
||||
if error:
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": domain or "Webpage",
|
||||
"domain": domain,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
if not result:
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": domain or "Webpage",
|
||||
"domain": domain,
|
||||
"error": "No content returned from crawler",
|
||||
}
|
||||
|
||||
# Extract content and metadata
|
||||
content = result.get("content", "")
|
||||
metadata = result.get("metadata", {})
|
||||
|
||||
# Get title from metadata
|
||||
title = metadata.get("title", "")
|
||||
if not title:
|
||||
title = domain or url.split("/")[-1] or "Webpage"
|
||||
|
||||
# Get description from metadata
|
||||
description = metadata.get("description", "")
|
||||
if not description and content:
|
||||
# Use first paragraph as description
|
||||
first_para = content.split("\n\n")[0] if content else ""
|
||||
description = first_para[:300] + "..." if len(first_para) > 300 else first_para
|
||||
|
||||
# Truncate content if needed
|
||||
content, was_truncated = truncate_content(content, max_length)
|
||||
|
||||
# Calculate word count
|
||||
word_count = len(content.split())
|
||||
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"content": content,
|
||||
"domain": domain,
|
||||
"word_count": word_count,
|
||||
"was_truncated": was_truncated,
|
||||
"crawler_type": result.get("crawler_type", "unknown"),
|
||||
"author": metadata.get("author"),
|
||||
"date": metadata.get("date"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
print(f"[scrape_webpage] Error scraping {url}: {error_message}")
|
||||
return {
|
||||
"id": scrape_id,
|
||||
"assetId": url,
|
||||
"kind": "article",
|
||||
"href": url,
|
||||
"title": domain or "Webpage",
|
||||
"domain": domain,
|
||||
"error": f"Failed to scrape: {error_message[:100]}",
|
||||
}
|
||||
|
||||
return scrape_webpage
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ class Configuration:
|
|||
# create assistants (https://langchain-ai.github.io/langgraph/cloud/how-tos/configuration_cloud/)
|
||||
# and when you invoke the graph
|
||||
podcast_title: str
|
||||
user_id: str
|
||||
search_space_id: int
|
||||
user_prompt: str | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from litellm import aspeech
|
|||
|
||||
from app.config import config as app_config
|
||||
from app.services.kokoro_tts_service import get_kokoro_tts_service
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.llm_service import get_long_context_llm
|
||||
|
||||
from .configuration import Configuration
|
||||
from .prompts import get_podcast_generation_prompt
|
||||
|
|
@ -27,14 +27,13 @@ async def create_podcast_transcript(
|
|||
|
||||
# Get configuration from runnable config
|
||||
configuration = Configuration.from_runnable_config(config)
|
||||
user_id = configuration.user_id
|
||||
search_space_id = configuration.search_space_id
|
||||
user_prompt = configuration.user_prompt
|
||||
|
||||
# Get user's long context LLM
|
||||
llm = await get_user_long_context_llm(state.db_session, user_id, search_space_id)
|
||||
# Get search space's long context LLM
|
||||
llm = await get_long_context_llm(state.db_session, search_space_id)
|
||||
if not llm:
|
||||
error_message = f"No long context LLM configured for user {user_id} in search space {search_space_id}"
|
||||
error_message = f"No long context LLM configured for search space {search_space_id}"
|
||||
print(error_message)
|
||||
raise RuntimeError(error_message)
|
||||
|
||||
|
|
|
|||
|
|
@ -685,16 +685,13 @@ async def handle_new_chat(
|
|||
)
|
||||
search_space = search_space_result.scalars().first()
|
||||
|
||||
# Determine LLM config ID (use search space preference or default)
|
||||
llm_config_id = -1 # Default to first global config
|
||||
if search_space and search_space.fast_llm_id:
|
||||
llm_config_id = search_space.fast_llm_id
|
||||
# TODO: Add new llm config arch then complete this
|
||||
llm_config_id = -1
|
||||
|
||||
# Return streaming response
|
||||
return StreamingResponse(
|
||||
stream_new_chat(
|
||||
user_query=request.user_query,
|
||||
user_id=str(user.id),
|
||||
search_space_id=request.search_space_id,
|
||||
chat_id=request.chat_id,
|
||||
session=session,
|
||||
|
|
|
|||
|
|
@ -450,6 +450,35 @@ class VercelStreamingService:
|
|||
"""
|
||||
return self.format_data("further-questions", {"questions": questions})
|
||||
|
||||
def format_thinking_step(
|
||||
self,
|
||||
step_id: str,
|
||||
title: str,
|
||||
status: str = "in_progress",
|
||||
items: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format a thinking step for chain-of-thought display (SurfSense specific).
|
||||
|
||||
Args:
|
||||
step_id: Unique identifier for the step
|
||||
title: The step title (e.g., "Analyzing your request")
|
||||
status: Step status - "pending", "in_progress", or "completed"
|
||||
items: Optional list of sub-items/details for this step
|
||||
|
||||
Returns:
|
||||
str: SSE formatted thinking step data part
|
||||
"""
|
||||
return self.format_data(
|
||||
"thinking-step",
|
||||
{
|
||||
"id": step_id,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"items": items or [],
|
||||
},
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Error Part
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ def generate_content_podcast_task(
|
|||
self,
|
||||
source_content: str,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
podcast_title: str = "SurfSense Podcast",
|
||||
user_prompt: str | None = None,
|
||||
) -> dict:
|
||||
|
|
@ -77,7 +76,6 @@ def generate_content_podcast_task(
|
|||
Args:
|
||||
source_content: The text content to convert into a podcast
|
||||
search_space_id: ID of the search space
|
||||
user_id: ID of the user (as string)
|
||||
podcast_title: Title for the podcast
|
||||
user_prompt: Optional instructions for podcast style/tone
|
||||
|
||||
|
|
@ -92,7 +90,6 @@ def generate_content_podcast_task(
|
|||
_generate_content_podcast(
|
||||
source_content,
|
||||
search_space_id,
|
||||
user_id,
|
||||
podcast_title,
|
||||
user_prompt,
|
||||
)
|
||||
|
|
@ -112,7 +109,6 @@ def generate_content_podcast_task(
|
|||
async def _generate_content_podcast(
|
||||
source_content: str,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
podcast_title: str = "SurfSense Podcast",
|
||||
user_prompt: str | None = None,
|
||||
) -> dict:
|
||||
|
|
@ -123,7 +119,6 @@ async def _generate_content_podcast(
|
|||
graph_config = {
|
||||
"configurable": {
|
||||
"podcast_title": podcast_title,
|
||||
"user_id": str(user_id),
|
||||
"search_space_id": search_space_id,
|
||||
"user_prompt": user_prompt,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ Data Stream Protocol (SSE format).
|
|||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -42,7 +40,6 @@ def format_attachments_as_context(attachments: list[ChatAttachment]) -> str:
|
|||
|
||||
async def stream_new_chat(
|
||||
user_query: str,
|
||||
user_id: str | UUID,
|
||||
search_space_id: int,
|
||||
chat_id: int,
|
||||
session: AsyncSession,
|
||||
|
|
@ -59,7 +56,6 @@ async def stream_new_chat(
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID (can be UUID object or string)
|
||||
search_space_id: The search space ID
|
||||
chat_id: The chat ID (used as LangGraph thread_id for memory)
|
||||
session: The database session
|
||||
|
|
@ -71,9 +67,6 @@ async def stream_new_chat(
|
|||
"""
|
||||
streaming_service = VercelStreamingService()
|
||||
|
||||
# Convert UUID to string if needed
|
||||
str(user_id) if isinstance(user_id, UUID) else user_id
|
||||
|
||||
# Track the current text block for streaming (defined early for exception handling)
|
||||
current_text_id: str | None = None
|
||||
|
||||
|
|
@ -107,8 +100,6 @@ async def stream_new_chat(
|
|||
db_session=session,
|
||||
connector_service=connector_service,
|
||||
checkpointer=checkpointer,
|
||||
user_id=str(user_id),
|
||||
enable_podcast=True,
|
||||
)
|
||||
|
||||
# Build input with message history from frontend
|
||||
|
|
@ -154,6 +145,49 @@ async def stream_new_chat(
|
|||
# Reset text tracking for this stream
|
||||
accumulated_text = ""
|
||||
|
||||
# Track thinking steps for chain-of-thought display
|
||||
thinking_step_counter = 0
|
||||
# Map run_id -> step_id for tool calls so we can update them on completion
|
||||
tool_step_ids: dict[str, str] = {}
|
||||
# Track the last active step so we can mark it complete at the end
|
||||
last_active_step_id: str | None = None
|
||||
last_active_step_title: str = ""
|
||||
last_active_step_items: list[str] = []
|
||||
# Track which steps have been completed to avoid duplicate completions
|
||||
completed_step_ids: set[str] = set()
|
||||
# Track if we just finished a tool (text flows silently after tools)
|
||||
just_finished_tool: bool = False
|
||||
|
||||
def next_thinking_step_id() -> str:
|
||||
nonlocal thinking_step_counter
|
||||
thinking_step_counter += 1
|
||||
return f"thinking-{thinking_step_counter}"
|
||||
|
||||
def complete_current_step() -> str | None:
|
||||
"""Complete the current active step and return the completion event, if any."""
|
||||
nonlocal last_active_step_id, last_active_step_title, last_active_step_items
|
||||
if last_active_step_id and last_active_step_id not in completed_step_ids:
|
||||
completed_step_ids.add(last_active_step_id)
|
||||
return streaming_service.format_thinking_step(
|
||||
step_id=last_active_step_id,
|
||||
title=last_active_step_title,
|
||||
status="completed",
|
||||
items=last_active_step_items if last_active_step_items else None,
|
||||
)
|
||||
return None
|
||||
|
||||
# Initial thinking step - analyzing the request
|
||||
analyze_step_id = next_thinking_step_id()
|
||||
last_active_step_id = analyze_step_id
|
||||
last_active_step_title = "Understanding your request"
|
||||
last_active_step_items = [f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}"]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=analyze_step_id,
|
||||
title="Understanding your request",
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
|
||||
# Stream the agent response with thread config for memory
|
||||
async for event in agent.astream_events(
|
||||
input_state, config=config, version="v2"
|
||||
|
|
@ -168,6 +202,18 @@ async def stream_new_chat(
|
|||
if content and isinstance(content, str):
|
||||
# Start a new text block if needed
|
||||
if current_text_id is None:
|
||||
# Complete any previous step
|
||||
completion_event = complete_current_step()
|
||||
if completion_event:
|
||||
yield completion_event
|
||||
|
||||
if just_finished_tool:
|
||||
# Clear the active step tracking - text flows without a dedicated step
|
||||
last_active_step_id = None
|
||||
last_active_step_title = ""
|
||||
last_active_step_items = []
|
||||
just_finished_tool = False
|
||||
|
||||
current_text_id = streaming_service.generate_text_id()
|
||||
yield streaming_service.format_text_start(current_text_id)
|
||||
|
||||
|
|
@ -188,6 +234,116 @@ async def stream_new_chat(
|
|||
yield streaming_service.format_text_end(current_text_id)
|
||||
current_text_id = None
|
||||
|
||||
# Complete any previous step EXCEPT "Synthesizing response"
|
||||
# (we want to reuse the Synthesizing step after tools complete)
|
||||
if last_active_step_title != "Synthesizing response":
|
||||
completion_event = complete_current_step()
|
||||
if completion_event:
|
||||
yield completion_event
|
||||
|
||||
# Reset the just_finished_tool flag since we're starting a new tool
|
||||
just_finished_tool = False
|
||||
|
||||
# Create thinking step for the tool call and store it for later update
|
||||
tool_step_id = next_thinking_step_id()
|
||||
tool_step_ids[run_id] = tool_step_id
|
||||
last_active_step_id = tool_step_id
|
||||
if tool_name == "search_knowledge_base":
|
||||
query = (
|
||||
tool_input.get("query", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else str(tool_input)
|
||||
)
|
||||
last_active_step_title = "Searching knowledge base"
|
||||
last_active_step_items = [f"Query: {query[:100]}{'...' if len(query) > 100 else ''}"]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=tool_step_id,
|
||||
title="Searching knowledge base",
|
||||
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 == "display_image":
|
||||
src = (
|
||||
tool_input.get("src", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else str(tool_input)
|
||||
)
|
||||
title = (
|
||||
tool_input.get("title", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else ""
|
||||
)
|
||||
last_active_step_title = "Displaying image"
|
||||
last_active_step_items = [
|
||||
f"Image: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}"
|
||||
]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=tool_step_id,
|
||||
title="Displaying image",
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
elif tool_name == "scrape_webpage":
|
||||
url = (
|
||||
tool_input.get("url", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else str(tool_input)
|
||||
)
|
||||
last_active_step_title = "Scraping webpage"
|
||||
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="Scraping webpage",
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
elif tool_name == "generate_podcast":
|
||||
podcast_title = (
|
||||
tool_input.get("podcast_title", "SurfSense Podcast")
|
||||
if isinstance(tool_input, dict)
|
||||
else "SurfSense Podcast"
|
||||
)
|
||||
# Get content length for context
|
||||
content_len = len(
|
||||
tool_input.get("source_content", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else ""
|
||||
)
|
||||
last_active_step_title = "Generating podcast"
|
||||
last_active_step_items = [
|
||||
f"Title: {podcast_title}",
|
||||
f"Content: {content_len:,} characters",
|
||||
"Preparing audio generation...",
|
||||
]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=tool_step_id,
|
||||
title="Generating podcast",
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
else:
|
||||
last_active_step_title = f"Using {tool_name.replace('_', ' ')}"
|
||||
last_active_step_items = []
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=tool_step_id,
|
||||
title=last_active_step_title,
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
# Stream tool info
|
||||
tool_call_id = (
|
||||
f"call_{run_id[:32]}"
|
||||
|
|
@ -214,6 +370,36 @@ 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 == "display_image":
|
||||
src = (
|
||||
tool_input.get("src", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else str(tool_input)
|
||||
)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Displaying image: {src[:60]}{'...' if len(src) > 60 else ''}",
|
||||
"info",
|
||||
)
|
||||
elif tool_name == "scrape_webpage":
|
||||
url = (
|
||||
tool_input.get("url", "")
|
||||
if isinstance(tool_input, dict)
|
||||
else str(tool_input)
|
||||
)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Scraping webpage: {url[:70]}{'...' if len(url) > 70 else ''}",
|
||||
"info",
|
||||
)
|
||||
elif tool_name == "generate_podcast":
|
||||
title = (
|
||||
tool_input.get("podcast_title", "SurfSense Podcast")
|
||||
|
|
@ -254,6 +440,155 @@ async def stream_new_chat(
|
|||
|
||||
tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown"
|
||||
|
||||
# Get the original tool step ID to update it (not create a new one)
|
||||
original_step_id = tool_step_ids.get(run_id, f"thinking-unknown-{run_id[:8]}")
|
||||
|
||||
# Mark the tool thinking step as completed using the SAME step ID
|
||||
# Also add to completed set so we don't try to complete it again
|
||||
completed_step_ids.add(original_step_id)
|
||||
if tool_name == "search_knowledge_base":
|
||||
# Get result count if available
|
||||
result_info = "Search completed"
|
||||
if isinstance(tool_output, dict):
|
||||
result_len = tool_output.get("result_length", 0)
|
||||
if result_len > 0:
|
||||
result_info = f"Found relevant information ({result_len} chars)"
|
||||
# Include original query in completed items
|
||||
completed_items = [*last_active_step_items, result_info]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title="Searching knowledge base",
|
||||
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 == "display_image":
|
||||
# Build completion items for image display
|
||||
if isinstance(tool_output, dict):
|
||||
title = tool_output.get("title", "")
|
||||
alt = tool_output.get("alt", "Image")
|
||||
display_name = title or alt
|
||||
completed_items = [
|
||||
*last_active_step_items,
|
||||
f"Showing: {display_name[:50]}{'...' if len(display_name) > 50 else ''}",
|
||||
]
|
||||
else:
|
||||
completed_items = [*last_active_step_items, "Image displayed"]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title="Displaying image",
|
||||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
elif tool_name == "scrape_webpage":
|
||||
# Build completion items for webpage scraping
|
||||
if isinstance(tool_output, dict):
|
||||
title = tool_output.get("title", "Webpage")
|
||||
word_count = tool_output.get("word_count", 0)
|
||||
has_error = "error" in tool_output
|
||||
if has_error:
|
||||
completed_items = [
|
||||
*last_active_step_items,
|
||||
f"Error: {tool_output.get('error', 'Failed to scrape')[:50]}",
|
||||
]
|
||||
else:
|
||||
completed_items = [
|
||||
*last_active_step_items,
|
||||
f"Title: {title[:50]}{'...' if len(title) > 50 else ''}",
|
||||
f"Extracted: {word_count:,} words",
|
||||
]
|
||||
else:
|
||||
completed_items = [*last_active_step_items, "Content extracted"]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title="Scraping webpage",
|
||||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
elif tool_name == "generate_podcast":
|
||||
# Build detailed completion items based on podcast status
|
||||
podcast_status = (
|
||||
tool_output.get("status", "unknown")
|
||||
if isinstance(tool_output, dict)
|
||||
else "unknown"
|
||||
)
|
||||
podcast_title = (
|
||||
tool_output.get("title", "Podcast")
|
||||
if isinstance(tool_output, dict)
|
||||
else "Podcast"
|
||||
)
|
||||
|
||||
if podcast_status == "processing":
|
||||
completed_items = [
|
||||
f"Title: {podcast_title}",
|
||||
"Audio generation started",
|
||||
"Processing in background...",
|
||||
]
|
||||
elif podcast_status == "already_generating":
|
||||
completed_items = [
|
||||
f"Title: {podcast_title}",
|
||||
"Podcast already in progress",
|
||||
"Please wait for it to complete",
|
||||
]
|
||||
elif podcast_status == "error":
|
||||
error_msg = (
|
||||
tool_output.get("error", "Unknown error")
|
||||
if isinstance(tool_output, dict)
|
||||
else "Unknown error"
|
||||
)
|
||||
completed_items = [
|
||||
f"Title: {podcast_title}",
|
||||
f"Error: {error_msg[:50]}",
|
||||
]
|
||||
else:
|
||||
completed_items = last_active_step_items
|
||||
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title="Generating podcast",
|
||||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
else:
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title=f"Using {tool_name.replace('_', ' ')}",
|
||||
status="completed",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
|
||||
# Mark that we just finished a tool - "Synthesizing response" will be created
|
||||
# when text actually starts flowing (not immediately)
|
||||
just_finished_tool = True
|
||||
# Clear the active step since the tool is done
|
||||
last_active_step_id = None
|
||||
last_active_step_title = ""
|
||||
last_active_step_items = []
|
||||
|
||||
# Handle different tool outputs
|
||||
if tool_name == "generate_podcast":
|
||||
# Stream the full podcast result so frontend can render the audio player
|
||||
|
|
@ -282,8 +617,89 @@ 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 == "display_image":
|
||||
# Stream the full image result so frontend can render the Image component
|
||||
yield streaming_service.format_tool_output_available(
|
||||
tool_call_id,
|
||||
tool_output
|
||||
if isinstance(tool_output, dict)
|
||||
else {"result": tool_output},
|
||||
)
|
||||
# Send terminal message
|
||||
if isinstance(tool_output, dict):
|
||||
title = tool_output.get("title") or tool_output.get("alt", "Image")
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Image displayed: {title[:40]}{'...' if len(title) > 40 else ''}",
|
||||
"success",
|
||||
)
|
||||
elif tool_name == "scrape_webpage":
|
||||
# Stream the scrape result so frontend can render the Article component
|
||||
# Note: We send metadata for display, but content goes to LLM for processing
|
||||
if isinstance(tool_output, dict):
|
||||
# Create a display-friendly output (without full content for the card)
|
||||
display_output = {
|
||||
k: v for k, v in tool_output.items() if k != "content"
|
||||
}
|
||||
# But keep a truncated content preview
|
||||
if "content" in tool_output:
|
||||
content = tool_output.get("content", "")
|
||||
display_output["content_preview"] = (
|
||||
content[:500] + "..." if len(content) > 500 else content
|
||||
)
|
||||
yield streaming_service.format_tool_output_available(
|
||||
tool_call_id,
|
||||
display_output,
|
||||
)
|
||||
else:
|
||||
yield streaming_service.format_tool_output_available(
|
||||
tool_call_id,
|
||||
{"result": tool_output},
|
||||
)
|
||||
# Send terminal message
|
||||
if isinstance(tool_output, dict) and "error" not in tool_output:
|
||||
title = tool_output.get("title", "Webpage")
|
||||
word_count = tool_output.get("word_count", 0)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Scraped: {title[:40]}{'...' if len(title) > 40 else ''} ({word_count:,} words)",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
tool_output.get("error", "Failed to scrape")
|
||||
if isinstance(tool_output, dict)
|
||||
else "Failed to scrape"
|
||||
)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Scrape 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))},
|
||||
|
|
@ -291,6 +707,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"):
|
||||
|
|
@ -302,6 +727,11 @@ async def stream_new_chat(
|
|||
if current_text_id is not None:
|
||||
yield streaming_service.format_text_end(current_text_id)
|
||||
|
||||
# Mark the last active thinking step as completed using the same title
|
||||
completion_event = complete_current_step()
|
||||
if completion_event:
|
||||
yield completion_event
|
||||
|
||||
# Finish the step and message
|
||||
yield streaming_service.format_finish_step()
|
||||
yield streaming_service.format_finish()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ 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 { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
import {
|
||||
|
|
@ -25,8 +29,26 @@ import {
|
|||
type MessageRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
|
||||
/**
|
||||
* Extract thinking steps from message content
|
||||
*/
|
||||
function extractThinkingSteps(content: unknown): ThinkingStep[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const thinkingPart = content.find(
|
||||
(part: unknown) =>
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
(part as { type: string }).type === "thinking-steps"
|
||||
) as { type: "thinking-steps"; steps: ThinkingStep[] } | undefined;
|
||||
|
||||
return thinkingPart?.steps || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend message to assistant-ui ThreadMessageLike format
|
||||
* Filters out 'thinking-steps' part as it's handled separately
|
||||
*/
|
||||
function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||
let content: ThreadMessageLike["content"];
|
||||
|
|
@ -34,7 +56,17 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
if (typeof msg.content === "string") {
|
||||
content = [{ type: "text", text: msg.content }];
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content as ThreadMessageLike["content"];
|
||||
// Filter out thinking-steps part - it's handled separately via messageThinkingSteps
|
||||
const filteredContent = msg.content.filter(
|
||||
(part: unknown) =>
|
||||
!(typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
(part as { type: string }).type === "thinking-steps")
|
||||
);
|
||||
content = filteredContent.length > 0
|
||||
? (filteredContent as ThreadMessageLike["content"])
|
||||
: [{ type: "text", text: "" }];
|
||||
} else {
|
||||
content = [{ type: "text", text: String(msg.content) }];
|
||||
}
|
||||
|
|
@ -50,7 +82,17 @@ 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", "display_image", "scrape_webpage"]);
|
||||
|
||||
/**
|
||||
* Type for thinking step data from the backend
|
||||
*/
|
||||
interface ThinkingStepData {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -59,6 +101,10 @@ export default function NewChatPage() {
|
|||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
// Store thinking steps per message ID
|
||||
const [messageThinkingSteps, setMessageThinkingSteps] = useState<
|
||||
Map<string, ThinkingStep[]>
|
||||
>(new Map());
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Create the attachment adapter for file processing
|
||||
|
|
@ -95,6 +141,20 @@ export default function NewChatPage() {
|
|||
if (response.messages && response.messages.length > 0) {
|
||||
const loadedMessages = response.messages.map(convertToThreadMessage);
|
||||
setMessages(loadedMessages);
|
||||
|
||||
// Extract and restore thinking steps from persisted messages
|
||||
const restoredThinkingSteps = new Map<string, ThinkingStep[]>();
|
||||
for (const msg of response.messages) {
|
||||
if (msg.role === "assistant") {
|
||||
const steps = extractThinkingSteps(msg.content);
|
||||
if (steps.length > 0) {
|
||||
restoredThinkingSteps.set(`msg-${msg.id}`, steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (restoredThinkingSteps.size > 0) {
|
||||
setMessageThinkingSteps(restoredThinkingSteps);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new thread
|
||||
|
|
@ -186,46 +246,99 @@ export default function NewChatPage() {
|
|||
|
||||
// Prepare assistant message
|
||||
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
||||
let accumulatedText = "";
|
||||
const toolCalls = new Map<
|
||||
string,
|
||||
{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
||||
|
||||
// Ordered content parts to preserve inline tool call positions
|
||||
// Each part is either a text segment or a tool call
|
||||
type ContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool-call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
};
|
||||
const contentParts: ContentPart[] = [];
|
||||
|
||||
// Track the current text segment index (for appending text deltas)
|
||||
let currentTextPartIndex = -1;
|
||||
|
||||
// Map to track tool call indices for updating results
|
||||
const toolCallIndices = new Map<string, number>();
|
||||
|
||||
// Helper to get or create the current text part for appending text
|
||||
const appendText = (delta: string) => {
|
||||
if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") {
|
||||
// Append to existing text part
|
||||
(contentParts[currentTextPartIndex] as { type: "text"; text: string }).text += delta;
|
||||
} else {
|
||||
// Create new text part
|
||||
contentParts.push({ type: "text", text: delta });
|
||||
currentTextPartIndex = contentParts.length - 1;
|
||||
}
|
||||
>();
|
||||
};
|
||||
|
||||
// Helper to add a tool call (this "breaks" the current text segment)
|
||||
const addToolCall = (toolCallId: string, toolName: string, args: Record<string, unknown>) => {
|
||||
if (TOOLS_WITH_UI.has(toolName)) {
|
||||
contentParts.push({
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
});
|
||||
toolCallIndices.set(toolCallId, contentParts.length - 1);
|
||||
// Reset text part index so next text creates a new segment
|
||||
currentTextPartIndex = -1;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to update a tool call's args or result
|
||||
const updateToolCall = (toolCallId: string, update: { args?: Record<string, unknown>; result?: unknown }) => {
|
||||
const index = toolCallIndices.get(toolCallId);
|
||||
if (index !== undefined && contentParts[index]?.type === "tool-call") {
|
||||
const tc = contentParts[index] as ContentPart & { type: "tool-call" };
|
||||
if (update.args) tc.args = update.args;
|
||||
if (update.result !== undefined) tc.result = update.result;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to build content
|
||||
const buildContent = (): ThreadMessageLike["content"] => {
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool-call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
> = [];
|
||||
if (accumulatedText) {
|
||||
parts.push({ type: "text", text: accumulatedText });
|
||||
// Helper to build content for UI (without thinking-steps)
|
||||
const buildContentForUI = (): ThreadMessageLike["content"] => {
|
||||
// Filter to only include text parts with content and tool-calls with UI
|
||||
const filtered = contentParts.filter((part) => {
|
||||
if (part.type === "text") return part.text.length > 0;
|
||||
if (part.type === "tool-call") return TOOLS_WITH_UI.has(part.toolName);
|
||||
return false;
|
||||
});
|
||||
return filtered.length > 0
|
||||
? (filtered as ThreadMessageLike["content"])
|
||||
: [{ type: "text", text: "" }];
|
||||
};
|
||||
|
||||
// Helper to build content for persistence (includes thinking-steps)
|
||||
const buildContentForPersistence = (): unknown[] => {
|
||||
const parts: unknown[] = [];
|
||||
|
||||
// Include thinking steps for persistence
|
||||
if (currentThinkingSteps.size > 0) {
|
||||
parts.push({
|
||||
type: "thinking-steps",
|
||||
steps: Array.from(currentThinkingSteps.values()),
|
||||
});
|
||||
}
|
||||
for (const toolCall of toolCalls.values()) {
|
||||
if (TOOLS_WITH_UI.has(toolCall.toolName)) {
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
args: toolCall.args,
|
||||
result: toolCall.result,
|
||||
});
|
||||
|
||||
// Add content parts (filtered)
|
||||
for (const part of contentParts) {
|
||||
if (part.type === "text" && part.text.length > 0) {
|
||||
parts.push(part);
|
||||
} else if (part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return parts.length > 0
|
||||
? (parts as ThreadMessageLike["content"])
|
||||
: [{ type: "text", text: "" }];
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
|
||||
};
|
||||
|
||||
// Add placeholder assistant message
|
||||
|
|
@ -309,64 +422,79 @@ export default function NewChatPage() {
|
|||
|
||||
switch (parsed.type) {
|
||||
case "text-delta":
|
||||
accumulatedText += parsed.delta;
|
||||
appendText(parsed.delta);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, content: buildContent() } : m
|
||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
toolCalls.set(parsed.toolCallId, {
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
args: {},
|
||||
});
|
||||
// Add tool call inline - this breaks the current text segment
|
||||
addToolCall(parsed.toolCallId, parsed.toolName, {});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, content: buildContent() } : m
|
||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case "tool-input-available": {
|
||||
const tc = toolCalls.get(parsed.toolCallId);
|
||||
if (tc) tc.args = parsed.input || {};
|
||||
else
|
||||
toolCalls.set(parsed.toolCallId, {
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
args: parsed.input || {},
|
||||
});
|
||||
// Update existing tool call's args, or add if not exists
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(parsed.toolCallId, { args: parsed.input || {} });
|
||||
} else {
|
||||
addToolCall(parsed.toolCallId, parsed.toolName, parsed.input || {});
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, content: buildContent() } : m
|
||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-output-available": {
|
||||
const tc = toolCalls.get(parsed.toolCallId);
|
||||
if (tc) {
|
||||
tc.result = parsed.output;
|
||||
if (
|
||||
tc.toolName === "generate_podcast" &&
|
||||
parsed.output?.status === "processing" &&
|
||||
parsed.output?.task_id
|
||||
) {
|
||||
setActivePodcastTaskId(parsed.output.task_id);
|
||||
// Update the tool call with its result
|
||||
updateToolCall(parsed.toolCallId, { result: parsed.output });
|
||||
// Handle podcast-specific logic
|
||||
if (parsed.output?.status === "processing" && parsed.output?.task_id) {
|
||||
// Check if this is a podcast tool by looking at the content part
|
||||
const idx = toolCallIndices.get(parsed.toolCallId);
|
||||
if (idx !== undefined) {
|
||||
const part = contentParts[idx];
|
||||
if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
|
||||
setActivePodcastTaskId(parsed.output.task_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, content: buildContent() } : m
|
||||
m.id === assistantMsgId ? { ...m, content: buildContentForUI() } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-thinking-step": {
|
||||
// Handle thinking step events for chain-of-thought display
|
||||
const stepData = parsed.data as ThinkingStepData;
|
||||
if (stepData?.id) {
|
||||
currentThinkingSteps.set(stepData.id, stepData);
|
||||
// Update message-specific thinking steps
|
||||
setMessageThinkingSteps((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(
|
||||
assistantMsgId,
|
||||
Array.from(currentThinkingSteps.values())
|
||||
);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw new Error(parsed.errorText || "Server error");
|
||||
}
|
||||
|
|
@ -381,9 +509,9 @@ export default function NewChatPage() {
|
|||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Persist assistant message
|
||||
const finalContent = buildContent();
|
||||
if (accumulatedText || toolCalls.size > 0) {
|
||||
// Persist assistant message (with thinking steps for restoration on refresh)
|
||||
const finalContent = buildContentForPersistence();
|
||||
if (contentParts.length > 0) {
|
||||
appendMessage(threadId, {
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
|
|
@ -415,6 +543,7 @@ export default function NewChatPage() {
|
|||
} finally {
|
||||
setIsRunning(false);
|
||||
abortControllerRef.current = null;
|
||||
// Note: We no longer clear thinking steps - they persist with the message
|
||||
}
|
||||
},
|
||||
[threadId, searchSpaceId, messages]
|
||||
|
|
@ -482,8 +611,11 @@ export default function NewChatPage() {
|
|||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<GeneratePodcastToolUI />
|
||||
<LinkPreviewToolUI />
|
||||
<DisplayImageToolUI />
|
||||
<ScrapeWebpageToolUI />
|
||||
<div className="h-[calc(100vh-64px)] max-h-[calc(100vh-64px)] overflow-hidden">
|
||||
<Thread />
|
||||
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import {
|
|||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAssistantState,
|
||||
useMessage,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
|
|
@ -18,11 +21,23 @@ import {
|
|||
DownloadIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
Plug2,
|
||||
Plus,
|
||||
RefreshCwIcon,
|
||||
Search,
|
||||
Sparkles,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { type FC, useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
ComposerAddAttachment,
|
||||
|
|
@ -32,41 +47,203 @@ import {
|
|||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtItem,
|
||||
ChainOfThoughtStep,
|
||||
ChainOfThoughtTrigger,
|
||||
} from "@/components/prompt-kit/chain-of-thought";
|
||||
import { DocumentsDataTable } from "@/components/new-chat/DocumentsDataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
|
||||
export const Thread: FC = () => {
|
||||
/**
|
||||
* Props for the Thread component
|
||||
*/
|
||||
interface ThreadProps {
|
||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||
}
|
||||
|
||||
// Context to pass thinking steps to AssistantMessage
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const ThinkingStepsContext = createContext<Map<string, ThinkingStep[]>>(new Map());
|
||||
|
||||
/**
|
||||
* Get icon based on step status and title
|
||||
*/
|
||||
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2 className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" />;
|
||||
}
|
||||
|
||||
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
|
||||
return <Search className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (titleLower.includes("analy") || titleLower.includes("understand")) {
|
||||
return <Brain className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return <Sparkles className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain of thought display component with smart expand/collapse behavior
|
||||
*/
|
||||
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({ steps, isThreadRunning = true }) => {
|
||||
// Track which steps the user has manually toggled (overrides auto behavior)
|
||||
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
|
||||
// Track previous step statuses to detect changes
|
||||
const prevStatusesRef = useRef<Record<string, string>>({});
|
||||
|
||||
// Derive effective status: if thread stopped and step is in_progress, treat as completed
|
||||
const getEffectiveStatus = (step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
return "completed"; // Thread was stopped, so mark as completed
|
||||
}
|
||||
return step.status;
|
||||
};
|
||||
|
||||
// Check if any step is effectively in progress
|
||||
const hasInProgressStep = steps.some(step => getEffectiveStatus(step) === "in_progress");
|
||||
|
||||
// Find the last completed step index (using effective status)
|
||||
const lastCompletedIndex = steps
|
||||
.map((s, i) => getEffectiveStatus(s) === "completed" ? i : -1)
|
||||
.filter(i => i !== -1)
|
||||
.pop();
|
||||
|
||||
// Clear manual overrides when a step's status changes
|
||||
useEffect(() => {
|
||||
const currentStatuses: Record<string, string> = {};
|
||||
steps.forEach(step => {
|
||||
currentStatuses[step.id] = step.status;
|
||||
// If status changed, clear any manual override for this step
|
||||
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
|
||||
setManualOverrides(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[step.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
prevStatusesRef.current = currentStatuses;
|
||||
}, [steps]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
// If user has manually toggled, respect that
|
||||
if (manualOverrides[step.id] !== undefined) {
|
||||
return manualOverrides[step.id];
|
||||
}
|
||||
// Auto behavior: open if in progress
|
||||
if (effectiveStatus === "in_progress") {
|
||||
return true;
|
||||
}
|
||||
// Auto behavior: keep last completed step open if no in-progress step
|
||||
if (!hasInProgressStep && index === lastCompletedIndex) {
|
||||
return true;
|
||||
}
|
||||
// Default: collapsed
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleToggle = (stepId: string, currentOpen: boolean) => {
|
||||
setManualOverrides(prev => ({
|
||||
...prev,
|
||||
[stepId]: !currentOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<ChainOfThought>
|
||||
{steps.map((step, index) => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
const icon = getStepIcon(effectiveStatus, step.title);
|
||||
const isOpen = getStepOpenState(step, index);
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
open={isOpen}
|
||||
onOpenChange={() => handleToggle(step.id, isOpen)}
|
||||
>
|
||||
<ChainOfThoughtTrigger
|
||||
leftIcon={icon}
|
||||
swapIconOnHover={effectiveStatus !== "in_progress"}
|
||||
className={cn(
|
||||
effectiveStatus === "in_progress" && "text-foreground font-medium",
|
||||
effectiveStatus === "completed" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</ChainOfThoughtTrigger>
|
||||
{step.items && step.items.length > 0 && (
|
||||
<ChainOfThoughtContent>
|
||||
{step.items.map((item, idx) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${idx}`}>
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
})}
|
||||
</ChainOfThought>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) => {
|
||||
return (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
>
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadScrollToBottom />
|
||||
<Composer />
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadScrollToBottom />
|
||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
<Composer />
|
||||
</div>
|
||||
</AssistantIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
</ThinkingStepsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -84,62 +261,90 @@ const ThreadScrollToBottom: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ThreadWelcome: FC = () => {
|
||||
return (
|
||||
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
|
||||
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
|
||||
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-semibold text-2xl duration-200">
|
||||
Hello there!
|
||||
</h1>
|
||||
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-xl delay-75 duration-200">
|
||||
How can I help you today?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThreadSuggestions />
|
||||
</div>
|
||||
);
|
||||
const getTimeBasedGreeting = (userEmail?: string): string => {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
// Extract first name from email if available
|
||||
const firstName = userEmail
|
||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
||||
: null;
|
||||
|
||||
// Array of greeting variations for each time period
|
||||
const morningGreetings = [
|
||||
"Good morning",
|
||||
"Rise and shine",
|
||||
"Morning",
|
||||
"Hey there",
|
||||
];
|
||||
|
||||
const afternoonGreetings = [
|
||||
"Good afternoon",
|
||||
"Afternoon",
|
||||
"Hey there",
|
||||
"Hi there",
|
||||
];
|
||||
|
||||
const eveningGreetings = [
|
||||
"Good evening",
|
||||
"Evening",
|
||||
"Hey there",
|
||||
"Hi there",
|
||||
];
|
||||
|
||||
const nightGreetings = [
|
||||
"Good night",
|
||||
"Evening",
|
||||
"Hey there",
|
||||
"Winding down",
|
||||
];
|
||||
|
||||
const lateNightGreetings = [
|
||||
"Still up",
|
||||
"Night owl mode",
|
||||
"The night is young",
|
||||
"Hi there",
|
||||
];
|
||||
|
||||
// Select a random greeting based on time
|
||||
let greeting: string;
|
||||
if (hour < 5) {
|
||||
// Late night: midnight to 5 AM
|
||||
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
||||
} else if (hour < 12) {
|
||||
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
||||
} else if (hour < 18) {
|
||||
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
||||
} else if (hour < 22) {
|
||||
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
||||
} else {
|
||||
// Night: 10 PM to midnight
|
||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
||||
}
|
||||
|
||||
// Add personalization with first name if available
|
||||
if (firstName) {
|
||||
return `${greeting}, ${firstName}!`;
|
||||
}
|
||||
|
||||
return `${greeting}!`;
|
||||
};
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
title: "What's the weather",
|
||||
label: "in San Francisco?",
|
||||
prompt: "What's the weather in San Francisco?",
|
||||
},
|
||||
{
|
||||
title: "Explain React hooks",
|
||||
label: "like useState and useEffect",
|
||||
prompt: "Explain React hooks like useState and useEffect",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ThreadSuggestions: FC = () => {
|
||||
const ThreadWelcome: FC = () => {
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
|
||||
return (
|
||||
<div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">
|
||||
{SUGGESTIONS.map((suggestion, index) => (
|
||||
<div
|
||||
key={suggestion.prompt}
|
||||
className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200"
|
||||
style={{ animationDelay: `${100 + index * 50}ms` }}
|
||||
>
|
||||
<ThreadPrimitive.Suggestion prompt={suggestion.prompt} autoSend asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-2xl border px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
|
||||
aria-label={suggestion.prompt}
|
||||
>
|
||||
<span className="aui-thread-welcome-suggestion-text-1 font-medium">
|
||||
{suggestion.title}
|
||||
</span>
|
||||
<span className="aui-thread-welcome-suggestion-text-2 text-muted-foreground">
|
||||
{suggestion.label}
|
||||
</span>
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
</div>
|
||||
))}
|
||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||
{/* Greeting positioned above the composer - fixed position */}
|
||||
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center z-10">
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-5xl delay-100 duration-500 ease-out fill-mode-both">
|
||||
{getTimeBasedGreeting(user?.email)}
|
||||
</h1>
|
||||
</div>
|
||||
{/* Composer - top edge fixed, expands downward only */}
|
||||
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
||||
<Composer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -217,7 +422,7 @@ const Composer: FC = () => {
|
|||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border border-input bg-background px-1 pt-2 outline-none transition-shadow has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
<ComposerAttachments />
|
||||
{/* -------- Input field w/ refs and handlers -------- */}
|
||||
<ComposerPrimitive.Input
|
||||
|
|
@ -225,7 +430,7 @@ const Composer: FC = () => {
|
|||
value={inputValue}
|
||||
onInput={handleInputOrKeyUp}
|
||||
onKeyUp={handleInputOrKeyUp}
|
||||
placeholder="Send a message..."
|
||||
placeholder="Ask SurfSense"
|
||||
className="aui-composer-input mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
rows={1}
|
||||
autoFocus
|
||||
|
|
@ -269,6 +474,147 @@ const Composer: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(false, searchSpaceId ? Number(searchSpaceId) : undefined);
|
||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
// Clear any pending close timeout
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Delay closing by 150ms for better UX
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none",
|
||||
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
aria-label={hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector"}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plug2 className="size-4" />
|
||||
{totalSourceCount > 0 ? (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
||||
{totalSourceCount > 99 ? "99+" : totalSourceCount}
|
||||
</span>
|
||||
) : (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center size-3 rounded-full bg-muted-foreground/30 border border-background">
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/60" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-64 p-3"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hasSources ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Connected Sources
|
||||
</p>
|
||||
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{totalSourceCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Document types from the search space */}
|
||||
{activeDocumentTypes.map(([docType, count]) => (
|
||||
<div
|
||||
key={docType}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
|
||||
>
|
||||
{getConnectorIcon(docType, "size-3.5")}
|
||||
<span className="truncate max-w-[100px]">{getDocumentTypeLabel(docType)}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Search source connectors */}
|
||||
{connectors.map((connector) => (
|
||||
<div
|
||||
key={`connector-${connector.id}`}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
|
||||
>
|
||||
{getConnectorIcon(connector.connector_type, "size-3.5")}
|
||||
<span className="truncate max-w-[100px]">{connector.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-1 border-t border-border/50">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors`}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plug2 className="size-3" />
|
||||
Manage connectors
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">No sources yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add documents or connect data sources to enhance search results.
|
||||
</p>
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add Connector
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerAction: FC = () => {
|
||||
// Check if any attachments are still being processed (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
|
|
@ -281,9 +627,20 @@ const ComposerAction: FC = () => {
|
|||
})
|
||||
);
|
||||
|
||||
// Check if composer text is empty
|
||||
const isComposerEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<ComposerAddAttachment />
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerAddAttachment />
|
||||
<ConnectorIndicator />
|
||||
</div>
|
||||
|
||||
{/* Show processing indicator when attachments are being processed */}
|
||||
{hasProcessingAttachments && (
|
||||
|
|
@ -294,19 +651,25 @@ const ComposerAction: FC = () => {
|
|||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={hasProcessingAttachments}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={hasProcessingAttachments ? "Wait for attachments to process" : "Send message"}
|
||||
tooltip={
|
||||
hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"aui-composer-send size-8 rounded-full",
|
||||
hasProcessingAttachments && "cursor-not-allowed opacity-50"
|
||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
disabled={hasProcessingAttachments}
|
||||
disabled={isSendDisabled}
|
||||
>
|
||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||
</TooltipIconButton>
|
||||
|
|
@ -340,12 +703,25 @@ const MessageError: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
const AssistantMessageInner: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
|
||||
// Get the current message ID to look up thinking steps
|
||||
const messageId = useMessage((m) => m.id);
|
||||
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
|
||||
|
||||
// Check if thread is still running (for stopping the spinner when cancelled)
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<>
|
||||
{/* Show thinking steps BEFORE the text response */}
|
||||
{thinkingSteps.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isThreadRunning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
|
|
@ -360,6 +736,17 @@ const AssistantMessage: FC = () => {
|
|||
<BranchPicker />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
148
surfsense_web/components/prompt-kit/chain-of-thought.tsx
Normal file
148
surfsense_web/components/prompt-kit/chain-of-thought.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brain, ChevronDown, Circle, Loader2, Search, Sparkles, Lightbulb, CheckCircle2 } from "lucide-react"
|
||||
import React from "react"
|
||||
|
||||
export type ChainOfThoughtItemProps = React.ComponentProps<"div">
|
||||
|
||||
export const ChainOfThoughtItem = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChainOfThoughtItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ChainOfThoughtTriggerProps = React.ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
leftIcon?: React.ReactNode
|
||||
swapIconOnHover?: boolean
|
||||
}
|
||||
|
||||
export const ChainOfThoughtTrigger = ({
|
||||
children,
|
||||
className,
|
||||
leftIcon,
|
||||
swapIconOnHover = true,
|
||||
...props
|
||||
}: ChainOfThoughtTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group text-muted-foreground hover:text-foreground flex cursor-pointer items-center justify-start gap-1 text-left text-sm transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{leftIcon ? (
|
||||
<span className="relative inline-flex size-4 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
swapIconOnHover && "group-hover:opacity-0"
|
||||
)}
|
||||
>
|
||||
{leftIcon}
|
||||
</span>
|
||||
{swapIconOnHover && (
|
||||
<ChevronDown className="absolute size-4 opacity-0 transition-opacity group-hover:opacity-100 group-data-[state=open]:rotate-180" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative inline-flex size-4 items-center justify-center">
|
||||
<Circle className="size-2 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
{!leftIcon && (
|
||||
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
export type ChainOfThoughtContentProps = React.ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>
|
||||
|
||||
export const ChainOfThoughtContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChainOfThoughtContentProps) => {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"text-popover-foreground data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[min-content_minmax(0,1fr)] gap-x-4">
|
||||
<div className="bg-primary/20 ml-1.75 h-full w-px group-data-[last=true]:hidden" />
|
||||
<div className="ml-1.75 h-full w-px bg-transparent group-data-[last=false]:hidden" />
|
||||
<div className="mt-2 space-y-2">{children}</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
}
|
||||
|
||||
export type ChainOfThoughtProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChainOfThought({ children, className }: ChainOfThoughtProps) {
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-0", className)}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{React.isValidElement(child) &&
|
||||
React.cloneElement(
|
||||
child as React.ReactElement<ChainOfThoughtStepProps>,
|
||||
{
|
||||
isLast: index === childrenArray.length - 1,
|
||||
}
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ChainOfThoughtStepProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const ChainOfThoughtStep = ({
|
||||
children,
|
||||
className,
|
||||
isLast = false,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps & React.ComponentProps<typeof Collapsible>) => {
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("group", className)}
|
||||
data-last={isLast}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="flex justify-start group-data-[last=true]:hidden">
|
||||
<div className="bg-primary/20 ml-1.75 h-4 w-px" />
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
425
surfsense_web/components/tool-ui/article/index.tsx
Normal file
425
surfsense_web/components/tool-ui/article/index.tsx
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
BookOpenIcon,
|
||||
CalendarIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Component, type ReactNode, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for serializable article data (from backend)
|
||||
*/
|
||||
const SerializableArticleSchema = z.object({
|
||||
id: z.string().default("article-unknown"),
|
||||
assetId: z.string().optional(),
|
||||
kind: z.literal("article").optional(),
|
||||
title: z.string().default("Untitled Article"),
|
||||
description: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
href: z.string().url().optional(),
|
||||
domain: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
word_count: z.number().optional(),
|
||||
wordCount: z.number().optional(),
|
||||
was_truncated: z.boolean().optional(),
|
||||
wasTruncated: z.boolean().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serializable article data type (from backend)
|
||||
*/
|
||||
export type SerializableArticle = z.infer<typeof SerializableArticleSchema>;
|
||||
|
||||
/**
|
||||
* Article component props
|
||||
*/
|
||||
export interface ArticleProps {
|
||||
/** Unique identifier for the article */
|
||||
id: string;
|
||||
/** Asset identifier (usually the URL) */
|
||||
assetId?: string;
|
||||
/** Article title */
|
||||
title: string;
|
||||
/** Brief description or excerpt */
|
||||
description?: string;
|
||||
/** Full content of the article (markdown) */
|
||||
content?: string;
|
||||
/** URL to the original article */
|
||||
href?: string;
|
||||
/** Domain of the article source */
|
||||
domain?: string;
|
||||
/** Author name */
|
||||
author?: string;
|
||||
/** Publication date */
|
||||
date?: string;
|
||||
/** Word count */
|
||||
wordCount?: number;
|
||||
/** Whether content was truncated */
|
||||
wasTruncated?: boolean;
|
||||
/** Optional max width */
|
||||
maxWidth?: string;
|
||||
/** Optional error message */
|
||||
error?: string;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
/** Response actions */
|
||||
responseActions?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
variant?: "default" | "outline";
|
||||
}>;
|
||||
/** Response action handler */
|
||||
onResponseAction?: (actionId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate serializable article data to ArticleProps
|
||||
*/
|
||||
export function parseSerializableArticle(data: unknown): ArticleProps {
|
||||
const result = SerializableArticleSchema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
console.warn("Invalid article data:", result.error.issues);
|
||||
// Return fallback with basic info
|
||||
const obj = (data && typeof data === "object" ? data : {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(obj.id || "article-unknown"),
|
||||
title: String(obj.title || "Untitled Article"),
|
||||
error: "Failed to parse article data",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = result.data;
|
||||
return {
|
||||
id: parsed.id,
|
||||
assetId: parsed.assetId,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
content: parsed.content,
|
||||
href: parsed.href,
|
||||
domain: parsed.domain,
|
||||
author: parsed.author,
|
||||
date: parsed.date,
|
||||
wordCount: parsed.word_count ?? parsed.wordCount,
|
||||
wasTruncated: parsed.was_truncated ?? parsed.wasTruncated,
|
||||
error: parsed.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format word count for display
|
||||
*/
|
||||
function formatWordCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k words`;
|
||||
}
|
||||
return `${count} words`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Article card component for displaying scraped webpage content
|
||||
*/
|
||||
export function Article({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
href,
|
||||
domain,
|
||||
author,
|
||||
date,
|
||||
wordCount,
|
||||
wasTruncated,
|
||||
maxWidth = "100%",
|
||||
error,
|
||||
className,
|
||||
responseActions,
|
||||
onResponseAction,
|
||||
}: ArticleProps) {
|
||||
const handleCardClick = useCallback(() => {
|
||||
if (href) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}, [href]);
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(
|
||||
"overflow-hidden border-destructive/20 bg-destructive/5",
|
||||
className
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertCircleIcon className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-destructive text-sm">
|
||||
Failed to scrape webpage
|
||||
</p>
|
||||
{href && (
|
||||
<p className="text-muted-foreground text-xs mt-0.5 truncate">
|
||||
{href}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(
|
||||
"group relative overflow-hidden transition-all duration-200",
|
||||
"hover:shadow-lg hover:border-primary/20",
|
||||
href && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
onClick={href ? handleCardClick : undefined}
|
||||
role={href ? "link" : undefined}
|
||||
tabIndex={href ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (href && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<BookOpenIcon className="size-5 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-sm line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs mt-1 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-xs text-muted-foreground">
|
||||
{domain && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1">
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
<span className="truncate max-w-[120px]">{domain}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Source: {domain}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{author && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="size-3" />
|
||||
<span className="truncate max-w-[100px]">{author}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Author: {author}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="size-3" />
|
||||
<span>{date}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{wordCount && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileTextIcon className="size-3" />
|
||||
<span>{formatWordCount(wordCount)}</span>
|
||||
{wasTruncated && (
|
||||
<span className="text-warning">(truncated)</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{wasTruncated
|
||||
? "Content was truncated due to length"
|
||||
: "Full article content available"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External link indicator */}
|
||||
{href && (
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response actions */}
|
||||
{responseActions && responseActions.length > 0 && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t">
|
||||
{responseActions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResponseAction?.(action.id);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
|
||||
action.variant === "outline"
|
||||
? "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state for article component
|
||||
*/
|
||||
export function ArticleLoading({
|
||||
title = "Loading article...",
|
||||
}: { title?: string }) {
|
||||
return (
|
||||
<Card className="overflow-hidden animate-pulse">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="size-10 rounded-lg bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-3 bg-muted rounded w-full" />
|
||||
<div className="h-3 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">{title}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for article component
|
||||
*/
|
||||
export function ArticleSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3 animate-pulse">
|
||||
<div className="size-10 rounded-lg bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-3 bg-muted rounded w-full" />
|
||||
<div className="h-3 bg-muted rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary props
|
||||
*/
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for article component
|
||||
*/
|
||||
export class ArticleErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): ErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<Card className="overflow-hidden border-destructive/20 bg-destructive/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircleIcon className="size-5 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to render article
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
317
surfsense_web/components/tool-ui/deepagent-thinking.tsx
Normal file
317
surfsense_web/components/tool-ui/deepagent-thinking.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react";
|
||||
import { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtItem,
|
||||
ChainOfThoughtStep,
|
||||
ChainOfThoughtTrigger,
|
||||
} from "@/components/prompt-kit/chain-of-thought";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Zod schemas for runtime validation
|
||||
*/
|
||||
const ThinkingStepSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
items: z.array(z.string()).default([]),
|
||||
status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
|
||||
});
|
||||
|
||||
const DeepAgentThinkingArgsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
});
|
||||
|
||||
const DeepAgentThinkingResultSchema = z.object({
|
||||
steps: z.array(ThinkingStepSchema).optional(),
|
||||
status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Types derived from Zod schemas
|
||||
*/
|
||||
type ThinkingStep = z.infer<typeof ThinkingStepSchema>;
|
||||
type DeepAgentThinkingArgs = z.infer<typeof DeepAgentThinkingArgsSchema>;
|
||||
type DeepAgentThinkingResult = z.infer<typeof DeepAgentThinkingResultSchema>;
|
||||
|
||||
/**
|
||||
* Parse and validate a single thinking step
|
||||
*/
|
||||
export function parseThinkingStep(data: unknown): ThinkingStep {
|
||||
const result = ThinkingStepSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid thinking step data:", result.error.issues);
|
||||
// Return a fallback step
|
||||
return {
|
||||
id: "unknown",
|
||||
title: "Processing...",
|
||||
items: [],
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate thinking result
|
||||
*/
|
||||
export function parseThinkingResult(data: unknown): DeepAgentThinkingResult {
|
||||
const result = DeepAgentThinkingResultSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid thinking result data:", result.error.issues);
|
||||
return {};
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon based on step status and type
|
||||
*/
|
||||
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
|
||||
// Check for specific step types based on title keywords
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (status === "in_progress") {
|
||||
return <Loader2 className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" />;
|
||||
}
|
||||
|
||||
// Default icons based on step type
|
||||
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
|
||||
return <Search className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (titleLower.includes("analy") || titleLower.includes("understand")) {
|
||||
return <Brain className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return <Sparkles className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display a single thinking step with controlled open state
|
||||
*/
|
||||
function ThinkingStepDisplay({
|
||||
step,
|
||||
isOpen,
|
||||
onToggle
|
||||
}: {
|
||||
step: ThinkingStep;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const icon = useMemo(() => getStepIcon(step.status, step.title), [step.status, step.title]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep open={isOpen} onOpenChange={onToggle}>
|
||||
<ChainOfThoughtTrigger
|
||||
leftIcon={icon}
|
||||
swapIconOnHover={step.status !== "in_progress"}
|
||||
className={cn(
|
||||
step.status === "in_progress" && "text-foreground font-medium",
|
||||
step.status === "completed" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</ChainOfThoughtTrigger>
|
||||
<ChainOfThoughtContent>
|
||||
{step.items.map((item, index) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${index}`}>
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state with animated thinking indicator
|
||||
*/
|
||||
function ThinkingLoadingState({ status }: { status?: string }) {
|
||||
const statusText = useMemo(() => {
|
||||
switch (status) {
|
||||
case "searching":
|
||||
return "Searching knowledge base...";
|
||||
case "synthesizing":
|
||||
return "Synthesizing response...";
|
||||
case "thinking":
|
||||
default:
|
||||
return "Thinking...";
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="my-3 flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-4 py-3">
|
||||
<div className="relative">
|
||||
<Brain className="size-5 text-primary" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{statusText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart chain of thought renderer with state management
|
||||
*/
|
||||
function SmartChainOfThought({ steps }: { steps: ThinkingStep[] }) {
|
||||
// Track which steps the user has manually toggled
|
||||
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
|
||||
// Track previous step statuses to detect changes
|
||||
const prevStatusesRef = useRef<Record<string, string>>({});
|
||||
|
||||
// Check if any step is currently in progress
|
||||
const hasInProgressStep = steps.some(step => step.status === "in_progress");
|
||||
|
||||
// Find the last completed step index
|
||||
const lastCompletedIndex = steps
|
||||
.map((s, i) => s.status === "completed" ? i : -1)
|
||||
.filter(i => i !== -1)
|
||||
.pop();
|
||||
|
||||
// Clear manual overrides when a step's status changes
|
||||
useEffect(() => {
|
||||
const currentStatuses: Record<string, string> = {};
|
||||
steps.forEach(step => {
|
||||
currentStatuses[step.id] = step.status;
|
||||
// If status changed, clear any manual override for this step
|
||||
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
|
||||
setManualOverrides(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[step.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
prevStatusesRef.current = currentStatuses;
|
||||
}, [steps]);
|
||||
|
||||
const getStepOpenState = (step: ThinkingStep, index: number): boolean => {
|
||||
// If user has manually toggled, respect that
|
||||
if (manualOverrides[step.id] !== undefined) {
|
||||
return manualOverrides[step.id];
|
||||
}
|
||||
// Auto behavior: open if in progress
|
||||
if (step.status === "in_progress") {
|
||||
return true;
|
||||
}
|
||||
// Auto behavior: keep last completed step open if no in-progress step
|
||||
if (!hasInProgressStep && index === lastCompletedIndex) {
|
||||
return true;
|
||||
}
|
||||
// Default: collapsed
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleToggle = (stepId: string, currentOpen: boolean) => {
|
||||
setManualOverrides(prev => ({
|
||||
...prev,
|
||||
[stepId]: !currentOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ChainOfThought>
|
||||
{steps.map((step, index) => {
|
||||
const isOpen = getStepOpenState(step, index);
|
||||
return (
|
||||
<ThinkingStepDisplay
|
||||
key={step.id}
|
||||
step={step}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => handleToggle(step.id, isOpen)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepAgent Thinking Tool UI Component
|
||||
*
|
||||
* This component displays the agent's chain-of-thought reasoning
|
||||
* when the deepagent is processing a query. It shows thinking steps
|
||||
* in a collapsible, hierarchical format.
|
||||
*/
|
||||
export const DeepAgentThinkingToolUI = makeAssistantToolUI<
|
||||
DeepAgentThinkingArgs,
|
||||
DeepAgentThinkingResult
|
||||
>({
|
||||
toolName: "deepagent_thinking",
|
||||
render: function DeepAgentThinkingUI({ result, status }) {
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return <ThinkingLoadingState status={result?.status} />;
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return null; // Don't show anything if cancelled
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return null; // Don't show error for thinking - it's not critical
|
||||
}
|
||||
}
|
||||
|
||||
// No result or no steps - don't render anything
|
||||
if (!result?.steps || result.steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render the chain of thought
|
||||
return (
|
||||
<div className="my-3 w-full">
|
||||
<SmartChainOfThought steps={result.steps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline Thinking Display Component
|
||||
*
|
||||
* A simpler version that can be used inline with the message content
|
||||
* for displaying reasoning without the full tool UI infrastructure.
|
||||
*/
|
||||
export function InlineThinkingDisplay({
|
||||
steps,
|
||||
isStreaming = false,
|
||||
className,
|
||||
}: {
|
||||
steps: ThinkingStep[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (steps.length === 0 && !isStreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("my-3 w-full", className)}>
|
||||
{isStreaming && steps.length === 0 ? (
|
||||
<ThinkingLoadingState />
|
||||
) : (
|
||||
<SmartChainOfThought steps={steps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ThinkingStep, DeepAgentThinkingArgs, DeepAgentThinkingResult };
|
||||
|
||||
154
surfsense_web/components/tool-ui/display-image.tsx
Normal file
154
surfsense_web/components/tool-ui/display-image.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, ImageIcon } from "lucide-react";
|
||||
import {
|
||||
Image,
|
||||
ImageErrorBoundary,
|
||||
ImageLoading,
|
||||
parseSerializableImage,
|
||||
} from "@/components/tool-ui/image";
|
||||
|
||||
/**
|
||||
* Type definitions for the display_image tool
|
||||
*/
|
||||
interface DisplayImageArgs {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface DisplayImageResult {
|
||||
id: string;
|
||||
assetId: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
domain?: string;
|
||||
ratio?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error state component shown when image display fails
|
||||
*/
|
||||
function ImageErrorState({ src, error }: { src: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 max-w-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertCircleIcon className="size-6 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-destructive text-sm">Failed to display image</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5 truncate">{src}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled state component
|
||||
*/
|
||||
function ImageCancelledState({ src }: { src: string }) {
|
||||
return (
|
||||
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground max-w-md">
|
||||
<p className="flex items-center gap-2">
|
||||
<ImageIcon className="size-4" />
|
||||
<span className="line-through truncate">Image: {src}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Image component with error handling
|
||||
* Note: Image component has built-in click handling via href/src,
|
||||
* so no additional responseActions needed.
|
||||
*/
|
||||
function ParsedImage({ result }: { result: unknown }) {
|
||||
const image = parseSerializableImage(result);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...image}
|
||||
maxWidth="420px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display Image Tool UI Component
|
||||
*
|
||||
* This component is registered with assistant-ui to render an image
|
||||
* when the display_image tool is called by the agent.
|
||||
*
|
||||
* It displays images with:
|
||||
* - Title and description
|
||||
* - Source attribution
|
||||
* - Hover overlay effects
|
||||
* - Click to open full size
|
||||
*/
|
||||
export const DisplayImageToolUI = makeAssistantToolUI<
|
||||
DisplayImageArgs,
|
||||
DisplayImageResult
|
||||
>({
|
||||
toolName: "display_image",
|
||||
render: function DisplayImageUI({ args, result, status }) {
|
||||
const src = args.src || "Unknown";
|
||||
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ImageLoading title={`Loading image...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return <ImageCancelledState src={src} />;
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return (
|
||||
<ImageErrorState
|
||||
src={src}
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ImageLoading title="Preparing image..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error result from the tool
|
||||
if (result.error) {
|
||||
return <ImageErrorState src={src} error={result.error} />;
|
||||
}
|
||||
|
||||
// Success - render the image
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ImageErrorBoundary>
|
||||
<ParsedImage result={result} />
|
||||
</ImageErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type { DisplayImageArgs, DisplayImageResult };
|
||||
|
||||
|
|
@ -3,37 +3,79 @@
|
|||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Audio } from "@/components/tool-ui/audio";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
||||
|
||||
/**
|
||||
* Type definitions for the generate_podcast tool
|
||||
* Zod schemas for runtime validation
|
||||
*/
|
||||
interface GeneratePodcastArgs {
|
||||
source_content: string;
|
||||
podcast_title?: string;
|
||||
user_prompt?: string;
|
||||
const GeneratePodcastArgsSchema = z.object({
|
||||
source_content: z.string(),
|
||||
podcast_title: z.string().optional(),
|
||||
user_prompt: z.string().optional(),
|
||||
});
|
||||
|
||||
const GeneratePodcastResultSchema = z.object({
|
||||
status: z.enum(["processing", "already_generating", "success", "error"]),
|
||||
task_id: z.string().optional(),
|
||||
podcast_id: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
transcript_entries: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const TaskStatusResponseSchema = z.object({
|
||||
status: z.enum(["processing", "success", "error"]),
|
||||
podcast_id: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
transcript_entries: z.number().optional(),
|
||||
state: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const PodcastTranscriptEntrySchema = z.object({
|
||||
speaker_id: z.number(),
|
||||
dialog: z.string(),
|
||||
});
|
||||
|
||||
const PodcastDetailsSchema = z.object({
|
||||
podcast_transcript: z.array(PodcastTranscriptEntrySchema).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Types derived from Zod schemas
|
||||
*/
|
||||
type GeneratePodcastArgs = z.infer<typeof GeneratePodcastArgsSchema>;
|
||||
type GeneratePodcastResult = z.infer<typeof GeneratePodcastResultSchema>;
|
||||
type TaskStatusResponse = z.infer<typeof TaskStatusResponseSchema>;
|
||||
type PodcastTranscriptEntry = z.infer<typeof PodcastTranscriptEntrySchema>;
|
||||
|
||||
/**
|
||||
* Parse and validate task status response
|
||||
*/
|
||||
function parseTaskStatusResponse(data: unknown): TaskStatusResponse {
|
||||
const result = TaskStatusResponseSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid task status response:", result.error.issues);
|
||||
return { status: "error", error: "Invalid response from server" };
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
interface GeneratePodcastResult {
|
||||
status: "processing" | "already_generating" | "success" | "error";
|
||||
task_id?: string;
|
||||
podcast_id?: number;
|
||||
title?: string;
|
||||
transcript_entries?: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TaskStatusResponse {
|
||||
status: "processing" | "success" | "error";
|
||||
podcast_id?: number;
|
||||
title?: string;
|
||||
transcript_entries?: number;
|
||||
state?: string;
|
||||
error?: string;
|
||||
/**
|
||||
* Parse and validate podcast details
|
||||
*/
|
||||
function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTranscriptEntry[] } {
|
||||
const result = PodcastDetailsSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
console.warn("Invalid podcast details:", result.error.issues);
|
||||
return {};
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -112,14 +154,6 @@ function AudioLoadingState({ title }: { title: string }) {
|
|||
/**
|
||||
* Podcast Player Component - Fetches audio and transcript with authentication
|
||||
*/
|
||||
/**
|
||||
* Transcript entry type for podcast transcripts
|
||||
*/
|
||||
interface PodcastTranscriptEntry {
|
||||
speaker_id: number;
|
||||
dialog: string;
|
||||
}
|
||||
|
||||
function PodcastPlayer({
|
||||
podcastId,
|
||||
title,
|
||||
|
|
@ -163,12 +197,12 @@ function PodcastPlayer({
|
|||
|
||||
try {
|
||||
// Fetch audio blob and podcast details in parallel
|
||||
const [audioResponse, podcastDetails] = await Promise.all([
|
||||
const [audioResponse, rawPodcastDetails] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
),
|
||||
baseApiService.get<{ podcast_transcript?: PodcastTranscriptEntry[] }>(
|
||||
baseApiService.get<unknown>(
|
||||
`/api/v1/podcasts/${podcastId}`
|
||||
),
|
||||
]);
|
||||
|
|
@ -184,8 +218,9 @@ function PodcastPlayer({
|
|||
objectUrlRef.current = objectUrl;
|
||||
setAudioSrc(objectUrl);
|
||||
|
||||
// Set transcript from podcast details
|
||||
if (podcastDetails?.podcast_transcript) {
|
||||
// Parse and validate podcast details, then set transcript
|
||||
const podcastDetails = parsePodcastDetails(rawPodcastDetails);
|
||||
if (podcastDetails.podcast_transcript) {
|
||||
setTranscript(podcastDetails.podcast_transcript);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -268,9 +303,10 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string })
|
|||
useEffect(() => {
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await baseApiService.get<TaskStatusResponse>(
|
||||
const rawResponse = await baseApiService.get<unknown>(
|
||||
`/api/v1/podcasts/task/${taskId}/status`
|
||||
);
|
||||
const response = parseTaskStatusResponse(rawResponse);
|
||||
setTaskStatus(response);
|
||||
|
||||
// Stop polling if task is complete or errored
|
||||
|
|
|
|||
309
surfsense_web/components/tool-ui/image/index.tsx
Normal file
309
surfsense_web/components/tool-ui/image/index.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react";
|
||||
import NextImage from "next/image";
|
||||
import { Component, type ReactNode, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Zod schemas for runtime validation
|
||||
*/
|
||||
const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "auto"]);
|
||||
const ImageFitSchema = z.enum(["cover", "contain"]);
|
||||
|
||||
const ImageSourceSchema = z.object({
|
||||
label: z.string(),
|
||||
iconUrl: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
});
|
||||
|
||||
const SerializableImageSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
src: z.string(),
|
||||
alt: z.string(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
href: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
ratio: AspectRatioSchema.optional(),
|
||||
source: ImageSourceSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Types derived from Zod schemas
|
||||
*/
|
||||
type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
type ImageFit = z.infer<typeof ImageFitSchema>;
|
||||
type ImageSource = z.infer<typeof ImageSourceSchema>;
|
||||
export type SerializableImage = z.infer<typeof SerializableImageSchema>;
|
||||
|
||||
/**
|
||||
* Props for the Image component
|
||||
*/
|
||||
export interface ImageProps {
|
||||
id: string;
|
||||
assetId: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
href?: string;
|
||||
domain?: string;
|
||||
ratio?: AspectRatio;
|
||||
fit?: ImageFit;
|
||||
source?: ImageSource;
|
||||
maxWidth?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate serializable image from tool result
|
||||
*/
|
||||
export function parseSerializableImage(result: unknown): SerializableImage {
|
||||
const parsed = SerializableImageSchema.safeParse(result);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.warn("Invalid image data:", parsed.error.issues);
|
||||
// Try to extract basic info for error display
|
||||
const obj = (result && typeof result === "object" ? result : {}) as Record<string, unknown>;
|
||||
throw new Error(`Invalid image: ${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 "auto":
|
||||
default:
|
||||
return "aspect-[4/3]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for Image component
|
||||
*/
|
||||
interface ImageErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ImageErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
ImageErrorBoundaryState
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ImageErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ImageIcon className="size-8" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for Image
|
||||
*/
|
||||
export function ImageSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
|
||||
return (
|
||||
<Card className="w-full overflow-hidden animate-pulse" style={{ maxWidth }}>
|
||||
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
|
||||
<ImageIcon className="size-12 text-muted-foreground/30" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Image Loading State
|
||||
*/
|
||||
export function ImageLoading({ title = "Loading image..." }: { title?: string }) {
|
||||
return (
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="size-8 text-muted-foreground animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Image Component
|
||||
*
|
||||
* Display images with metadata and attribution.
|
||||
* Features hover overlay with title and source attribution.
|
||||
*/
|
||||
export function Image({
|
||||
id,
|
||||
src,
|
||||
alt,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
domain,
|
||||
ratio = "4:3",
|
||||
fit = "cover",
|
||||
source,
|
||||
maxWidth = "420px",
|
||||
className,
|
||||
}: ImageProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const aspectRatioClass = getAspectRatioClass(ratio);
|
||||
const displayDomain = domain || source?.label;
|
||||
|
||||
const handleClick = () => {
|
||||
const targetUrl = href || source?.url || src;
|
||||
if (targetUrl) {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
<div className={cn("bg-muted flex items-center justify-center", aspectRatioClass)}>
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ImageIcon className="size-8" />
|
||||
<p className="text-sm">Image not available</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(
|
||||
"group w-full overflow-hidden cursor-pointer transition-shadow duration-200 hover:shadow-lg",
|
||||
className
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={cn("relative w-full overflow-hidden bg-muted", aspectRatioClass)}>
|
||||
{/* Image */}
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
className={cn(
|
||||
"transition-transform duration-300",
|
||||
fit === "cover" ? "object-cover" : "object-contain",
|
||||
isHovered && "scale-105"
|
||||
)}
|
||||
unoptimized
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
|
||||
{/* Hover overlay - appears on hover */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent",
|
||||
"transition-opacity duration-200",
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{/* Content at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h3 className="font-semibold text-white text-base leading-tight line-clamp-2 mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-white/80 text-sm line-clamp-2 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Source attribution */}
|
||||
{displayDomain && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{source?.iconUrl ? (
|
||||
<NextImage
|
||||
src={source.iconUrl}
|
||||
alt={source.label}
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<ExternalLinkIcon className="size-4 text-white/70" />
|
||||
)}
|
||||
<span className="text-white/70 text-sm">{displayDomain}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Always visible domain badge (bottom right, shown when NOT hovered) */}
|
||||
{displayDomain && !isHovered && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-black/60 text-white border-0 text-xs backdrop-blur-sm"
|
||||
>
|
||||
{displayDomain}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,3 +8,55 @@
|
|||
|
||||
export { Audio } from "./audio";
|
||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
export {
|
||||
DeepAgentThinkingToolUI,
|
||||
InlineThinkingDisplay,
|
||||
type ThinkingStep,
|
||||
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";
|
||||
export {
|
||||
Image,
|
||||
ImageErrorBoundary,
|
||||
ImageLoading,
|
||||
ImageSkeleton,
|
||||
parseSerializableImage,
|
||||
type ImageProps,
|
||||
type SerializableImage,
|
||||
} from "./image";
|
||||
export {
|
||||
DisplayImageToolUI,
|
||||
type DisplayImageArgs,
|
||||
type DisplayImageResult,
|
||||
} from "./display-image";
|
||||
export {
|
||||
Article,
|
||||
ArticleErrorBoundary,
|
||||
ArticleLoading,
|
||||
ArticleSkeleton,
|
||||
parseSerializableArticle,
|
||||
type ArticleProps,
|
||||
type SerializableArticle,
|
||||
} from "./article";
|
||||
export {
|
||||
ScrapeWebpageToolUI,
|
||||
type ScrapeWebpageArgs,
|
||||
type ScrapeWebpageResult,
|
||||
} from "./scrape-webpage";
|
||||
|
|
|
|||
226
surfsense_web/components/tool-ui/link-preview.tsx
Normal file
226
surfsense_web/components/tool-ui/link-preview.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react";
|
||||
import {
|
||||
MediaCard,
|
||||
MediaCardErrorBoundary,
|
||||
MediaCardLoading,
|
||||
parseSerializableMediaCard,
|
||||
type SerializableMediaCard,
|
||||
} from "@/components/tool-ui/media-card";
|
||||
|
||||
/**
|
||||
* Type definitions for the link_preview tool
|
||||
*/
|
||||
interface LinkPreviewArgs {
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface LinkPreviewResult {
|
||||
id: string;
|
||||
assetId: string;
|
||||
kind: "link";
|
||||
href: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumb?: string;
|
||||
domain?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error state component shown when link preview fails
|
||||
*/
|
||||
function LinkPreviewErrorState({ url, error }: { url: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 max-w-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertCircleIcon className="size-6 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-destructive text-sm">Failed to load preview</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5 truncate">{url}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled state component
|
||||
*/
|
||||
function LinkPreviewCancelledState({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground max-w-md">
|
||||
<p className="flex items-center gap-2">
|
||||
<LinkIcon className="size-4" />
|
||||
<span className="line-through truncate">Preview: {url}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed MediaCard component with error handling
|
||||
*/
|
||||
function ParsedMediaCard({ result }: { result: unknown }) {
|
||||
const card = parseSerializableMediaCard(result);
|
||||
|
||||
return (
|
||||
<MediaCard
|
||||
{...card}
|
||||
maxWidth="420px"
|
||||
responseActions={[
|
||||
{ id: "open", label: "Open", variant: "default" },
|
||||
]}
|
||||
onResponseAction={(id) => {
|
||||
if (id === "open" && card.href) {
|
||||
window.open(card.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link Preview Tool UI Component
|
||||
*
|
||||
* This component is registered with assistant-ui to render a rich
|
||||
* link preview card when the link_preview tool is called by the agent.
|
||||
*
|
||||
* It displays website metadata including:
|
||||
* - Title and description
|
||||
* - Thumbnail/Open Graph image
|
||||
* - Domain name
|
||||
* - Clickable link to open in new tab
|
||||
*/
|
||||
export const LinkPreviewToolUI = makeAssistantToolUI<
|
||||
LinkPreviewArgs,
|
||||
LinkPreviewResult
|
||||
>({
|
||||
toolName: "link_preview",
|
||||
render: function LinkPreviewUI({ args, result, status }) {
|
||||
const url = args.url || "Unknown URL";
|
||||
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<MediaCardLoading title={`Loading preview for ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return <LinkPreviewCancelledState url={url} />;
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return (
|
||||
<LinkPreviewErrorState
|
||||
url={url}
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<MediaCardLoading title={`Fetching metadata for ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error result from the tool
|
||||
if (result.error) {
|
||||
return <LinkPreviewErrorState url={url} error={result.error} />;
|
||||
}
|
||||
|
||||
// Success - render the media card
|
||||
return (
|
||||
<div className="my-4">
|
||||
<MediaCardErrorBoundary>
|
||||
<ParsedMediaCard result={result} />
|
||||
</MediaCardErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Multiple Link Previews Tool UI Component
|
||||
*
|
||||
* This component handles cases where multiple links need to be previewed.
|
||||
* It renders a grid of link preview cards.
|
||||
*/
|
||||
interface MultiLinkPreviewArgs {
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
interface MultiLinkPreviewResult {
|
||||
previews: LinkPreviewResult[];
|
||||
errors?: { url: string; error: string }[];
|
||||
}
|
||||
|
||||
export const MultiLinkPreviewToolUI = makeAssistantToolUI<
|
||||
MultiLinkPreviewArgs,
|
||||
MultiLinkPreviewResult
|
||||
>({
|
||||
toolName: "multi_link_preview",
|
||||
render: function MultiLinkPreviewUI({ args, result, status }) {
|
||||
const urls = args.urls || [];
|
||||
|
||||
// Loading state
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4 grid gap-4 sm:grid-cols-2">
|
||||
{urls.slice(0, 4).map((url, index) => (
|
||||
<MediaCardLoading key={`loading-${url}-${index}`} title="Loading..." />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Incomplete state
|
||||
if (status.type === "incomplete") {
|
||||
return (
|
||||
<div className="my-4 text-muted-foreground text-sm">
|
||||
<p className="flex items-center gap-2">
|
||||
<LinkIcon className="size-4" />
|
||||
<span>Link previews cancelled</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No result
|
||||
if (!result || !result.previews) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render grid of previews
|
||||
return (
|
||||
<div className="my-4 grid gap-4 sm:grid-cols-2">
|
||||
{result.previews.map((preview) => (
|
||||
<MediaCardErrorBoundary key={preview.id}>
|
||||
<ParsedMediaCard result={preview} />
|
||||
</MediaCardErrorBoundary>
|
||||
))}
|
||||
{result.errors?.map((err) => (
|
||||
<LinkPreviewErrorState key={err.url} url={err.url} error={err.error} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
|
||||
|
||||
356
surfsense_web/components/tool-ui/media-card/index.tsx
Normal file
356
surfsense_web/components/tool-ui/media-card/index.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } 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 { 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", "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"]).optional(),
|
||||
confirmLabel: z.string().optional(),
|
||||
});
|
||||
|
||||
const SerializableMediaCardSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
kind: MediaCardKindSchema,
|
||||
href: z.string().optional(),
|
||||
src: z.string().optional(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
thumb: z.string().optional(),
|
||||
ratio: AspectRatioSchema.optional(),
|
||||
domain: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Types derived from Zod schemas
|
||||
*/
|
||||
type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
type MediaCardKind = z.infer<typeof MediaCardKindSchema>;
|
||||
type ResponseAction = z.infer<typeof ResponseActionSchema>;
|
||||
export type SerializableMediaCard = z.infer<typeof SerializableMediaCardSchema>;
|
||||
|
||||
/**
|
||||
* 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 "21:9":
|
||||
return "aspect-[21/9]";
|
||||
case "auto":
|
||||
default:
|
||||
return "aspect-[2/1]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon based on media card kind
|
||||
*/
|
||||
function getKindIcon(kind: MediaCardKind) {
|
||||
switch (kind) {
|
||||
case "link":
|
||||
return <LinkIcon className="size-5" />;
|
||||
case "image":
|
||||
return <ImageIcon className="size-5" />;
|
||||
case "video":
|
||||
case "audio":
|
||||
return <Globe className="size-5" />;
|
||||
default:
|
||||
return <LinkIcon className="size-5" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for MediaCard
|
||||
*/
|
||||
interface MediaCardErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class MediaCardErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
MediaCardErrorBoundaryState
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): MediaCardErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card className="w-full max-w-md border-destructive/20 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<LinkIcon className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-destructive text-sm">Failed to load preview</p>
|
||||
<p className="text-muted-foreground text-xs truncate">
|
||||
{this.state.error?.message || "An error occurred"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for MediaCard
|
||||
*/
|
||||
export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
|
||||
return (
|
||||
<Card
|
||||
className="w-full overflow-hidden animate-pulse"
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
<div className="aspect-[2/1] bg-muted" />
|
||||
<CardContent className="p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-full rounded bg-muted" />
|
||||
<div className="mt-1 h-3 w-2/3 rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaCard Component
|
||||
*
|
||||
* A rich media card for displaying link previews, images, and other media
|
||||
* in AI chat applications. Supports thumbnails, descriptions, and actions.
|
||||
*/
|
||||
export function MediaCard({
|
||||
id,
|
||||
kind,
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
thumb,
|
||||
ratio = "auto",
|
||||
domain,
|
||||
maxWidth = "420px",
|
||||
alt,
|
||||
className,
|
||||
responseActions,
|
||||
onResponseAction,
|
||||
}: MediaCardProps) {
|
||||
const aspectRatioClass = getAspectRatioClass(ratio);
|
||||
const displayDomain = domain || (href ? new URL(href).hostname.replace("www.", "") : undefined);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (href) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden transition-all duration-200",
|
||||
"hover:shadow-lg hover:border-primary/20",
|
||||
href && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
onClick={href ? handleCardClick : undefined}
|
||||
role={href ? "link" : undefined}
|
||||
tabIndex={href ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (href && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{thumb && (
|
||||
<div className={cn("relative w-full overflow-hidden bg-muted", aspectRatioClass)}>
|
||||
<Image
|
||||
src={thumb}
|
||||
alt={alt || title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
unoptimized
|
||||
onError={(e) => {
|
||||
// Hide broken images
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback when no thumbnail */}
|
||||
{!thumb && (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50",
|
||||
aspectRatioClass
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
{getKindIcon(kind)}
|
||||
<span className="text-xs">{kind === "link" ? "Link Preview" : kind}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Domain favicon placeholder */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Globe className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Domain badge */}
|
||||
{displayDomain && (
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{displayDomain}
|
||||
</Badge>
|
||||
{href && (
|
||||
<ExternalLinkIcon className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-foreground text-sm leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1.5 text-muted-foreground text-xs leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Actions */}
|
||||
{responseActions && responseActions.length > 0 && (
|
||||
<div
|
||||
className="mt-4 flex items-center justify-end gap-2 border-t pt-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{responseActions.map((action) => (
|
||||
<Tooltip key={action.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={action.variant || "secondary"}
|
||||
size="sm"
|
||||
onClick={() => onResponseAction?.(action.id)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{action.confirmLabel && (
|
||||
<TooltipContent>
|
||||
<p>{action.confirmLabel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaCard Loading State
|
||||
*/
|
||||
export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) {
|
||||
return (
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="aspect-[2/1] bg-muted animate-pulse flex items-center justify-center">
|
||||
<Loader2 className="size-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-muted animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-center text-muted-foreground text-sm">{title}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
163
surfsense_web/components/tool-ui/scrape-webpage.tsx
Normal file
163
surfsense_web/components/tool-ui/scrape-webpage.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, FileTextIcon } from "lucide-react";
|
||||
import {
|
||||
Article,
|
||||
ArticleErrorBoundary,
|
||||
ArticleLoading,
|
||||
parseSerializableArticle,
|
||||
} from "@/components/tool-ui/article";
|
||||
|
||||
/**
|
||||
* Type definitions for the scrape_webpage tool
|
||||
*/
|
||||
interface ScrapeWebpageArgs {
|
||||
url: string;
|
||||
max_length?: number;
|
||||
}
|
||||
|
||||
interface ScrapeWebpageResult {
|
||||
id: string;
|
||||
assetId: string;
|
||||
kind: "article";
|
||||
href: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
domain?: string;
|
||||
author?: string;
|
||||
date?: string;
|
||||
word_count?: number;
|
||||
was_truncated?: boolean;
|
||||
crawler_type?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error state component shown when webpage scraping fails
|
||||
*/
|
||||
function ScrapeErrorState({ url, error }: { url: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 max-w-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertCircleIcon className="size-6 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-destructive text-sm">Failed to scrape webpage</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5 truncate">{url}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled state component
|
||||
*/
|
||||
function ScrapeCancelledState({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground max-w-md">
|
||||
<p className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-4" />
|
||||
<span className="line-through truncate">Scraping: {url}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Article component with error handling
|
||||
*/
|
||||
function ParsedArticle({ result }: { result: unknown }) {
|
||||
const article = parseSerializableArticle(result);
|
||||
|
||||
return (
|
||||
<Article
|
||||
{...article}
|
||||
maxWidth="480px"
|
||||
responseActions={[
|
||||
{ id: "open", label: "Open Source", variant: "default" },
|
||||
]}
|
||||
onResponseAction={(id) => {
|
||||
if (id === "open" && article.href) {
|
||||
window.open(article.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape Webpage Tool UI Component
|
||||
*
|
||||
* This component is registered with assistant-ui to render an article card
|
||||
* when the scrape_webpage tool is called by the agent.
|
||||
*
|
||||
* It displays scraped webpage content including:
|
||||
* - Title and description
|
||||
* - Author and date (if available)
|
||||
* - Word count
|
||||
* - Link to original source
|
||||
*/
|
||||
export const ScrapeWebpageToolUI = makeAssistantToolUI<
|
||||
ScrapeWebpageArgs,
|
||||
ScrapeWebpageResult
|
||||
>({
|
||||
toolName: "scrape_webpage",
|
||||
render: function ScrapeWebpageUI({ args, result, status }) {
|
||||
const url = args.url || "Unknown URL";
|
||||
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ArticleLoading title={`Scraping ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return <ScrapeCancelledState url={url} />;
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return (
|
||||
<ScrapeErrorState
|
||||
url={url}
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ArticleLoading title={`Extracting content from ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error result from the tool
|
||||
if (result.error) {
|
||||
return <ScrapeErrorState url={url} error={result.error} />;
|
||||
}
|
||||
|
||||
// Success - render the article card
|
||||
return (
|
||||
<div className="my-4">
|
||||
<ArticleErrorBoundary>
|
||||
<ParsedArticle result={result} />
|
||||
</ArticleErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type { ScrapeWebpageArgs, ScrapeWebpageResult };
|
||||
|
||||
|
|
@ -1,21 +1,33 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue