mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-28 21:49:40 +02:00
Merge pull request #596 from MODSetter/dev
feat: fixed web crawler, jotai migrations & new agent test script
This commit is contained in:
commit
07fa070760
30 changed files with 3065 additions and 583 deletions
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
|
|
@ -32,6 +32,19 @@
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false,
|
"justMyCode": false,
|
||||||
"cwd": "${workspaceFolder}/surfsense_backend"
|
"cwd": "${workspaceFolder}/surfsense_backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Chat DeepAgent",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "app.agents.new_chat.chat_deepagent",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": false,
|
||||||
|
"cwd": "${workspaceFolder}/surfsense_backend",
|
||||||
|
"python": "${command:python.interpreterPath}",
|
||||||
|
"env": {
|
||||||
|
"PYTHONPATH": "${workspaceFolder}/surfsense_backend"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
27
surfsense_backend/app/agents/new_chat/__init__.py
Normal file
27
surfsense_backend/app/agents/new_chat/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Chat agents module."""
|
||||||
|
|
||||||
|
from app.agents.new_chat.chat_deepagent import (
|
||||||
|
SURFSENSE_CITATION_INSTRUCTIONS,
|
||||||
|
SURFSENSE_SYSTEM_PROMPT,
|
||||||
|
SurfSenseContextSchema,
|
||||||
|
build_surfsense_system_prompt,
|
||||||
|
create_chat_litellm_from_config,
|
||||||
|
create_search_knowledge_base_tool,
|
||||||
|
create_surfsense_deep_agent,
|
||||||
|
format_documents_for_context,
|
||||||
|
load_llm_config_from_yaml,
|
||||||
|
search_knowledge_base_async,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SURFSENSE_CITATION_INSTRUCTIONS",
|
||||||
|
"SURFSENSE_SYSTEM_PROMPT",
|
||||||
|
"SurfSenseContextSchema",
|
||||||
|
"build_surfsense_system_prompt",
|
||||||
|
"create_chat_litellm_from_config",
|
||||||
|
"create_search_knowledge_base_tool",
|
||||||
|
"create_surfsense_deep_agent",
|
||||||
|
"format_documents_for_context",
|
||||||
|
"load_llm_config_from_yaml",
|
||||||
|
"search_knowledge_base_async",
|
||||||
|
]
|
||||||
998
surfsense_backend/app/agents/new_chat/chat_deepagent.py
Normal file
998
surfsense_backend/app/agents/new_chat/chat_deepagent.py
Normal file
|
|
@ -0,0 +1,998 @@
|
||||||
|
"""
|
||||||
|
Test script for create_deep_agent with ChatLiteLLM from global_llm_config.yaml
|
||||||
|
|
||||||
|
This demonstrates:
|
||||||
|
1. Loading LLM config from global_llm_config.yaml
|
||||||
|
2. Creating a ChatLiteLLM instance
|
||||||
|
3. Using context_schema to add custom state fields
|
||||||
|
4. Creating a search_knowledge_base tool similar to fetch_relevant_documents
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path so 'app' module can be found when running directly
|
||||||
|
_THIS_FILE = Path(__file__).resolve()
|
||||||
|
_BACKEND_ROOT = _THIS_FILE.parent.parent.parent.parent # surfsense_backend/
|
||||||
|
if str(_BACKEND_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from deepagents import create_deep_agent
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from langchain_litellm import ChatLiteLLM
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import async_session_maker
|
||||||
|
from app.services.connector_service import ConnectorService
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LLM Configuration Loading
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def load_llm_config_from_yaml(llm_config_id: int = -1) -> dict | None:
|
||||||
|
"""
|
||||||
|
Load a specific LLM config from global_llm_config.yaml.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_config_id: The id of the config to load (default: -1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LLM config dict or None if not found
|
||||||
|
"""
|
||||||
|
# Get the config file path
|
||||||
|
base_dir = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
config_file = base_dir / "app" / "config" / "global_llm_config.yaml"
|
||||||
|
|
||||||
|
# Fallback to example file if main config doesn't exist
|
||||||
|
if not config_file.exists():
|
||||||
|
config_file = base_dir / "app" / "config" / "global_llm_config.example.yaml"
|
||||||
|
if not config_file.exists():
|
||||||
|
print("Error: No global_llm_config.yaml or example file found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file, encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
configs = data.get("global_llm_configs", [])
|
||||||
|
for cfg in configs:
|
||||||
|
if isinstance(cfg, dict) and cfg.get("id") == llm_config_id:
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
print(f"Error: Global LLM config id {llm_config_id} not found")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading config: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None:
|
||||||
|
"""
|
||||||
|
Create a ChatLiteLLM instance from a global LLM config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_config: LLM configuration dictionary from YAML
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChatLiteLLM instance or None on error
|
||||||
|
"""
|
||||||
|
# Provider mapping (same as in llm_service.py)
|
||||||
|
provider_map = {
|
||||||
|
"OPENAI": "openai",
|
||||||
|
"ANTHROPIC": "anthropic",
|
||||||
|
"GROQ": "groq",
|
||||||
|
"COHERE": "cohere",
|
||||||
|
"GOOGLE": "gemini",
|
||||||
|
"OLLAMA": "ollama",
|
||||||
|
"MISTRAL": "mistral",
|
||||||
|
"AZURE_OPENAI": "azure",
|
||||||
|
"OPENROUTER": "openrouter",
|
||||||
|
"XAI": "xai",
|
||||||
|
"BEDROCK": "bedrock",
|
||||||
|
"VERTEX_AI": "vertex_ai",
|
||||||
|
"TOGETHER_AI": "together_ai",
|
||||||
|
"FIREWORKS_AI": "fireworks_ai",
|
||||||
|
"DEEPSEEK": "openai",
|
||||||
|
"ALIBABA_QWEN": "openai",
|
||||||
|
"MOONSHOT": "openai",
|
||||||
|
"ZHIPU": "openai",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the model string
|
||||||
|
if llm_config.get("custom_provider"):
|
||||||
|
model_string = f"{llm_config['custom_provider']}/{llm_config['model_name']}"
|
||||||
|
else:
|
||||||
|
provider = llm_config.get("provider", "").upper()
|
||||||
|
provider_prefix = provider_map.get(provider, provider.lower())
|
||||||
|
model_string = f"{provider_prefix}/{llm_config['model_name']}"
|
||||||
|
|
||||||
|
# Create ChatLiteLLM instance
|
||||||
|
litellm_kwargs = {
|
||||||
|
"model": model_string,
|
||||||
|
"api_key": llm_config.get("api_key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional parameters
|
||||||
|
if llm_config.get("api_base"):
|
||||||
|
litellm_kwargs["api_base"] = llm_config["api_base"]
|
||||||
|
|
||||||
|
# Add any additional litellm parameters
|
||||||
|
if llm_config.get("litellm_params"):
|
||||||
|
litellm_kwargs.update(llm_config["litellm_params"])
|
||||||
|
|
||||||
|
return ChatLiteLLM(**litellm_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Custom Context Schema
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SurfSenseContextSchema(TypedDict):
|
||||||
|
"""
|
||||||
|
Custom state schema for the SurfSense deep agent.
|
||||||
|
|
||||||
|
This extends the default agent state with custom fields.
|
||||||
|
The default state already includes:
|
||||||
|
- messages: Conversation history
|
||||||
|
- todos: Task list from TodoListMiddleware
|
||||||
|
- files: Virtual filesystem from FilesystemMiddleware
|
||||||
|
|
||||||
|
We're adding fields needed for knowledge base search:
|
||||||
|
- search_space_id: The user's search space ID
|
||||||
|
- db_session: Database session (injected at runtime)
|
||||||
|
- connector_service: Connector service instance (injected at runtime)
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_space_id: int
|
||||||
|
# These are runtime-injected and won't be serialized
|
||||||
|
# db_session and connector_service are passed when invoking the agent
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Knowledge Base Search Tool
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Canonical connector values used internally by ConnectorService
|
||||||
|
_ALL_CONNECTORS: list[str] = [
|
||||||
|
"EXTENSION",
|
||||||
|
"FILE",
|
||||||
|
"SLACK_CONNECTOR",
|
||||||
|
"NOTION_CONNECTOR",
|
||||||
|
"YOUTUBE_VIDEO",
|
||||||
|
"GITHUB_CONNECTOR",
|
||||||
|
"ELASTICSEARCH_CONNECTOR",
|
||||||
|
"LINEAR_CONNECTOR",
|
||||||
|
"JIRA_CONNECTOR",
|
||||||
|
"CONFLUENCE_CONNECTOR",
|
||||||
|
"CLICKUP_CONNECTOR",
|
||||||
|
"GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
"GOOGLE_GMAIL_CONNECTOR",
|
||||||
|
"DISCORD_CONNECTOR",
|
||||||
|
"AIRTABLE_CONNECTOR",
|
||||||
|
"TAVILY_API",
|
||||||
|
"SEARXNG_API",
|
||||||
|
"LINKUP_API",
|
||||||
|
"BAIDU_SEARCH_API",
|
||||||
|
"LUMA_CONNECTOR",
|
||||||
|
"NOTE",
|
||||||
|
"BOOKSTACK_CONNECTOR",
|
||||||
|
"CRAWLED_URL",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_connectors(connectors_to_search: list[str] | None) -> list[str]:
|
||||||
|
"""
|
||||||
|
Normalize connectors provided by the model.
|
||||||
|
|
||||||
|
- Accepts user-facing enums like WEBCRAWLER_CONNECTOR and maps them to canonical
|
||||||
|
ConnectorService types.
|
||||||
|
- Drops unknown values.
|
||||||
|
- If None/empty, defaults to searching across all known connectors.
|
||||||
|
"""
|
||||||
|
if not connectors_to_search:
|
||||||
|
return list(_ALL_CONNECTORS)
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
for raw in connectors_to_search:
|
||||||
|
c = (raw or "").strip().upper()
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
if c == "WEBCRAWLER_CONNECTOR":
|
||||||
|
c = "CRAWLED_URL"
|
||||||
|
normalized.append(c)
|
||||||
|
|
||||||
|
# de-dupe while preserving order + filter unknown
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for c in normalized:
|
||||||
|
if c in seen:
|
||||||
|
continue
|
||||||
|
if c not in _ALL_CONNECTORS:
|
||||||
|
continue
|
||||||
|
seen.add(c)
|
||||||
|
out.append(c)
|
||||||
|
return out if out else list(_ALL_CONNECTORS)
|
||||||
|
|
||||||
|
|
||||||
|
SURFSENSE_CITATION_INSTRUCTIONS = """
|
||||||
|
<citation_instructions>
|
||||||
|
CRITICAL CITATION REQUIREMENTS:
|
||||||
|
|
||||||
|
1. For EVERY piece of information you include from the documents, add a citation in the format [citation:chunk_id] where chunk_id is the exact value from the `<chunk id='...'>` tag inside `<document_content>`.
|
||||||
|
2. Make sure ALL factual statements from the documents have proper citations.
|
||||||
|
3. If multiple chunks support the same point, include all relevant citations [citation:chunk_id1], [citation:chunk_id2].
|
||||||
|
4. You MUST use the exact chunk_id values from the `<chunk id='...'>` attributes. Do not create your own citation numbers.
|
||||||
|
5. Every citation MUST be in the format [citation:chunk_id] where chunk_id is the exact chunk id value.
|
||||||
|
6. Never modify or change the chunk_id - always use the original values exactly as provided in the chunk tags.
|
||||||
|
7. Do not return citations as clickable links.
|
||||||
|
8. Never format citations as markdown links like "([citation:5](https://example.com))". Always use plain square brackets only.
|
||||||
|
9. Citations must ONLY appear as [citation:chunk_id] or [citation:chunk_id1], [citation:chunk_id2] format - never with parentheses, hyperlinks, or other formatting.
|
||||||
|
10. Never make up chunk IDs. Only use chunk_id values that are explicitly provided in the `<chunk id='...'>` tags.
|
||||||
|
11. If you are unsure about a chunk_id, do not include a citation rather than guessing or making one up.
|
||||||
|
|
||||||
|
<document_structure_example>
|
||||||
|
The documents you receive are structured like this:
|
||||||
|
|
||||||
|
<document>
|
||||||
|
<document_metadata>
|
||||||
|
<document_id>42</document_id>
|
||||||
|
<document_type>GITHUB_CONNECTOR</document_type>
|
||||||
|
<title><![CDATA[Some repo / file / issue title]]></title>
|
||||||
|
<url><![CDATA[https://example.com]]></url>
|
||||||
|
<metadata_json><![CDATA[{{"any":"other metadata"}}]]></metadata_json>
|
||||||
|
</document_metadata>
|
||||||
|
|
||||||
|
<document_content>
|
||||||
|
<chunk id='123'><![CDATA[First chunk text...]]></chunk>
|
||||||
|
<chunk id='124'><![CDATA[Second chunk text...]]></chunk>
|
||||||
|
</document_content>
|
||||||
|
</document>
|
||||||
|
|
||||||
|
IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124). Do NOT cite document_id.
|
||||||
|
</document_structure_example>
|
||||||
|
|
||||||
|
<citation_format>
|
||||||
|
- Every fact from the documents must have a citation in the format [citation:chunk_id] where chunk_id is the EXACT id value from a `<chunk id='...'>` tag
|
||||||
|
- Citations should appear at the end of the sentence containing the information they support
|
||||||
|
- Multiple citations should be separated by commas: [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3]
|
||||||
|
- No need to return references section. Just citations in answer.
|
||||||
|
- NEVER create your own citation format - use the exact chunk_id values from the documents in the [citation:chunk_id] format
|
||||||
|
- NEVER format citations as clickable links or as markdown links like "([citation:5](https://example.com))". Always use plain square brackets only
|
||||||
|
- NEVER make up chunk IDs if you are unsure about the chunk_id. It is better to omit the citation than to guess
|
||||||
|
</citation_format>
|
||||||
|
|
||||||
|
<citation_examples>
|
||||||
|
CORRECT citation formats:
|
||||||
|
- [citation:5]
|
||||||
|
- [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3]
|
||||||
|
|
||||||
|
INCORRECT citation formats (DO NOT use):
|
||||||
|
- Using parentheses and markdown links: ([citation:5](https://github.com/MODSetter/SurfSense))
|
||||||
|
- Using parentheses around brackets: ([citation:5])
|
||||||
|
- Using hyperlinked text: [link to source 5](https://example.com)
|
||||||
|
- Using footnote style: ... library¹
|
||||||
|
- Making up source IDs when source_id is unknown
|
||||||
|
- Using old IEEE format: [1], [2], [3]
|
||||||
|
- Using source types instead of IDs: [citation:GITHUB_CONNECTOR] instead of [citation:5]
|
||||||
|
</citation_examples>
|
||||||
|
|
||||||
|
<citation_output_example>
|
||||||
|
Based on your GitHub repositories and video content, Python's asyncio library provides tools for writing concurrent code using the async/await syntax [citation:5]. It's particularly useful for I/O-bound and high-level structured network code [citation:5].
|
||||||
|
|
||||||
|
The key advantage of asyncio is that it can improve performance by allowing other code to run while waiting for I/O operations to complete [citation:12]. This makes it excellent for scenarios like web scraping, API calls, database operations, or any situation where your program spends time waiting for external resources.
|
||||||
|
|
||||||
|
However, from your video learning, it's important to note that asyncio is not suitable for CPU-bound tasks as it runs on a single thread [citation:12]. For computationally intensive work, you'd want to use multiprocessing instead.
|
||||||
|
</citation_output_example>
|
||||||
|
</citation_instructions>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_or_datetime(value: str) -> datetime:
|
||||||
|
"""
|
||||||
|
Parse either an ISO date (YYYY-MM-DD) or ISO datetime into an aware UTC datetime.
|
||||||
|
|
||||||
|
- If `value` is a date, interpret it as start-of-day in UTC.
|
||||||
|
- If `value` is a datetime without timezone, assume UTC.
|
||||||
|
"""
|
||||||
|
raw = (value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("Empty date string")
|
||||||
|
|
||||||
|
# Date-only
|
||||||
|
if "T" not in raw:
|
||||||
|
d = datetime.fromisoformat(raw).date()
|
||||||
|
return datetime(d.year, d.month, d.day, tzinfo=UTC)
|
||||||
|
|
||||||
|
# Datetime (may be naive)
|
||||||
|
dt = datetime.fromisoformat(raw)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=UTC)
|
||||||
|
return dt.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_date_range(
|
||||||
|
start_date: datetime | None,
|
||||||
|
end_date: datetime | None,
|
||||||
|
) -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Resolve a date range, defaulting to the last 2 years if not provided.
|
||||||
|
Ensures start_date <= end_date.
|
||||||
|
"""
|
||||||
|
resolved_end = end_date or datetime.now(UTC)
|
||||||
|
resolved_start = start_date or (resolved_end - timedelta(days=730))
|
||||||
|
|
||||||
|
if resolved_start > resolved_end:
|
||||||
|
resolved_start, resolved_end = resolved_end, resolved_start
|
||||||
|
|
||||||
|
return resolved_start, resolved_end
|
||||||
|
|
||||||
|
|
||||||
|
def format_documents_for_context(documents: list[dict[str, Any]]) -> str:
|
||||||
|
"""
|
||||||
|
Format retrieved documents into a readable context string for the LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
documents: List of document dictionaries from connector search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with document contents and metadata
|
||||||
|
"""
|
||||||
|
if not documents:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Group chunks by document id (preferred) to produce the XML structure.
|
||||||
|
#
|
||||||
|
# IMPORTANT: ConnectorService returns **document-grouped** results of the form:
|
||||||
|
# {
|
||||||
|
# "document": {...},
|
||||||
|
# "chunks": [{"chunk_id": 123, "content": "..."}, ...],
|
||||||
|
# "source": "NOTION_CONNECTOR" | "FILE" | ...
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# We must preserve chunk_id so citations like [citation:123] are possible.
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
document_info = (doc.get("document") or {}) if isinstance(doc, dict) else {}
|
||||||
|
metadata = (
|
||||||
|
(document_info.get("metadata") or {})
|
||||||
|
if isinstance(document_info, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
if not metadata and isinstance(doc, dict):
|
||||||
|
# Some result shapes may place metadata at the top level.
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
|
||||||
|
source = (
|
||||||
|
(doc.get("source") if isinstance(doc, dict) else None)
|
||||||
|
or metadata.get("document_type")
|
||||||
|
or "UNKNOWN"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document identity (prefer document_id; otherwise fall back to type+title+url)
|
||||||
|
document_id_val = document_info.get("id")
|
||||||
|
title = (
|
||||||
|
document_info.get("title") or metadata.get("title") or "Untitled Document"
|
||||||
|
)
|
||||||
|
url = (
|
||||||
|
metadata.get("url")
|
||||||
|
or metadata.get("source")
|
||||||
|
or metadata.get("page_url")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_key = (
|
||||||
|
str(document_id_val)
|
||||||
|
if document_id_val is not None
|
||||||
|
else f"{source}::{title}::{url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if doc_key not in grouped:
|
||||||
|
grouped[doc_key] = {
|
||||||
|
"document_id": document_id_val
|
||||||
|
if document_id_val is not None
|
||||||
|
else doc_key,
|
||||||
|
"document_type": metadata.get("document_type") or source,
|
||||||
|
"title": title,
|
||||||
|
"url": url,
|
||||||
|
"metadata": metadata,
|
||||||
|
"chunks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefer document-grouped chunks if available
|
||||||
|
chunks_list = doc.get("chunks") if isinstance(doc, dict) else None
|
||||||
|
if isinstance(chunks_list, list) and chunks_list:
|
||||||
|
for ch in chunks_list:
|
||||||
|
if not isinstance(ch, dict):
|
||||||
|
continue
|
||||||
|
chunk_id = ch.get("chunk_id") or ch.get("id")
|
||||||
|
content = (ch.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
grouped[doc_key]["chunks"].append(
|
||||||
|
{"chunk_id": chunk_id, "content": content}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback: treat this as a flat chunk-like object
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
chunk_id = doc.get("chunk_id") or doc.get("id")
|
||||||
|
content = (doc.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
grouped[doc_key]["chunks"].append({"chunk_id": chunk_id, "content": content})
|
||||||
|
|
||||||
|
# Render XML expected by citation instructions
|
||||||
|
parts: list[str] = []
|
||||||
|
for g in grouped.values():
|
||||||
|
metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
|
||||||
|
|
||||||
|
parts.append("<document>")
|
||||||
|
parts.append("<document_metadata>")
|
||||||
|
parts.append(f" <document_id>{g['document_id']}</document_id>")
|
||||||
|
parts.append(f" <document_type>{g['document_type']}</document_type>")
|
||||||
|
parts.append(f" <title><![CDATA[{g['title']}]]></title>")
|
||||||
|
parts.append(f" <url><![CDATA[{g['url']}]]></url>")
|
||||||
|
parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
|
||||||
|
parts.append("</document_metadata>")
|
||||||
|
parts.append("")
|
||||||
|
parts.append("<document_content>")
|
||||||
|
|
||||||
|
for ch in g["chunks"]:
|
||||||
|
ch_content = ch["content"]
|
||||||
|
ch_id = ch["chunk_id"]
|
||||||
|
if ch_id is None:
|
||||||
|
parts.append(f" <chunk><![CDATA[{ch_content}]]></chunk>")
|
||||||
|
else:
|
||||||
|
parts.append(f" <chunk id='{ch_id}'><![CDATA[{ch_content}]]></chunk>")
|
||||||
|
|
||||||
|
parts.append("</document_content>")
|
||||||
|
parts.append("</document>")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def search_knowledge_base_async(
|
||||||
|
query: str,
|
||||||
|
search_space_id: int,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
connector_service: ConnectorService,
|
||||||
|
connectors_to_search: list[str] | None = None,
|
||||||
|
top_k: int = 10,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
end_date: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search the user's knowledge base for relevant documents.
|
||||||
|
|
||||||
|
This is the async implementation that searches across multiple connectors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query
|
||||||
|
search_space_id: The user's search space ID
|
||||||
|
db_session: Database session
|
||||||
|
connector_service: Initialized connector service
|
||||||
|
connectors_to_search: Optional list of connector types to search. If omitted, searches all.
|
||||||
|
top_k: Number of results per connector
|
||||||
|
start_date: Optional start datetime (UTC) for filtering documents
|
||||||
|
end_date: Optional end datetime (UTC) for filtering documents
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with search results
|
||||||
|
"""
|
||||||
|
all_documents = []
|
||||||
|
|
||||||
|
# Resolve date range (default last 2 years)
|
||||||
|
resolved_start_date, resolved_end_date = _resolve_date_range(
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
connectors = _normalize_connectors(connectors_to_search)
|
||||||
|
|
||||||
|
for connector in connectors:
|
||||||
|
try:
|
||||||
|
if connector == "YOUTUBE_VIDEO":
|
||||||
|
_, chunks = await connector_service.search_youtube(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "EXTENSION":
|
||||||
|
_, chunks = await connector_service.search_extension(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "CRAWLED_URL":
|
||||||
|
_, chunks = await connector_service.search_crawled_urls(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "FILE":
|
||||||
|
_, chunks = await connector_service.search_files(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "SLACK_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_slack(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "NOTION_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_notion(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "GITHUB_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_github(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "LINEAR_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_linear(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "TAVILY_API":
|
||||||
|
_, chunks = await connector_service.search_tavily(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "SEARXNG_API":
|
||||||
|
_, chunks = await connector_service.search_searxng(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "LINKUP_API":
|
||||||
|
# Keep behavior aligned with researcher: default "standard"
|
||||||
|
_, chunks = await connector_service.search_linkup(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
mode="standard",
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "BAIDU_SEARCH_API":
|
||||||
|
_, chunks = await connector_service.search_baidu(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "DISCORD_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_discord(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "JIRA_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_jira(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "GOOGLE_CALENDAR_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_google_calendar(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "AIRTABLE_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_airtable(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "GOOGLE_GMAIL_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_google_gmail(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "CONFLUENCE_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_confluence(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "CLICKUP_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_clickup(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "LUMA_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_luma(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "ELASTICSEARCH_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_elasticsearch(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "NOTE":
|
||||||
|
_, chunks = await connector_service.search_notes(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "BOOKSTACK_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_bookstack(
|
||||||
|
user_query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=resolved_start_date,
|
||||||
|
end_date=resolved_end_date,
|
||||||
|
)
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error searching connector {connector}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Deduplicate by content hash
|
||||||
|
seen_doc_ids: set[Any] = set()
|
||||||
|
seen_hashes: set[int] = set()
|
||||||
|
deduplicated: list[dict[str, Any]] = []
|
||||||
|
for doc in all_documents:
|
||||||
|
doc_id = (doc.get("document", {}) or {}).get("id")
|
||||||
|
content = (doc.get("content", "") or "").strip()
|
||||||
|
content_hash = hash(content)
|
||||||
|
|
||||||
|
if (doc_id and doc_id in seen_doc_ids) or content_hash in seen_hashes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if doc_id:
|
||||||
|
seen_doc_ids.add(doc_id)
|
||||||
|
seen_hashes.add(content_hash)
|
||||||
|
deduplicated.append(doc)
|
||||||
|
|
||||||
|
return format_documents_for_context(deduplicated)
|
||||||
|
|
||||||
|
|
||||||
|
def create_search_knowledge_base_tool(
|
||||||
|
search_space_id: int,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
connector_service: ConnectorService,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the search_knowledge_base tool with injected dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_space_id: The user's search space ID
|
||||||
|
db_session: Database session
|
||||||
|
connector_service: Initialized connector service
|
||||||
|
connectors_to_search: List of connector types to search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured tool function
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def search_knowledge_base(
|
||||||
|
query: str,
|
||||||
|
top_k: int = 10,
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
connectors_to_search: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Search the user's personal knowledge base for relevant information.
|
||||||
|
|
||||||
|
Use this tool to find documents, notes, files, web pages, and other content
|
||||||
|
that may help answer the user's question.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- If the user requests a specific source type (e.g. "my notes", "Slack messages"),
|
||||||
|
pass `connectors_to_search=[...]` using the enums below.
|
||||||
|
- If `connectors_to_search` is omitted/empty, the system will search broadly.
|
||||||
|
|
||||||
|
## Available connector enums for `connectors_to_search`
|
||||||
|
|
||||||
|
- EXTENSION: "Web content saved via SurfSense browser extension" (personal browsing history)
|
||||||
|
- FILE: "User-uploaded documents (PDFs, Word, etc.)" (personal files)
|
||||||
|
- NOTE: "SurfSense Notes" (notes created inside SurfSense)
|
||||||
|
- SLACK_CONNECTOR: "Slack conversations and shared content" (personal workspace communications)
|
||||||
|
- NOTION_CONNECTOR: "Notion workspace pages and databases" (personal knowledge management)
|
||||||
|
- YOUTUBE_VIDEO: "YouTube video transcripts and metadata" (personally saved videos)
|
||||||
|
- GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions)
|
||||||
|
- ELASTICSEARCH_CONNECTOR: "Elasticsearch indexed documents and data" (personal Elasticsearch instances and custom data sources)
|
||||||
|
- LINEAR_CONNECTOR: "Linear project issues and discussions" (personal project management)
|
||||||
|
- JIRA_CONNECTOR: "Jira project issues, tickets, and comments" (personal project tracking)
|
||||||
|
- CONFLUENCE_CONNECTOR: "Confluence pages and comments" (personal project documentation)
|
||||||
|
- CLICKUP_CONNECTOR: "ClickUp tasks and project data" (personal task management)
|
||||||
|
- GOOGLE_CALENDAR_CONNECTOR: "Google Calendar events, meetings, and schedules" (personal calendar and time management)
|
||||||
|
- GOOGLE_GMAIL_CONNECTOR: "Google Gmail emails and conversations" (personal emails and communications)
|
||||||
|
- DISCORD_CONNECTOR: "Discord server conversations and shared content" (personal community communications)
|
||||||
|
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
|
||||||
|
- TAVILY_API: "Tavily search API results" (personalized search results)
|
||||||
|
- SEARXNG_API: "SearxNG search API results" (personalized search results)
|
||||||
|
- LINKUP_API: "Linkup search API results" (personalized search results)
|
||||||
|
- BAIDU_SEARCH_API: "Baidu search API results" (personalized search results)
|
||||||
|
- LUMA_CONNECTOR: "Luma events"
|
||||||
|
- WEBCRAWLER_CONNECTOR: "Webpages indexed by SurfSense" (personally selected websites)
|
||||||
|
- BOOKSTACK_CONNECTOR: "BookStack pages" (personal documentation)
|
||||||
|
|
||||||
|
NOTE: `WEBCRAWLER_CONNECTOR` is mapped internally to the canonical document type `CRAWLED_URL`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query - be specific and include key terms
|
||||||
|
top_k: Number of results to retrieve (default: 10)
|
||||||
|
start_date: Optional ISO date/datetime (e.g. "2025-12-12" or "2025-12-12T00:00:00+00:00")
|
||||||
|
end_date: Optional ISO date/datetime (e.g. "2025-12-19" or "2025-12-19T23:59:59+00:00")
|
||||||
|
connectors_to_search: Optional list of connector enums to search. If omitted, searches all.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with relevant documents and their content
|
||||||
|
"""
|
||||||
|
parsed_start: datetime | None = None
|
||||||
|
parsed_end: datetime | None = None
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
parsed_start = _parse_date_or_datetime(start_date)
|
||||||
|
if end_date:
|
||||||
|
parsed_end = _parse_date_or_datetime(end_date)
|
||||||
|
|
||||||
|
return await search_knowledge_base_async(
|
||||||
|
query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
db_session=db_session,
|
||||||
|
connector_service=connector_service,
|
||||||
|
connectors_to_search=connectors_to_search,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=parsed_start,
|
||||||
|
end_date=parsed_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
return search_knowledge_base
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# System Prompt
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_surfsense_system_prompt(today: datetime | None = None) -> str:
|
||||||
|
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<system_instruction>
|
||||||
|
You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base.
|
||||||
|
|
||||||
|
Today's date (UTC): {resolved_today}
|
||||||
|
|
||||||
|
</system_instruction>
|
||||||
|
<tools>
|
||||||
|
You have access to the following tools:
|
||||||
|
- search_knowledge_base: Search the user's personal knowledge base for relevant information.
|
||||||
|
- Args:
|
||||||
|
- query: The search query - be specific and include key terms
|
||||||
|
- top_k: Number of results to retrieve (default: 10)
|
||||||
|
- start_date: Optional ISO date/datetime (e.g. "2025-12-12" or "2025-12-12T00:00:00+00:00")
|
||||||
|
- end_date: Optional ISO date/datetime (e.g. "2025-12-19" or "2025-12-19T23:59:59+00:00")
|
||||||
|
- connectors_to_search: Optional list of connector enums to search. If omitted, searches all.
|
||||||
|
- Returns: Formatted string with relevant documents and their content
|
||||||
|
</tools>
|
||||||
|
<tool_call_examples>
|
||||||
|
- User: "Fetch all my notes and what's in them?"
|
||||||
|
- Call: `search_knowledge_base(query="*", top_k=50, connectors_to_search=["NOTE"])`
|
||||||
|
|
||||||
|
- User: "What did I discuss on Slack last week about the React migration?"
|
||||||
|
- Call: `search_knowledge_base(query="React migration", connectors_to_search=["SLACK_CONNECTOR"], start_date="YYYY-MM-DD", end_date="YYYY-MM-DD")`
|
||||||
|
</tool_call_examples>
|
||||||
|
|
||||||
|
{SURFSENSE_CITATION_INSTRUCTIONS}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SURFSENSE_SYSTEM_PROMPT = build_surfsense_system_prompt()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Deep Agent Factory
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def create_surfsense_deep_agent(
|
||||||
|
llm: ChatLiteLLM,
|
||||||
|
search_space_id: int,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
connector_service: ConnectorService,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a SurfSense deep agent with knowledge base search capability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm: ChatLiteLLM instance
|
||||||
|
search_space_id: The user's search space ID
|
||||||
|
db_session: Database session
|
||||||
|
connector_service: Initialized connector service
|
||||||
|
connectors_to_search: List of connector types to search (default: common connectors)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompiledStateGraph: The configured deep agent
|
||||||
|
"""
|
||||||
|
# Create the search tool with injected dependencies
|
||||||
|
search_tool = create_search_knowledge_base_tool(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
db_session=db_session,
|
||||||
|
connector_service=connector_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the deep agent
|
||||||
|
agent = create_deep_agent(
|
||||||
|
model=llm,
|
||||||
|
tools=[search_tool],
|
||||||
|
system_prompt=build_surfsense_system_prompt(),
|
||||||
|
context_schema=SurfSenseContextSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Runner
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def run_test():
|
||||||
|
"""Run a basic test of the deep agent."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Creating Deep Agent with ChatLiteLLM from global config...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Create ChatLiteLLM from global config
|
||||||
|
# Use global LLM config by id (negative ids are reserved for global configs)
|
||||||
|
llm_config = load_llm_config_from_yaml(llm_config_id=-2)
|
||||||
|
if not llm_config:
|
||||||
|
raise ValueError("Failed to load LLM config from YAML")
|
||||||
|
llm = create_chat_litellm_from_config(llm_config)
|
||||||
|
if not llm:
|
||||||
|
raise ValueError("Failed to create ChatLiteLLM instance")
|
||||||
|
|
||||||
|
# Create a real DB session + ConnectorService, then build the full SurfSense agent.
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Use the known dev search space id
|
||||||
|
search_space_id = 5
|
||||||
|
|
||||||
|
connector_service = ConnectorService(session, search_space_id=search_space_id)
|
||||||
|
|
||||||
|
agent = create_surfsense_deep_agent(
|
||||||
|
llm=llm,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
db_session=session,
|
||||||
|
connector_service=connector_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nAgent created successfully!")
|
||||||
|
print(f"Agent type: {type(agent)}")
|
||||||
|
|
||||||
|
# Invoke the agent with initial state
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Invoking SurfSense agent (create_surfsense_deep_agent)...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
initial_state = {
|
||||||
|
"messages": [HumanMessage(content=("What are my notes from last 3 days?"))],
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"\nUsing search_space_id: {search_space_id}")
|
||||||
|
|
||||||
|
result = await agent.ainvoke(initial_state)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Agent Response:")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Print the response
|
||||||
|
if "messages" in result:
|
||||||
|
for msg in result["messages"]:
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
content = msg.content if hasattr(msg, "content") else str(msg)
|
||||||
|
print(f"\n--- [{msg_type}] ---\n{content}\n")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
@ -1163,6 +1163,33 @@ async def fetch_relevant_documents(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif connector == "BOOKSTACK_CONNECTOR":
|
||||||
|
(
|
||||||
|
source_object,
|
||||||
|
bookstack_chunks,
|
||||||
|
) = await connector_service.search_bookstack(
|
||||||
|
user_query=reformulated_query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to sources and raw documents
|
||||||
|
if source_object:
|
||||||
|
all_sources.append(source_object)
|
||||||
|
all_raw_documents.extend(bookstack_chunks)
|
||||||
|
|
||||||
|
# Stream found document count
|
||||||
|
if streaming_service and writer:
|
||||||
|
writer(
|
||||||
|
{
|
||||||
|
"yield_value": streaming_service.format_terminal_info_delta(
|
||||||
|
f"📚 Found {len(bookstack_chunks)} BookStack pages related to your query"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
elif connector == "NOTE":
|
elif connector == "NOTE":
|
||||||
(
|
(
|
||||||
source_object,
|
source_object,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str:
|
||||||
"LUMA_CONNECTOR": "✨",
|
"LUMA_CONNECTOR": "✨",
|
||||||
"ELASTICSEARCH_CONNECTOR": "⚡",
|
"ELASTICSEARCH_CONNECTOR": "⚡",
|
||||||
"WEBCRAWLER_CONNECTOR": "🌐",
|
"WEBCRAWLER_CONNECTOR": "🌐",
|
||||||
|
"BOOKSTACK_CONNECTOR": "📚",
|
||||||
"NOTE": "📝",
|
"NOTE": "📝",
|
||||||
}
|
}
|
||||||
return connector_emojis.get(connector_name, "🔎")
|
return connector_emojis.get(connector_name, "🔎")
|
||||||
|
|
@ -60,6 +61,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
|
||||||
"LUMA_CONNECTOR": "Luma",
|
"LUMA_CONNECTOR": "Luma",
|
||||||
"ELASTICSEARCH_CONNECTOR": "Elasticsearch",
|
"ELASTICSEARCH_CONNECTOR": "Elasticsearch",
|
||||||
"WEBCRAWLER_CONNECTOR": "Web Pages",
|
"WEBCRAWLER_CONNECTOR": "Web Pages",
|
||||||
|
"BOOKSTACK_CONNECTOR": "BookStack",
|
||||||
"NOTE": "Notes",
|
"NOTE": "Notes",
|
||||||
}
|
}
|
||||||
return connector_friendly_names.get(connector_name, connector_name)
|
return connector_friendly_names.get(connector_name, connector_name)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,17 @@ A module for crawling web pages and extracting content using Firecrawl or AsyncC
|
||||||
Provides a unified interface for web scraping.
|
Provides a unified interface for web scraping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import trafilatura
|
||||||
import validators
|
import validators
|
||||||
|
from fake_useragent import UserAgent
|
||||||
from firecrawl import AsyncFirecrawlApp
|
from firecrawl import AsyncFirecrawlApp
|
||||||
from langchain_community.document_loaders import AsyncChromiumLoader
|
from langchain_community.document_loaders import AsyncChromiumLoader
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WebCrawlerConnector:
|
class WebCrawlerConnector:
|
||||||
"""Class for crawling web pages and extracting content."""
|
"""Class for crawling web pages and extracting content."""
|
||||||
|
|
@ -121,7 +126,8 @@ class WebCrawlerConnector:
|
||||||
|
|
||||||
async def _crawl_with_chromium(self, url: str) -> dict[str, Any]:
|
async def _crawl_with_chromium(self, url: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Crawl URL using AsyncChromiumLoader.
|
Crawl URL using AsyncChromiumLoader with Trafilatura for content extraction.
|
||||||
|
Falls back to raw HTML if Trafilatura extraction fails.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: URL to crawl
|
url: URL to crawl
|
||||||
|
|
@ -132,33 +138,106 @@ class WebCrawlerConnector:
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If crawling fails
|
Exception: If crawling fails
|
||||||
"""
|
"""
|
||||||
crawl_loader = AsyncChromiumLoader(urls=[url], headless=True)
|
# Generate a realistic User-Agent to avoid bot detection
|
||||||
|
ua = UserAgent()
|
||||||
|
user_agent = ua.random
|
||||||
|
|
||||||
|
# Pass User-Agent to AsyncChromiumLoader
|
||||||
|
crawl_loader = AsyncChromiumLoader(
|
||||||
|
urls=[url], headless=True, user_agent=user_agent
|
||||||
|
)
|
||||||
documents = await crawl_loader.aload()
|
documents = await crawl_loader.aload()
|
||||||
|
|
||||||
if not documents:
|
if not documents:
|
||||||
raise ValueError(f"Failed to load content from {url}")
|
raise ValueError(f"Failed to load content from {url}")
|
||||||
|
|
||||||
doc = documents[0]
|
doc = documents[0]
|
||||||
|
raw_html = doc.page_content
|
||||||
|
|
||||||
# Extract basic metadata from the document
|
# Extract basic metadata from the document
|
||||||
metadata = doc.metadata if doc.metadata else {}
|
base_metadata = doc.metadata if doc.metadata else {}
|
||||||
|
|
||||||
|
# Try to extract main content using Trafilatura
|
||||||
|
extracted_content = None
|
||||||
|
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,
|
||||||
|
output_format="markdown", # Get clean markdown
|
||||||
|
include_comments=False, # Exclude comments
|
||||||
|
include_tables=True, # Keep tables
|
||||||
|
include_images=True, # Keep image references
|
||||||
|
include_links=True, # Keep links
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
)
|
||||||
|
extracted_content = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Trafilatura extraction failed for {url}: {e}. "
|
||||||
|
"Falling back to raw HTML"
|
||||||
|
)
|
||||||
|
extracted_content = None
|
||||||
|
|
||||||
|
# Build metadata, preferring Trafilatura metadata when available
|
||||||
|
metadata = {
|
||||||
|
"source": url,
|
||||||
|
"title": (
|
||||||
|
trafilatura_metadata.title
|
||||||
|
if trafilatura_metadata and trafilatura_metadata.title
|
||||||
|
else base_metadata.get("title", url)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add additional metadata from Trafilatura if available
|
||||||
|
if trafilatura_metadata:
|
||||||
|
if trafilatura_metadata.description:
|
||||||
|
metadata["description"] = trafilatura_metadata.description
|
||||||
|
if trafilatura_metadata.author:
|
||||||
|
metadata["author"] = trafilatura_metadata.author
|
||||||
|
if trafilatura_metadata.date:
|
||||||
|
metadata["date"] = trafilatura_metadata.date
|
||||||
|
|
||||||
|
# Add any remaining base metadata
|
||||||
|
metadata.update(base_metadata)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"content": doc.page_content,
|
"content": extracted_content if extracted_content else raw_html,
|
||||||
"metadata": {
|
"metadata": metadata,
|
||||||
"source": url,
|
|
||||||
"title": metadata.get("title", url),
|
|
||||||
**metadata,
|
|
||||||
},
|
|
||||||
"crawler_type": "chromium",
|
"crawler_type": "chromium",
|
||||||
}
|
}
|
||||||
|
|
||||||
def format_to_structured_document(self, crawl_result: dict[str, Any]) -> str:
|
def format_to_structured_document(
|
||||||
|
self, crawl_result: dict[str, Any], exclude_metadata: bool = False
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Format crawl result as a structured document.
|
Format crawl result as a structured document.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
crawl_result: Result from crawl_url method
|
crawl_result: Result from crawl_url method
|
||||||
|
exclude_metadata: If True, excludes ALL metadata fields from the document.
|
||||||
|
This is useful for content hash generation to ensure the hash
|
||||||
|
only changes when actual content changes, not when metadata
|
||||||
|
(which often contains dynamic fields like timestamps, IDs, etc.) changes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Structured document string
|
Structured document string
|
||||||
|
|
@ -166,15 +245,17 @@ class WebCrawlerConnector:
|
||||||
metadata = crawl_result["metadata"]
|
metadata = crawl_result["metadata"]
|
||||||
content = crawl_result["content"]
|
content = crawl_result["content"]
|
||||||
|
|
||||||
document_parts = ["<DOCUMENT>", "<METADATA>"]
|
document_parts = ["<DOCUMENT>"]
|
||||||
|
|
||||||
# Add all metadata fields
|
# Include metadata section only if not excluded
|
||||||
for key, value in metadata.items():
|
if not exclude_metadata:
|
||||||
document_parts.append(f"{key.upper()}: {value}")
|
document_parts.append("<METADATA>")
|
||||||
|
for key, value in metadata.items():
|
||||||
|
document_parts.append(f"{key.upper()}: {value}")
|
||||||
|
document_parts.append("</METADATA>")
|
||||||
|
|
||||||
document_parts.extend(
|
document_parts.extend(
|
||||||
[
|
[
|
||||||
"</METADATA>",
|
|
||||||
"<CONTENT>",
|
"<CONTENT>",
|
||||||
"FORMAT: markdown",
|
"FORMAT: markdown",
|
||||||
"TEXT_START",
|
"TEXT_START",
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,13 @@ from app.schemas import (
|
||||||
ChatRead,
|
ChatRead,
|
||||||
ChatReadWithoutMessages,
|
ChatReadWithoutMessages,
|
||||||
ChatUpdate,
|
ChatUpdate,
|
||||||
|
NewChatRequest,
|
||||||
)
|
)
|
||||||
from app.tasks.stream_connector_search_results import stream_connector_search_results
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
from app.tasks.chat.stream_connector_search_results import (
|
||||||
|
stream_connector_search_results,
|
||||||
|
)
|
||||||
|
from app.tasks.chat.stream_new_chat import stream_new_chat
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
from app.utils.validators import (
|
from app.utils.validators import (
|
||||||
|
|
@ -152,6 +157,87 @@ async def handle_chat_data(
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/new_chat")
|
||||||
|
async def handle_new_chat(
|
||||||
|
request: NewChatRequest,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle new chat requests using the SurfSense deep agent.
|
||||||
|
|
||||||
|
This endpoint uses the new deep agent with the Vercel AI SDK
|
||||||
|
Data Stream Protocol (SSE format).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: NewChatRequest containing chat_id, user_query, and search_space_id
|
||||||
|
session: Database session
|
||||||
|
user: Current authenticated user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with SSE formatted data
|
||||||
|
"""
|
||||||
|
# Validate the user query
|
||||||
|
if not request.user_query or not request.user_query.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="User query cannot be empty")
|
||||||
|
|
||||||
|
# Check if the user has chat access to the search space
|
||||||
|
try:
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
request.search_space_id,
|
||||||
|
Permission.CHATS_CREATE.value,
|
||||||
|
"You don't have permission to use chat in this search space",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="You don't have access to this search space"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Get LLM config ID from search space preferences (optional enhancement)
|
||||||
|
# For now, we use the default global config (-1)
|
||||||
|
llm_config_id = -1
|
||||||
|
|
||||||
|
# Optionally load LLM preferences from search space
|
||||||
|
try:
|
||||||
|
search_space_result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == request.search_space_id)
|
||||||
|
)
|
||||||
|
search_space = search_space_result.scalars().first()
|
||||||
|
|
||||||
|
if search_space:
|
||||||
|
# Use strategic_llm_id if available, otherwise fall back to fast_llm_id
|
||||||
|
if search_space.strategic_llm_id is not None:
|
||||||
|
llm_config_id = search_space.strategic_llm_id
|
||||||
|
elif search_space.fast_llm_id is not None:
|
||||||
|
llm_config_id = search_space.fast_llm_id
|
||||||
|
except Exception:
|
||||||
|
# Fall back to default config on any error
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create the streaming response
|
||||||
|
# chat_id is used as LangGraph's thread_id for automatic chat history management
|
||||||
|
response = StreamingResponse(
|
||||||
|
stream_new_chat(
|
||||||
|
user_query=request.user_query.strip(),
|
||||||
|
user_id=user.id,
|
||||||
|
search_space_id=request.search_space_id,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
session=session,
|
||||||
|
llm_config_id=llm_config_id,
|
||||||
|
),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the required headers for Vercel AI SDK
|
||||||
|
headers = VercelStreamingService.get_response_headers()
|
||||||
|
for key, value in headers.items():
|
||||||
|
response.headers[key] = value
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chats", response_model=ChatRead)
|
@router.post("/chats", response_model=ChatRead)
|
||||||
async def create_chat(
|
async def create_chat(
|
||||||
chat: ChatCreate,
|
chat: ChatCreate,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from .chats import (
|
||||||
ChatRead,
|
ChatRead,
|
||||||
ChatReadWithoutMessages,
|
ChatReadWithoutMessages,
|
||||||
ChatUpdate,
|
ChatUpdate,
|
||||||
|
NewChatRequest,
|
||||||
)
|
)
|
||||||
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
|
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
|
||||||
from .documents import (
|
from .documents import (
|
||||||
|
|
@ -97,6 +98,7 @@ __all__ = [
|
||||||
"MembershipRead",
|
"MembershipRead",
|
||||||
"MembershipReadWithUser",
|
"MembershipReadWithUser",
|
||||||
"MembershipUpdate",
|
"MembershipUpdate",
|
||||||
|
"NewChatRequest",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
"PermissionInfo",
|
"PermissionInfo",
|
||||||
"PermissionsListResponse",
|
"PermissionsListResponse",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,14 @@ class AISDKChatRequest(BaseModel):
|
||||||
data: dict[str, Any] | None = None
|
data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NewChatRequest(BaseModel):
|
||||||
|
"""Request schema for the new deep agent chat endpoint."""
|
||||||
|
|
||||||
|
chat_id: int
|
||||||
|
user_query: str
|
||||||
|
search_space_id: int
|
||||||
|
|
||||||
|
|
||||||
class ChatCreate(ChatBase):
|
class ChatCreate(ChatBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,24 +252,28 @@ class ConnectorService:
|
||||||
# Get more results from each retriever for better fusion
|
# Get more results from each retriever for better fusion
|
||||||
retriever_top_k = top_k * 2
|
retriever_top_k = top_k * 2
|
||||||
|
|
||||||
# Run both searches in parallel
|
# IMPORTANT:
|
||||||
chunk_results, doc_results = await asyncio.gather(
|
# These retrievers share the same AsyncSession. AsyncSession does not permit
|
||||||
self.chunk_retriever.hybrid_search(
|
# concurrent awaits that require DB IO on the same session/connection.
|
||||||
query_text=query_text,
|
# Running these in parallel can raise:
|
||||||
top_k=retriever_top_k,
|
# "This session is provisioning a new connection; concurrent operations are not permitted"
|
||||||
search_space_id=search_space_id,
|
#
|
||||||
document_type=document_type,
|
# So we run them sequentially.
|
||||||
start_date=start_date,
|
chunk_results = await self.chunk_retriever.hybrid_search(
|
||||||
end_date=end_date,
|
query_text=query_text,
|
||||||
),
|
top_k=retriever_top_k,
|
||||||
self.document_retriever.hybrid_search(
|
search_space_id=search_space_id,
|
||||||
query_text=query_text,
|
document_type=document_type,
|
||||||
top_k=retriever_top_k,
|
start_date=start_date,
|
||||||
search_space_id=search_space_id,
|
end_date=end_date,
|
||||||
document_type=document_type,
|
)
|
||||||
start_date=start_date,
|
doc_results = await self.document_retriever.hybrid_search(
|
||||||
end_date=end_date,
|
query_text=query_text,
|
||||||
),
|
top_k=retriever_top_k,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
document_type=document_type,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Helper to extract document_id from our doc-grouped result
|
# Helper to extract document_id from our doc-grouped result
|
||||||
|
|
@ -2432,7 +2436,6 @@ class ConnectorService:
|
||||||
async def search_bookstack(
|
async def search_bookstack(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
start_date: datetime | None = None,
|
start_date: datetime | None = None,
|
||||||
|
|
|
||||||
699
surfsense_backend/app/services/new_streaming_service.py
Normal file
699
surfsense_backend/app/services/new_streaming_service.py
Normal file
|
|
@ -0,0 +1,699 @@
|
||||||
|
"""
|
||||||
|
Vercel AI SDK Data Stream Protocol Implementation
|
||||||
|
|
||||||
|
This module implements the Vercel AI SDK streaming protocol for use with
|
||||||
|
@ai-sdk/react's useChat and useCompletion hooks.
|
||||||
|
|
||||||
|
Protocol Reference:
|
||||||
|
- Uses Server-Sent Events (SSE) format
|
||||||
|
- Requires 'x-vercel-ai-ui-message-stream: v1' header
|
||||||
|
- Supports text, reasoning, sources, files, tools, data, and error parts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id() -> str:
|
||||||
|
"""Generate a unique ID for stream parts."""
|
||||||
|
return f"msg_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamContext:
|
||||||
|
"""
|
||||||
|
Maintains context for streaming operations.
|
||||||
|
Tracks active text and reasoning blocks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message_id: str = field(default_factory=generate_id)
|
||||||
|
active_text_id: str | None = None
|
||||||
|
active_reasoning_id: str | None = None
|
||||||
|
step_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VercelStreamingService:
|
||||||
|
"""
|
||||||
|
Implements the Vercel AI SDK Data Stream Protocol.
|
||||||
|
|
||||||
|
This service formats messages according to the SSE-based protocol
|
||||||
|
that the AI SDK frontend expects. All messages are formatted as:
|
||||||
|
data: {json_object}\n\n
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
service = VercelStreamingService()
|
||||||
|
|
||||||
|
# Start a message
|
||||||
|
yield service.format_message_start()
|
||||||
|
|
||||||
|
# Stream text content
|
||||||
|
text_id = service.generate_text_id()
|
||||||
|
yield service.format_text_start(text_id)
|
||||||
|
yield service.format_text_delta(text_id, "Hello, ")
|
||||||
|
yield service.format_text_delta(text_id, "world!")
|
||||||
|
yield service.format_text_end(text_id)
|
||||||
|
|
||||||
|
# Finish the message
|
||||||
|
yield service.format_finish()
|
||||||
|
yield service.format_done()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.context = StreamContext()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_response_headers() -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get the required HTTP headers for Vercel AI SDK streaming.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Headers to include in the streaming response
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"x-vercel-ai-ui-message-stream": "v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_sse(data: Any) -> str:
|
||||||
|
"""
|
||||||
|
Format data as a Server-Sent Event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The data to format (will be JSON serialized if not a string)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted string
|
||||||
|
"""
|
||||||
|
if isinstance(data, str):
|
||||||
|
return f"data: {data}\n\n"
|
||||||
|
return f"data: {json.dumps(data)}\n\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_text_id() -> str:
|
||||||
|
"""Generate a unique ID for a text block."""
|
||||||
|
return f"text_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_reasoning_id() -> str:
|
||||||
|
"""Generate a unique ID for a reasoning block."""
|
||||||
|
return f"reasoning_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_tool_call_id() -> str:
|
||||||
|
"""Generate a unique ID for a tool call."""
|
||||||
|
return f"call_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Message Lifecycle Parts
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_message_start(self, message_id: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Format the start of a new message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Optional custom message ID. If not provided, one is generated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted message start part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"start","messageId":"msg_abc123"}
|
||||||
|
"""
|
||||||
|
if message_id:
|
||||||
|
self.context.message_id = message_id
|
||||||
|
else:
|
||||||
|
self.context.message_id = generate_id()
|
||||||
|
|
||||||
|
return self._format_sse({"type": "start", "messageId": self.context.message_id})
|
||||||
|
|
||||||
|
def format_finish(self) -> str:
|
||||||
|
"""
|
||||||
|
Format the finish message part.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted finish part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"finish"}
|
||||||
|
"""
|
||||||
|
return self._format_sse({"type": "finish"})
|
||||||
|
|
||||||
|
def format_done(self) -> str:
|
||||||
|
"""
|
||||||
|
Format the stream termination marker.
|
||||||
|
|
||||||
|
This should be the last thing sent in a stream.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted done marker
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: [DONE]
|
||||||
|
"""
|
||||||
|
return "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Text Parts (start/delta/end pattern)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_text_start(self, text_id: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Format the start of a text block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_id: Optional custom text block ID. If not provided, one is generated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted text start part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"text-start","id":"text_abc123"}
|
||||||
|
"""
|
||||||
|
if text_id is None:
|
||||||
|
text_id = self.generate_text_id()
|
||||||
|
self.context.active_text_id = text_id
|
||||||
|
return self._format_sse({"type": "text-start", "id": text_id})
|
||||||
|
|
||||||
|
def format_text_delta(self, text_id: str, delta: str) -> str:
|
||||||
|
"""
|
||||||
|
Format a text delta (incremental content).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_id: The text block ID
|
||||||
|
delta: The incremental text content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted text delta part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"text-delta","id":"text_abc123","delta":"Hello"}
|
||||||
|
"""
|
||||||
|
return self._format_sse({"type": "text-delta", "id": text_id, "delta": delta})
|
||||||
|
|
||||||
|
def format_text_end(self, text_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Format the end of a text block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_id: The text block ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted text end part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"text-end","id":"text_abc123"}
|
||||||
|
"""
|
||||||
|
if self.context.active_text_id == text_id:
|
||||||
|
self.context.active_text_id = None
|
||||||
|
return self._format_sse({"type": "text-end", "id": text_id})
|
||||||
|
|
||||||
|
def stream_text(self, text_id: str, text: str, chunk_size: int = 10) -> list[str]:
|
||||||
|
"""
|
||||||
|
Convenience method to stream text in chunks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_id: The text block ID
|
||||||
|
text: The full text to stream
|
||||||
|
chunk_size: Size of each chunk (default 10 characters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of SSE formatted text delta parts
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for i in range(0, len(text), chunk_size):
|
||||||
|
chunk = text[i : i + chunk_size]
|
||||||
|
parts.append(self.format_text_delta(text_id, chunk))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Reasoning Parts (start/delta/end pattern)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_reasoning_start(self, reasoning_id: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Format the start of a reasoning block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reasoning_id: Optional custom reasoning block ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted reasoning start part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"reasoning-start","id":"reasoning_abc123"}
|
||||||
|
"""
|
||||||
|
if reasoning_id is None:
|
||||||
|
reasoning_id = self.generate_reasoning_id()
|
||||||
|
self.context.active_reasoning_id = reasoning_id
|
||||||
|
return self._format_sse({"type": "reasoning-start", "id": reasoning_id})
|
||||||
|
|
||||||
|
def format_reasoning_delta(self, reasoning_id: str, delta: str) -> str:
|
||||||
|
"""
|
||||||
|
Format a reasoning delta (incremental reasoning content).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reasoning_id: The reasoning block ID
|
||||||
|
delta: The incremental reasoning content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted reasoning delta part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"reasoning-delta","id":"reasoning_abc123","delta":"Let me think..."}
|
||||||
|
"""
|
||||||
|
return self._format_sse(
|
||||||
|
{"type": "reasoning-delta", "id": reasoning_id, "delta": delta}
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_reasoning_end(self, reasoning_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Format the end of a reasoning block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reasoning_id: The reasoning block ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted reasoning end part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"reasoning-end","id":"reasoning_abc123"}
|
||||||
|
"""
|
||||||
|
if self.context.active_reasoning_id == reasoning_id:
|
||||||
|
self.context.active_reasoning_id = None
|
||||||
|
return self._format_sse({"type": "reasoning-end", "id": reasoning_id})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Source Parts
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_source_url(
|
||||||
|
self, url: str, source_id: str | None = None, title: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format a source URL reference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The source URL
|
||||||
|
source_id: Optional source identifier (defaults to URL)
|
||||||
|
title: Optional title for the source
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted source URL part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"source-url","sourceId":"https://example.com","url":"https://example.com"}
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"type": "source-url",
|
||||||
|
"sourceId": source_id or url,
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
if title:
|
||||||
|
data["title"] = title
|
||||||
|
return self._format_sse(data)
|
||||||
|
|
||||||
|
def format_source_document(
|
||||||
|
self,
|
||||||
|
source_id: str,
|
||||||
|
media_type: str = "file",
|
||||||
|
title: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format a source document reference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_id: The source identifier
|
||||||
|
media_type: The media type (e.g., "file", "pdf", "document")
|
||||||
|
title: Optional title for the document
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted source document part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"source-document","sourceId":"doc_123","mediaType":"file","title":"Report"}
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"type": "source-document",
|
||||||
|
"sourceId": source_id,
|
||||||
|
"mediaType": media_type,
|
||||||
|
}
|
||||||
|
if title:
|
||||||
|
data["title"] = title
|
||||||
|
if description:
|
||||||
|
data["description"] = description
|
||||||
|
return self._format_sse(data)
|
||||||
|
|
||||||
|
def format_sources(self, sources: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Format multiple sources.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sources: List of source objects with 'url', 'title', 'type' fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of SSE formatted source parts
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for source in sources:
|
||||||
|
url = source.get("url")
|
||||||
|
if url:
|
||||||
|
parts.append(
|
||||||
|
self.format_source_url(
|
||||||
|
url=url,
|
||||||
|
source_id=source.get("id", url),
|
||||||
|
title=source.get("title"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
self.format_source_document(
|
||||||
|
source_id=source.get("id", ""),
|
||||||
|
media_type=source.get("type", "file"),
|
||||||
|
title=source.get("title"),
|
||||||
|
description=source.get("description"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# File Part
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_file(self, url: str, media_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Format a file reference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The file URL
|
||||||
|
media_type: The MIME type (e.g., "image/png", "application/pdf")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted file part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"file","url":"https://example.com/file.png","mediaType":"image/png"}
|
||||||
|
"""
|
||||||
|
return self._format_sse({"type": "file", "url": url, "mediaType": media_type})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Custom Data Parts
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_data(self, data_type: str, data: Any) -> str:
|
||||||
|
"""
|
||||||
|
Format custom data with a type-specific suffix.
|
||||||
|
|
||||||
|
The type will be prefixed with 'data-' automatically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_type: The custom data type suffix (e.g., "weather", "chart")
|
||||||
|
data: The data payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted data part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"data-weather","data":{"location":"SF","temperature":100}}
|
||||||
|
"""
|
||||||
|
return self._format_sse({"type": f"data-{data_type}", "data": data})
|
||||||
|
|
||||||
|
def format_terminal_info(self, text: str, message_type: str = "info") -> str:
|
||||||
|
"""
|
||||||
|
Format terminal info as custom data (SurfSense specific).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The terminal message text
|
||||||
|
message_type: The message type (info, error, success, warning)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted terminal info data part
|
||||||
|
"""
|
||||||
|
return self.format_data("terminal-info", {"text": text, "type": message_type})
|
||||||
|
|
||||||
|
def format_further_questions(self, questions: list[str]) -> str:
|
||||||
|
"""
|
||||||
|
Format further questions as custom data (SurfSense specific).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
questions: List of suggested follow-up questions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted further questions data part
|
||||||
|
"""
|
||||||
|
return self.format_data("further-questions", {"questions": questions})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Error Part
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_error(self, error_text: str) -> str:
|
||||||
|
"""
|
||||||
|
Format an error message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_text: The error message text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted error part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"error","errorText":"Something went wrong"}
|
||||||
|
"""
|
||||||
|
return self._format_sse({"type": "error", "errorText": error_text})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Tool Parts
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_tool_input_start(self, tool_call_id: str, tool_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Format the start of tool input streaming.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call_id: The unique tool call identifier
|
||||||
|
tool_name: The name of the tool being called
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted tool input start part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"tool-input-start","toolCallId":"call_abc123","toolName":"getWeather"}
|
||||||
|
"""
|
||||||
|
return self._format_sse(
|
||||||
|
{
|
||||||
|
"type": "tool-input-start",
|
||||||
|
"toolCallId": tool_call_id,
|
||||||
|
"toolName": tool_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_tool_input_delta(self, tool_call_id: str, input_text_delta: str) -> str:
|
||||||
|
"""
|
||||||
|
Format incremental tool input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call_id: The tool call identifier
|
||||||
|
input_text_delta: The incremental input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted tool input delta part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"tool-input-delta","toolCallId":"call_abc123","inputTextDelta":"San Fran"}
|
||||||
|
"""
|
||||||
|
return self._format_sse(
|
||||||
|
{
|
||||||
|
"type": "tool-input-delta",
|
||||||
|
"toolCallId": tool_call_id,
|
||||||
|
"inputTextDelta": input_text_delta,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_tool_input_available(
|
||||||
|
self, tool_call_id: str, tool_name: str, input_data: dict[str, Any]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format the completion of tool input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call_id: The tool call identifier
|
||||||
|
tool_name: The name of the tool
|
||||||
|
input_data: The complete tool input parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted tool input available part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"tool-input-available","toolCallId":"call_abc123","toolName":"getWeather","input":{"city":"SF"}}
|
||||||
|
"""
|
||||||
|
return self._format_sse(
|
||||||
|
{
|
||||||
|
"type": "tool-input-available",
|
||||||
|
"toolCallId": tool_call_id,
|
||||||
|
"toolName": tool_name,
|
||||||
|
"input": input_data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_tool_output_available(self, tool_call_id: str, output: Any) -> str:
|
||||||
|
"""
|
||||||
|
Format tool execution output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call_id: The tool call identifier
|
||||||
|
output: The tool execution result
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted tool output available part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"tool-output-available","toolCallId":"call_abc123","output":{"weather":"sunny"}}
|
||||||
|
"""
|
||||||
|
return self._format_sse(
|
||||||
|
{
|
||||||
|
"type": "tool-output-available",
|
||||||
|
"toolCallId": tool_call_id,
|
||||||
|
"output": output,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Step Parts
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def format_start_step(self) -> str:
|
||||||
|
"""
|
||||||
|
Format the start of a step (one LLM API call).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted start step part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"start-step"}
|
||||||
|
"""
|
||||||
|
self.context.step_count += 1
|
||||||
|
return self._format_sse({"type": "start-step"})
|
||||||
|
|
||||||
|
def format_finish_step(self) -> str:
|
||||||
|
"""
|
||||||
|
Format the completion of a step.
|
||||||
|
|
||||||
|
This is necessary for correctly processing multiple stitched
|
||||||
|
assistant calls, e.g., when calling tools in the backend.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted finish step part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"finish-step"}
|
||||||
|
"""
|
||||||
|
return self._format_sse({"type": "finish-step"})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Convenience Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def stream_full_text(self, text: str, chunk_size: int = 10) -> list[str]:
|
||||||
|
"""
|
||||||
|
Convenience method to stream a complete text block.
|
||||||
|
|
||||||
|
Generates: text-start, text-deltas, text-end
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The full text to stream
|
||||||
|
chunk_size: Size of each chunk
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of all SSE formatted parts
|
||||||
|
"""
|
||||||
|
text_id = self.generate_text_id()
|
||||||
|
parts = [self.format_text_start(text_id)]
|
||||||
|
parts.extend(self.stream_text(text_id, text, chunk_size))
|
||||||
|
parts.append(self.format_text_end(text_id))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def stream_full_reasoning(self, reasoning: str, chunk_size: int = 20) -> list[str]:
|
||||||
|
"""
|
||||||
|
Convenience method to stream a complete reasoning block.
|
||||||
|
|
||||||
|
Generates: reasoning-start, reasoning-deltas, reasoning-end
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reasoning: The full reasoning text
|
||||||
|
chunk_size: Size of each chunk
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of all SSE formatted parts
|
||||||
|
"""
|
||||||
|
reasoning_id = self.generate_reasoning_id()
|
||||||
|
parts = [self.format_reasoning_start(reasoning_id)]
|
||||||
|
for i in range(0, len(reasoning), chunk_size):
|
||||||
|
chunk = reasoning[i : i + chunk_size]
|
||||||
|
parts.append(self.format_reasoning_delta(reasoning_id, chunk))
|
||||||
|
parts.append(self.format_reasoning_end(reasoning_id))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def create_complete_response(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
sources: list[dict[str, Any]] | None = None,
|
||||||
|
reasoning: str | None = None,
|
||||||
|
further_questions: list[str] | None = None,
|
||||||
|
chunk_size: int = 10,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Create a complete streaming response with all parts.
|
||||||
|
|
||||||
|
This is a convenience method that generates a full response
|
||||||
|
including message start, optional reasoning, text, sources,
|
||||||
|
further questions, and finish markers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The main response text
|
||||||
|
sources: Optional list of source references
|
||||||
|
reasoning: Optional reasoning/thinking content
|
||||||
|
further_questions: Optional follow-up questions
|
||||||
|
chunk_size: Size of text chunks
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of all SSE formatted parts in correct order
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Start message
|
||||||
|
parts.append(self.format_message_start())
|
||||||
|
parts.append(self.format_start_step())
|
||||||
|
|
||||||
|
# Reasoning (if provided)
|
||||||
|
if reasoning:
|
||||||
|
parts.extend(self.stream_full_reasoning(reasoning))
|
||||||
|
|
||||||
|
# Sources (before main text)
|
||||||
|
if sources:
|
||||||
|
parts.extend(self.format_sources(sources))
|
||||||
|
|
||||||
|
# Main text content
|
||||||
|
parts.extend(self.stream_full_text(text, chunk_size))
|
||||||
|
|
||||||
|
# Further questions (if provided)
|
||||||
|
if further_questions:
|
||||||
|
parts.append(self.format_further_questions(further_questions))
|
||||||
|
|
||||||
|
# Finish
|
||||||
|
parts.append(self.format_finish_step())
|
||||||
|
parts.append(self.format_finish())
|
||||||
|
parts.append(self.format_done())
|
||||||
|
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the streaming context for a new message."""
|
||||||
|
self.context = StreamContext()
|
||||||
210
surfsense_backend/app/tasks/chat/stream_new_chat.py
Normal file
210
surfsense_backend/app/tasks/chat/stream_new_chat.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""
|
||||||
|
Streaming task for the new SurfSense deep agent chat.
|
||||||
|
|
||||||
|
This module streams responses from the deep agent using the Vercel AI SDK
|
||||||
|
Data Stream Protocol (SSE format).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.new_chat.chat_deepagent import (
|
||||||
|
create_chat_litellm_from_config,
|
||||||
|
create_surfsense_deep_agent,
|
||||||
|
load_llm_config_from_yaml,
|
||||||
|
)
|
||||||
|
from app.services.connector_service import ConnectorService
|
||||||
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_new_chat(
|
||||||
|
user_query: str,
|
||||||
|
user_id: str | UUID,
|
||||||
|
search_space_id: int,
|
||||||
|
chat_id: int,
|
||||||
|
session: AsyncSession,
|
||||||
|
llm_config_id: int = -1,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream chat responses from the new SurfSense deep agent.
|
||||||
|
|
||||||
|
This uses the Vercel AI SDK Data Stream Protocol (SSE format) for streaming.
|
||||||
|
The chat_id is used as LangGraph's thread_id for memory/checkpointing,
|
||||||
|
so chat history is automatically managed by LangGraph.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_query: The user's query
|
||||||
|
user_id: The user's ID (can be UUID object or string)
|
||||||
|
search_space_id: The search space ID
|
||||||
|
chat_id: The chat ID (used as LangGraph thread_id for memory)
|
||||||
|
session: The database session
|
||||||
|
llm_config_id: The LLM configuration ID (default: -1 for first global config)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: SSE formatted response strings
|
||||||
|
"""
|
||||||
|
streaming_service = VercelStreamingService()
|
||||||
|
|
||||||
|
# Convert UUID to string if needed
|
||||||
|
str(user_id) if isinstance(user_id, UUID) else user_id
|
||||||
|
|
||||||
|
# Track the current text block for streaming (defined early for exception handling)
|
||||||
|
current_text_id: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load LLM config
|
||||||
|
llm_config = load_llm_config_from_yaml(llm_config_id=llm_config_id)
|
||||||
|
if not llm_config:
|
||||||
|
yield streaming_service.format_error(
|
||||||
|
f"Failed to load LLM config with id {llm_config_id}"
|
||||||
|
)
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create ChatLiteLLM instance
|
||||||
|
llm = create_chat_litellm_from_config(llm_config)
|
||||||
|
if not llm:
|
||||||
|
yield streaming_service.format_error("Failed to create LLM instance")
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create connector service
|
||||||
|
connector_service = ConnectorService(session, search_space_id=search_space_id)
|
||||||
|
|
||||||
|
# Create the deep agent
|
||||||
|
agent = create_surfsense_deep_agent(
|
||||||
|
llm=llm,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
db_session=session,
|
||||||
|
connector_service=connector_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build input with just the current user query
|
||||||
|
# Chat history is managed by LangGraph via thread_id
|
||||||
|
input_state = {
|
||||||
|
"messages": [HumanMessage(content=user_query)],
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure LangGraph with thread_id for memory
|
||||||
|
config = {
|
||||||
|
"configurable": {
|
||||||
|
"thread_id": str(chat_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the message stream
|
||||||
|
yield streaming_service.format_message_start()
|
||||||
|
yield streaming_service.format_start_step()
|
||||||
|
|
||||||
|
# Reset text tracking for this stream
|
||||||
|
accumulated_text = ""
|
||||||
|
|
||||||
|
# Stream the agent response with thread config for memory
|
||||||
|
async for event in agent.astream_events(
|
||||||
|
input_state, config=config, version="v2"
|
||||||
|
):
|
||||||
|
event_type = event.get("event", "")
|
||||||
|
|
||||||
|
# Handle chat model stream events (text streaming)
|
||||||
|
if event_type == "on_chat_model_stream":
|
||||||
|
chunk = event.get("data", {}).get("chunk")
|
||||||
|
if chunk and hasattr(chunk, "content"):
|
||||||
|
content = chunk.content
|
||||||
|
if content and isinstance(content, str):
|
||||||
|
# Start a new text block if needed
|
||||||
|
if current_text_id is None:
|
||||||
|
current_text_id = streaming_service.generate_text_id()
|
||||||
|
yield streaming_service.format_text_start(current_text_id)
|
||||||
|
|
||||||
|
# Stream the text delta
|
||||||
|
yield streaming_service.format_text_delta(
|
||||||
|
current_text_id, content
|
||||||
|
)
|
||||||
|
accumulated_text += content
|
||||||
|
|
||||||
|
# Handle tool calls
|
||||||
|
elif event_type == "on_tool_start":
|
||||||
|
tool_name = event.get("name", "unknown_tool")
|
||||||
|
run_id = event.get("run_id", "")
|
||||||
|
tool_input = event.get("data", {}).get("input", {})
|
||||||
|
|
||||||
|
# End current text block if any
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
current_text_id = None
|
||||||
|
|
||||||
|
# Stream tool info
|
||||||
|
tool_call_id = (
|
||||||
|
f"call_{run_id[:32]}"
|
||||||
|
if run_id
|
||||||
|
else streaming_service.generate_tool_call_id()
|
||||||
|
)
|
||||||
|
yield streaming_service.format_tool_input_start(tool_call_id, tool_name)
|
||||||
|
yield streaming_service.format_tool_input_available(
|
||||||
|
tool_call_id,
|
||||||
|
tool_name,
|
||||||
|
tool_input
|
||||||
|
if isinstance(tool_input, dict)
|
||||||
|
else {"input": tool_input},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send terminal info about the tool call
|
||||||
|
if tool_name == "search_knowledge_base":
|
||||||
|
query = (
|
||||||
|
tool_input.get("query", "")
|
||||||
|
if isinstance(tool_input, dict)
|
||||||
|
else str(tool_input)
|
||||||
|
)
|
||||||
|
yield streaming_service.format_terminal_info(
|
||||||
|
f"Searching knowledge base: {query[:100]}{'...' if len(query) > 100 else ''}",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "on_tool_end":
|
||||||
|
run_id = event.get("run_id", "")
|
||||||
|
tool_output = event.get("data", {}).get("output", "")
|
||||||
|
|
||||||
|
tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown"
|
||||||
|
|
||||||
|
# Don't stream the full output (can be very large), just acknowledge
|
||||||
|
yield streaming_service.format_tool_output_available(
|
||||||
|
tool_call_id,
|
||||||
|
{"status": "completed", "result_length": len(str(tool_output))},
|
||||||
|
)
|
||||||
|
|
||||||
|
yield streaming_service.format_terminal_info(
|
||||||
|
"Knowledge base search completed", "success"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle chain/agent end to close any open text blocks
|
||||||
|
elif event_type in ("on_chain_end", "on_agent_end"):
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
current_text_id = None
|
||||||
|
|
||||||
|
# Ensure text block is closed
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
|
||||||
|
# Finish the step and message
|
||||||
|
yield streaming_service.format_finish_step()
|
||||||
|
yield streaming_service.format_finish()
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Handle any errors
|
||||||
|
error_message = f"Error during chat: {e!s}"
|
||||||
|
print(f"[stream_new_chat] {error_message}")
|
||||||
|
|
||||||
|
# Close any open text block
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
|
||||||
|
yield streaming_service.format_error(error_message)
|
||||||
|
yield streaming_service.format_finish_step()
|
||||||
|
yield streaming_service.format_finish()
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
|
@ -177,7 +177,7 @@ async def index_crawled_urls(
|
||||||
documents_skipped += 1
|
documents_skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Format content as structured document
|
# Format content as structured document for summary generation (includes all metadata)
|
||||||
structured_document = crawler.format_to_structured_document(
|
structured_document = crawler.format_to_structured_document(
|
||||||
crawl_result
|
crawl_result
|
||||||
)
|
)
|
||||||
|
|
@ -187,10 +187,14 @@ async def index_crawled_urls(
|
||||||
DocumentType.CRAWLED_URL, url, search_space_id
|
DocumentType.CRAWLED_URL, url, search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate content hash
|
# Generate content hash using a version WITHOUT metadata
|
||||||
# TODO: To fix this by not including dynamic content like date, time, etc.
|
# This ensures the hash only changes when actual content changes,
|
||||||
|
# not when metadata (which contains dynamic fields like timestamps, IDs, etc.) changes
|
||||||
|
structured_document_for_hash = crawler.format_to_structured_document(
|
||||||
|
crawl_result, exclude_metadata=True
|
||||||
|
)
|
||||||
content_hash = generate_content_hash(
|
content_hash = generate_content_hash(
|
||||||
structured_document, search_space_id
|
structured_document_for_hash, search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if document with this unique identifier already exists
|
# Check if document with this unique identifier already exists
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ dependencies = [
|
||||||
"litellm>=1.80.10",
|
"litellm>=1.80.10",
|
||||||
"langchain-litellm>=0.3.5",
|
"langchain-litellm>=0.3.5",
|
||||||
"langgraph>=1.0.5",
|
"langgraph>=1.0.5",
|
||||||
|
"fake-useragent>=2.2.0",
|
||||||
|
"deepagents>=0.3.0",
|
||||||
|
"trafilatura>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
217
surfsense_backend/uv.lock
generated
217
surfsense_backend/uv.lock
generated
|
|
@ -180,6 +180,25 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anthropic"
|
||||||
|
version = "0.75.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "distro" },
|
||||||
|
{ name = "docstring-parser" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "jiter" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "antlr4-python3-runtime"
|
name = "antlr4-python3-runtime"
|
||||||
version = "4.9.3"
|
version = "4.9.3"
|
||||||
|
|
@ -383,6 +402,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317 },
|
{ url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "babel"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backoff"
|
name = "backoff"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
|
|
@ -537,6 +565,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/24/a4301564a979368d6f3644f47acc921450b5524b8846e827237d98b04746/botocore-1.42.8-py3-none-any.whl", hash = "sha256:4cb89c74dd9083d16e45868749b999265a91309b2499907c84adeffa0a8df89b", size = 14534173 },
|
{ url = "https://files.pythonhosted.org/packages/1c/24/a4301564a979368d6f3644f47acc921450b5524b8846e827237d98b04746/botocore-1.42.8-py3-none-any.whl", hash = "sha256:4cb89c74dd9083d16e45868749b999265a91309b2499907c84adeffa0a8df89b", size = 14534173 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bracex"
|
||||||
|
version = "2.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "build"
|
name = "build"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -928,6 +965,20 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214 },
|
{ url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "courlan"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "babel" },
|
||||||
|
{ name = "tld" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/54/6d6ceeff4bed42e7a10d6064d35ee43a810e7b3e8beb4abeae8cff4713ae/courlan-1.3.2.tar.gz", hash = "sha256:0b66f4db3a9c39a6e22dd247c72cfaa57d68ea660e94bb2c84ec7db8712af190", size = 206382, upload-time = "2024-10-29T16:40:20.994Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "45.0.4"
|
version = "45.0.4"
|
||||||
|
|
@ -1073,6 +1124,36 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 },
|
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deepagents"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "langchain" },
|
||||||
|
{ name = "langchain-anthropic" },
|
||||||
|
{ name = "langchain-core" },
|
||||||
|
{ name = "wcmatch" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/d3c2840bd0e66b6cd5948aa69625e129328ad261308e18fcb9a9420709da/deepagents-0.3.0.tar.gz", hash = "sha256:3dd4d2ed53efb1ef78aeb1020a5696c0ec7e58e627b305a6665d33fe6fbdedff", size = 51387 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e9/60bab7f37ff38bf982ea578e457ed1878ded613a3425462bcd07b00487e9/deepagents-0.3.0-py3-none-any.whl", hash = "sha256:9e23532d8d535dc2b0b4e0834453a1223a6a8f81b77947c0faf54537d05ce89a", size = 54065 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dateparser"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "pytz" },
|
||||||
|
{ name = "regex" },
|
||||||
|
{ name = "tzlocal" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/30/064144f0df1749e7bb5faaa7f52b007d7c2d08ec08fed8411aba87207f68/dateparser-1.2.2.tar.gz", hash = "sha256:986316f17cb8cdc23ea8ce563027c5ef12fc725b6fb1d137c14ca08777c5ecf7", size = 329840, upload-time = "2025-06-26T09:29:23.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453, upload-time = "2025-06-26T09:29:21.412Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "defusedxml"
|
name = "defusedxml"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
|
@ -1284,6 +1365,15 @@ version = "0.6.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901 }
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901 }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "docstring-parser"
|
||||||
|
version = "0.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docutils"
|
name = "docutils"
|
||||||
version = "0.21.2"
|
version = "0.21.2"
|
||||||
|
|
@ -1419,6 +1509,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fake-useragent"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.115.9"
|
version = "0.115.9"
|
||||||
|
|
@ -2119,6 +2218,22 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173 },
|
{ url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "htmldate"
|
||||||
|
version = "1.9.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "dateparser" },
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/10/ead9dabc999f353c3aa5d0dc0835b1e355215a5ecb489a7f4ef2ddad5e33/htmldate-1.9.4.tar.gz", hash = "sha256:1129063e02dd0354b74264de71e950c0c3fcee191178321418ccad2074cc8ed0", size = 44690, upload-time = "2025-11-04T17:46:44.983Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/bd/adfcdaaad5805c0c5156aeefd64c1e868c05e9c1cd6fd21751f168cd88c7/htmldate-1.9.4-py3-none-any.whl", hash = "sha256:1b94bcc4e08232a5b692159903acf95548b6a7492dddca5bb123d89d6325921c", size = 31558, upload-time = "2025-11-04T17:46:43.258Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
|
|
@ -2509,6 +2624,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437 },
|
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "justext"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "lxml", extra = ["html-clean"] },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/f3/45890c1b314f0d04e19c1c83d534e611513150939a7cf039664d9ab1e649/justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05", size = 828521, upload-time = "2025-02-25T20:21:49.934Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/ac/52f4e86d1924a7fc05af3aeb34488570eccc39b4af90530dd6acecdf16b5/justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7", size = 837940, upload-time = "2025-02-25T20:21:44.179Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyring"
|
name = "keyring"
|
||||||
version = "25.6.0"
|
version = "25.6.0"
|
||||||
|
|
@ -2650,6 +2777,20 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/00/4e3fa0d90f5a5c376ccb8ca983d0f0f7287783dfac48702e18f01d24673b/langchain-1.2.0-py3-none-any.whl", hash = "sha256:82f0d17aa4fbb11560b30e1e7d4aeb75e3ad71ce09b85c90ab208b181a24ffac", size = 102828 },
|
{ url = "https://files.pythonhosted.org/packages/23/00/4e3fa0d90f5a5c376ccb8ca983d0f0f7287783dfac48702e18f01d24673b/langchain-1.2.0-py3-none-any.whl", hash = "sha256:82f0d17aa4fbb11560b30e1e7d4aeb75e3ad71ce09b85c90ab208b181a24ffac", size = 102828 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "langchain-anthropic"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anthropic" },
|
||||||
|
{ name = "langchain-core" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/de/50/cc3b3e0410d86de457d7a100dde763fc1c33c4ce884e883659aa4cf95538/langchain_anthropic-1.3.0.tar.gz", hash = "sha256:497a937ee0310c588196bff37f39f02d43d87bff3a12d16278bdbc3bd0e9a80b", size = 707207 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/ca/0725bc347a9c226da9d76f85bf7d03115caec7dbc87876af68579c4ab24e/langchain_anthropic-1.3.0-py3-none-any.whl", hash = "sha256:3823560e1df15d6082636baa04f87cb59052ba70aada0eba381c4679b1ce0eba", size = 45724 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-community"
|
name = "langchain-community"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
|
|
@ -3018,6 +3159,23 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 },
|
{ url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
html-clean = [
|
||||||
|
{ name = "lxml-html-clean" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml-html-clean"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "lxml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d9/cb/c9c5bb2a9c47292e236a808dd233a03531f53b626f36259dcd32b49c76da/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c", size = 21498, upload-time = "2025-10-02T20:49:24.895Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/4a/63a9540e3ca73709f4200564a737d63a4c8c9c4dd032bab8535f507c190a/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e", size = 14177, upload-time = "2025-10-02T20:49:23.749Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "magika"
|
name = "magika"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|
@ -4321,7 +4479,7 @@ dependencies = [
|
||||||
{ name = "pinecone-plugin-interface" },
|
{ name = "pinecone-plugin-interface" },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "urllib3", marker = "python_full_version < '4.0'" },
|
{ name = "urllib3", marker = "python_full_version < '4'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/38/12731d4af470851b4963eba616605868a8599ef4df51c7b6c928e5f3166d/pinecone-7.3.0.tar.gz", hash = "sha256:307edc155621d487c20dc71b76c3ad5d6f799569ba42064190d03917954f9a7b", size = 235256 }
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/38/12731d4af470851b4963eba616605868a8599ef4df51c7b6c928e5f3166d/pinecone-7.3.0.tar.gz", hash = "sha256:307edc155621d487c20dc71b76c3ad5d6f799569ba42064190d03917954f9a7b", size = 235256 }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|
@ -6034,10 +6192,12 @@ dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
{ name = "celery", extra = ["redis"] },
|
{ name = "celery", extra = ["redis"] },
|
||||||
{ name = "chonkie", extra = ["all"] },
|
{ name = "chonkie", extra = ["all"] },
|
||||||
|
{ name = "deepagents" },
|
||||||
{ name = "discord-py" },
|
{ name = "discord-py" },
|
||||||
{ name = "docling" },
|
{ name = "docling" },
|
||||||
{ name = "elasticsearch" },
|
{ name = "elasticsearch" },
|
||||||
{ name = "en-core-web-sm" },
|
{ name = "en-core-web-sm" },
|
||||||
|
{ name = "fake-useragent" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastapi-users", extra = ["oauth", "sqlalchemy"] },
|
{ name = "fastapi-users", extra = ["oauth", "sqlalchemy"] },
|
||||||
{ name = "faster-whisper" },
|
{ name = "faster-whisper" },
|
||||||
|
|
@ -6070,6 +6230,7 @@ dependencies = [
|
||||||
{ name = "spacy" },
|
{ name = "spacy" },
|
||||||
{ name = "static-ffmpeg" },
|
{ name = "static-ffmpeg" },
|
||||||
{ name = "tavily-python" },
|
{ name = "tavily-python" },
|
||||||
|
{ name = "trafilatura" },
|
||||||
{ name = "unstructured", extra = ["all-docs"] },
|
{ name = "unstructured", extra = ["all-docs"] },
|
||||||
{ name = "unstructured-client" },
|
{ name = "unstructured-client" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
|
@ -6089,10 +6250,12 @@ requires-dist = [
|
||||||
{ name = "boto3", specifier = ">=1.35.0" },
|
{ name = "boto3", specifier = ">=1.35.0" },
|
||||||
{ name = "celery", extras = ["redis"], specifier = ">=5.5.3" },
|
{ name = "celery", extras = ["redis"], specifier = ">=5.5.3" },
|
||||||
{ name = "chonkie", extras = ["all"], specifier = ">=1.4.0" },
|
{ name = "chonkie", extras = ["all"], specifier = ">=1.4.0" },
|
||||||
|
{ name = "deepagents", specifier = ">=0.3.0" },
|
||||||
{ name = "discord-py", specifier = ">=2.5.2" },
|
{ name = "discord-py", specifier = ">=2.5.2" },
|
||||||
{ name = "docling", specifier = ">=2.15.0" },
|
{ name = "docling", specifier = ">=2.15.0" },
|
||||||
{ name = "elasticsearch", specifier = ">=9.1.1" },
|
{ name = "elasticsearch", specifier = ">=9.1.1" },
|
||||||
{ name = "en-core-web-sm", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" },
|
{ name = "en-core-web-sm", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" },
|
||||||
|
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.8" },
|
{ name = "fastapi", specifier = ">=0.115.8" },
|
||||||
{ name = "fastapi-users", extras = ["oauth", "sqlalchemy"], specifier = ">=14.0.1" },
|
{ name = "fastapi-users", extras = ["oauth", "sqlalchemy"], specifier = ">=14.0.1" },
|
||||||
{ name = "faster-whisper", specifier = ">=1.1.0" },
|
{ name = "faster-whisper", specifier = ">=1.1.0" },
|
||||||
|
|
@ -6125,6 +6288,7 @@ requires-dist = [
|
||||||
{ name = "spacy", specifier = ">=3.8.7" },
|
{ name = "spacy", specifier = ">=3.8.7" },
|
||||||
{ name = "static-ffmpeg", specifier = ">=2.13" },
|
{ name = "static-ffmpeg", specifier = ">=2.13" },
|
||||||
{ name = "tavily-python", specifier = ">=0.3.2" },
|
{ name = "tavily-python", specifier = ">=0.3.2" },
|
||||||
|
{ name = "trafilatura", specifier = ">=2.0.0" },
|
||||||
{ name = "unstructured", extras = ["all-docs"], specifier = ">=0.16.25" },
|
{ name = "unstructured", extras = ["all-docs"], specifier = ">=0.16.25" },
|
||||||
{ name = "unstructured-client", specifier = ">=0.30.0" },
|
{ name = "unstructured-client", specifier = ">=0.30.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||||
|
|
@ -6276,6 +6440,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/d0/179abca8b984b3deefd996f362b612c39da73b60f685921e6cd58b6125b4/timm-1.0.15-py3-none-any.whl", hash = "sha256:5a3dc460c24e322ecc7fd1f3e3eb112423ddee320cb059cc1956fbc9731748ef", size = 2361373 },
|
{ url = "https://files.pythonhosted.org/packages/6c/d0/179abca8b984b3deefd996f362b612c39da73b60f685921e6cd58b6125b4/timm-1.0.15-py3-none-any.whl", hash = "sha256:5a3dc460c24e322ecc7fd1f3e3eb112423ddee320cb059cc1956fbc9731748ef", size = 2361373 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tld"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/a1/5723b07a70c1841a80afc9ac572fdf53488306848d844cd70519391b0d26/tld-0.13.1.tar.gz", hash = "sha256:75ec00936cbcf564f67361c41713363440b6c4ef0f0c1592b5b0fbe72c17a350", size = 462000, upload-time = "2025-05-21T22:18:29.341Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/70/b2f38360c3fc4bc9b5e8ef429e1fde63749144ac583c2dbdf7e21e27a9ad/tld-0.13.1-py2.py3-none-any.whl", hash = "sha256:a2d35109433ac83486ddf87e3c4539ab2c5c2478230e5d9c060a18af4b03aa7c", size = 274718, upload-time = "2025-05-21T22:18:25.811Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokenizers"
|
name = "tokenizers"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
|
|
@ -6399,6 +6572,24 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
|
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trafilatura"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "courlan" },
|
||||||
|
{ name = "htmldate" },
|
||||||
|
{ name = "justext" },
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/25/e3ebeefdebfdfae8c4a4396f5a6ea51fc6fa0831d63ce338e5090a8003dc/trafilatura-2.0.0.tar.gz", hash = "sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247", size = 253404, upload-time = "2024-12-03T15:23:24.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/b6/097367f180b6383a3581ca1b86fcae284e52075fa941d1232df35293363c/trafilatura-2.0.0-py3-none-any.whl", hash = "sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d", size = 132557, upload-time = "2024-12-03T15:23:21.41Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transformers"
|
name = "transformers"
|
||||||
version = "4.52.4"
|
version = "4.52.4"
|
||||||
|
|
@ -6641,6 +6832,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
|
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzlocal"
|
||||||
|
version = "5.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unstructured"
|
name = "unstructured"
|
||||||
version = "0.17.2"
|
version = "0.17.2"
|
||||||
|
|
@ -6968,6 +7171,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315 },
|
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcmatch"
|
||||||
|
version = "10.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bracex" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wcwidth"
|
name = "wcwidth"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
||||||
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
|
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
|
||||||
|
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
|
|
@ -17,7 +18,6 @@ import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { useUserAccess } from "@/hooks/use-rbac";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
|
|
@ -69,7 +69,7 @@ export function DashboardClientLayout({
|
||||||
);
|
);
|
||||||
}, [preferences]);
|
}, [preferences]);
|
||||||
|
|
||||||
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
|
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||||
|
|
||||||
// Skip onboarding check if we're already on the onboarding page
|
// Skip onboarding check if we're already on the onboarding page
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,15 @@ import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
createInviteMutationAtom,
|
||||||
|
deleteInviteMutationAtom,
|
||||||
|
} from "@/atoms/invites/invites-mutation.atoms";
|
||||||
|
import {
|
||||||
|
deleteMemberMutationAtom,
|
||||||
|
updateMemberMutationAtom,
|
||||||
|
} from "@/atoms/members/members-mutation.atoms";
|
||||||
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
|
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
|
||||||
import {
|
import {
|
||||||
createRoleMutationAtom,
|
createRoleMutationAtom,
|
||||||
|
|
@ -107,20 +116,23 @@ import {
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type {
|
||||||
|
CreateInviteRequest,
|
||||||
|
DeleteInviteRequest,
|
||||||
|
Invite,
|
||||||
|
} from "@/contracts/types/invites.types";
|
||||||
|
import type {
|
||||||
|
DeleteMembershipRequest,
|
||||||
|
Membership,
|
||||||
|
UpdateMembershipRequest,
|
||||||
|
} from "@/contracts/types/members.types";
|
||||||
import type {
|
import type {
|
||||||
CreateRoleRequest,
|
CreateRoleRequest,
|
||||||
DeleteRoleRequest,
|
DeleteRoleRequest,
|
||||||
Role,
|
Role,
|
||||||
UpdateRoleRequest,
|
UpdateRoleRequest,
|
||||||
} from "@/contracts/types/roles.types";
|
} from "@/contracts/types/roles.types";
|
||||||
import {
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
type Invite,
|
|
||||||
type InviteCreate,
|
|
||||||
type Member,
|
|
||||||
useInvites,
|
|
||||||
useMembers,
|
|
||||||
useUserAccess,
|
|
||||||
} from "@/hooks/use-rbac";
|
|
||||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -154,18 +166,54 @@ export default function TeamManagementPage() {
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const [activeTab, setActiveTab] = useState("members");
|
const [activeTab, setActiveTab] = useState("members");
|
||||||
|
|
||||||
const { access, loading: accessLoading, hasPermission } = useUserAccess(searchSpaceId);
|
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(permission: string) => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.is_owner) return true;
|
||||||
|
return access.permissions?.includes(permission) ?? false;
|
||||||
|
},
|
||||||
|
[access]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
members,
|
data: members = [],
|
||||||
loading: membersLoading,
|
isLoading: membersLoading,
|
||||||
fetchMembers,
|
refetch: fetchMembers,
|
||||||
updateMemberRole,
|
} = useAtomValue(membersAtom);
|
||||||
removeMember,
|
|
||||||
} = useMembers(searchSpaceId);
|
|
||||||
|
|
||||||
const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
|
const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
|
||||||
const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
|
const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
|
||||||
const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom);
|
const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom);
|
||||||
|
const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom);
|
||||||
|
|
||||||
|
const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom);
|
||||||
|
const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom);
|
||||||
|
const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom);
|
||||||
|
|
||||||
|
const handleRevokeInvite = useCallback(
|
||||||
|
async (inviteId: number): Promise<boolean> => {
|
||||||
|
const request: DeleteInviteRequest = {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
invite_id: inviteId,
|
||||||
|
};
|
||||||
|
await revokeInvite(request);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[revokeInvite, searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateInvite = useCallback(
|
||||||
|
async (inviteData: CreateInviteRequest["data"]) => {
|
||||||
|
const request: CreateInviteRequest = {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
data: inviteData,
|
||||||
|
};
|
||||||
|
return await createInvite(request);
|
||||||
|
},
|
||||||
|
[createInvite, searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
const handleUpdateRole = useCallback(
|
const handleUpdateRole = useCallback(
|
||||||
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
|
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
|
||||||
|
|
@ -202,6 +250,32 @@ export default function TeamManagementPage() {
|
||||||
[createRole, searchSpaceId]
|
[createRole, searchSpaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUpdateMember = useCallback(
|
||||||
|
async (membershipId: number, roleId: number | null): Promise<Membership> => {
|
||||||
|
const request: UpdateMembershipRequest = {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
membership_id: membershipId,
|
||||||
|
data: {
|
||||||
|
role_id: roleId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (await updateMember(request)) as Membership;
|
||||||
|
},
|
||||||
|
[updateMember, searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveMember = useCallback(
|
||||||
|
async (membershipId: number) => {
|
||||||
|
const request: DeleteMembershipRequest = {
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
membership_id: membershipId,
|
||||||
|
};
|
||||||
|
await deleteMember(request);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[deleteMember, searchSpaceId]
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
data: roles = [],
|
data: roles = [],
|
||||||
isLoading: rolesLoading,
|
isLoading: rolesLoading,
|
||||||
|
|
@ -212,12 +286,14 @@ export default function TeamManagementPage() {
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
invites,
|
data: invites = [],
|
||||||
loading: invitesLoading,
|
isLoading: invitesLoading,
|
||||||
fetchInvites,
|
refetch: fetchInvites,
|
||||||
createInvite,
|
} = useQuery({
|
||||||
revokeInvite,
|
queryKey: cacheKeys.invites.all(searchSpaceId.toString()),
|
||||||
} = useInvites(searchSpaceId);
|
queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
|
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
|
||||||
const permissions = permissionsData?.permissions || [];
|
const permissions = permissionsData?.permissions || [];
|
||||||
|
|
@ -387,7 +463,7 @@ export default function TeamManagementPage() {
|
||||||
{activeTab === "invites" && canInvite && (
|
{activeTab === "invites" && canInvite && (
|
||||||
<CreateInviteDialog
|
<CreateInviteDialog
|
||||||
roles={roles}
|
roles={roles}
|
||||||
onCreateInvite={createInvite}
|
onCreateInvite={handleCreateInvite}
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -404,8 +480,8 @@ export default function TeamManagementPage() {
|
||||||
members={members}
|
members={members}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
loading={membersLoading}
|
loading={membersLoading}
|
||||||
onUpdateRole={updateMemberRole}
|
onUpdateRole={handleUpdateMember}
|
||||||
onRemoveMember={removeMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
canManageRoles={hasPermission("members:manage_roles")}
|
canManageRoles={hasPermission("members:manage_roles")}
|
||||||
canRemove={hasPermission("members:remove")}
|
canRemove={hasPermission("members:remove")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -427,7 +503,7 @@ export default function TeamManagementPage() {
|
||||||
<InvitesTab
|
<InvitesTab
|
||||||
invites={invites}
|
invites={invites}
|
||||||
loading={invitesLoading}
|
loading={invitesLoading}
|
||||||
onRevokeInvite={revokeInvite}
|
onRevokeInvite={handleRevokeInvite}
|
||||||
canRevoke={canInvite}
|
canRevoke={canInvite}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -449,10 +525,10 @@ function MembersTab({
|
||||||
canManageRoles,
|
canManageRoles,
|
||||||
canRemove,
|
canRemove,
|
||||||
}: {
|
}: {
|
||||||
members: Member[];
|
members: Membership[];
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Member>;
|
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||||
canManageRoles: boolean;
|
canManageRoles: boolean;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
|
|
@ -1016,7 +1092,7 @@ function CreateInviteDialog({
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
}: {
|
}: {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
onCreateInvite: (data: InviteCreate) => Promise<Invite>;
|
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -1031,7 +1107,7 @@ function CreateInviteDialog({
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const data: InviteCreate = {};
|
const data: CreateInviteRequest["data"] = {};
|
||||||
if (name) data.name = name;
|
if (name) data.name = name;
|
||||||
if (roleId && roleId !== "default") data.role_id = Number(roleId);
|
if (roleId && roleId !== "default") data.role_id = Number(roleId);
|
||||||
if (maxUses) data.max_uses = Number(maxUses);
|
if (maxUses) data.max_uses = Number(maxUses);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -16,7 +18,9 @@ import { motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { use, useEffect, useState } from "react";
|
import { use, useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -26,22 +30,48 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { useInviteInfo } from "@/hooks/use-rbac";
|
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
||||||
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export default function InviteAcceptPage() {
|
export default function InviteAcceptPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const inviteCode = params.invite_code as string;
|
const inviteCode = params.invite_code as string;
|
||||||
|
|
||||||
const { inviteInfo, loading, acceptInvite } = useInviteInfo(inviteCode);
|
const { data: inviteInfo = null, isLoading: loading } = useQuery({
|
||||||
|
queryKey: cacheKeys.invites.info(inviteCode),
|
||||||
|
enabled: !!inviteCode,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!inviteCode) return null;
|
||||||
|
return invitesApiService.getInviteInfo({
|
||||||
|
invite_code: inviteCode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: acceptInviteMutation } = useAtomValue(acceptInviteMutationAtom);
|
||||||
|
|
||||||
|
const acceptInvite = useCallback(async () => {
|
||||||
|
if (!inviteCode) {
|
||||||
|
toast.error("No invite code provided");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await acceptInviteMutation({ invite_code: inviteCode });
|
||||||
|
return result;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to accept invite");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [inviteCode, acceptInviteMutation]);
|
||||||
|
|
||||||
const [accepting, setAccepting] = useState(false);
|
const [accepting, setAccepting] = useState(false);
|
||||||
const [accepted, setAccepted] = useState(false);
|
const [accepted, setAccepted] = useState(false);
|
||||||
const [acceptedData, setAcceptedData] = useState<{
|
const [acceptedData, setAcceptedData] = useState<AcceptInviteResponse | null>(null);
|
||||||
search_space_id: number;
|
|
||||||
search_space_name: string;
|
|
||||||
role_name: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
|
|
||||||
85
surfsense_web/atoms/invites/invites-mutation.atoms.ts
Normal file
85
surfsense_web/atoms/invites/invites-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { atomWithMutation } from "jotai-tanstack-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type {
|
||||||
|
AcceptInviteRequest,
|
||||||
|
CreateInviteRequest,
|
||||||
|
DeleteInviteRequest,
|
||||||
|
UpdateInviteRequest,
|
||||||
|
} from "@/contracts/types/invites.types";
|
||||||
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation atom for creating an invite
|
||||||
|
*/
|
||||||
|
export const createInviteMutationAtom = atomWithMutation(() => ({
|
||||||
|
mutationFn: async (request: CreateInviteRequest) => {
|
||||||
|
return invitesApiService.createInvite(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.invites.all(variables.search_space_id.toString()),
|
||||||
|
});
|
||||||
|
toast.success("Invite created successfully");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error creating invite:", error);
|
||||||
|
toast.error("Failed to create invite");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation atom for updating an invite
|
||||||
|
*/
|
||||||
|
export const updateInviteMutationAtom = atomWithMutation(() => ({
|
||||||
|
mutationFn: async (request: UpdateInviteRequest) => {
|
||||||
|
return invitesApiService.updateInvite(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.invites.all(variables.search_space_id.toString()),
|
||||||
|
});
|
||||||
|
toast.success("Invite updated successfully");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error updating invite:", error);
|
||||||
|
toast.error("Failed to update invite");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation atom for deleting an invite
|
||||||
|
*/
|
||||||
|
export const deleteInviteMutationAtom = atomWithMutation(() => ({
|
||||||
|
mutationFn: async (request: DeleteInviteRequest) => {
|
||||||
|
return invitesApiService.deleteInvite(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.invites.all(variables.search_space_id.toString()),
|
||||||
|
});
|
||||||
|
toast.success("Invite deleted successfully");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error deleting invite:", error);
|
||||||
|
toast.error("Failed to delete invite");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation atom for accepting an invite
|
||||||
|
*/
|
||||||
|
export const acceptInviteMutationAtom = atomWithMutation(() => ({
|
||||||
|
mutationFn: async (request: AcceptInviteRequest) => {
|
||||||
|
return invitesApiService.acceptInvite(request);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all });
|
||||||
|
toast.success("Invite accepted successfully");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error accepting invite:", error);
|
||||||
|
toast.error("Failed to accept invite");
|
||||||
|
},
|
||||||
|
}));
|
||||||
22
surfsense_web/atoms/invites/invites-query.atoms.ts
Normal file
22
surfsense_web/atoms/invites/invites-query.atoms.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
export const invitesAtom = atomWithQuery((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.invites.all(searchSpaceId?.toString() ?? ""),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!searchSpaceId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return invitesApiService.getInvites({
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
64
surfsense_web/atoms/members/members-mutation.atoms.ts
Normal file
64
surfsense_web/atoms/members/members-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { atomWithMutation } from "jotai-tanstack-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type {
|
||||||
|
DeleteMembershipRequest,
|
||||||
|
DeleteMembershipResponse,
|
||||||
|
LeaveSearchSpaceRequest,
|
||||||
|
LeaveSearchSpaceResponse,
|
||||||
|
UpdateMembershipRequest,
|
||||||
|
UpdateMembershipResponse,
|
||||||
|
} from "@/contracts/types/members.types";
|
||||||
|
import { membersApiService } from "@/lib/apis/members-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
|
export const updateMemberMutationAtom = atomWithMutation(() => {
|
||||||
|
return {
|
||||||
|
mutationFn: async (request: UpdateMembershipRequest) => {
|
||||||
|
return membersApiService.updateMember(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_: UpdateMembershipResponse, request: UpdateMembershipRequest) => {
|
||||||
|
toast.success("Member updated successfully");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.members.all(request.search_space_id.toString()),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to update member");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteMemberMutationAtom = atomWithMutation(() => {
|
||||||
|
return {
|
||||||
|
mutationFn: async (request: DeleteMembershipRequest) => {
|
||||||
|
return membersApiService.deleteMember(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_: DeleteMembershipResponse, request: DeleteMembershipRequest) => {
|
||||||
|
toast.success("Member removed successfully");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.members.all(request.search_space_id.toString()),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to remove member");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
|
||||||
|
return {
|
||||||
|
mutationFn: async (request: LeaveSearchSpaceRequest) => {
|
||||||
|
return membersApiService.leaveSearchSpace(request);
|
||||||
|
},
|
||||||
|
onSuccess: (_: LeaveSearchSpaceResponse, request: LeaveSearchSpaceRequest) => {
|
||||||
|
toast.success("Successfully left the search space");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: cacheKeys.members.all(request.search_space_id.toString()),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to leave search space");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
40
surfsense_web/atoms/members/members-query.atoms.ts
Normal file
40
surfsense_web/atoms/members/members-query.atoms.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { membersApiService } from "@/lib/apis/members-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
export const membersAtom = atomWithQuery((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!searchSpaceId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return membersApiService.getMembers({
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const myAccessAtom = atomWithQuery((get) => {
|
||||||
|
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: cacheKeys.members.myAccess(searchSpaceId?.toString() ?? ""),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!searchSpaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return membersApiService.getMyAccess({
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -77,11 +77,10 @@ export const getInviteInfoRequest = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getInviteInfoResponse = z.object({
|
export const getInviteInfoResponse = z.object({
|
||||||
invite_code: z.string(),
|
|
||||||
search_space_name: z.string(),
|
search_space_name: z.string(),
|
||||||
role_name: z.string().nullable(),
|
role_name: z.string().nullable(),
|
||||||
expires_at: z.string().nullable(),
|
|
||||||
is_valid: z.boolean(),
|
is_valid: z.boolean(),
|
||||||
|
message: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,6 +93,8 @@ export const acceptInviteRequest = z.object({
|
||||||
export const acceptInviteResponse = z.object({
|
export const acceptInviteResponse = z.object({
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
|
search_space_name: z.string(),
|
||||||
|
role_name: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Invite = z.infer<typeof invite>;
|
export type Invite = z.infer<typeof invite>;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const updateMembershipRequest = z.object({
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
membership_id: z.number(),
|
membership_id: z.number(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
role_id: z.number(),
|
role_id: z.number().nullable(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ export const getMyAccessRequest = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getMyAccessResponse = z.object({
|
export const getMyAccessResponse = z.object({
|
||||||
user_id: z.string(),
|
search_space_name: z.string(),
|
||||||
search_space_id: z.number(),
|
search_space_id: z.number(),
|
||||||
is_owner: z.boolean(),
|
is_owner: z.boolean(),
|
||||||
permissions: z.array(z.string()),
|
permissions: z.array(z.string()),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export * from "./use-debounced-value";
|
export * from "./use-debounced-value";
|
||||||
export * from "./use-logs";
|
export * from "./use-logs";
|
||||||
export * from "./use-rbac";
|
|
||||||
export * from "./use-search-source-connectors";
|
export * from "./use-search-source-connectors";
|
||||||
|
|
|
||||||
|
|
@ -1,499 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { authenticatedFetch, getBearerToken, handleUnauthorized } from "@/lib/auth-utils";
|
|
||||||
|
|
||||||
// ============ Types ============
|
|
||||||
|
|
||||||
export interface Role {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
is_default: boolean;
|
|
||||||
is_system_role: boolean;
|
|
||||||
search_space_id: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Member {
|
|
||||||
id: number;
|
|
||||||
user_id: string;
|
|
||||||
search_space_id: number;
|
|
||||||
role_id: number | null;
|
|
||||||
is_owner: boolean;
|
|
||||||
joined_at: string;
|
|
||||||
created_at: string;
|
|
||||||
role: Role | null;
|
|
||||||
user_email: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Invite {
|
|
||||||
id: number;
|
|
||||||
invite_code: string;
|
|
||||||
search_space_id: number;
|
|
||||||
role_id: number | null;
|
|
||||||
created_by_id: string | null;
|
|
||||||
expires_at: string | null;
|
|
||||||
max_uses: number | null;
|
|
||||||
uses_count: number;
|
|
||||||
is_active: boolean;
|
|
||||||
name: string | null;
|
|
||||||
created_at: string;
|
|
||||||
role: Role | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InviteCreate {
|
|
||||||
name?: string;
|
|
||||||
role_id?: number;
|
|
||||||
expires_at?: string;
|
|
||||||
max_uses?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InviteUpdate {
|
|
||||||
name?: string;
|
|
||||||
role_id?: number;
|
|
||||||
expires_at?: string;
|
|
||||||
max_uses?: number;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleCreate {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
permissions: string[];
|
|
||||||
is_default?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleUpdate {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
permissions?: string[];
|
|
||||||
is_default?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionInfo {
|
|
||||||
value: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserAccess {
|
|
||||||
search_space_id: number;
|
|
||||||
search_space_name: string;
|
|
||||||
is_owner: boolean;
|
|
||||||
role_name: string | null;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InviteInfo {
|
|
||||||
search_space_name: string;
|
|
||||||
role_name: string | null;
|
|
||||||
is_valid: boolean;
|
|
||||||
message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Members Hook ============
|
|
||||||
|
|
||||||
export function useMembers(searchSpaceId: number) {
|
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchMembers = useCallback(async () => {
|
|
||||||
if (!searchSpaceId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members`,
|
|
||||||
{ method: "GET" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to fetch members");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setMembers(data);
|
|
||||||
setError(null);
|
|
||||||
return data;
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Failed to fetch members");
|
|
||||||
console.error("Error fetching members:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [searchSpaceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMembers();
|
|
||||||
}, [fetchMembers]);
|
|
||||||
|
|
||||||
const updateMemberRole = useCallback(
|
|
||||||
async (membershipId: number, roleId: number | null) => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ role_id: roleId }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to update member role");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedMember = await response.json();
|
|
||||||
setMembers((prev) => prev.map((m) => (m.id === membershipId ? updatedMember : m)));
|
|
||||||
toast.success("Member role updated successfully");
|
|
||||||
return updatedMember;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to update member role");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeMember = useCallback(
|
|
||||||
async (membershipId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`,
|
|
||||||
{ method: "DELETE" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to remove member");
|
|
||||||
}
|
|
||||||
|
|
||||||
setMembers((prev) => prev.filter((m) => m.id !== membershipId));
|
|
||||||
toast.success("Member removed successfully");
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to remove member");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const leaveSearchSpace = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/me`,
|
|
||||||
{ method: "DELETE" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to leave search space");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Successfully left the search space");
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to leave search space");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [searchSpaceId]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
members,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchMembers,
|
|
||||||
updateMemberRole,
|
|
||||||
removeMember,
|
|
||||||
leaveSearchSpace,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Roles Hook ============
|
|
||||||
|
|
||||||
export function useInvites(searchSpaceId: number) {
|
|
||||||
const [invites, setInvites] = useState<Invite[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchInvites = useCallback(async () => {
|
|
||||||
if (!searchSpaceId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`,
|
|
||||||
{ method: "GET" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to fetch invites");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setInvites(data);
|
|
||||||
setError(null);
|
|
||||||
return data;
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Failed to fetch invites");
|
|
||||||
console.error("Error fetching invites:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [searchSpaceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchInvites();
|
|
||||||
}, [fetchInvites]);
|
|
||||||
|
|
||||||
const createInvite = useCallback(
|
|
||||||
async (inviteData: InviteCreate) => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(inviteData),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to create invite");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newInvite = await response.json();
|
|
||||||
setInvites((prev) => [...prev, newInvite]);
|
|
||||||
toast.success("Invite created successfully");
|
|
||||||
return newInvite;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to create invite");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateInvite = useCallback(
|
|
||||||
async (inviteId: number, inviteData: InviteUpdate) => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(inviteData),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to update invite");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedInvite = await response.json();
|
|
||||||
setInvites((prev) => prev.map((i) => (i.id === inviteId ? updatedInvite : i)));
|
|
||||||
toast.success("Invite updated successfully");
|
|
||||||
return updatedInvite;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to update invite");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const revokeInvite = useCallback(
|
|
||||||
async (inviteId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`,
|
|
||||||
{ method: "DELETE" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to revoke invite");
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvites((prev) => prev.filter((i) => i.id !== inviteId));
|
|
||||||
toast.success("Invite revoked successfully");
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to revoke invite");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
invites,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchInvites,
|
|
||||||
createInvite,
|
|
||||||
updateInvite,
|
|
||||||
revokeInvite,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Permissions Hook ============
|
|
||||||
|
|
||||||
export function useUserAccess(searchSpaceId: number) {
|
|
||||||
const [access, setAccess] = useState<UserAccess | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchAccess = useCallback(async () => {
|
|
||||||
if (!searchSpaceId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/my-access`,
|
|
||||||
{ method: "GET" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to fetch access info");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setAccess(data);
|
|
||||||
setError(null);
|
|
||||||
return data;
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Failed to fetch access info");
|
|
||||||
console.error("Error fetching access:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [searchSpaceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAccess();
|
|
||||||
}, [fetchAccess]);
|
|
||||||
|
|
||||||
// Helper function to check if user has a specific permission
|
|
||||||
const hasPermission = useCallback(
|
|
||||||
(permission: string) => {
|
|
||||||
if (!access) return false;
|
|
||||||
// Owner/full access check
|
|
||||||
if (access.permissions.includes("*")) return true;
|
|
||||||
return access.permissions.includes(permission);
|
|
||||||
},
|
|
||||||
[access]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper function to check if user has any of the given permissions
|
|
||||||
const hasAnyPermission = useCallback(
|
|
||||||
(permissions: string[]) => {
|
|
||||||
if (!access) return false;
|
|
||||||
if (access.permissions.includes("*")) return true;
|
|
||||||
return permissions.some((p) => access.permissions.includes(p));
|
|
||||||
},
|
|
||||||
[access]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
access,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchAccess,
|
|
||||||
hasPermission,
|
|
||||||
hasAnyPermission,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Invite Info Hook (Public) ============
|
|
||||||
|
|
||||||
export function useInviteInfo(inviteCode: string | null) {
|
|
||||||
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchInviteInfo = useCallback(async () => {
|
|
||||||
if (!inviteCode) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/${inviteCode}/info`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to fetch invite info");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setInviteInfo(data);
|
|
||||||
setError(null);
|
|
||||||
return data;
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Failed to fetch invite info");
|
|
||||||
console.error("Error fetching invite info:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [inviteCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchInviteInfo();
|
|
||||||
}, [fetchInviteInfo]);
|
|
||||||
|
|
||||||
const acceptInvite = useCallback(async () => {
|
|
||||||
if (!inviteCode) {
|
|
||||||
toast.error("No invite code provided");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/accept`,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ invite_code: inviteCode }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.detail || "Failed to accept invite");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
toast.success(data.message || "Successfully joined the search space");
|
|
||||||
return data;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || "Failed to accept invite");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [inviteCode]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
inviteInfo,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchInviteInfo,
|
|
||||||
acceptInvite,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
151
surfsense_web/lib/apis/invites-api.service.ts
Normal file
151
surfsense_web/lib/apis/invites-api.service.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import {
|
||||||
|
type AcceptInviteRequest,
|
||||||
|
type AcceptInviteResponse,
|
||||||
|
acceptInviteRequest,
|
||||||
|
acceptInviteResponse,
|
||||||
|
type CreateInviteRequest,
|
||||||
|
type CreateInviteResponse,
|
||||||
|
createInviteRequest,
|
||||||
|
createInviteResponse,
|
||||||
|
type DeleteInviteRequest,
|
||||||
|
type DeleteInviteResponse,
|
||||||
|
deleteInviteRequest,
|
||||||
|
deleteInviteResponse,
|
||||||
|
type GetInviteInfoRequest,
|
||||||
|
type GetInviteInfoResponse,
|
||||||
|
type GetInvitesRequest,
|
||||||
|
type GetInvitesResponse,
|
||||||
|
getInviteInfoRequest,
|
||||||
|
getInviteInfoResponse,
|
||||||
|
getInvitesRequest,
|
||||||
|
getInvitesResponse,
|
||||||
|
type UpdateInviteRequest,
|
||||||
|
type UpdateInviteResponse,
|
||||||
|
updateInviteRequest,
|
||||||
|
updateInviteResponse,
|
||||||
|
} from "@/contracts/types/invites.types";
|
||||||
|
import { ValidationError } from "@/lib/error";
|
||||||
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
|
class InvitesApiService {
|
||||||
|
/**
|
||||||
|
* Create a new invite
|
||||||
|
*/
|
||||||
|
createInvite = async (request: CreateInviteRequest) => {
|
||||||
|
const parsedRequest = createInviteRequest.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/searchspaces/${parsedRequest.data.search_space_id}/invites`,
|
||||||
|
createInviteResponse,
|
||||||
|
{
|
||||||
|
body: parsedRequest.data.data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all invites for a search space
|
||||||
|
*/
|
||||||
|
getInvites = async (request: GetInvitesRequest) => {
|
||||||
|
const parsedRequest = getInvitesRequest.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/searchspaces/${parsedRequest.data.search_space_id}/invites`,
|
||||||
|
getInvitesResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an invite
|
||||||
|
*/
|
||||||
|
updateInvite = async (request: UpdateInviteRequest) => {
|
||||||
|
const parsedRequest = updateInviteRequest.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.put(
|
||||||
|
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`,
|
||||||
|
updateInviteResponse,
|
||||||
|
{
|
||||||
|
body: parsedRequest.data.data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an invite
|
||||||
|
*/
|
||||||
|
deleteInvite = async (request: DeleteInviteRequest) => {
|
||||||
|
const parsedRequest = deleteInviteRequest.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/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`,
|
||||||
|
deleteInviteResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invite info by invite code
|
||||||
|
*/
|
||||||
|
getInviteInfo = async (request: GetInviteInfoRequest) => {
|
||||||
|
const parsedRequest = getInviteInfoRequest.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/invites/${parsedRequest.data.invite_code}/info`,
|
||||||
|
getInviteInfoResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an invite
|
||||||
|
*/
|
||||||
|
acceptInvite = async (request: AcceptInviteRequest) => {
|
||||||
|
const parsedRequest = acceptInviteRequest.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/invites/accept`, acceptInviteResponse, {
|
||||||
|
body: parsedRequest.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invitesApiService = new InvitesApiService();
|
||||||
126
surfsense_web/lib/apis/members-api.service.ts
Normal file
126
surfsense_web/lib/apis/members-api.service.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import {
|
||||||
|
type DeleteMembershipRequest,
|
||||||
|
type DeleteMembershipResponse,
|
||||||
|
deleteMembershipRequest,
|
||||||
|
deleteMembershipResponse,
|
||||||
|
type GetMembersRequest,
|
||||||
|
type GetMembersResponse,
|
||||||
|
type GetMyAccessRequest,
|
||||||
|
type GetMyAccessResponse,
|
||||||
|
getMembersRequest,
|
||||||
|
getMembersResponse,
|
||||||
|
getMyAccessRequest,
|
||||||
|
getMyAccessResponse,
|
||||||
|
type LeaveSearchSpaceRequest,
|
||||||
|
type LeaveSearchSpaceResponse,
|
||||||
|
leaveSearchSpaceRequest,
|
||||||
|
leaveSearchSpaceResponse,
|
||||||
|
type UpdateMembershipRequest,
|
||||||
|
type UpdateMembershipResponse,
|
||||||
|
updateMembershipRequest,
|
||||||
|
updateMembershipResponse,
|
||||||
|
} from "@/contracts/types/members.types";
|
||||||
|
import { ValidationError } from "@/lib/error";
|
||||||
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
|
class MembersApiService {
|
||||||
|
/**
|
||||||
|
* Get members of a search space
|
||||||
|
*/
|
||||||
|
getMembers = async (request: GetMembersRequest) => {
|
||||||
|
const parsedRequest = getMembersRequest.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/searchspaces/${parsedRequest.data.search_space_id}/members`,
|
||||||
|
getMembersResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's role
|
||||||
|
*/
|
||||||
|
updateMember = async (request: UpdateMembershipRequest) => {
|
||||||
|
const parsedRequest = updateMembershipRequest.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.put(
|
||||||
|
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`,
|
||||||
|
updateMembershipResponse,
|
||||||
|
{
|
||||||
|
body: parsedRequest.data.data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a member from search space
|
||||||
|
*/
|
||||||
|
deleteMember = async (request: DeleteMembershipRequest) => {
|
||||||
|
const parsedRequest = deleteMembershipRequest.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/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`,
|
||||||
|
deleteMembershipResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave a search space (remove self)
|
||||||
|
*/
|
||||||
|
leaveSearchSpace = async (request: LeaveSearchSpaceRequest) => {
|
||||||
|
const parsedRequest = leaveSearchSpaceRequest.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/searchspaces/${parsedRequest.data.search_space_id}/members/me`,
|
||||||
|
leaveSearchSpaceResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's access information for a search space
|
||||||
|
*/
|
||||||
|
getMyAccess = async (request: GetMyAccessRequest) => {
|
||||||
|
const parsedRequest = getMyAccessRequest.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/searchspaces/${parsedRequest.data.search_space_id}/my-access`,
|
||||||
|
getMyAccessResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const membersApiService = new MembersApiService();
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { GetChatsRequest } from "@/contracts/types/chat.types";
|
import type { GetChatsRequest } from "@/contracts/types/chat.types";
|
||||||
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
|
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
|
||||||
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
|
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
|
||||||
|
import type { GetMembersRequest } from "@/contracts/types/members.types";
|
||||||
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
||||||
import type { GetRolesRequest } from "@/contracts/types/roles.types";
|
import type { GetRolesRequest } from "@/contracts/types/roles.types";
|
||||||
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
||||||
|
|
@ -52,4 +53,12 @@ export const cacheKeys = {
|
||||||
permissions: {
|
permissions: {
|
||||||
all: () => ["permissions"] as const,
|
all: () => ["permissions"] as const,
|
||||||
},
|
},
|
||||||
|
members: {
|
||||||
|
all: (searchSpaceId: string) => ["members", searchSpaceId] as const,
|
||||||
|
myAccess: (searchSpaceId: string) => ["members", "my-access", searchSpaceId] as const,
|
||||||
|
},
|
||||||
|
invites: {
|
||||||
|
all: (searchSpaceId: string) => ["invites", searchSpaceId] as const,
|
||||||
|
info: (inviteCode: string) => ["invites", "info", inviteCode] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue