Merge pull request #635 from MODSetter/dev

feat: sweet UX updates
This commit is contained in:
Rohan Verma 2025-12-27 13:45:44 -08:00 committed by GitHub
commit c2e6bf2018
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 6123 additions and 3884 deletions

View file

@ -75,7 +75,6 @@ surfsense_backend/lib64/
# Logs
**/*.log
**/logs/
# Temporary files
**/tmp/

View file

@ -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,

View file

@ -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 `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- 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>
"""

View file

@ -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",

View file

@ -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
# =========================================================================

View file

@ -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

View file

@ -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,
}
)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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(

View file

@ -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!");

View file

@ -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) {

View file

@ -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!");

View file

@ -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!");

View file

@ -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`);

View file

@ -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!");

View file

@ -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`);

View file

@ -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`);

View file

@ -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`);

View file

@ -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
);

View file

@ -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) {

View file

@ -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!");

View file

@ -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!");

View file

@ -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!");

View file

@ -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!");

View file

@ -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!");

View file

@ -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`);

View file

@ -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!");

View file

@ -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!");

View file

@ -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!");

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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 <></>;
}

View 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;
}

View 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)),
});
},
};
});

View 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!,
},
});
},
};
});

View 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,
});

View 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) });
},
};
});

View file

@ -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>

View 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} />;
}
}

View file

@ -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 () => {

View file

@ -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"
)}
>

View file

@ -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();

View file

@ -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"
)}
>

View file

@ -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}>

View file

@ -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>

View file

@ -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>

View file

@ -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,
};

View file

@ -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";

View file

@ -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,
};

View 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;
}
}

View 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>
);
};

View 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,
};
}

View file

@ -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,
};

View 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>
);
};

View file

@ -0,0 +1,2 @@
export * from "./action-buttons";
export * from "./schema";

View 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>;

View 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 };

View file

@ -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: {

View 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>;

View 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>;

View file

@ -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);

View file

@ -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");
}
},
};

View file

@ -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 };
}

View file

@ -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 || "");

View 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();

View 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();

View file

@ -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,
},
};

View file

@ -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",

View file

@ -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": "连接您的工具",