mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
commit
c2e6bf2018
72 changed files with 6123 additions and 3884 deletions
|
|
@ -75,7 +75,6 @@ surfsense_backend/lib64/
|
|||
|
||||
# Logs
|
||||
**/*.log
|
||||
**/logs/
|
||||
|
||||
# Temporary files
|
||||
**/tmp/
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ def create_surfsense_deep_agent(
|
|||
- display_image: Display images in chat
|
||||
- scrape_webpage: Extract content from webpages
|
||||
|
||||
The agent also includes TodoListMiddleware by default (via create_deep_agent) which provides:
|
||||
- write_todos: Create and update planning/todo lists for complex tasks
|
||||
|
||||
The system prompt can be configured via agent_config:
|
||||
- Custom system instructions (or use defaults)
|
||||
- Citation toggle (enable/disable citation requirements)
|
||||
|
|
@ -138,6 +141,7 @@ def create_surfsense_deep_agent(
|
|||
system_prompt = build_surfsense_system_prompt()
|
||||
|
||||
# Create the deep agent with system prompt and checkpointer
|
||||
# Note: TodoListMiddleware (write_todos) is included by default in create_deep_agent
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=tools,
|
||||
|
|
|
|||
|
|
@ -64,18 +64,23 @@ You have access to the following tools:
|
|||
- 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 from a URL to the user.
|
||||
- Use this tool ONLY when you have a valid public HTTP/HTTPS image URL to show.
|
||||
- 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
|
||||
- IMPORTANT: Do NOT use this tool for user-uploaded image attachments!
|
||||
* User attachments are already visible in the chat UI - the user can see them
|
||||
* This tool requires a valid HTTP/HTTPS URL, not a local file path
|
||||
* When a user uploads an image, just analyze it and respond - don't try to display it again
|
||||
- Valid use cases:
|
||||
* Showing an image from a URL the user explicitly mentioned in their message
|
||||
* Displaying images found in scraped webpage content (from scrape_webpage tool)
|
||||
* Showing a publicly accessible diagram or chart from a known URL
|
||||
|
||||
CRITICAL - NEVER USE THIS TOOL FOR USER-UPLOADED ATTACHMENTS:
|
||||
When a user uploads/attaches an image file to their message:
|
||||
* The image is ALREADY VISIBLE in the chat UI as a thumbnail on their message
|
||||
* You do NOT have a URL for their uploaded image - only extracted text/description
|
||||
* Calling display_image will FAIL and show "Image not available" error
|
||||
* Simply analyze the image content and respond with your analysis - DO NOT try to display it
|
||||
* The user can already see their own uploaded image - they don't need you to show it again
|
||||
|
||||
- Args:
|
||||
- src: The URL of the image to display (must be a valid HTTP/HTTPS image URL, not a local path)
|
||||
- src: The URL of the image (MUST be a valid public HTTP/HTTPS URL that you know exists)
|
||||
- alt: Alternative text describing the image (for accessibility)
|
||||
- title: Optional title to display below the image
|
||||
- description: Optional description providing context about the image
|
||||
|
|
@ -104,6 +109,20 @@ You have access to the following tools:
|
|||
* 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.
|
||||
|
||||
6. write_todos: Create and update a planning/todo list to break down complex tasks.
|
||||
- IMPORTANT: Use this tool when the user asks you to create a plan, break down a task, or explain something in structured steps.
|
||||
- This tool creates a visual plan with progress tracking that the user can see in the UI.
|
||||
- When to use:
|
||||
* User asks to "create a plan" or "break down" a task
|
||||
* User asks for "steps" to do something
|
||||
* User asks you to "explain" something in sections
|
||||
* Any multi-step task that would benefit from structured planning
|
||||
- Args:
|
||||
- todos: List of todo items, each with:
|
||||
* content: Description of the task (required)
|
||||
* status: "pending", "in_progress", or "completed" (required)
|
||||
- The tool automatically adds IDs and formats the output for the UI.
|
||||
</tools>
|
||||
<tool_call_examples>
|
||||
- User: "Fetch all my notes and what's in them?"
|
||||
|
|
@ -134,8 +153,15 @@ You have access to the following tools:
|
|||
- 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 uploads an image file and asks: "What is this image about?"
|
||||
- DO NOT call display_image! The user's uploaded image is already visible in the chat.
|
||||
- Simply analyze the image content (which you receive as extracted text/description) and respond.
|
||||
- WRONG: `display_image(src="...", ...)` - This will fail with "Image not available"
|
||||
- CORRECT: Just provide your analysis directly: "Based on the image you shared, this appears to be..."
|
||||
|
||||
- User uploads a screenshot and asks: "Can you explain what's in this image?"
|
||||
- DO NOT call display_image! Just analyze and respond directly.
|
||||
- The user can already see their screenshot - they don't need you to display it again.
|
||||
|
||||
- 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")`
|
||||
|
|
@ -154,6 +180,34 @@ You have access to the following tools:
|
|||
- 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
|
||||
|
||||
- User: "Create a plan for building a user authentication system"
|
||||
- Call: `write_todos(todos=[{"content": "Design database schema for users and sessions", "status": "in_progress"}, {"content": "Implement registration and login endpoints", "status": "pending"}, {"content": "Add password reset functionality", "status": "pending"}])`
|
||||
- Then explain each step in detail as you work through them
|
||||
|
||||
- User: "Break down how to build a REST API into steps"
|
||||
- Call: `write_todos(todos=[{"content": "Design API endpoints and data models", "status": "in_progress"}, {"content": "Set up server framework and routing", "status": "pending"}, {"content": "Implement CRUD operations", "status": "pending"}, {"content": "Add authentication and error handling", "status": "pending"}])`
|
||||
- Then provide detailed explanations for each step
|
||||
|
||||
- User: "Help me plan my trip to Japan"
|
||||
- Call: `write_todos(todos=[{"content": "Research best time to visit and book flights", "status": "in_progress"}, {"content": "Plan itinerary for cities to visit", "status": "pending"}, {"content": "Book accommodations", "status": "pending"}, {"content": "Prepare travel documents and currency", "status": "pending"}])`
|
||||
- Then provide travel preparation guidance
|
||||
|
||||
- User: "Break down how to learn guitar"
|
||||
- Call: `write_todos(todos=[{"content": "Learn basic chords and finger positioning", "status": "in_progress"}, {"content": "Practice strumming patterns", "status": "pending"}, {"content": "Learn to read tabs and sheet music", "status": "pending"}, {"content": "Master simple songs", "status": "pending"}])`
|
||||
- Then provide learning milestones and tips
|
||||
|
||||
- User: "Plan my workout routine for the week"
|
||||
- Call: `write_todos(todos=[{"content": "Monday: Upper body strength training", "status": "in_progress"}, {"content": "Tuesday: Cardio and core workout", "status": "pending"}, {"content": "Wednesday: Rest or light stretching", "status": "pending"}, {"content": "Thursday: Lower body strength training", "status": "pending"}, {"content": "Friday: Full body HIIT session", "status": "pending"}])`
|
||||
- Then provide exercise details and tips
|
||||
|
||||
- User: "Help me organize my home renovation project"
|
||||
- Call: `write_todos(todos=[{"content": "Define scope and create budget", "status": "in_progress"}, {"content": "Research and hire contractors", "status": "pending"}, {"content": "Obtain necessary permits", "status": "pending"}, {"content": "Order materials and fixtures", "status": "pending"}, {"content": "Execute renovation phases", "status": "pending"}])`
|
||||
- Then provide detailed renovation guidance
|
||||
|
||||
- User: "What steps should I take to start a podcast?"
|
||||
- Call: `write_todos(todos=[{"content": "Define podcast concept and target audience", "status": "in_progress"}, {"content": "Set up recording equipment and software", "status": "pending"}, {"content": "Plan episode structure and content", "status": "pending"}, {"content": "Record and edit first episodes", "status": "pending"}, {"content": "Choose hosting platform and publish", "status": "pending"}])`
|
||||
- Then provide podcast launch guidance
|
||||
</tool_call_examples>
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -280,15 +280,18 @@ def create_link_preview_tool():
|
|||
url = f"https://{url}"
|
||||
|
||||
try:
|
||||
# Generate a random User-Agent to avoid bot detection
|
||||
ua = UserAgent()
|
||||
user_agent = ua.random
|
||||
|
||||
# Use a browser-like User-Agent to fetch Open Graph metadata.
|
||||
# This is the same approach used by Slack, Discord, Twitter, etc. for link previews.
|
||||
# We're only fetching publicly available metadata (title, description, thumbnail)
|
||||
# that websites intentionally expose via OG tags for link preview purposes.
|
||||
async with httpx.AsyncClient(
|
||||
timeout=10.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
|||
),
|
||||
requires=[], # firecrawl_api_key is optional
|
||||
),
|
||||
# Note: write_todos is now provided by TodoListMiddleware from deepagents
|
||||
# =========================================================================
|
||||
# ADD YOUR CUSTOM TOOLS BELOW
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ class WebCrawlerConnector:
|
|||
Initialize the WebCrawlerConnector class.
|
||||
|
||||
Args:
|
||||
firecrawl_api_key: Firecrawl API key (optional, will use AsyncChromiumLoader if not provided)
|
||||
firecrawl_api_key: Firecrawl API key (optional). If provided, Firecrawl will be tried first
|
||||
and Chromium will be used as fallback if Firecrawl fails. If not provided,
|
||||
Chromium will be used directly.
|
||||
"""
|
||||
self.firecrawl_api_key = firecrawl_api_key
|
||||
self.use_firecrawl = bool(firecrawl_api_key)
|
||||
|
|
@ -46,6 +48,9 @@ class WebCrawlerConnector:
|
|||
"""
|
||||
Crawl a single URL and extract its content.
|
||||
|
||||
If Firecrawl API key is provided, tries Firecrawl first and falls back to Chromium
|
||||
if Firecrawl fails. If no Firecrawl API key is provided, uses Chromium directly.
|
||||
|
||||
Args:
|
||||
url: URL to crawl
|
||||
formats: List of formats to extract (e.g., ["markdown", "html"]) - only for Firecrawl
|
||||
|
|
@ -56,19 +61,37 @@ class WebCrawlerConnector:
|
|||
- content: Extracted content (markdown or HTML)
|
||||
- metadata: Page metadata (title, description, etc.)
|
||||
- source: Original URL
|
||||
- crawler_type: Type of crawler used
|
||||
- crawler_type: Type of crawler used ("firecrawl" or "chromium")
|
||||
"""
|
||||
try:
|
||||
# Validate URL
|
||||
if not validators.url(url):
|
||||
return None, f"Invalid URL: {url}"
|
||||
|
||||
# Try Firecrawl first if API key is provided
|
||||
if self.use_firecrawl:
|
||||
result = await self._crawl_with_firecrawl(url, formats)
|
||||
try:
|
||||
logger.info(f"[webcrawler] Using Firecrawl for: {url}")
|
||||
result = await self._crawl_with_firecrawl(url, formats)
|
||||
return result, None
|
||||
except Exception as firecrawl_error:
|
||||
# Firecrawl failed, fallback to Chromium
|
||||
logger.warning(
|
||||
f"[webcrawler] Firecrawl failed, falling back to Chromium+Trafilatura for: {url}"
|
||||
)
|
||||
try:
|
||||
result = await self._crawl_with_chromium(url)
|
||||
return result, None
|
||||
except Exception as chromium_error:
|
||||
return (
|
||||
None,
|
||||
f"Both Firecrawl and Chromium failed. Firecrawl error: {firecrawl_error!s}, Chromium error: {chromium_error!s}",
|
||||
)
|
||||
else:
|
||||
# No Firecrawl API key, use Chromium directly
|
||||
logger.info(f"[webcrawler] Using Chromium+Trafilatura for: {url}")
|
||||
result = await self._crawl_with_chromium(url)
|
||||
|
||||
return result, None
|
||||
return result, None
|
||||
|
||||
except Exception as e:
|
||||
return None, f"Error crawling URL {url}: {e!s}"
|
||||
|
|
@ -162,10 +185,6 @@ class WebCrawlerConnector:
|
|||
trafilatura_metadata = None
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting to extract main content from {url} using Trafilatura"
|
||||
)
|
||||
|
||||
# Extract main content as markdown
|
||||
extracted_content = trafilatura.extract(
|
||||
raw_html,
|
||||
|
|
@ -179,23 +198,10 @@ class WebCrawlerConnector:
|
|||
# Extract metadata using Trafilatura
|
||||
trafilatura_metadata = trafilatura.extract_metadata(raw_html)
|
||||
|
||||
if extracted_content and len(extracted_content.strip()) > 0:
|
||||
logger.info(
|
||||
f"Successfully extracted main content from {url} using Trafilatura "
|
||||
f"({len(extracted_content)} chars vs {len(raw_html)} chars raw HTML)"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Trafilatura extraction returned empty content for {url}, "
|
||||
"falling back to raw HTML"
|
||||
)
|
||||
if not extracted_content or len(extracted_content.strip()) == 0:
|
||||
extracted_content = None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Trafilatura extraction failed for {url}: {e}. "
|
||||
"Falling back to raw HTML"
|
||||
)
|
||||
except Exception:
|
||||
extracted_content = None
|
||||
|
||||
# Build metadata, preferring Trafilatura metadata when available
|
||||
|
|
|
|||
|
|
@ -319,6 +319,9 @@ async def get_logs_summary(
|
|||
if log.log_metadata
|
||||
else "Unknown"
|
||||
)
|
||||
document_id = (
|
||||
log.log_metadata.get("document_id") if log.log_metadata else None
|
||||
)
|
||||
summary["active_tasks"].append(
|
||||
{
|
||||
"id": log.id,
|
||||
|
|
@ -326,6 +329,7 @@ async def get_logs_summary(
|
|||
"message": log.message,
|
||||
"started_at": log.created_at,
|
||||
"source": log.source,
|
||||
"document_id": document_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,30 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
|||
return "\n".join(context_parts)
|
||||
|
||||
|
||||
def extract_todos_from_deepagents(command_output) -> dict:
|
||||
"""
|
||||
Extract todos from deepagents' TodoListMiddleware Command output.
|
||||
|
||||
deepagents returns a Command object with:
|
||||
- Command.update['todos'] = [{'content': '...', 'status': '...'}]
|
||||
|
||||
Returns the todos directly (no transformation needed - UI matches deepagents format).
|
||||
"""
|
||||
todos_data = []
|
||||
if hasattr(command_output, "update"):
|
||||
# It's a Command object from deepagents
|
||||
update = command_output.update
|
||||
todos_data = update.get("todos", [])
|
||||
elif isinstance(command_output, dict):
|
||||
# Already a dict - check if it has todos directly or in update
|
||||
if "todos" in command_output:
|
||||
todos_data = command_output.get("todos", [])
|
||||
elif "update" in command_output and isinstance(command_output["update"], dict):
|
||||
todos_data = command_output["update"].get("todos", [])
|
||||
|
||||
return {"todos": todos_data}
|
||||
|
||||
|
||||
async def stream_new_chat(
|
||||
user_query: str,
|
||||
search_space_id: int,
|
||||
|
|
@ -146,6 +170,16 @@ async def stream_new_chat(
|
|||
# Create connector service
|
||||
connector_service = ConnectorService(session, search_space_id=search_space_id)
|
||||
|
||||
# Get Firecrawl API key from webcrawler connector if configured
|
||||
from app.db import SearchSourceConnectorType
|
||||
|
||||
firecrawl_api_key = None
|
||||
webcrawler_connector = await connector_service.get_connector_by_type(
|
||||
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, search_space_id
|
||||
)
|
||||
if webcrawler_connector and webcrawler_connector.config:
|
||||
firecrawl_api_key = webcrawler_connector.config.get("FIRECRAWL_API_KEY")
|
||||
|
||||
# Get the PostgreSQL checkpointer for persistent conversation memory
|
||||
checkpointer = await get_checkpointer()
|
||||
|
||||
|
|
@ -157,6 +191,7 @@ async def stream_new_chat(
|
|||
connector_service=connector_service,
|
||||
checkpointer=checkpointer,
|
||||
agent_config=agent_config, # Pass prompt configuration
|
||||
firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured
|
||||
)
|
||||
|
||||
# Build input with message history from frontend
|
||||
|
|
@ -211,7 +246,8 @@ async def stream_new_chat(
|
|||
config = {
|
||||
"configurable": {
|
||||
"thread_id": str(chat_id),
|
||||
}
|
||||
},
|
||||
"recursion_limit": 80, # Increase from default 25 to allow more tool iterations
|
||||
}
|
||||
|
||||
# Start the message stream
|
||||
|
|
@ -233,6 +269,8 @@ async def stream_new_chat(
|
|||
completed_step_ids: set[str] = set()
|
||||
# Track if we just finished a tool (text flows silently after tools)
|
||||
just_finished_tool: bool = False
|
||||
# Track write_todos calls to show "Creating plan" vs "Updating plan"
|
||||
write_todos_call_count: int = 0
|
||||
|
||||
def next_thinking_step_id() -> str:
|
||||
nonlocal thinking_step_counter
|
||||
|
|
@ -441,6 +479,60 @@ async def stream_new_chat(
|
|||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
elif tool_name == "write_todos":
|
||||
# Track write_todos calls for better messaging
|
||||
write_todos_call_count += 1
|
||||
todos = (
|
||||
tool_input.get("todos", [])
|
||||
if isinstance(tool_input, dict)
|
||||
else []
|
||||
)
|
||||
todo_count = len(todos) if isinstance(todos, list) else 0
|
||||
|
||||
if write_todos_call_count == 1:
|
||||
# First call - creating the plan
|
||||
last_active_step_title = "Creating plan"
|
||||
last_active_step_items = [f"Defining {todo_count} tasks..."]
|
||||
else:
|
||||
# Subsequent calls - updating the plan
|
||||
# Try to provide context about what's being updated
|
||||
in_progress_count = (
|
||||
sum(
|
||||
1
|
||||
for t in todos
|
||||
if isinstance(t, dict)
|
||||
and t.get("status") == "in_progress"
|
||||
)
|
||||
if isinstance(todos, list)
|
||||
else 0
|
||||
)
|
||||
completed_count = (
|
||||
sum(
|
||||
1
|
||||
for t in todos
|
||||
if isinstance(t, dict)
|
||||
and t.get("status") == "completed"
|
||||
)
|
||||
if isinstance(todos, list)
|
||||
else 0
|
||||
)
|
||||
|
||||
last_active_step_title = "Updating progress"
|
||||
last_active_step_items = (
|
||||
[
|
||||
f"Progress: {completed_count}/{todo_count} completed",
|
||||
f"In progress: {in_progress_count} tasks",
|
||||
]
|
||||
if completed_count > 0
|
||||
else [f"Working on {todo_count} tasks"]
|
||||
)
|
||||
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=tool_step_id,
|
||||
title=last_active_step_title,
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
elif tool_name == "generate_podcast":
|
||||
podcast_title = (
|
||||
tool_input.get("podcast_title", "SurfSense Podcast")
|
||||
|
|
@ -465,6 +557,15 @@ async def stream_new_chat(
|
|||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
# elif tool_name == "ls":
|
||||
# last_active_step_title = "Exploring files"
|
||||
# last_active_step_items = []
|
||||
# yield streaming_service.format_thinking_step(
|
||||
# step_id=tool_step_id,
|
||||
# title="Exploring files",
|
||||
# status="in_progress",
|
||||
# items=None,
|
||||
# )
|
||||
else:
|
||||
last_active_step_title = f"Using {tool_name.replace('_', ' ')}"
|
||||
last_active_step_items = []
|
||||
|
|
@ -546,9 +647,11 @@ async def stream_new_chat(
|
|||
tool_name = event.get("name", "unknown_tool")
|
||||
raw_output = event.get("data", {}).get("output", "")
|
||||
|
||||
# Extract content from ToolMessage if needed
|
||||
# LangGraph may return a ToolMessage object instead of raw dict
|
||||
if hasattr(raw_output, "content"):
|
||||
# Handle deepagents' write_todos Command object specially
|
||||
if tool_name == "write_todos" and hasattr(raw_output, "update"):
|
||||
# deepagents returns a Command object - extract todos directly
|
||||
tool_output = extract_todos_from_deepagents(raw_output)
|
||||
elif hasattr(raw_output, "content"):
|
||||
# It's a ToolMessage object - extract the content
|
||||
content = raw_output.content
|
||||
# If content is a string that looks like JSON, try to parse it
|
||||
|
|
@ -707,6 +810,104 @@ async def stream_new_chat(
|
|||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
elif tool_name == "write_todos":
|
||||
# Build completion items for planning/updating
|
||||
if isinstance(tool_output, dict):
|
||||
todos = tool_output.get("todos", [])
|
||||
todo_count = len(todos) if isinstance(todos, list) else 0
|
||||
completed_count = (
|
||||
sum(
|
||||
1
|
||||
for t in todos
|
||||
if isinstance(t, dict)
|
||||
and t.get("status") == "completed"
|
||||
)
|
||||
if isinstance(todos, list)
|
||||
else 0
|
||||
)
|
||||
in_progress_count = (
|
||||
sum(
|
||||
1
|
||||
for t in todos
|
||||
if isinstance(t, dict)
|
||||
and t.get("status") == "in_progress"
|
||||
)
|
||||
if isinstance(todos, list)
|
||||
else 0
|
||||
)
|
||||
|
||||
# Use context-aware completion message
|
||||
if last_active_step_title == "Creating plan":
|
||||
completed_items = [f"Created {todo_count} tasks"]
|
||||
else:
|
||||
# Updating progress - show stats
|
||||
completed_items = [
|
||||
f"Progress: {completed_count}/{todo_count} completed",
|
||||
]
|
||||
if in_progress_count > 0:
|
||||
# Find the currently in-progress task name
|
||||
in_progress_task = next(
|
||||
(
|
||||
t.get("content", "")[:40]
|
||||
for t in todos
|
||||
if isinstance(t, dict)
|
||||
and t.get("status") == "in_progress"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if in_progress_task:
|
||||
completed_items.append(
|
||||
f"Current: {in_progress_task}..."
|
||||
)
|
||||
else:
|
||||
completed_items = ["Plan updated"]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title=last_active_step_title,
|
||||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
elif tool_name == "ls":
|
||||
# Build completion items showing file names found
|
||||
if isinstance(tool_output, dict):
|
||||
result = tool_output.get("result", "")
|
||||
elif isinstance(tool_output, str):
|
||||
result = tool_output
|
||||
else:
|
||||
result = str(tool_output) if tool_output else ""
|
||||
|
||||
# Parse file paths and extract just the file names
|
||||
file_names = []
|
||||
if result:
|
||||
# The ls tool returns paths, extract just the file/folder names
|
||||
for line in result.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
# Get just the filename from the path
|
||||
name = line.rstrip("/").split("/")[-1]
|
||||
if name and len(name) <= 40:
|
||||
file_names.append(name)
|
||||
elif name:
|
||||
file_names.append(name[:37] + "...")
|
||||
|
||||
# Build display items - wrap file names in brackets for icon rendering
|
||||
if file_names:
|
||||
if len(file_names) <= 5:
|
||||
# Wrap each file name in brackets for styled tile rendering
|
||||
completed_items = [f"[{name}]" for name in file_names]
|
||||
else:
|
||||
# Show first few with brackets and count
|
||||
completed_items = [f"[{name}]" for name in file_names[:4]]
|
||||
completed_items.append(f"(+{len(file_names) - 4} more)")
|
||||
else:
|
||||
completed_items = ["No files found"]
|
||||
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title="Exploring files",
|
||||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
else:
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
|
|
@ -843,6 +1044,27 @@ async def stream_new_chat(
|
|||
yield streaming_service.format_terminal_info(
|
||||
"Knowledge base search completed", "success"
|
||||
)
|
||||
elif tool_name == "write_todos":
|
||||
# Stream the full write_todos result so frontend can render the Plan 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 with plan info
|
||||
if isinstance(tool_output, dict):
|
||||
todos = tool_output.get("todos", [])
|
||||
todo_count = len(todos) if isinstance(todos, list) else 0
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Plan created ({todo_count} tasks)",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
yield streaming_service.format_terminal_info(
|
||||
"Plan created",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
# Default handling for other tools
|
||||
yield streaming_service.format_tool_output_available(
|
||||
|
|
|
|||
5981
surfsense_backend/uv.lock
generated
5981
surfsense_backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
|
|
@ -15,6 +16,12 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteConnectorMutationAtom,
|
||||
indexConnectorMutationAtom,
|
||||
updateConnectorMutationAtom,
|
||||
} from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -59,7 +66,6 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
|
|
@ -84,8 +90,12 @@ export default function ConnectorsPage() {
|
|||
const searchSpaceId = params.search_space_id as string;
|
||||
const today = new Date();
|
||||
|
||||
const { connectors, isLoading, error, deleteConnector, indexConnector, updateConnector } =
|
||||
useSearchSourceConnectors(false, parseInt(searchSpaceId));
|
||||
const { data: connectors = [], isLoading, error } = useAtomValue(connectorsAtom);
|
||||
|
||||
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
|
||||
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
|
||||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
|
||||
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
|
||||
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
|
|
@ -117,11 +127,9 @@ export default function ConnectorsPage() {
|
|||
if (connectorToDelete === null) return;
|
||||
|
||||
try {
|
||||
await deleteConnector(connectorToDelete);
|
||||
toast.success(t("delete_success"));
|
||||
await deleteConnector({ id: connectorToDelete });
|
||||
} catch (error) {
|
||||
console.error("Error deleting connector:", error);
|
||||
toast.error(t("delete_failed"));
|
||||
} finally {
|
||||
setConnectorToDelete(null);
|
||||
}
|
||||
|
|
@ -144,7 +152,14 @@ export default function ConnectorsPage() {
|
|||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
|
||||
await indexConnector({
|
||||
connector_id: selectedConnectorForIndexing,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
toast.success(t("indexing_started"));
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
|
|
@ -161,7 +176,12 @@ export default function ConnectorsPage() {
|
|||
const handleQuickIndexConnector = async (connectorId: number) => {
|
||||
setIndexingConnectorId(connectorId);
|
||||
try {
|
||||
await indexConnector(connectorId, searchSpaceId);
|
||||
await indexConnector({
|
||||
connector_id: connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
toast.success(t("indexing_started"));
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
|
|
@ -221,9 +241,12 @@ export default function ConnectorsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
await updateConnector(selectedConnectorForPeriodic, {
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: frequency,
|
||||
await updateConnector({
|
||||
id: selectedConnectorForPeriodic,
|
||||
data: {
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: frequency,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,8 @@ import { useEffect, useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -21,10 +24,8 @@ import {
|
|||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const apiConnectorFormSchema = z.object({
|
||||
|
|
@ -85,7 +86,8 @@ export default function EditConnectorPage() {
|
|||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
|
||||
const { connectors, updateConnector } = useSearchSourceConnectors(false, parseInt(searchSpaceId));
|
||||
const { data: connectors = [] } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
|
@ -99,14 +101,12 @@ export default function EditConnectorPage() {
|
|||
},
|
||||
});
|
||||
|
||||
// Find connector in the list
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find((c) => c.id === connectorId);
|
||||
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
|
||||
// Check if connector type is supported
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
form.reset({
|
||||
|
|
@ -114,14 +114,12 @@ export default function EditConnectorPage() {
|
|||
api_key: currentConnector.config[apiKeyField] || "",
|
||||
});
|
||||
} else {
|
||||
// Redirect if not a supported connector type
|
||||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
// If connectors are loaded but this one isn't found
|
||||
toast.error("Connector not found");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
|
@ -135,18 +133,20 @@ export default function EditConnectorPage() {
|
|||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
|
||||
// Only update the API key if a new one was provided
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
updatedConfig[apiKeyField] = values.api_key;
|
||||
}
|
||||
|
||||
await updateConnector(connectorId, {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type,
|
||||
config: updatedConfig,
|
||||
is_indexable: connector.is_indexable,
|
||||
last_indexed_at: connector.last_indexed_at,
|
||||
await updateConnector({
|
||||
id: connectorId,
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type as EnumConnectorName,
|
||||
config: updatedConfig,
|
||||
is_indexable: connector.is_indexable,
|
||||
last_indexed_at: connector.last_indexed_at,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Connector updated successfully!");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -18,10 +20,7 @@ import {
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
// import { IconBrandAirtable } from "@tabler/icons-react";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function AirtableConnectorPage() {
|
||||
|
|
@ -31,11 +30,12 @@ export default function AirtableConnectorPage() {
|
|||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors } = useSearchSourceConnectors(true, parseInt(searchSpaceId));
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors(parseInt(searchSpaceId)).then((data) => {
|
||||
const connector = data.find(
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.AIRTABLE_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -38,7 +40,6 @@ import {
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const baiduSearchApiFormSchema = z.object({
|
||||
|
|
@ -61,7 +62,7 @@ export default function BaiduSearchApiPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<BaiduSearchApiFormValues>({
|
||||
|
|
@ -95,8 +96,8 @@ export default function BaiduSearchApiPage() {
|
|||
config.BAIDU_ENABLE_DEEP_SEARCH = values.enable_deep_search;
|
||||
}
|
||||
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.BAIDU_SEARCH_API,
|
||||
config,
|
||||
|
|
@ -106,8 +107,10 @@ export default function BaiduSearchApiPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Baidu Search connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -24,7 +26,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const bookstackConnectorFormSchema = z.object({
|
||||
|
|
@ -50,7 +51,7 @@ export default function BookStackConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<BookStackConnectorFormValues>({
|
||||
|
|
@ -67,8 +68,8 @@ export default function BookStackConnectorPage() {
|
|||
const onSubmit = async (values: BookStackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -82,8 +83,10 @@ export default function BookStackConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("BookStack connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, ExternalLink, Eye, EyeOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
|
|
@ -22,7 +24,6 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const clickupConnectorFormSchema = z.object({
|
||||
|
|
@ -41,7 +42,7 @@ export default function ClickUpConnectorPage() {
|
|||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showApiToken, setShowApiToken] = useState(false);
|
||||
|
||||
|
|
@ -59,20 +60,23 @@ export default function ClickUpConnectorPage() {
|
|||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const connectorData = {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
|
||||
is_indexable: true,
|
||||
config: {
|
||||
CLICKUP_API_TOKEN: values.api_token,
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
|
||||
is_indexable: true,
|
||||
config: {
|
||||
CLICKUP_API_TOKEN: values.api_token,
|
||||
},
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
};
|
||||
|
||||
await createConnector(connectorData, parseInt(searchSpaceId));
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("ClickUp connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -24,7 +26,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const confluenceConnectorFormSchema = z.object({
|
||||
|
|
@ -60,7 +61,7 @@ export default function ConfluenceConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ConfluenceConnectorFormValues>({
|
||||
|
|
@ -77,8 +78,8 @@ export default function ConfluenceConnectorPage() {
|
|||
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -92,8 +93,10 @@ export default function ConfluenceConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Confluence connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -37,7 +39,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const discordConnectorFormSchema = z.object({
|
||||
|
|
@ -58,7 +59,7 @@ export default function DiscordConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<DiscordConnectorFormValues>({
|
||||
|
|
@ -73,8 +74,8 @@ export default function DiscordConnectorPage() {
|
|||
const onSubmit = async (values: DiscordConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.DISCORD_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -86,8 +87,10 @@ export default function DiscordConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Discord connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
@ -9,7 +10,7 @@ import { useId, useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -40,10 +41,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const elasticsearchConnectorFormSchema = z
|
||||
|
|
@ -91,7 +90,7 @@ export default function ElasticsearchConnectorPage() {
|
|||
const authBasicId = useId();
|
||||
const authApiKeyId = useId();
|
||||
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ElasticsearchConnectorFormValues>({
|
||||
|
|
@ -173,19 +172,21 @@ export default function ElasticsearchConnectorPage() {
|
|||
config.ELASTICSEARCH_MAX_DOCUMENTS = values.max_documents;
|
||||
}
|
||||
|
||||
const connectorPayload = {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
config,
|
||||
};
|
||||
|
||||
// Use existing hook method
|
||||
await createConnector(connectorPayload, searchSpaceIdNum);
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
config,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Elasticsearch connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -38,8 +40,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
// Define the form schema with Zod for GitHub PAT entry step
|
||||
|
|
@ -85,7 +85,7 @@ export default function GithubConnectorPage() {
|
|||
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
||||
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
||||
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form for PAT entry
|
||||
const form = useForm<GithubPatFormValues>({
|
||||
|
|
@ -141,8 +141,8 @@ export default function GithubConnectorPage() {
|
|||
|
||||
setIsCreatingConnector(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: connectorName, // Use the stored name
|
||||
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -155,8 +155,10 @@ export default function GithubConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("GitHub connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -9,6 +10,7 @@ import { useEffect, useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -20,10 +22,7 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleCalendarConnectorPage() {
|
||||
|
|
@ -33,11 +32,12 @@ export default function GoogleCalendarConnectorPage() {
|
|||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors } = useSearchSourceConnectors(true, parseInt(searchSpaceId));
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors(parseInt(searchSpaceId)).then((data) => {
|
||||
const connector = data.find(
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) =>
|
||||
c.connector_type === EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -9,6 +10,7 @@ import { useEffect, useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -20,10 +22,7 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleGmailConnectorPage() {
|
||||
|
|
@ -33,11 +32,12 @@ export default function GoogleGmailConnectorPage() {
|
|||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors } = useSearchSourceConnectors(true, parseInt(searchSpaceId));
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors(parseInt(searchSpaceId)).then((data) => {
|
||||
const connector = data.find(
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.GOOGLE_GMAIL_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -37,7 +39,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const jiraConnectorFormSchema = z.object({
|
||||
|
|
@ -73,7 +74,7 @@ export default function JiraConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<JiraConnectorFormValues>({
|
||||
|
|
@ -90,8 +91,8 @@ export default function JiraConnectorPage() {
|
|||
const onSubmit = async (values: JiraConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.JIRA_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -105,8 +106,10 @@ export default function JiraConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Jira connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -37,7 +39,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const linearConnectorFormSchema = z.object({
|
||||
|
|
@ -62,7 +63,7 @@ export default function LinearConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LinearConnectorFormValues>({
|
||||
|
|
@ -77,8 +78,8 @@ export default function LinearConnectorPage() {
|
|||
const onSubmit = async (values: LinearConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LINEAR_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -90,8 +91,10 @@ export default function LinearConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Linear connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -30,7 +32,6 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const linkupApiFormSchema = z.object({
|
||||
|
|
@ -50,7 +51,7 @@ export default function LinkupApiPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LinkupApiFormValues>({
|
||||
|
|
@ -65,8 +66,8 @@ export default function LinkupApiPage() {
|
|||
const onSubmit = async (values: LinkupApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LINKUP_API,
|
||||
config: {
|
||||
|
|
@ -78,8 +79,10 @@ export default function LinkupApiPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Linkup API connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -9,6 +10,8 @@ import { useEffect, useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -30,10 +33,7 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const lumaConnectorFormSchema = z.object({
|
||||
|
|
@ -55,10 +55,8 @@ export default function LumaConnectorPage() {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors, createConnector } = useSearchSourceConnectors(
|
||||
true,
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LumaConnectorFormValues>({
|
||||
|
|
@ -69,29 +67,26 @@ export default function LumaConnectorPage() {
|
|||
},
|
||||
});
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors(parseInt(searchSpaceId))
|
||||
.then((data) => {
|
||||
if (data && Array.isArray(data)) {
|
||||
const connector = data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching connectors:", error);
|
||||
});
|
||||
}, [fetchConnectors, searchSpaceId]);
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LumaConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LUMA_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -103,8 +98,10 @@ export default function LumaConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Luma connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -37,7 +39,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const notionConnectorFormSchema = z.object({
|
||||
|
|
@ -57,7 +58,7 @@ export default function NotionConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
|
|
@ -72,8 +73,8 @@ export default function NotionConnectorPage() {
|
|||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.NOTION_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -85,8 +86,10 @@ export default function NotionConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Notion connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -31,7 +33,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
const searxngFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
|
|
@ -67,7 +68,7 @@ export default function SearxngConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
const form = useForm<SearxngFormValues>({
|
||||
resolver: zodResolver(searxngFormSchema),
|
||||
|
|
@ -115,8 +116,8 @@ export default function SearxngConnectorPage() {
|
|||
config.SEARXNG_VERIFY_SSL = false;
|
||||
}
|
||||
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SEARXNG_API,
|
||||
config,
|
||||
|
|
@ -126,8 +127,10 @@ export default function SearxngConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("SearxNG connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -37,7 +39,6 @@ import { Input } from "@/components/ui/input";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const slackConnectorFormSchema = z.object({
|
||||
|
|
@ -57,7 +58,7 @@ export default function SlackConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
|
|
@ -72,8 +73,8 @@ export default function SlackConnectorPage() {
|
|||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SLACK_CONNECTOR,
|
||||
config: {
|
||||
|
|
@ -85,8 +86,10 @@ export default function SlackConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Slack connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -8,6 +9,7 @@ import { useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -30,7 +32,6 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const tavilyApiFormSchema = z.object({
|
||||
|
|
@ -50,7 +51,7 @@ export default function TavilyApiPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
|
|
@ -65,8 +66,8 @@ export default function TavilyApiPage() {
|
|||
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.TAVILY_API,
|
||||
config: {
|
||||
|
|
@ -78,8 +79,10 @@ export default function TavilyApiPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Tavily API connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Globe, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -9,6 +10,8 @@ import { useEffect, useState } from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -31,10 +34,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const webcrawlerConnectorFormSchema = z.object({
|
||||
|
|
@ -55,10 +55,8 @@ export default function WebcrawlerConnectorPage() {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors, createConnector } = useSearchSourceConnectors(
|
||||
true,
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<WebcrawlerConnectorFormValues>({
|
||||
|
|
@ -71,22 +69,16 @@ export default function WebcrawlerConnectorPage() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors(parseInt(searchSpaceId))
|
||||
.then((data) => {
|
||||
if (data && Array.isArray(data)) {
|
||||
const connector = data.find(
|
||||
(c: SearchSourceConnector) =>
|
||||
c.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching connectors:", error);
|
||||
});
|
||||
}, [fetchConnectors, searchSpaceId]);
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: WebcrawlerConnectorFormValues) => {
|
||||
|
|
@ -104,8 +96,8 @@ export default function WebcrawlerConnectorPage() {
|
|||
config.INITIAL_URLS = values.initial_urls;
|
||||
}
|
||||
|
||||
await createConnector(
|
||||
{
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
config: config,
|
||||
|
|
@ -115,8 +107,10 @@ export default function WebcrawlerConnectorPage() {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
parseInt(searchSpaceId)
|
||||
);
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Webcrawler connector created successfully!");
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
interface ProcessingIndicatorProps {
|
||||
activeTasksCount: number;
|
||||
}
|
||||
|
||||
export function ProcessingIndicator({ activeTasksCount }: ProcessingIndicatorProps) {
|
||||
const t = useTranslations("documents");
|
||||
|
||||
if (activeTasksCount === 0) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
animate={{ opacity: 1, height: "auto", marginBottom: 24 }}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert className="border-border bg-primary/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertTitle className="text-primary font-semibold">
|
||||
{t("processing_documents")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-muted-foreground">
|
||||
{t("active_tasks_count", { count: activeTasksCount })}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,19 +2,23 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useLogsSummary } from "@/hooks/use-logs";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { DocumentsFilters } from "./components/DocumentsFilters";
|
||||
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
|
||||
import { PaginationControls } from "./components/PaginationControls";
|
||||
import { ProcessingIndicator } from "./components/ProcessingIndicator";
|
||||
import type { ColumnVisibility } from "./components/types";
|
||||
|
||||
function useDebounced<T>(value: T, delay = 250) {
|
||||
|
|
@ -127,7 +131,22 @@ export default function DocumentsTable() {
|
|||
} else {
|
||||
await refetchDocuments();
|
||||
}
|
||||
}, [debouncedSearch, refetchSearch, refetchDocuments]);
|
||||
toast.success(t("refresh_success") || "Documents refreshed");
|
||||
}, [debouncedSearch, refetchSearch, refetchDocuments, t]);
|
||||
|
||||
// Set up polling for active tasks
|
||||
const { summary } = useLogsSummary(searchSpaceId, 24, { refetchInterval: 5000 });
|
||||
const activeTasksCount = summary?.active_tasks.length || 0;
|
||||
const prevActiveTasksCount = useRef(activeTasksCount);
|
||||
|
||||
// Auto-refresh when a task finishes
|
||||
useEffect(() => {
|
||||
if (prevActiveTasksCount.current > activeTasksCount) {
|
||||
// A task has finished!
|
||||
refreshCurrentView();
|
||||
}
|
||||
prevActiveTasksCount.current = activeTasksCount;
|
||||
}, [activeTasksCount, refreshCurrentView]);
|
||||
|
||||
// Create a delete function for single document deletion
|
||||
const deleteDocument = useCallback(
|
||||
|
|
@ -189,8 +208,26 @@ export default function DocumentsTable() {
|
|||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full px-6 py-4 min-h-[calc(100vh-64px)]"
|
||||
className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]"
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center justify-between"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<ProcessingIndicator activeTasksCount={activeTasksCount} />
|
||||
|
||||
<DocumentsFilters
|
||||
typeCounts={typeCounts ?? {}}
|
||||
selectedIds={selectedIds}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
|
|
@ -44,8 +45,13 @@ import {
|
|||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useContext, useId, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useContext, useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createLogMutationAtom,
|
||||
deleteLogMutationAtom,
|
||||
updateLogMutationAtom,
|
||||
} from "@/atoms/logs/log-mutation.atoms";
|
||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -89,7 +95,8 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { type Log, type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs";
|
||||
import type { CreateLogRequest, Log, UpdateLogRequest } from "@/contracts/types/log.types";
|
||||
import { type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define animation variants for reuse
|
||||
|
|
@ -334,13 +341,50 @@ export default function LogsManagePage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
|
||||
const {
|
||||
logs,
|
||||
loading: logsLoading,
|
||||
error: logsError,
|
||||
refreshLogs,
|
||||
deleteLog,
|
||||
} = useLogs(searchSpaceId);
|
||||
const { mutateAsync: deleteLogMutation } = useAtomValue(deleteLogMutationAtom);
|
||||
const { mutateAsync: updateLogMutation } = useAtomValue(updateLogMutationAtom);
|
||||
const { mutateAsync: createLogMutation } = useAtomValue(createLogMutationAtom);
|
||||
|
||||
const createLog = useCallback(
|
||||
async (data: CreateLogRequest) => {
|
||||
try {
|
||||
await createLogMutation(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to create log:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[createLogMutation]
|
||||
);
|
||||
|
||||
const updateLog = useCallback(
|
||||
async (logId: number, data: UpdateLogRequest) => {
|
||||
try {
|
||||
await updateLogMutation({ logId, data });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to update log:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateLogMutation]
|
||||
);
|
||||
|
||||
const deleteLog = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deleteLogMutation({ id });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete log:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteLogMutation]
|
||||
);
|
||||
|
||||
const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(searchSpaceId);
|
||||
const {
|
||||
summary,
|
||||
loading: summaryLoading,
|
||||
|
|
@ -408,7 +452,7 @@ export default function LogsManagePage() {
|
|||
return;
|
||||
}
|
||||
|
||||
const deletePromises = selectedRows.map((row) => deleteLog(row.original.id));
|
||||
const deletePromises = selectedRows.map((row) => deleteLog(row.original.id)); // Already passes { id } via wrapper
|
||||
|
||||
try {
|
||||
const results = await Promise.all(deletePromises);
|
||||
|
|
@ -437,7 +481,7 @@ export default function LogsManagePage() {
|
|||
<LogsContext.Provider
|
||||
value={{
|
||||
deleteLog: deleteLog || (() => Promise.resolve(false)),
|
||||
refreshLogs: refreshLogs || (() => Promise.resolve()),
|
||||
refreshLogs: () => refreshLogs().then(() => void 0),
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
|
|
@ -524,7 +568,7 @@ export default function LogsManagePage() {
|
|||
table={table}
|
||||
logs={logs}
|
||||
loading={logsLoading}
|
||||
error={logsError}
|
||||
error={logsError?.message ?? null}
|
||||
onRefresh={refreshLogs}
|
||||
id={id}
|
||||
t={t}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type ThreadMessageLike,
|
||||
useExternalStoreRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -17,6 +18,11 @@ import {
|
|||
mentionedDocumentsAtom,
|
||||
messageDocumentsMapAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
clearPlanOwnerRegistry,
|
||||
extractWriteTodosFromContent,
|
||||
hydratePlanStateAtom,
|
||||
} from "@/atoms/chat/plan-state.atom";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
|
|
@ -24,6 +30,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
|||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
import {
|
||||
|
|
@ -91,9 +98,45 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
|
|||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for persisted attachment info
|
||||
*/
|
||||
const PersistedAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
contentType: z.string().optional(),
|
||||
imageDataUrl: z.string().optional(),
|
||||
extractedContent: z.string().optional(),
|
||||
});
|
||||
|
||||
const AttachmentsPartSchema = z.object({
|
||||
type: z.literal("attachments"),
|
||||
items: z.array(PersistedAttachmentSchema),
|
||||
});
|
||||
|
||||
type PersistedAttachment = z.infer<typeof PersistedAttachmentSchema>;
|
||||
|
||||
/**
|
||||
* Extract persisted attachments from message content (type-safe with Zod)
|
||||
*/
|
||||
function extractPersistedAttachments(content: unknown): PersistedAttachment[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
for (const part of content) {
|
||||
const result = AttachmentsPartSchema.safeParse(part);
|
||||
if (result.success) {
|
||||
return result.data.items;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend message to assistant-ui ThreadMessageLike format
|
||||
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
|
||||
* Restores attachments for user messages from persisted data
|
||||
*/
|
||||
function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||
let content: ThreadMessageLike["content"];
|
||||
|
|
@ -105,8 +148,12 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
const filteredContent = msg.content.filter((part: unknown) => {
|
||||
if (typeof part !== "object" || part === null || !("type" in part)) return true;
|
||||
const partType = (part as { type: string }).type;
|
||||
// Filter out thinking-steps and mentioned-documents
|
||||
return partType !== "thinking-steps" && partType !== "mentioned-documents";
|
||||
// Filter out thinking-steps, mentioned-documents, and attachments
|
||||
return (
|
||||
partType !== "thinking-steps" &&
|
||||
partType !== "mentioned-documents" &&
|
||||
partType !== "attachments"
|
||||
);
|
||||
});
|
||||
content =
|
||||
filteredContent.length > 0
|
||||
|
|
@ -116,11 +163,31 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
|||
content = [{ type: "text", text: String(msg.content) }];
|
||||
}
|
||||
|
||||
// Restore attachments for user messages
|
||||
let attachments: ThreadMessageLike["attachments"];
|
||||
if (msg.role === "user") {
|
||||
const persistedAttachments = extractPersistedAttachments(msg.content);
|
||||
if (persistedAttachments.length > 0) {
|
||||
attachments = persistedAttachments.map((att) => ({
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
type: att.type as "document" | "image" | "file",
|
||||
contentType: att.contentType || "application/octet-stream",
|
||||
status: { type: "complete" as const },
|
||||
content: [],
|
||||
// Custom fields for our ChatAttachment interface
|
||||
imageDataUrl: att.imageDataUrl,
|
||||
extractedContent: att.extractedContent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `msg-${msg.id}`,
|
||||
role: msg.role,
|
||||
content,
|
||||
createdAt: new Date(msg.created_at),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +199,7 @@ const TOOLS_WITH_UI = new Set([
|
|||
"link_preview",
|
||||
"display_image",
|
||||
"scrape_webpage",
|
||||
"write_todos",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -146,6 +214,7 @@ interface ThinkingStepData {
|
|||
|
||||
export default function NewChatPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
|
|
@ -163,6 +232,7 @@ export default function NewChatPage() {
|
|||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
||||
|
||||
// Create the attachment adapter for file processing
|
||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||
|
|
@ -198,6 +268,7 @@ export default function NewChatPage() {
|
|||
setMentionedDocumentIds([]);
|
||||
setMentionedDocuments([]);
|
||||
setMessageDocumentsMap({});
|
||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
|
|
@ -219,6 +290,11 @@ export default function NewChatPage() {
|
|||
if (steps.length > 0) {
|
||||
restoredThinkingSteps.set(`msg-${msg.id}`, steps);
|
||||
}
|
||||
// Hydrate write_todos plan state from persisted tool calls
|
||||
const writeTodosCalls = extractWriteTodosFromContent(msg.content);
|
||||
for (const todoData of writeTodosCalls) {
|
||||
hydratePlanState(todoData);
|
||||
}
|
||||
}
|
||||
if (msg.role === "user") {
|
||||
const docs = extractMentionedDocuments(msg.content);
|
||||
|
|
@ -247,7 +323,13 @@ export default function NewChatPage() {
|
|||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments]);
|
||||
}, [
|
||||
urlChatId,
|
||||
setMessageDocumentsMap,
|
||||
setMentionedDocumentIds,
|
||||
setMentionedDocuments,
|
||||
hydratePlanState,
|
||||
]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -306,6 +388,7 @@ export default function NewChatPage() {
|
|||
|
||||
// Lazy thread creation: create thread on first message if it doesn't exist
|
||||
let currentThreadId = threadId;
|
||||
let isNewThread = false;
|
||||
if (!currentThreadId) {
|
||||
try {
|
||||
const newThread = await createThread(searchSpaceId, "New Chat");
|
||||
|
|
@ -315,6 +398,7 @@ export default function NewChatPage() {
|
|||
// Track chat creation
|
||||
trackChatCreated(searchSpaceId, currentThreadId);
|
||||
|
||||
isNewThread = true;
|
||||
// Update URL silently using browser API (not router.replace) to avoid
|
||||
// interrupting the ongoing fetch/streaming with React navigation
|
||||
window.history.replaceState(
|
||||
|
|
@ -361,25 +445,50 @@ export default function NewChatPage() {
|
|||
}));
|
||||
}
|
||||
|
||||
// Persist user message with mentioned documents (don't await, fire and forget)
|
||||
const persistContent =
|
||||
mentionedDocuments.length > 0
|
||||
? [
|
||||
...message.content,
|
||||
{
|
||||
type: "mentioned-documents",
|
||||
documents: mentionedDocuments.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: message.content;
|
||||
// Persist user message with mentioned documents and attachments (don't await, fire and forget)
|
||||
const persistContent: unknown[] = [...message.content];
|
||||
|
||||
// Add mentioned documents for persistence
|
||||
if (mentionedDocuments.length > 0) {
|
||||
persistContent.push({
|
||||
type: "mentioned-documents",
|
||||
documents: mentionedDocuments.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Add attachments for persistence (so they survive page reload)
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
persistContent.push({
|
||||
type: "attachments",
|
||||
items: message.attachments.map((att) => ({
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
contentType: (att as { contentType?: string }).contentType,
|
||||
// Include imageDataUrl for images so they can be displayed after reload
|
||||
imageDataUrl: (att as { imageDataUrl?: string }).imageDataUrl,
|
||||
// Include extractedContent for context (already extracted, no re-processing needed)
|
||||
extractedContent: (att as { extractedContent?: string }).extractedContent,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
appendMessage(currentThreadId, {
|
||||
role: "user",
|
||||
content: persistContent,
|
||||
}).catch((err) => console.error("Failed to persist user message:", err));
|
||||
})
|
||||
.then(() => {
|
||||
// For new threads, the backend updates the title from the first user message
|
||||
// Invalidate threads query so sidebar shows the updated title in real-time
|
||||
if (isNewThread) {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to persist user message:", err));
|
||||
|
||||
// Start streaming response
|
||||
setIsRunning(true);
|
||||
|
|
@ -676,7 +785,19 @@ export default function NewChatPage() {
|
|||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Request was cancelled
|
||||
// Request was cancelled by user - persist partial response if any content was received
|
||||
const hasContent = contentParts.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text.length > 0) ||
|
||||
(part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName))
|
||||
);
|
||||
if (hasContent && currentThreadId) {
|
||||
const partialContent = buildContentForPersistence();
|
||||
appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: partialContent,
|
||||
}).catch((err) => console.error("Failed to persist partial assistant message:", err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error("[NewChatPage] Chat error:", error);
|
||||
|
|
@ -720,6 +841,7 @@ export default function NewChatPage() {
|
|||
setMentionedDocumentIds,
|
||||
setMentionedDocuments,
|
||||
setMessageDocumentsMap,
|
||||
queryClient,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -789,6 +911,7 @@ export default function NewChatPage() {
|
|||
<LinkPreviewToolUI />
|
||||
<DisplayImageToolUI />
|
||||
<ScrapeWebpageToolUI />
|
||||
<WriteTodosToolUI />
|
||||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ export default function SearchSpaceDashboardPage() {
|
|||
const { search_space_id } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
router.push(`/dashboard/${search_space_id}/chats`);
|
||||
}, []);
|
||||
router.push(`/dashboard/${search_space_id}/new-chat`);
|
||||
}, [router, search_space_id]);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
|
|
|||
224
surfsense_web/atoms/chat/plan-state.atom.ts
Normal file
224
surfsense_web/atoms/chat/plan-state.atom.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Plan State Atom
|
||||
*
|
||||
* Tracks the latest state of each plan by title.
|
||||
* When write_todos is called multiple times with the same title,
|
||||
* only the FIRST component renders (stays fixed in position),
|
||||
* subsequent calls just update the shared state.
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
|
||||
export interface PlanTodo {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
}
|
||||
|
||||
export interface PlanState {
|
||||
id: string;
|
||||
title: string;
|
||||
todos: PlanTodo[];
|
||||
lastUpdated: number;
|
||||
/** The toolCallId of the first component that rendered this plan */
|
||||
ownerToolCallId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SYNCHRONOUS ownership registry - prevents race conditions
|
||||
* Only ONE plan allowed per conversation - first plan wins
|
||||
*/
|
||||
let firstPlanOwner: { toolCallId: string; title: string } | null = null;
|
||||
|
||||
/**
|
||||
* Register as owner of a plan SYNCHRONOUSLY
|
||||
* ONE PLAN PER CONVERSATION: Only the first write_todos call renders.
|
||||
* All subsequent calls update the state but don't render their own card.
|
||||
*/
|
||||
export function registerPlanOwner(title: string, toolCallId: string): boolean {
|
||||
if (!firstPlanOwner) {
|
||||
// First plan in this conversation - claim ownership
|
||||
firstPlanOwner = { toolCallId, title };
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we're the owner
|
||||
return firstPlanOwner.toolCallId === toolCallId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canonical title for a plan
|
||||
* Returns the first plan's title if one exists, otherwise the provided title
|
||||
*/
|
||||
export function getCanonicalPlanTitle(title: string): string {
|
||||
return firstPlanOwner?.title || title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plan already exists in this conversation
|
||||
*/
|
||||
export function hasPlan(): boolean {
|
||||
return firstPlanOwner !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first plan's info
|
||||
*/
|
||||
export function getFirstPlanInfo(): { toolCallId: string; title: string } | null {
|
||||
return firstPlanOwner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a toolCallId is the owner of the plan SYNCHRONOUSLY
|
||||
*/
|
||||
export function isPlanOwner(toolCallId: string): boolean {
|
||||
return !firstPlanOwner || firstPlanOwner.toolCallId === toolCallId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ownership registry (call when starting a new chat)
|
||||
*/
|
||||
export function clearPlanOwnerRegistry(): void {
|
||||
firstPlanOwner = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of plan title -> latest plan state
|
||||
* Using title as key since it stays constant across updates
|
||||
*/
|
||||
export const planStatesAtom = atom<Map<string, PlanState>>(new Map());
|
||||
|
||||
/**
|
||||
* Input type for updating plan state
|
||||
*/
|
||||
export interface UpdatePlanInput {
|
||||
id: string;
|
||||
title: string;
|
||||
todos: PlanTodo[];
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper atom to update a plan state
|
||||
*/
|
||||
export const updatePlanStateAtom = atom(null, (get, set, plan: UpdatePlanInput) => {
|
||||
const states = new Map(get(planStatesAtom));
|
||||
|
||||
// Register ownership synchronously if not already done
|
||||
registerPlanOwner(plan.title, plan.toolCallId);
|
||||
|
||||
// Get the actual owner from the first plan
|
||||
const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId;
|
||||
|
||||
// Always use the canonical (first) title for the plan key
|
||||
const canonicalTitle = getCanonicalPlanTitle(plan.title);
|
||||
|
||||
states.set(canonicalTitle, {
|
||||
id: plan.id,
|
||||
title: canonicalTitle,
|
||||
todos: plan.todos,
|
||||
lastUpdated: Date.now(),
|
||||
ownerToolCallId,
|
||||
});
|
||||
set(planStatesAtom, states);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper atom to get the latest plan state by title
|
||||
*/
|
||||
export const getPlanStateAtom = atom((get) => {
|
||||
const states = get(planStatesAtom);
|
||||
return (title: string) => states.get(title);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper atom to clear all plan states (useful when starting a new chat)
|
||||
*/
|
||||
export const clearPlanStatesAtom = atom(null, (get, set) => {
|
||||
clearPlanOwnerRegistry();
|
||||
set(planStatesAtom, new Map());
|
||||
});
|
||||
|
||||
/**
|
||||
* Hydrate plan state from persisted message content
|
||||
* Call this when loading messages from the database to restore plan state
|
||||
*/
|
||||
export interface HydratePlanInput {
|
||||
toolCallId: string;
|
||||
result: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
todos?: Array<{
|
||||
id?: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const hydratePlanStateAtom = atom(null, (get, set, plan: HydratePlanInput) => {
|
||||
if (!plan.result?.todos || plan.result.todos.length === 0) return;
|
||||
|
||||
const states = new Map(get(planStatesAtom));
|
||||
const title = plan.result.title || "Plan";
|
||||
|
||||
// Register this as the owner if no plan exists yet
|
||||
registerPlanOwner(title, plan.toolCallId);
|
||||
|
||||
// Get the canonical title
|
||||
const canonicalTitle = getCanonicalPlanTitle(title);
|
||||
const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId;
|
||||
|
||||
// Only set if this is newer or doesn't exist
|
||||
const existing = states.get(canonicalTitle);
|
||||
if (!existing) {
|
||||
states.set(canonicalTitle, {
|
||||
id: plan.result.id || `plan-${Date.now()}`,
|
||||
title: canonicalTitle,
|
||||
todos: plan.result.todos.map((t, i) => ({
|
||||
id: t.id || `todo-${i}`,
|
||||
content: t.content,
|
||||
status: t.status,
|
||||
})),
|
||||
lastUpdated: Date.now(),
|
||||
ownerToolCallId,
|
||||
});
|
||||
set(planStatesAtom, states);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract write_todos tool call data from message content
|
||||
* Returns an array of { toolCallId, result } for each write_todos call found
|
||||
*/
|
||||
export function extractWriteTodosFromContent(content: unknown): HydratePlanInput[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const results: HydratePlanInput[] = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
(part as { type: string }).type === "tool-call" &&
|
||||
"toolName" in part &&
|
||||
(part as { toolName: string }).toolName === "write_todos" &&
|
||||
"toolCallId" in part &&
|
||||
"result" in part
|
||||
) {
|
||||
const toolCall = part as {
|
||||
toolCallId: string;
|
||||
result: HydratePlanInput["result"];
|
||||
};
|
||||
if (toolCall.result) {
|
||||
results.push({
|
||||
toolCallId: toolCall.toolCallId,
|
||||
result: toolCall.result,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
100
surfsense_web/atoms/connectors/connector-mutation.atoms.ts
Normal file
100
surfsense_web/atoms/connectors/connector-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateConnectorRequest,
|
||||
DeleteConnectorRequest,
|
||||
GetConnectorsResponse,
|
||||
IndexConnectorRequest,
|
||||
IndexConnectorResponse,
|
||||
UpdateConnectorRequest,
|
||||
} from "@/contracts/types/connector.types";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const createConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateConnectorRequest) => {
|
||||
return connectorsApiService.createConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateConnectorRequest) => {
|
||||
return connectorsApiService.updateConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: UpdateConnectorRequest) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.byId(String(request.id)),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: DeleteConnectorRequest) => {
|
||||
return connectorsApiService.deleteConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: DeleteConnectorRequest) => {
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.connectors.all(searchSpaceId!),
|
||||
(oldData: GetConnectorsResponse | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((connector) => connector.id !== request.id);
|
||||
}
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.byId(String(request.id)),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const indexConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.index(),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: IndexConnectorRequest) => {
|
||||
return connectorsApiService.indexConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: (response: IndexConnectorResponse) => {
|
||||
toast.success(response.message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.byId(String(response.connector_id)),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
21
surfsense_web/atoms/connectors/connector-query.atoms.ts
Normal file
21
surfsense_web/atoms/connectors/connector-query.atoms.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const connectorsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
return connectorsApiService.getConnectors({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId!,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
7
surfsense_web/atoms/connectors/ui.atoms.ts
Normal file
7
surfsense_web/atoms/connectors/ui.atoms.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { atom } from "jotai";
|
||||
import type { GetConnectorsRequest } from "@/contracts/types/connector.types";
|
||||
|
||||
export const globalConnectorsQueryParamsAtom = atom<GetConnectorsRequest["queryParams"]>({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
});
|
||||
68
surfsense_web/atoms/logs/log-mutation.atoms.ts
Normal file
68
surfsense_web/atoms/logs/log-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import type {
|
||||
CreateLogRequest,
|
||||
DeleteLogRequest,
|
||||
UpdateLogRequest,
|
||||
} from "@/contracts/types/log.types";
|
||||
import { logsApiService } from "@/lib/apis/logs-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
/**
|
||||
* Create Log Mutation
|
||||
*/
|
||||
export const createLogMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
return {
|
||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateLogRequest) => logsApiService.createLog(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.logs.list(searchSpaceId ?? undefined) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Update Log Mutation
|
||||
*/
|
||||
export const updateLogMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
return {
|
||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async ({ logId, data }: { logId: number; data: UpdateLogRequest }) =>
|
||||
logsApiService.updateLog(logId, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.logs.detail(variables.logId) });
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.logs.list(searchSpaceId ?? undefined) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete Log Mutation
|
||||
*/
|
||||
export const deleteLogMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
return {
|
||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: DeleteLogRequest) => logsApiService.deleteLog(request),
|
||||
onSuccess: (_data, request) => {
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.logs.list(searchSpaceId ?? undefined) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||
});
|
||||
if (request?.id)
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.logs.detail(request.id) });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -15,8 +15,6 @@ import {
|
|||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
|
|
@ -28,8 +26,6 @@ import {
|
|||
Plug2,
|
||||
Plus,
|
||||
RefreshCwIcon,
|
||||
Search,
|
||||
Sparkles,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -75,13 +71,8 @@ import {
|
|||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtItem,
|
||||
ChainOfThoughtStep,
|
||||
ChainOfThoughtTrigger,
|
||||
} from "@/components/prompt-kit/chain-of-thought";
|
||||
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -103,124 +94,146 @@ interface ThreadProps {
|
|||
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
|
||||
* Chain of thought display component - single collapsible dropdown design
|
||||
*/
|
||||
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>>({});
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 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;
|
||||
});
|
||||
// Derive effective status for each step
|
||||
const getEffectiveStatus = useCallback(
|
||||
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
return "completed";
|
||||
}
|
||||
});
|
||||
prevStatusesRef.current = currentStatuses;
|
||||
}, [steps]);
|
||||
return step.status;
|
||||
},
|
||||
[isThreadRunning]
|
||||
);
|
||||
|
||||
// Calculate summary info
|
||||
const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length;
|
||||
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
|
||||
const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning;
|
||||
const isProcessing = isThreadRunning && !allCompleted;
|
||||
|
||||
// Auto-collapse when all tasks are completed
|
||||
useEffect(() => {
|
||||
if (allCompleted) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [allCompleted]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
const getStepOpenState = (step: ThinkingStep): boolean => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
// If user has manually toggled, respect that
|
||||
if (manualOverrides[step.id] !== undefined) {
|
||||
return manualOverrides[step.id];
|
||||
// Generate header text
|
||||
const getHeaderText = () => {
|
||||
if (allCompleted) {
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
}
|
||||
// Auto behavior: open if in progress
|
||||
if (effectiveStatus === "in_progress") {
|
||||
return true;
|
||||
if (inProgressStep) {
|
||||
return inProgressStep.title;
|
||||
}
|
||||
// Default: collapsed (all steps collapse when processing is done)
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleToggle = (stepId: string, currentOpen: boolean) => {
|
||||
setManualOverrides((prev) => ({
|
||||
...prev,
|
||||
[stepId]: !currentOpen,
|
||||
}));
|
||||
if (isProcessing) {
|
||||
return `Processing ${completedSteps}/${steps.length} steps`;
|
||||
}
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<ChainOfThought>
|
||||
{steps.map((step) => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
const icon = getStepIcon(effectiveStatus, step.title);
|
||||
const isOpen = getStepOpenState(step);
|
||||
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 className="rounded-lg">
|
||||
{/* Main collapsible header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{/* Header text with shimmer if processing or has in-progress step */}
|
||||
{isProcessing || inProgressStep ? (
|
||||
<TextShimmerLoader text={getHeaderText()} size="sm" />
|
||||
) : (
|
||||
<span>{getHeaderText()}</span>
|
||||
)}
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Collapsible content with CSS grid animation */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{steps.map((step, index) => {
|
||||
const effectiveStatus = getEffectiveStatus(step);
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{/* Dot and line column */}
|
||||
<div className="relative flex flex-col items-center w-2">
|
||||
{/* Vertical connection line - extends to next dot */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-1/2 top-[15px] -bottom-[7px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||
)}
|
||||
{/* Step dot - on top of line */}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
{/* Step title */}
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-5",
|
||||
effectiveStatus === "in_progress" && "text-foreground font-medium",
|
||||
effectiveStatus === "completed" && "text-muted-foreground",
|
||||
effectiveStatus === "pending" && "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<TextShimmerLoader text={step.title} size="sm" />
|
||||
) : (
|
||||
step.title
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step items (sub-content) */}
|
||||
{step.items && step.items.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{step.items.map((item, idx) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-item-${idx}`} className="text-xs">
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -286,7 +299,7 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), head
|
|||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
>
|
||||
{/* Optional sticky header for model selector etc. */}
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
|
@ -428,13 +441,6 @@ const Composer: FC = () => {
|
|||
}
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Reset auto-focus flag when thread becomes non-empty (user sent a message)
|
||||
useEffect(() => {
|
||||
if (!isThreadEmpty) {
|
||||
hasAutoFocusedRef.current = false;
|
||||
}
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Sync mentioned document IDs to atom for use in chat request
|
||||
useEffect(() => {
|
||||
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
||||
|
|
@ -561,7 +567,7 @@ const Composer: FC = () => {
|
|||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Ask SurfSense (type @ to mention docs)"
|
||||
placeholder="Ask SurfSense or @mention docs"
|
||||
onMentionTrigger={handleMentionTrigger}
|
||||
onMentionClose={handleMentionClose}
|
||||
onChange={handleEditorChange}
|
||||
|
|
@ -683,14 +689,10 @@ const ConnectorIndicator: FC = () => {
|
|||
) : (
|
||||
<>
|
||||
<Plug2 className="size-4" />
|
||||
{totalSourceCount > 0 ? (
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -917,7 +919,7 @@ const AssistantMessageInner: FC = () => {
|
|||
<MessageError />
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 ml-2 flex">
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||
<BranchPicker />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
|
|
|
|||
66
surfsense_web/components/prompt-kit/loader.tsx
Normal file
66
surfsense_web/components/prompt-kit/loader.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LoaderProps {
|
||||
variant?: "text-shimmer";
|
||||
size?: "sm" | "md" | "lg";
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TextShimmerLoader - A text loader with a shimmer gradient animation
|
||||
* Used for in-progress states in write_todos and chain-of-thought
|
||||
*/
|
||||
export function TextShimmerLoader({
|
||||
text = "Thinking",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
text?: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: -200% 50%; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
|
||||
"bg-[length:200%_auto] bg-clip-text font-medium text-transparent",
|
||||
"animate-[shimmer_4s_infinite_linear]",
|
||||
textSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader component - currently only supports text-shimmer variant
|
||||
* Can be extended with more variants if needed in the future
|
||||
*/
|
||||
export function Loader({ variant = "text-shimmer", size = "md", text, className }: LoaderProps) {
|
||||
switch (variant) {
|
||||
case "text-shimmer":
|
||||
default:
|
||||
return <TextShimmerLoader text={text} size={size} className={className} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||
|
|
@ -50,7 +50,13 @@ export function AppSidebarProvider({
|
|||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current chat ID from URL params
|
||||
const currentChatId = params?.chat_id
|
||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
const [isDeletingThread, setIsDeletingThread] = useState(false);
|
||||
|
||||
// Editor state for handling unsaved changes
|
||||
|
|
@ -61,7 +67,6 @@ export function AppSidebarProvider({
|
|||
const {
|
||||
data: threadsData,
|
||||
error: threadError,
|
||||
isLoading: isLoadingThreads,
|
||||
refetch: refetchThreads,
|
||||
} = useQuery({
|
||||
queryKey: ["threads", searchSpaceId],
|
||||
|
|
@ -73,7 +78,6 @@ export function AppSidebarProvider({
|
|||
data: searchSpace,
|
||||
isLoading: isLoadingSearchSpace,
|
||||
error: searchSpaceError,
|
||||
refetch: fetchSearchSpace,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
|
|
@ -83,12 +87,7 @@ export function AppSidebarProvider({
|
|||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Fetch notes
|
||||
const {
|
||||
data: notesData,
|
||||
error: notesError,
|
||||
isLoading: isLoadingNotes,
|
||||
refetch: refetchNotes,
|
||||
} = useQuery({
|
||||
const { data: notesData, refetch: refetchNotes } = useQuery({
|
||||
queryKey: ["notes", searchSpaceId],
|
||||
queryFn: () =>
|
||||
notesApiService.getNotes({
|
||||
|
|
@ -108,11 +107,6 @@ export function AppSidebarProvider({
|
|||
} | null>(null);
|
||||
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
||||
|
||||
// Retry function
|
||||
const retryFetch = useCallback(() => {
|
||||
fetchSearchSpace();
|
||||
}, [fetchSearchSpace]);
|
||||
|
||||
// Transform threads to the format expected by AppSidebar
|
||||
const recentChats = useMemo(() => {
|
||||
if (!threadsData?.threads) return [];
|
||||
|
|
@ -149,6 +143,10 @@ export function AppSidebarProvider({
|
|||
await deleteThread(threadToDelete.id);
|
||||
// Invalidate threads query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
// Only navigate to new-chat if the deleted chat is currently open
|
||||
if (currentChatId === threadToDelete.id) {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting thread:", error);
|
||||
} finally {
|
||||
|
|
@ -156,7 +154,7 @@ export function AppSidebarProvider({
|
|||
setShowDeleteDialog(false);
|
||||
setThreadToDelete(null);
|
||||
}
|
||||
}, [threadToDelete, queryClient, searchSpaceId]);
|
||||
}, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
||||
|
||||
// Handle delete note with confirmation
|
||||
const handleDeleteNote = useCallback(async () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
|
@ -47,7 +47,15 @@ interface AllChatsSidebarProps {
|
|||
export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get the current chat ID from URL to check if user is deleting the currently open chat
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
: params.chat_id
|
||||
? Number(params.chat_id)
|
||||
: null;
|
||||
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -126,6 +134,15 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
|||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
|
||||
// If the deleted chat is currently open, close sidebar first then redirect
|
||||
if (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
// Wait for sidebar close animation to complete before navigating
|
||||
setTimeout(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, 250);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting thread:", error);
|
||||
toast.error(t("error_deleting_chat") || "Failed to delete chat");
|
||||
|
|
@ -133,7 +150,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
|||
setDeletingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t]
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
||||
);
|
||||
|
||||
// Handle thread archive/unarchive
|
||||
|
|
@ -293,6 +310,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
|||
const isDeleting = deletingThreadId === thread.id;
|
||||
const isArchiving = archivingThreadId === thread.id;
|
||||
const isBusy = isDeleting || isArchiving;
|
||||
const isActive = currentChatId === thread.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -301,6 +319,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
|||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { format } from "date-fns";
|
||||
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
|
@ -37,7 +37,11 @@ export function AllNotesSidebar({
|
|||
}: AllNotesSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get the current note ID from URL to highlight the open note
|
||||
const currentNoteId = params.note_id ? Number(params.note_id) : null;
|
||||
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
|
@ -208,7 +212,7 @@ export function AllNotesSidebar({
|
|||
aria-label={t("all_notes") || "All Notes"}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 pb-3 space-y-3 border-b">
|
||||
<div className="flex-shrink-0 p-4 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("all_notes") || "All Notes"}</h2>
|
||||
<Button
|
||||
|
|
@ -260,6 +264,7 @@ export function AllNotesSidebar({
|
|||
<div className="space-y-1">
|
||||
{notes.map((note) => {
|
||||
const isDeleting = deletingNoteId === note.id;
|
||||
const isActive = currentNoteId === note.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -268,6 +273,7 @@ export function AllNotesSidebar({
|
|||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isDeleting && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
|
|
@ -370,7 +376,7 @@ export function AllNotesSidebar({
|
|||
|
||||
{/* Footer with Add Note button */}
|
||||
{onAddNote && notes.length > 0 && (
|
||||
<div className="flex-shrink-0 p-3 border-t">
|
||||
<div className="flex-shrink-0 p-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onAddNote();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -71,6 +71,7 @@ export function NavChats({
|
|||
}: NavChatsProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isMobile = useIsMobile();
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
|
@ -142,6 +143,7 @@ export function NavChats({
|
|||
<SidebarMenu>
|
||||
{chats.map((chat) => {
|
||||
const isDeletingChat = isDeleting === chat.id;
|
||||
const isActive = pathname === chat.url;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={chat.id || chat.name} className="group/chat">
|
||||
|
|
@ -151,6 +153,7 @@ export function NavChats({
|
|||
disabled={isDeletingChat}
|
||||
className={cn(
|
||||
"pr-8", // Make room for the action button
|
||||
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isDeletingChat && "opacity-50"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ interface NavMainProps {
|
|||
|
||||
export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
||||
const t = useTranslations("nav_menu");
|
||||
const pathname = usePathname();
|
||||
|
||||
// Translation function that handles both exact matches and fallback to original
|
||||
const translateTitle = (title: string): string => {
|
||||
|
|
@ -55,6 +57,35 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
|||
return key ? t(key) : title;
|
||||
};
|
||||
|
||||
// Check if an item is active based on pathname
|
||||
const isItemActive = useCallback(
|
||||
(item: NavItem): boolean => {
|
||||
if (!pathname) return false;
|
||||
|
||||
// For items without sub-items, check if pathname matches or starts with the URL
|
||||
if (!item.items?.length) {
|
||||
// Chat item: active ONLY when on new-chat page without a specific chat ID
|
||||
// (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123)
|
||||
if (item.url.includes("/new-chat")) {
|
||||
// Match exactly the new-chat base URL (ends with /new-chat)
|
||||
return pathname.endsWith("/new-chat");
|
||||
}
|
||||
// Logs item: active when on logs page
|
||||
if (item.url.includes("/logs")) {
|
||||
return pathname.includes("/logs");
|
||||
}
|
||||
// Check exact match or prefix match
|
||||
return pathname === item.url || pathname.startsWith(`${item.url}/`);
|
||||
}
|
||||
|
||||
// For items with sub-items (like Sources), check if any sub-item URL matches
|
||||
return item.items.some(
|
||||
(subItem) => pathname === subItem.url || pathname.startsWith(subItem.url)
|
||||
);
|
||||
},
|
||||
[pathname]
|
||||
);
|
||||
|
||||
// Memoize items to prevent unnecessary re-renders
|
||||
const memoizedItems = useMemo(() => items, [items]);
|
||||
|
||||
|
|
@ -88,14 +119,15 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
|||
{memoizedItems.map((item, index) => {
|
||||
const translatedTitle = translateTitle(item.title);
|
||||
const hasSub = !!item.items?.length;
|
||||
const isItemOpen = expandedItems[item.title] ?? item.isActive ?? false;
|
||||
const isActive = isItemActive(item);
|
||||
const isItemOpen = expandedItems[item.title] ?? isActive ?? false;
|
||||
return (
|
||||
<Collapsible
|
||||
key={`${item.title}-${index}`}
|
||||
asChild
|
||||
open={hasSub ? isItemOpen : undefined}
|
||||
onOpenChange={hasSub ? (open) => handleOpenChange(item.title, open) : undefined}
|
||||
defaultOpen={!hasSub ? item.isActive : undefined}
|
||||
defaultOpen={!hasSub ? isActive : undefined}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
{hasSub ? (
|
||||
|
|
@ -105,7 +137,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
|||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={translatedTitle}
|
||||
isActive={item.isActive}
|
||||
isActive={isActive}
|
||||
aria-label={`${translatedTitle} with submenu`}
|
||||
>
|
||||
<button type="button" className="flex items-center gap-2 w-full text-left">
|
||||
|
|
@ -147,7 +179,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
|||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={translatedTitle}
|
||||
isActive={item.isActive}
|
||||
isActive={isActive}
|
||||
aria-label={translatedTitle}
|
||||
>
|
||||
<a href={item.url}>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import {
|
|||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useLogsSummary } from "@/hooks/use-logs";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AllNotesSidebar } from "./all-notes-sidebar";
|
||||
|
|
@ -72,11 +73,27 @@ export function NavNotes({
|
|||
}: NavNotesProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isMobile = useIsMobile();
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
||||
|
||||
// Poll for active reindexing tasks to show inline loading indicators
|
||||
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
// Create a Set of document IDs that are currently being reindexed
|
||||
const reindexingDocumentIds = useMemo(() => {
|
||||
if (!summary?.active_tasks) return new Set<number>();
|
||||
return new Set(
|
||||
summary.active_tasks
|
||||
.filter((task) => task.document_id != null)
|
||||
.map((task) => task.document_id as number)
|
||||
);
|
||||
}, [summary?.active_tasks]);
|
||||
|
||||
// Auto-collapse on smaller screens when Sources is expanded
|
||||
useEffect(() => {
|
||||
if (isSourcesExpanded && isMobile) {
|
||||
|
|
@ -157,6 +174,8 @@ export function NavNotes({
|
|||
{notes.length > 0 ? (
|
||||
notes.map((note) => {
|
||||
const isDeletingNote = isDeleting === note.id;
|
||||
const isActive = pathname === note.url;
|
||||
const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={note.id || note.name} className="group/note">
|
||||
|
|
@ -166,10 +185,15 @@ export function NavNotes({
|
|||
disabled={isDeletingNote}
|
||||
className={cn(
|
||||
"pr-8", // Make room for the action button
|
||||
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
isDeletingNote && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<note.icon className="h-4 w-4 shrink-0" />
|
||||
{isReindexing ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<note.icon className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{note.name}</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
|
|
|
|||
|
|
@ -36,12 +36,21 @@ export function NavSecondary({
|
|||
<SidebarMenu>
|
||||
{memoizedItems.map((item, index) => (
|
||||
<SidebarMenuItem key={`${item.title}-${index}`}>
|
||||
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
{item.url === "#" ? (
|
||||
// Non-interactive display item (e.g., search space name)
|
||||
<div className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-xs text-sidebar-foreground">
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
// Interactive link item
|
||||
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, ImageIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Image,
|
||||
ImageErrorBoundary,
|
||||
|
|
@ -9,27 +10,41 @@ import {
|
|||
parseSerializableImage,
|
||||
} from "@/components/tool-ui/image";
|
||||
|
||||
/**
|
||||
* Type definitions for the display_image tool
|
||||
*/
|
||||
interface DisplayImageArgs {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
// ============================================================================
|
||||
// Zod Schemas
|
||||
// ============================================================================
|
||||
|
||||
interface DisplayImageResult {
|
||||
id: string;
|
||||
assetId: string;
|
||||
src: string;
|
||||
alt?: string; // Made optional - parseSerializableImage provides fallback
|
||||
title?: string;
|
||||
description?: string;
|
||||
domain?: string;
|
||||
ratio?: string;
|
||||
error?: string;
|
||||
}
|
||||
/**
|
||||
* Schema for display_image tool arguments
|
||||
*/
|
||||
const DisplayImageArgsSchema = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string().nullish(),
|
||||
title: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for display_image tool result
|
||||
*/
|
||||
const DisplayImageResultSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
src: z.string(),
|
||||
alt: z.string().nullish(),
|
||||
title: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
domain: z.string().nullish(),
|
||||
ratio: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DisplayImageArgs = z.infer<typeof DisplayImageArgsSchema>;
|
||||
type DisplayImageResult = z.infer<typeof DisplayImageResultSchema>;
|
||||
|
||||
/**
|
||||
* Error state component shown when image display fails
|
||||
|
|
@ -142,4 +157,9 @@ export const DisplayImageToolUI = makeAssistantToolUI<DisplayImageArgs, DisplayI
|
|||
},
|
||||
});
|
||||
|
||||
export type { DisplayImageArgs, DisplayImageResult };
|
||||
export {
|
||||
DisplayImageArgsSchema,
|
||||
DisplayImageResultSchema,
|
||||
type DisplayImageArgs,
|
||||
type DisplayImageResult,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ export {
|
|||
} from "./deepagent-thinking";
|
||||
export {
|
||||
type DisplayImageArgs,
|
||||
DisplayImageArgsSchema,
|
||||
type DisplayImageResult,
|
||||
DisplayImageResultSchema,
|
||||
DisplayImageToolUI,
|
||||
} from "./display-image";
|
||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
|
|
@ -40,10 +42,14 @@ export {
|
|||
} from "./image";
|
||||
export {
|
||||
type LinkPreviewArgs,
|
||||
LinkPreviewArgsSchema,
|
||||
type LinkPreviewResult,
|
||||
LinkPreviewResultSchema,
|
||||
LinkPreviewToolUI,
|
||||
type MultiLinkPreviewArgs,
|
||||
MultiLinkPreviewArgsSchema,
|
||||
type MultiLinkPreviewResult,
|
||||
MultiLinkPreviewResultSchema,
|
||||
MultiLinkPreviewToolUI,
|
||||
} from "./link-preview";
|
||||
export {
|
||||
|
|
@ -55,8 +61,20 @@ export {
|
|||
parseSerializableMediaCard,
|
||||
type SerializableMediaCard,
|
||||
} from "./media-card";
|
||||
export {
|
||||
Plan,
|
||||
PlanErrorBoundary,
|
||||
type PlanProps,
|
||||
type PlanTodo,
|
||||
parseSerializablePlan,
|
||||
type SerializablePlan,
|
||||
type TodoStatus,
|
||||
} from "./plan";
|
||||
export {
|
||||
type ScrapeWebpageArgs,
|
||||
ScrapeWebpageArgsSchema,
|
||||
type ScrapeWebpageResult,
|
||||
ScrapeWebpageResultSchema,
|
||||
ScrapeWebpageToolUI,
|
||||
} from "./scrape-webpage";
|
||||
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
MediaCard,
|
||||
MediaCardErrorBoundary,
|
||||
|
|
@ -10,25 +11,39 @@ import {
|
|||
type SerializableMediaCard,
|
||||
} from "@/components/tool-ui/media-card";
|
||||
|
||||
/**
|
||||
* Type definitions for the link_preview tool
|
||||
*/
|
||||
interface LinkPreviewArgs {
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
// ============================================================================
|
||||
// Zod Schemas
|
||||
// ============================================================================
|
||||
|
||||
interface LinkPreviewResult {
|
||||
id: string;
|
||||
assetId: string;
|
||||
kind: "link";
|
||||
href: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumb?: string;
|
||||
domain?: string;
|
||||
error?: string;
|
||||
}
|
||||
/**
|
||||
* Schema for link_preview tool arguments
|
||||
*/
|
||||
const LinkPreviewArgsSchema = z.object({
|
||||
url: z.string(),
|
||||
title: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for link_preview tool result
|
||||
*/
|
||||
const LinkPreviewResultSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
kind: z.literal("link"),
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullish(),
|
||||
thumb: z.string().nullish(),
|
||||
domain: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type LinkPreviewArgs = z.infer<typeof LinkPreviewArgsSchema>;
|
||||
type LinkPreviewResult = z.infer<typeof LinkPreviewResultSchema>;
|
||||
|
||||
/**
|
||||
* Error state component shown when link preview fails
|
||||
|
|
@ -150,20 +165,35 @@ export const LinkPreviewToolUI = makeAssistantToolUI<LinkPreviewArgs, LinkPrevie
|
|||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
// ============================================================================
|
||||
// Multi Link Preview Schemas
|
||||
// ============================================================================
|
||||
|
||||
interface MultiLinkPreviewResult {
|
||||
previews: LinkPreviewResult[];
|
||||
errors?: { url: string; error: string }[];
|
||||
}
|
||||
/**
|
||||
* Schema for multi_link_preview tool arguments
|
||||
*/
|
||||
const MultiLinkPreviewArgsSchema = z.object({
|
||||
urls: z.array(z.string()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for error items in multi_link_preview result
|
||||
*/
|
||||
const MultiLinkPreviewErrorSchema = z.object({
|
||||
url: z.string(),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for multi_link_preview tool result
|
||||
*/
|
||||
const MultiLinkPreviewResultSchema = z.object({
|
||||
previews: z.array(LinkPreviewResultSchema),
|
||||
errors: z.array(MultiLinkPreviewErrorSchema).nullish(),
|
||||
});
|
||||
|
||||
type MultiLinkPreviewArgs = z.infer<typeof MultiLinkPreviewArgsSchema>;
|
||||
type MultiLinkPreviewResult = z.infer<typeof MultiLinkPreviewResultSchema>;
|
||||
|
||||
export const MultiLinkPreviewToolUI = makeAssistantToolUI<
|
||||
MultiLinkPreviewArgs,
|
||||
|
|
@ -217,4 +247,13 @@ export const MultiLinkPreviewToolUI = makeAssistantToolUI<
|
|||
},
|
||||
});
|
||||
|
||||
export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
|
||||
export {
|
||||
LinkPreviewArgsSchema,
|
||||
LinkPreviewResultSchema,
|
||||
MultiLinkPreviewArgsSchema,
|
||||
MultiLinkPreviewResultSchema,
|
||||
type LinkPreviewArgs,
|
||||
type LinkPreviewResult,
|
||||
type MultiLinkPreviewArgs,
|
||||
type MultiLinkPreviewResult,
|
||||
};
|
||||
|
|
|
|||
52
surfsense_web/components/tool-ui/plan/index.tsx
Normal file
52
surfsense_web/components/tool-ui/plan/index.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export * from "./plan";
|
||||
export * from "./schema";
|
||||
|
||||
// ============================================================================
|
||||
// Error Boundary
|
||||
// ============================================================================
|
||||
|
||||
interface PlanErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface PlanErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class PlanErrorBoundary extends Component<PlanErrorBoundaryProps, PlanErrorBoundaryState> {
|
||||
constructor(props: PlanErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): PlanErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl border-destructive/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<span className="text-sm">Failed to render plan</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
229
surfsense_web/components/tool-ui/plan/plan.tsx
Normal file
229
surfsense_web/components/tool-ui/plan/plan.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2, Circle, CircleDashed, ListTodo, PartyPopper, XCircle } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Action, ActionsConfig } from "../shared/schema";
|
||||
import type { TodoStatus } from "./schema";
|
||||
|
||||
// ============================================================================
|
||||
// Status Icon Component
|
||||
// ============================================================================
|
||||
|
||||
interface StatusIconProps {
|
||||
status: TodoStatus;
|
||||
className?: string;
|
||||
/** When false, in_progress items show as static (no spinner) */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const StatusIcon: FC<StatusIconProps> = ({ status, className, isStreaming = true }) => {
|
||||
const baseClass = cn("size-4 shrink-0", className);
|
||||
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className={cn(baseClass, "text-emerald-500")} />;
|
||||
case "in_progress":
|
||||
// Only animate the spinner if we're actively streaming
|
||||
// When streaming is stopped, show as a static dashed circle
|
||||
return (
|
||||
<CircleDashed
|
||||
className={cn(baseClass, "text-primary", isStreaming && "animate-spin")}
|
||||
style={isStreaming ? { animationDuration: "3s" } : undefined}
|
||||
/>
|
||||
);
|
||||
case "cancelled":
|
||||
return <XCircle className={cn(baseClass, "text-destructive")} />;
|
||||
case "pending":
|
||||
default:
|
||||
return <Circle className={cn(baseClass, "text-muted-foreground")} />;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Todo Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface TodoItemProps {
|
||||
todo: { id: string; content: string; status: TodoStatus };
|
||||
/** When false, in_progress items show as static (no spinner/pulse) */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const TodoItem: FC<TodoItemProps> = ({ todo, isStreaming = true }) => {
|
||||
const isStrikethrough = todo.status === "completed" || todo.status === "cancelled";
|
||||
// Only show shimmer animation if streaming and in progress
|
||||
const isShimmer = todo.status === "in_progress" && isStreaming;
|
||||
|
||||
// Render the content with optional shimmer effect
|
||||
const renderContent = () => {
|
||||
if (isShimmer) {
|
||||
return <TextShimmerLoader text={todo.content} size="md" />;
|
||||
}
|
||||
return (
|
||||
<span className={cn("text-sm text-muted-foreground", isStrikethrough && "line-through")}>
|
||||
{todo.content}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<StatusIcon status={todo.status} isStreaming={isStreaming} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Plan Component
|
||||
// ============================================================================
|
||||
|
||||
export interface PlanProps {
|
||||
id: string;
|
||||
title: string;
|
||||
todos: Array<{ id: string; content: string; status: TodoStatus }>;
|
||||
maxVisibleTodos?: number;
|
||||
showProgress?: boolean;
|
||||
/** When false, in_progress items show as static (no spinner/pulse animations) */
|
||||
isStreaming?: boolean;
|
||||
responseActions?: Action[] | ActionsConfig;
|
||||
className?: string;
|
||||
onResponseAction?: (actionId: string) => void;
|
||||
onBeforeResponseAction?: (actionId: string) => boolean;
|
||||
}
|
||||
|
||||
export const Plan: FC<PlanProps> = ({
|
||||
id,
|
||||
title,
|
||||
todos,
|
||||
maxVisibleTodos = 4,
|
||||
showProgress = true,
|
||||
isStreaming = true,
|
||||
responseActions,
|
||||
className,
|
||||
onResponseAction,
|
||||
onBeforeResponseAction,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Calculate progress
|
||||
const progress = useMemo(() => {
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
const total = todos.filter((t) => t.status !== "cancelled").length;
|
||||
return { completed, total, percentage: total > 0 ? (completed / total) * 100 : 0 };
|
||||
}, [todos]);
|
||||
|
||||
const isAllComplete = progress.completed === progress.total && progress.total > 0;
|
||||
|
||||
// Split todos for collapsible display
|
||||
const visibleTodos = todos.slice(0, maxVisibleTodos);
|
||||
const hiddenTodos = todos.slice(maxVisibleTodos);
|
||||
const hasHiddenTodos = hiddenTodos.length > 0;
|
||||
|
||||
// Handle action click
|
||||
const handleAction = (actionId: string) => {
|
||||
if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) {
|
||||
return;
|
||||
}
|
||||
onResponseAction?.(actionId);
|
||||
};
|
||||
|
||||
// Normalize actions to array
|
||||
const actionArray: Action[] = useMemo(() => {
|
||||
if (!responseActions) return [];
|
||||
if (Array.isArray(responseActions)) return responseActions;
|
||||
return [
|
||||
responseActions.confirm && { ...responseActions.confirm, id: "confirm" },
|
||||
responseActions.cancel && { ...responseActions.cancel, id: "cancel" },
|
||||
].filter(Boolean) as Action[];
|
||||
}, [responseActions]);
|
||||
|
||||
const TodoList: FC<{ items: typeof todos }> = ({ items }) => {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{items.map((todo) => (
|
||||
<TodoItem key={todo.id} todo={todo} isStreaming={isStreaming} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id={id} className={cn("w-full max-w-xl", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<ListTodo className="size-5 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-base font-semibold text-muted-foreground">{title}</CardTitle>
|
||||
</div>
|
||||
{isAllComplete && (
|
||||
<div className="flex items-center gap-1 text-emerald-500">
|
||||
<PartyPopper className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProgress && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{progress.completed} of {progress.total} complete
|
||||
</span>
|
||||
<span>{Math.round(progress.percentage)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progress.percentage}
|
||||
className="h-1.5 bg-muted [&>div]:bg-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<TodoList items={visibleTodos} />
|
||||
|
||||
{hasHiddenTodos && (
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded
|
||||
? "Show less"
|
||||
: `Show ${hiddenTodos.length} more ${hiddenTodos.length === 1 ? "task" : "tasks"}`}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<TodoList items={hiddenTodos} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{actionArray.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-4 mt-2 border-t">
|
||||
{actionArray.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant || "default"}
|
||||
size="sm"
|
||||
disabled={action.disabled}
|
||||
onClick={() => handleAction(action.id)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
91
surfsense_web/components/tool-ui/plan/schema.ts
Normal file
91
surfsense_web/components/tool-ui/plan/schema.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Todo item status
|
||||
*/
|
||||
export const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
|
||||
export type TodoStatus = z.infer<typeof TodoStatusSchema>;
|
||||
|
||||
/**
|
||||
* Single todo item in a plan
|
||||
* Matches deepagents TodoListMiddleware output: { content, status }
|
||||
* id is auto-generated if not provided
|
||||
*/
|
||||
export const PlanTodoSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
content: z.string(),
|
||||
status: TodoStatusSchema,
|
||||
});
|
||||
|
||||
export type PlanTodo = z.infer<typeof PlanTodoSchema>;
|
||||
|
||||
/**
|
||||
* Serializable plan schema for tool results
|
||||
* Matches deepagents TodoListMiddleware output format
|
||||
* id/title are auto-generated if not provided
|
||||
*/
|
||||
export const SerializablePlanSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
todos: z.array(PlanTodoSchema).min(1),
|
||||
maxVisibleTodos: z.number().optional(),
|
||||
showProgress: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type SerializablePlan = z.infer<typeof SerializablePlanSchema>;
|
||||
|
||||
/**
|
||||
* Normalized plan with required fields (after auto-generation)
|
||||
*/
|
||||
export interface NormalizedPlan {
|
||||
id: string;
|
||||
title: string;
|
||||
todos: Array<{ id: string; content: string; status: TodoStatus }>;
|
||||
maxVisibleTodos?: number;
|
||||
showProgress?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and normalize a plan from tool result
|
||||
* Auto-generates id/title if not provided (for deepagents compatibility)
|
||||
*/
|
||||
export function parseSerializablePlan(data: unknown): NormalizedPlan {
|
||||
const result = SerializablePlanSchema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
console.warn("Invalid plan data:", result.error.issues);
|
||||
|
||||
// Try to extract basic info for fallback
|
||||
const obj = (data && typeof data === "object" ? data : {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: typeof obj.id === "string" ? obj.id : `plan-${Date.now()}`,
|
||||
title: typeof obj.title === "string" ? obj.title : "Plan",
|
||||
todos: Array.isArray(obj.todos)
|
||||
? obj.todos.map((t: unknown, i: number) => {
|
||||
const todo = t as Record<string, unknown>;
|
||||
return {
|
||||
id: typeof todo?.id === "string" ? todo.id : `todo-${i}`,
|
||||
content: typeof todo?.content === "string" ? todo.content : "Task",
|
||||
status: TodoStatusSchema.safeParse(todo?.status).success
|
||||
? (todo.status as TodoStatus)
|
||||
: ("pending" as const),
|
||||
};
|
||||
})
|
||||
: [{ id: "1", content: "No tasks", status: "pending" as const }],
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize: add id/title if missing
|
||||
return {
|
||||
id: result.data.id || `plan-${Date.now()}`,
|
||||
title: result.data.title || "Plan",
|
||||
todos: result.data.todos.map((t, i) => ({
|
||||
id: t.id || `todo-${i}`,
|
||||
content: t.content,
|
||||
status: t.status,
|
||||
})),
|
||||
maxVisibleTodos: result.data.maxVisibleTodos,
|
||||
showProgress: result.data.showProgress,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, FileTextIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Article,
|
||||
ArticleErrorBoundary,
|
||||
|
|
@ -9,30 +10,44 @@ import {
|
|||
parseSerializableArticle,
|
||||
} from "@/components/tool-ui/article";
|
||||
|
||||
/**
|
||||
* Type definitions for the scrape_webpage tool
|
||||
*/
|
||||
interface ScrapeWebpageArgs {
|
||||
url: string;
|
||||
max_length?: number;
|
||||
}
|
||||
// ============================================================================
|
||||
// Zod Schemas
|
||||
// ============================================================================
|
||||
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Schema for scrape_webpage tool arguments
|
||||
*/
|
||||
const ScrapeWebpageArgsSchema = z.object({
|
||||
url: z.string(),
|
||||
max_length: z.number().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for scrape_webpage tool result
|
||||
*/
|
||||
const ScrapeWebpageResultSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
kind: z.literal("article"),
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullish(),
|
||||
content: z.string().nullish(),
|
||||
domain: z.string().nullish(),
|
||||
author: z.string().nullish(),
|
||||
date: z.string().nullish(),
|
||||
word_count: z.number().nullish(),
|
||||
was_truncated: z.boolean().nullish(),
|
||||
crawler_type: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type ScrapeWebpageArgs = z.infer<typeof ScrapeWebpageArgsSchema>;
|
||||
type ScrapeWebpageResult = z.infer<typeof ScrapeWebpageResultSchema>;
|
||||
|
||||
/**
|
||||
* Error state component shown when webpage scraping fails
|
||||
|
|
@ -154,4 +169,9 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI<ScrapeWebpageArgs, Scrape
|
|||
},
|
||||
});
|
||||
|
||||
export type { ScrapeWebpageArgs, ScrapeWebpageResult };
|
||||
export {
|
||||
ScrapeWebpageArgsSchema,
|
||||
ScrapeWebpageResultSchema,
|
||||
type ScrapeWebpageArgs,
|
||||
type ScrapeWebpageResult,
|
||||
};
|
||||
|
|
|
|||
41
surfsense_web/components/tool-ui/shared/action-buttons.tsx
Normal file
41
surfsense_web/components/tool-ui/shared/action-buttons.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Action, ActionsConfig } from "./schema";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
actions?: Action[] | ActionsConfig;
|
||||
onAction?: (actionId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ActionButtons: FC<ActionButtonsProps> = ({ actions, onAction, disabled }) => {
|
||||
if (!actions) return null;
|
||||
|
||||
// Normalize actions to array format
|
||||
const actionArray: Action[] = Array.isArray(actions)
|
||||
? actions
|
||||
: ([
|
||||
actions.confirm && { ...actions.confirm, id: "confirm" },
|
||||
actions.cancel && { ...actions.cancel, id: "cancel" },
|
||||
].filter(Boolean) as Action[]);
|
||||
|
||||
if (actionArray.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-3">
|
||||
{actionArray.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={action.variant || "default"}
|
||||
size="sm"
|
||||
disabled={disabled || action.disabled}
|
||||
onClick={() => onAction?.(action.id)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
surfsense_web/components/tool-ui/shared/index.ts
Normal file
2
surfsense_web/components/tool-ui/shared/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./action-buttons";
|
||||
export * from "./schema";
|
||||
23
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
23
surfsense_web/components/tool-ui/shared/schema.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Shared action schema for tool UI components
|
||||
*/
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
|
||||
/**
|
||||
* Actions configuration schema
|
||||
*/
|
||||
export const ActionsConfigSchema = z.object({
|
||||
confirm: ActionSchema.optional(),
|
||||
cancel: ActionSchema.optional(),
|
||||
});
|
||||
|
||||
export type ActionsConfig = z.infer<typeof ActionsConfigSchema>;
|
||||
158
surfsense_web/components/tool-ui/write-todos.tsx
Normal file
158
surfsense_web/components/tool-ui/write-todos.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getCanonicalPlanTitle,
|
||||
planStatesAtom,
|
||||
registerPlanOwner,
|
||||
updatePlanStateAtom,
|
||||
} from "@/atoms/chat/plan-state.atom";
|
||||
import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
|
||||
|
||||
// ============================================================================
|
||||
// Zod Schemas - Matching deepagents TodoListMiddleware output
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for a single todo item (matches deepagents output)
|
||||
*/
|
||||
const TodoItemSchema = z.object({
|
||||
content: z.string(),
|
||||
status: TodoStatusSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for write_todos tool args/result (matches deepagents output)
|
||||
* deepagents provides: { todos: [{ content, status }] }
|
||||
*/
|
||||
const WriteTodosSchema = z.object({
|
||||
todos: z.array(TodoItemSchema).nullish(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type WriteTodosData = z.infer<typeof WriteTodosSchema>;
|
||||
|
||||
/**
|
||||
* Loading state component
|
||||
*/
|
||||
function WriteTodosLoading() {
|
||||
return (
|
||||
<div className="my-4 w-full max-w-xl rounded-2xl border bg-card/60 px-5 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Creating plan...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WriteTodos Tool UI Component
|
||||
*
|
||||
* Displays the agent's planning/todo list with a beautiful UI.
|
||||
* Uses deepagents TodoListMiddleware output directly: { todos: [{ content, status }] }
|
||||
*
|
||||
* FIXED POSITION: When multiple write_todos calls happen in a conversation,
|
||||
* only the FIRST component renders. Subsequent updates just update the
|
||||
* shared state, and the first component reads from it.
|
||||
*/
|
||||
export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosData, WriteTodosData>({
|
||||
toolName: "write_todos",
|
||||
render: function WriteTodosUI({ args, result, status, toolCallId }) {
|
||||
const updatePlanState = useSetAtom(updatePlanStateAtom);
|
||||
const planStates = useAtomValue(planStatesAtom);
|
||||
|
||||
// Check if the THREAD is running
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Use result if available, otherwise args (for streaming)
|
||||
const data = result || args;
|
||||
const hasTodos = data?.todos && data.todos.length > 0;
|
||||
|
||||
// Fixed title for all plans in conversation
|
||||
const planTitle = "Plan";
|
||||
|
||||
// SYNCHRONOUS ownership check
|
||||
const isOwner = useMemo(() => {
|
||||
return registerPlanOwner(planTitle, toolCallId);
|
||||
}, [planTitle, toolCallId]);
|
||||
|
||||
// Get canonical title
|
||||
const canonicalTitle = useMemo(() => getCanonicalPlanTitle(planTitle), [planTitle]);
|
||||
|
||||
// Register/update the plan state
|
||||
useEffect(() => {
|
||||
if (hasTodos) {
|
||||
const normalizedPlan = parseSerializablePlan({ todos: data.todos });
|
||||
updatePlanState({
|
||||
id: normalizedPlan.id,
|
||||
title: canonicalTitle,
|
||||
todos: normalizedPlan.todos,
|
||||
toolCallId,
|
||||
});
|
||||
}
|
||||
}, [data, hasTodos, canonicalTitle, updatePlanState, toolCallId]);
|
||||
|
||||
// Get the current plan state
|
||||
const currentPlanState = planStates.get(canonicalTitle);
|
||||
|
||||
// If we're NOT the owner, render nothing
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
if (hasTodos) {
|
||||
const plan = parseSerializablePlan({ todos: data.todos });
|
||||
return (
|
||||
<div className="my-4">
|
||||
<PlanErrorBoundary>
|
||||
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
|
||||
</PlanErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <WriteTodosLoading />;
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (currentPlanState || hasTodos) {
|
||||
const plan = currentPlanState || parseSerializablePlan({ todos: data?.todos || [] });
|
||||
return (
|
||||
<div className="my-4">
|
||||
<PlanErrorBoundary>
|
||||
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
|
||||
</PlanErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Success - render the plan
|
||||
const planToRender =
|
||||
currentPlanState || (hasTodos ? parseSerializablePlan({ todos: data.todos }) : null);
|
||||
if (!planToRender) {
|
||||
return <WriteTodosLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<PlanErrorBoundary>
|
||||
<Plan {...planToRender} showProgress={true} isStreaming={isThreadRunning} />
|
||||
</PlanErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export { WriteTodosSchema, type WriteTodosData };
|
||||
|
|
@ -449,7 +449,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
159
surfsense_web/contracts/types/connector.types.ts
Normal file
159
surfsense_web/contracts/types/connector.types.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { z } from "zod";
|
||||
import { paginationQueryParams } from ".";
|
||||
|
||||
export const searchSourceConnectorTypeEnum = z.enum([
|
||||
"SERPER_API",
|
||||
"TAVILY_API",
|
||||
"SEARXNG_API",
|
||||
"LINKUP_API",
|
||||
"BAIDU_SEARCH_API",
|
||||
"SLACK_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"GITHUB_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"DISCORD_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"CONFLUENCE_CONNECTOR",
|
||||
"CLICKUP_CONNECTOR",
|
||||
"GOOGLE_CALENDAR_CONNECTOR",
|
||||
"GOOGLE_GMAIL_CONNECTOR",
|
||||
"AIRTABLE_CONNECTOR",
|
||||
"LUMA_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
"WEBCRAWLER_CONNECTOR",
|
||||
"BOOKSTACK_CONNECTOR",
|
||||
]);
|
||||
|
||||
export const searchSourceConnector = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
connector_type: searchSourceConnectorTypeEnum,
|
||||
is_indexable: z.boolean(),
|
||||
last_indexed_at: z.string().nullable(),
|
||||
config: z.record(z.string(), z.any()),
|
||||
periodic_indexing_enabled: z.boolean(),
|
||||
indexing_frequency_minutes: z.number().nullable(),
|
||||
next_scheduled_at: z.string().nullable(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get connectors
|
||||
*/
|
||||
export const getConnectorsRequest = z.object({
|
||||
queryParams: paginationQueryParams
|
||||
.pick({ skip: true, limit: true })
|
||||
.extend({
|
||||
search_space_id: z.number().or(z.string()).nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export const getConnectorsResponse = z.array(searchSourceConnector);
|
||||
|
||||
/**
|
||||
* Get connector
|
||||
*/
|
||||
export const getConnectorRequest = searchSourceConnector.pick({ id: true });
|
||||
|
||||
export const getConnectorResponse = searchSourceConnector;
|
||||
|
||||
/**
|
||||
* Create connector
|
||||
*/
|
||||
export const createConnectorRequest = z.object({
|
||||
data: searchSourceConnector.pick({
|
||||
name: true,
|
||||
connector_type: true,
|
||||
is_indexable: true,
|
||||
last_indexed_at: true,
|
||||
config: true,
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: true,
|
||||
next_scheduled_at: true,
|
||||
}),
|
||||
queryParams: z.object({
|
||||
search_space_id: z.number().or(z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
export const createConnectorResponse = searchSourceConnector;
|
||||
|
||||
/**
|
||||
* Update connector
|
||||
*/
|
||||
export const updateConnectorRequest = z.object({
|
||||
id: z.number(),
|
||||
data: searchSourceConnector
|
||||
.pick({
|
||||
name: true,
|
||||
connector_type: true,
|
||||
is_indexable: true,
|
||||
last_indexed_at: true,
|
||||
config: true,
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: true,
|
||||
next_scheduled_at: true,
|
||||
})
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateConnectorResponse = searchSourceConnector;
|
||||
|
||||
/**
|
||||
* Delete connector
|
||||
*/
|
||||
export const deleteConnectorRequest = searchSourceConnector.pick({ id: true });
|
||||
|
||||
export const deleteConnectorResponse = z.object({
|
||||
message: z.literal("Search source connector deleted successfully"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Index connector
|
||||
*/
|
||||
export const indexConnectorRequest = z.object({
|
||||
connector_id: z.number(),
|
||||
queryParams: z.object({
|
||||
search_space_id: z.number().or(z.string()),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const indexConnectorResponse = z.object({
|
||||
message: z.string(),
|
||||
connector_id: z.number(),
|
||||
search_space_id: z.number(),
|
||||
indexing_from: z.string(),
|
||||
indexing_to: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* List GitHub repositories
|
||||
*/
|
||||
export const listGitHubRepositoriesRequest = z.object({
|
||||
github_pat: z.string(),
|
||||
});
|
||||
|
||||
export const listGitHubRepositoriesResponse = z.array(z.record(z.string(), z.any()));
|
||||
|
||||
// Inferred types
|
||||
export type SearchSourceConnectorType = z.infer<typeof searchSourceConnectorTypeEnum>;
|
||||
export type SearchSourceConnector = z.infer<typeof searchSourceConnector>;
|
||||
export type GetConnectorsRequest = z.infer<typeof getConnectorsRequest>;
|
||||
export type GetConnectorsResponse = z.infer<typeof getConnectorsResponse>;
|
||||
export type GetConnectorRequest = z.infer<typeof getConnectorRequest>;
|
||||
export type GetConnectorResponse = z.infer<typeof getConnectorResponse>;
|
||||
export type CreateConnectorRequest = z.infer<typeof createConnectorRequest>;
|
||||
export type CreateConnectorResponse = z.infer<typeof createConnectorResponse>;
|
||||
export type UpdateConnectorRequest = z.infer<typeof updateConnectorRequest>;
|
||||
export type UpdateConnectorResponse = z.infer<typeof updateConnectorResponse>;
|
||||
export type DeleteConnectorRequest = z.infer<typeof deleteConnectorRequest>;
|
||||
export type DeleteConnectorResponse = z.infer<typeof deleteConnectorResponse>;
|
||||
export type IndexConnectorRequest = z.infer<typeof indexConnectorRequest>;
|
||||
export type IndexConnectorResponse = z.infer<typeof indexConnectorResponse>;
|
||||
export type ListGitHubRepositoriesRequest = z.infer<typeof listGitHubRepositoriesRequest>;
|
||||
export type ListGitHubRepositoriesResponse = z.infer<typeof listGitHubRepositoriesResponse>;
|
||||
134
surfsense_web/contracts/types/log.types.ts
Normal file
134
surfsense_web/contracts/types/log.types.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { z } from "zod";
|
||||
import { paginationQueryParams } from ".";
|
||||
|
||||
/**
|
||||
* ENUMS
|
||||
*/
|
||||
export const logLevelEnum = z.enum(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]);
|
||||
|
||||
export const logStatusEnum = z.enum(["IN_PROGRESS", "SUCCESS", "FAILED"]);
|
||||
|
||||
/**
|
||||
* Base log schema
|
||||
*/
|
||||
export const log = z.object({
|
||||
id: z.number(),
|
||||
level: logLevelEnum,
|
||||
status: logStatusEnum,
|
||||
message: z.string(),
|
||||
source: z.string().nullable().optional(),
|
||||
log_metadata: z.record(z.string(), z.any()).nullable().optional(),
|
||||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
});
|
||||
|
||||
export const logBase = log.omit({ id: true, created_at: true });
|
||||
|
||||
/**
|
||||
* Create log
|
||||
*/
|
||||
export const createLogRequest = logBase.extend({ search_space_id: z.number() });
|
||||
export const createLogResponse = log;
|
||||
|
||||
/**
|
||||
* Update log
|
||||
*/
|
||||
export const updateLogRequest = logBase.partial();
|
||||
export const updateLogResponse = log;
|
||||
|
||||
/**
|
||||
* Delete log
|
||||
*/
|
||||
export const deleteLogRequest = z.object({ id: z.number() });
|
||||
export const deleteLogResponse = z.object({
|
||||
message: z.string().default("Log deleted successfully"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get logs (list)
|
||||
*/
|
||||
export const logFilters = z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
level: logLevelEnum.optional(),
|
||||
status: logStatusEnum.optional(),
|
||||
source: z.string().optional(),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getLogsRequest = z.object({
|
||||
queryParams: paginationQueryParams
|
||||
.extend({
|
||||
search_space_id: z.number().optional(),
|
||||
level: logLevelEnum.optional(),
|
||||
status: logStatusEnum.optional(),
|
||||
source: z.string().optional(),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
export const getLogsResponse = z.array(log);
|
||||
|
||||
/**
|
||||
* Get single log
|
||||
*/
|
||||
export const getLogRequest = z.object({ id: z.number() });
|
||||
export const getLogResponse = log;
|
||||
|
||||
/**
|
||||
* Log summary (used for summary dashboard)
|
||||
*/
|
||||
export const logActiveTask = z.object({
|
||||
id: z.number(),
|
||||
task_name: z.string(),
|
||||
message: z.string(),
|
||||
started_at: z.string(),
|
||||
source: z.string().nullable().optional(),
|
||||
document_id: z.number().nullable().optional(),
|
||||
});
|
||||
export const logFailure = z.object({
|
||||
id: z.number(),
|
||||
task_name: z.string(),
|
||||
message: z.string(),
|
||||
failed_at: z.string(),
|
||||
source: z.string().nullable().optional(),
|
||||
error_details: z.string().nullable().optional(),
|
||||
});
|
||||
export const logSummary = z.object({
|
||||
total_logs: z.number(),
|
||||
time_window_hours: z.number(),
|
||||
by_status: z.record(z.string(), z.number()),
|
||||
by_level: z.record(z.string(), z.number()),
|
||||
by_source: z.record(z.string(), z.number()),
|
||||
active_tasks: z.array(logActiveTask),
|
||||
recent_failures: z.array(logFailure),
|
||||
});
|
||||
export const getLogSummaryRequest = z.object({
|
||||
search_space_id: z.number(),
|
||||
hours: z.number().optional(),
|
||||
});
|
||||
export const getLogSummaryResponse = logSummary;
|
||||
|
||||
/**
|
||||
* Typescript types
|
||||
*/
|
||||
export type Log = z.infer<typeof log>;
|
||||
export type LogLevelEnum = z.infer<typeof logLevelEnum>;
|
||||
export type LogStatusEnum = z.infer<typeof logStatusEnum>;
|
||||
export type LogFilters = z.infer<typeof logFilters>;
|
||||
export type CreateLogRequest = z.infer<typeof createLogRequest>;
|
||||
export type CreateLogResponse = z.infer<typeof createLogResponse>;
|
||||
export type UpdateLogRequest = z.infer<typeof updateLogRequest>;
|
||||
export type UpdateLogResponse = z.infer<typeof updateLogResponse>;
|
||||
export type DeleteLogRequest = z.infer<typeof deleteLogRequest>;
|
||||
export type DeleteLogResponse = z.infer<typeof deleteLogResponse>;
|
||||
export type GetLogsRequest = z.infer<typeof getLogsRequest>;
|
||||
export type GetLogsResponse = z.infer<typeof getLogsResponse>;
|
||||
export type GetLogRequest = z.infer<typeof getLogRequest>;
|
||||
export type GetLogResponse = z.infer<typeof getLogResponse>;
|
||||
export type LogSummary = z.infer<typeof logSummary>;
|
||||
export type LogFailure = z.infer<typeof logFailure>;
|
||||
export type LogActiveTask = z.infer<typeof logActiveTask>;
|
||||
export type GetLogSummaryRequest = z.infer<typeof getLogSummaryRequest>;
|
||||
export type GetLogSummaryResponse = z.infer<typeof getLogSummaryResponse>;
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import {
|
||||
type EditConnectorFormValues,
|
||||
type EditMode,
|
||||
|
|
@ -11,10 +14,8 @@ import {
|
|||
type GithubRepo,
|
||||
githubPatSchema,
|
||||
} from "@/components/editConnector/types";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
const normalizeListInput = (value: unknown): string[] => {
|
||||
|
|
@ -51,11 +52,8 @@ const normalizeBoolean = (value: unknown): boolean | null => {
|
|||
|
||||
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
connectors,
|
||||
updateConnector,
|
||||
isLoading: connectorsLoading,
|
||||
} = useSearchSourceConnectors(false, parseInt(searchSpaceId));
|
||||
const { data: connectors = [], isLoading: connectorsLoading } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
|
||||
// State managed by the hook
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
|
|
@ -532,7 +530,13 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
}
|
||||
|
||||
try {
|
||||
await updateConnector(connectorId, updatePayload);
|
||||
await updateConnector({
|
||||
id: connectorId,
|
||||
data: {
|
||||
...updatePayload,
|
||||
connector_type: connector.connector_type as EnumConnectorName,
|
||||
},
|
||||
});
|
||||
toast.success("Connector updated!");
|
||||
const newlySavedConfig = updatePayload.config || originalConfig;
|
||||
setOriginalConfig(newlySavedConfig);
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
// Types for connector API
|
||||
export interface ConnectorConfig {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
id: number;
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: ConnectorConfig;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface CreateConnectorRequest {
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: ConnectorConfig;
|
||||
}
|
||||
|
||||
// Get connector type display name
|
||||
export const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
TAVILY_API: "Tavily API",
|
||||
SEARXNG_API: "SearxNG",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// API service for connectors
|
||||
export const ConnectorService = {
|
||||
// Create a new connector
|
||||
async createConnector(data: CreateConnectorRequest): Promise<Connector> {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to create connector");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get all connectors
|
||||
async getConnectors(skip = 0, limit = 100): Promise<Connector[]> {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to fetch connectors");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get a specific connector
|
||||
async getConnector(connectorId: number): Promise<Connector> {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to fetch connector");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Update a connector
|
||||
async updateConnector(connectorId: number, data: CreateConnectorRequest): Promise<Connector> {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to update connector");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Delete a connector
|
||||
async deleteConnector(connectorId: number): Promise<void> {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to delete connector");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { logsApiService } from "@/lib/apis/logs-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
||||
export type LogStatus = "IN_PROGRESS" | "SUCCESS" | "FAILED";
|
||||
|
|
@ -38,6 +39,7 @@ export interface LogSummary {
|
|||
message: string;
|
||||
started_at: string;
|
||||
source?: string;
|
||||
document_id?: number;
|
||||
}>;
|
||||
recent_failures: Array<{
|
||||
id: number;
|
||||
|
|
@ -50,267 +52,96 @@ export interface LogSummary {
|
|||
}
|
||||
|
||||
export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Memoize filters to prevent infinite re-renders
|
||||
const memoizedFilters = useMemo(() => filters, [JSON.stringify(filters)]);
|
||||
|
||||
const buildQueryParams = useCallback(
|
||||
(customFilters: LogFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
const allFilters = { ...memoizedFilters, ...customFilters };
|
||||
|
||||
if (allFilters.search_space_id) {
|
||||
params.append("search_space_id", allFilters.search_space_id.toString());
|
||||
params["search_space_id"] = allFilters.search_space_id.toString();
|
||||
}
|
||||
if (allFilters.level) {
|
||||
params.append("level", allFilters.level);
|
||||
params["level"] = allFilters.level;
|
||||
}
|
||||
if (allFilters.status) {
|
||||
params.append("status", allFilters.status);
|
||||
params["status"] = allFilters.status;
|
||||
}
|
||||
if (allFilters.source) {
|
||||
params.append("source", allFilters.source);
|
||||
params["source"] = allFilters.source;
|
||||
}
|
||||
if (allFilters.start_date) {
|
||||
params.append("start_date", allFilters.start_date);
|
||||
params["start_date"] = allFilters.start_date;
|
||||
}
|
||||
if (allFilters.end_date) {
|
||||
params.append("end_date", allFilters.end_date);
|
||||
params["end_date"] = allFilters.end_date;
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
return params;
|
||||
},
|
||||
[memoizedFilters]
|
||||
);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (customFilters: LogFilters = {}, options: { skip?: number; limit?: number } = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams(buildQueryParams(customFilters));
|
||||
if (options.skip !== undefined) params.append("skip", options.skip.toString());
|
||||
if (options.limit !== undefined) params.append("limit", options.limit.toString());
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs?${params}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch logs");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setLogs(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch logs");
|
||||
console.error("Error fetching logs:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[buildQueryParams]
|
||||
);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
const initialFilters = searchSpaceId
|
||||
? { ...memoizedFilters, search_space_id: searchSpaceId }
|
||||
: memoizedFilters;
|
||||
fetchLogs(initialFilters);
|
||||
}, [searchSpaceId, fetchLogs, memoizedFilters]);
|
||||
|
||||
// Function to refresh the logs list
|
||||
const refreshLogs = useCallback(
|
||||
async (customFilters: LogFilters = {}) => {
|
||||
const finalFilters = searchSpaceId
|
||||
? { ...customFilters, search_space_id: searchSpaceId }
|
||||
: customFilters;
|
||||
return await fetchLogs(finalFilters);
|
||||
},
|
||||
[searchSpaceId, fetchLogs]
|
||||
);
|
||||
|
||||
// Function to create a new log
|
||||
// Use silent: true to suppress toast notifications (for internal/background operations)
|
||||
const createLog = useCallback(
|
||||
async (logData: Omit<Log, "id" | "created_at">, options?: { silent?: boolean }) => {
|
||||
const { silent = false } = options || {};
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify(logData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to create log");
|
||||
}
|
||||
|
||||
const newLog = await response.json();
|
||||
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||
// Only show toast if not silent
|
||||
if (!silent) {
|
||||
toast.success("Log created successfully");
|
||||
}
|
||||
return newLog;
|
||||
} catch (err: any) {
|
||||
// Only show error toast if not silent
|
||||
if (!silent) {
|
||||
toast.error(err.message || "Failed to create log");
|
||||
}
|
||||
console.error("Error creating log:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Function to update a log
|
||||
const updateLog = useCallback(
|
||||
async (
|
||||
logId: number,
|
||||
updateData: Partial<Omit<Log, "id" | "created_at" | "search_space_id">>
|
||||
) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updateData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to update log");
|
||||
}
|
||||
|
||||
const updatedLog = await response.json();
|
||||
setLogs((prevLogs) => prevLogs.map((log) => (log.id === logId ? updatedLog : log)));
|
||||
toast.success("Log updated successfully");
|
||||
return updatedLog;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to update log");
|
||||
console.error("Error updating log:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Function to delete a log
|
||||
const deleteLog = useCallback(async (logId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to delete log");
|
||||
}
|
||||
|
||||
setLogs((prevLogs) => prevLogs.filter((log) => log.id !== logId));
|
||||
toast.success("Log deleted successfully");
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to delete log");
|
||||
console.error("Error deleting log:", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to get a single log
|
||||
const getLog = useCallback(async (logId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch log");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to fetch log");
|
||||
console.error("Error fetching log:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
const {
|
||||
data: logs,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.logs.withQueryParams({
|
||||
search_space_id: searchSpaceId,
|
||||
skip: 0,
|
||||
limit: 5,
|
||||
...buildQueryParams(filters ?? {}),
|
||||
}),
|
||||
queryFn: () =>
|
||||
logsApiService.getLogs({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
skip: 0,
|
||||
limit: 5,
|
||||
...buildQueryParams(filters ?? {}),
|
||||
},
|
||||
}),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
logs: logs ?? [],
|
||||
loading,
|
||||
error,
|
||||
refreshLogs,
|
||||
createLog,
|
||||
updateLog,
|
||||
deleteLog,
|
||||
getLog,
|
||||
fetchLogs,
|
||||
refreshLogs: refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// Separate hook for log summary
|
||||
export function useLogsSummary(searchSpaceId: number, hours: number = 24) {
|
||||
const [summary, setSummary] = useState<LogSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Separate hook for log summary with optional polling support for document processing indicator UI
|
||||
export function useLogsSummary(
|
||||
searchSpaceId: number,
|
||||
hours: number = 24,
|
||||
options: { refetchInterval?: number } = {}
|
||||
) {
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.logs.summary(searchSpaceId),
|
||||
queryFn: () =>
|
||||
logsApiService.getLogSummary({
|
||||
search_space_id: searchSpaceId,
|
||||
hours: hours,
|
||||
}),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 3 * 60 * 1000,
|
||||
// Enable refetch interval for document processing indicator polling
|
||||
refetchInterval:
|
||||
options.refetchInterval && options.refetchInterval > 0 ? options.refetchInterval : undefined,
|
||||
});
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/search-space/${searchSpaceId}/summary?hours=${hours}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch logs summary");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSummary(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch logs summary");
|
||||
console.error("Error fetching logs summary:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchSpaceId, hours]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, [fetchSummary]);
|
||||
|
||||
const refreshSummary = useCallback(() => {
|
||||
return fetchSummary();
|
||||
}, [fetchSummary]);
|
||||
|
||||
return { summary, loading, error, refreshSummary };
|
||||
return { summary, loading, error, refreshSummary: refetch };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,18 +21,23 @@ export type RequestOptions = {
|
|||
};
|
||||
|
||||
class BaseApiService {
|
||||
bearerToken: string;
|
||||
baseUrl: string;
|
||||
|
||||
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed
|
||||
|
||||
constructor(bearerToken: string, baseUrl: string) {
|
||||
this.bearerToken = bearerToken;
|
||||
// Use a getter to always read fresh token from localStorage
|
||||
// This ensures the token is always up-to-date after login/logout
|
||||
get bearerToken(): string {
|
||||
return typeof window !== "undefined" ? getBearerToken() || "" : "";
|
||||
}
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
setBearerToken(bearerToken: string) {
|
||||
this.bearerToken = bearerToken;
|
||||
// Keep for backward compatibility, but token is now always read from localStorage
|
||||
setBearerToken(_bearerToken: string) {
|
||||
// No-op: token is now always read fresh from localStorage via the getter
|
||||
}
|
||||
|
||||
async request<T, R extends ResponseType = ResponseType.JSON>(
|
||||
|
|
@ -293,7 +298,4 @@ class BaseApiService {
|
|||
}
|
||||
}
|
||||
|
||||
export const baseApiService = new BaseApiService(
|
||||
typeof window !== "undefined" ? getBearerToken() || "" : "",
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || ""
|
||||
);
|
||||
export const baseApiService = new BaseApiService(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "");
|
||||
|
|
|
|||
200
surfsense_web/lib/apis/connectors-api.service.ts
Normal file
200
surfsense_web/lib/apis/connectors-api.service.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
type CreateConnectorRequest,
|
||||
createConnectorRequest,
|
||||
createConnectorResponse,
|
||||
type DeleteConnectorRequest,
|
||||
deleteConnectorRequest,
|
||||
deleteConnectorResponse,
|
||||
type GetConnectorRequest,
|
||||
type GetConnectorsRequest,
|
||||
getConnectorRequest,
|
||||
getConnectorResponse,
|
||||
getConnectorsRequest,
|
||||
getConnectorsResponse,
|
||||
type IndexConnectorRequest,
|
||||
indexConnectorRequest,
|
||||
indexConnectorResponse,
|
||||
type ListGitHubRepositoriesRequest,
|
||||
listGitHubRepositoriesRequest,
|
||||
listGitHubRepositoriesResponse,
|
||||
type UpdateConnectorRequest,
|
||||
updateConnectorRequest,
|
||||
updateConnectorResponse,
|
||||
} from "@/contracts/types/connector.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class ConnectorsApiService {
|
||||
/**
|
||||
* Get all connectors for a search space
|
||||
*/
|
||||
getConnectors = async (request: GetConnectorsRequest) => {
|
||||
const parsedRequest = getConnectorsRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Transform query params to be string values
|
||||
const transformedQueryParams = parsedRequest.data.queryParams
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => {
|
||||
return [k, String(v)];
|
||||
})
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const queryParams = transformedQueryParams
|
||||
? new URLSearchParams(transformedQueryParams).toString()
|
||||
: "";
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/search-source-connectors?${queryParams}`,
|
||||
getConnectorsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single connector by ID
|
||||
*/
|
||||
getConnector = async (request: GetConnectorRequest) => {
|
||||
const parsedRequest = getConnectorRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/search-source-connectors/${request.id}`,
|
||||
getConnectorResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new connector
|
||||
*/
|
||||
createConnector = async (request: CreateConnectorRequest) => {
|
||||
const parsedRequest = createConnectorRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { data, queryParams } = parsedRequest.data;
|
||||
|
||||
// Transform query params to be string values
|
||||
const transformedQueryParams = Object.fromEntries(
|
||||
Object.entries(queryParams).map(([k, v]) => {
|
||||
return [k, String(v)];
|
||||
})
|
||||
);
|
||||
|
||||
const queryString = new URLSearchParams(transformedQueryParams).toString();
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/search-source-connectors?${queryString}`,
|
||||
createConnectorResponse,
|
||||
{
|
||||
body: data,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing connector
|
||||
*/
|
||||
updateConnector = async (request: UpdateConnectorRequest) => {
|
||||
const parsedRequest = updateConnectorRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { id, data } = parsedRequest.data;
|
||||
|
||||
return baseApiService.put(`/api/v1/search-source-connectors/${id}`, updateConnectorResponse, {
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a connector
|
||||
*/
|
||||
deleteConnector = async (request: DeleteConnectorRequest) => {
|
||||
const parsedRequest = deleteConnectorRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.delete(
|
||||
`/api/v1/search-source-connectors/${request.id}`,
|
||||
deleteConnectorResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Index connector content
|
||||
*/
|
||||
indexConnector = async (request: IndexConnectorRequest) => {
|
||||
const parsedRequest = indexConnectorRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { connector_id, queryParams } = parsedRequest.data;
|
||||
|
||||
// Transform query params to be string values
|
||||
const transformedQueryParams = Object.fromEntries(
|
||||
Object.entries(queryParams).map(([k, v]) => {
|
||||
return [k, String(v)];
|
||||
})
|
||||
);
|
||||
|
||||
const queryString = new URLSearchParams(transformedQueryParams).toString();
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/search-source-connectors/${connector_id}/index?${queryString}`,
|
||||
indexConnectorResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* List GitHub repositories using a Personal Access Token
|
||||
*/
|
||||
listGitHubRepositories = async (request: ListGitHubRepositoriesRequest) => {
|
||||
const parsedRequest = listGitHubRepositoriesRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(`/api/v1/github/repositories`, listGitHubRepositoriesResponse, {
|
||||
body: parsedRequest.data,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const connectorsApiService = new ConnectorsApiService();
|
||||
128
surfsense_web/lib/apis/logs-api.service.ts
Normal file
128
surfsense_web/lib/apis/logs-api.service.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import {
|
||||
type CreateLogRequest,
|
||||
createLogRequest,
|
||||
createLogResponse,
|
||||
type DeleteLogRequest,
|
||||
deleteLogRequest,
|
||||
deleteLogResponse,
|
||||
type GetLogRequest,
|
||||
type GetLogSummaryRequest,
|
||||
type GetLogsRequest,
|
||||
getLogRequest,
|
||||
getLogResponse,
|
||||
getLogSummaryRequest,
|
||||
getLogSummaryResponse,
|
||||
getLogsRequest,
|
||||
getLogsResponse,
|
||||
type Log,
|
||||
log,
|
||||
type UpdateLogRequest,
|
||||
updateLogRequest,
|
||||
updateLogResponse,
|
||||
} from "@/contracts/types/log.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class LogsApiService {
|
||||
/**
|
||||
* Get a list of logs with optional filtering and pagination
|
||||
*/
|
||||
getLogs = async (request: GetLogsRequest) => {
|
||||
const parsedRequest = getLogsRequest.safeParse(request);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
// Transform query params to be string values
|
||||
const transformedQueryParams = parsedRequest.data.queryParams
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => {
|
||||
// Handle array values (document_type)
|
||||
if (Array.isArray(v)) {
|
||||
return [k, v.join(",")];
|
||||
}
|
||||
return [k, String(v)];
|
||||
})
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const queryParams = transformedQueryParams
|
||||
? new URLSearchParams(transformedQueryParams).toString()
|
||||
: "";
|
||||
return baseApiService.get(`/api/v1/logs?${queryParams}`, getLogsResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single log by ID
|
||||
*/
|
||||
getLog = async (request: GetLogRequest) => {
|
||||
const parsedRequest = getLogRequest.safeParse(request);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
return baseApiService.get(`/api/v1/logs/${request.id}`, getLogResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a log entry
|
||||
*/
|
||||
createLog = async (request: CreateLogRequest) => {
|
||||
const parsedRequest = createLogRequest.safeParse(request);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
return baseApiService.post(`/api/v1/logs`, createLogResponse, {
|
||||
body: parsedRequest.data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a log entry
|
||||
*/
|
||||
updateLog = async (logId: number, request: UpdateLogRequest) => {
|
||||
const parsedRequest = updateLogRequest.safeParse(request);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
return baseApiService.put(`/api/v1/logs/${logId}`, updateLogResponse, {
|
||||
body: parsedRequest.data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a log entry
|
||||
*/
|
||||
deleteLog = async (request: DeleteLogRequest) => {
|
||||
const parsedRequest = deleteLogRequest.safeParse(request);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
return baseApiService.delete(`/api/v1/logs/${parsedRequest.data.id}`, deleteLogResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get summary for logs by search space
|
||||
*/
|
||||
getLogSummary = async (request: GetLogSummaryRequest) => {
|
||||
const parsedRequest = getLogSummaryRequest.safeParse(request);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
const { search_space_id, hours } = parsedRequest.data;
|
||||
const url = `/api/v1/logs/search-space/${search_space_id}/summary${hours ? `?hours=${hours}` : ""}`;
|
||||
return baseApiService.get(url, getLogSummaryResponse);
|
||||
};
|
||||
}
|
||||
|
||||
export const logsApiService = new LogsApiService();
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import type { GetConnectorsRequest } from "@/contracts/types/connector.types";
|
||||
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
|
||||
import type { GetLogsRequest } from "@/contracts/types/log.types";
|
||||
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
||||
|
||||
export const cacheKeys = {
|
||||
|
|
@ -18,6 +20,13 @@ export const cacheKeys = {
|
|||
typeCounts: (searchSpaceId?: string) => ["documents", "type-counts", searchSpaceId] as const,
|
||||
byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
|
||||
},
|
||||
logs: {
|
||||
list: (searchSpaceId?: number | string) => ["logs", "list", searchSpaceId] as const,
|
||||
detail: (logId: number | string) => ["logs", "detail", logId] as const,
|
||||
summary: (searchSpaceId?: number | string) => ["logs", "summary", searchSpaceId] as const,
|
||||
withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
|
||||
["logs", "with-query-params", ...(queries ? Object.values(queries) : [])] as const,
|
||||
},
|
||||
newLLMConfigs: {
|
||||
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
|
||||
byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
|
||||
|
|
@ -52,4 +61,11 @@ export const cacheKeys = {
|
|||
all: (searchSpaceId: string) => ["invites", searchSpaceId] as const,
|
||||
info: (inviteCode: string) => ["invites", "info", inviteCode] as const,
|
||||
},
|
||||
connectors: {
|
||||
all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const,
|
||||
withQueryParams: (queries: GetConnectorsRequest["queryParams"]) =>
|
||||
["connectors", ...(queries ? Object.values(queries) : [])] as const,
|
||||
byId: (connectorId: string) => ["connector", connectorId] as const,
|
||||
index: () => ["connector", "index"] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -267,7 +267,11 @@
|
|||
"content_summary": "Content Summary",
|
||||
"view_full": "View Full Content",
|
||||
"filter_placeholder": "Filter by title...",
|
||||
"rows_per_page": "Rows per page"
|
||||
"rows_per_page": "Rows per page",
|
||||
"refresh": "Refresh",
|
||||
"refresh_success": "Documents refreshed",
|
||||
"processing_documents": "Processing documents...",
|
||||
"active_tasks_count": "{count} active task(s)"
|
||||
},
|
||||
"add_connector": {
|
||||
"title": "Connect Your Tools",
|
||||
|
|
|
|||
|
|
@ -267,7 +267,11 @@
|
|||
"content_summary": "内容摘要",
|
||||
"view_full": "查看完整内容",
|
||||
"filter_placeholder": "按标题筛选...",
|
||||
"rows_per_page": "每页行数"
|
||||
"rows_per_page": "每页行数",
|
||||
"refresh": "刷新",
|
||||
"refresh_success": "文档已刷新",
|
||||
"processing_documents": "正在处理文档...",
|
||||
"active_tasks_count": "{count} 个正在进行的工作项"
|
||||
},
|
||||
"add_connector": {
|
||||
"title": "连接您的工具",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue