mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
Merge pull request #1576 from AnishSarkar22/feat/ci-ui-changes
feat: Polish workspace UI and complete workspace terminology migration
This commit is contained in:
commit
7bf559cd5b
289 changed files with 6519 additions and 5444 deletions
|
|
@ -0,0 +1,31 @@
|
|||
"""Remove AI file sort flag.
|
||||
|
||||
Revision ID: 172
|
||||
Revises: 171
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "172"
|
||||
down_revision: str | None = "171"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("workspaces", "ai_file_sort_enabled")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"workspaces",
|
||||
sa.Column(
|
||||
"ai_file_sort_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
|
@ -1732,10 +1732,6 @@ class Workspace(BaseModel, TimestampMixin):
|
|||
Integer, nullable=True, default=0, server_default="0"
|
||||
) # For vision/screenshot analysis, defaults to Auto mode
|
||||
|
||||
ai_file_sort_enabled = Column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""``document.entered_folder``: a document became a member of a folder.
|
||||
|
||||
Fires once per arrival, however the document got there (upload, AI sort, move).
|
||||
Fires once per arrival, however the document got there (upload or move).
|
||||
The payload carries the fields a user can filter a trigger on.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -427,8 +427,6 @@ class IndexingPipelineService:
|
|||
log_index_success(ctx, chunk_count=chunk_count)
|
||||
outcome_status = "success"
|
||||
|
||||
await self._enqueue_ai_sort_if_enabled(document)
|
||||
|
||||
except RETRYABLE_LLM_ERRORS as e:
|
||||
ot.record_error(persist_span, e)
|
||||
log_retryable_llm_error(ctx, e)
|
||||
|
|
@ -564,29 +562,6 @@ class IndexingPipelineService:
|
|||
)
|
||||
return len(new_texts)
|
||||
|
||||
async def _enqueue_ai_sort_if_enabled(self, document: Document) -> None:
|
||||
"""Fire-and-forget: enqueue incremental AI sort if the workspace has it enabled."""
|
||||
try:
|
||||
from app.db import Workspace
|
||||
|
||||
result = await self.session.execute(
|
||||
select(Workspace.ai_file_sort_enabled).where(
|
||||
Workspace.id == document.workspace_id
|
||||
)
|
||||
)
|
||||
enabled = result.scalar()
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
from app.tasks.celery_tasks.document_tasks import ai_sort_document_task
|
||||
|
||||
user_id = str(document.created_by_id) if document.created_by_id else ""
|
||||
ai_sort_document_task.delay(document.workspace_id, user_id, document.id)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Failed to enqueue AI sort for document %s", document.id, exc_info=True
|
||||
)
|
||||
|
||||
async def index_batch_parallel(
|
||||
self,
|
||||
connector_docs: list[ConnectorDocument],
|
||||
|
|
|
|||
|
|
@ -543,7 +543,7 @@ def record_kb_search_duration(
|
|||
_record(
|
||||
_kb_search_duration(),
|
||||
duration_ms,
|
||||
{"search_space.id": workspace_id, "search.surface": surface},
|
||||
{"workspace.id": workspace_id, "search.surface": surface},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ def kb_search_span(
|
|||
"""Span around knowledge-base search routines."""
|
||||
attrs: dict[str, Any] = {}
|
||||
if workspace_id is not None:
|
||||
attrs["search_space.id"] = int(workspace_id)
|
||||
attrs["workspace.id"] = int(workspace_id)
|
||||
if query_chars is not None:
|
||||
attrs["query.chars"] = int(query_chars)
|
||||
if extra:
|
||||
|
|
@ -303,7 +303,7 @@ def chat_request_span(
|
|||
if chat_id is not None:
|
||||
attrs["chat.id"] = int(chat_id)
|
||||
if workspace_id is not None:
|
||||
attrs["search_space.id"] = int(workspace_id)
|
||||
attrs["workspace.id"] = int(workspace_id)
|
||||
if flow:
|
||||
attrs["chat.flow"] = flow
|
||||
if request_id:
|
||||
|
|
|
|||
|
|
@ -189,7 +189,6 @@ async def read_workspaces(
|
|||
citations_enabled=space.citations_enabled,
|
||||
api_access_enabled=space.api_access_enabled,
|
||||
qna_custom_instructions=space.qna_custom_instructions,
|
||||
ai_file_sort_enabled=space.ai_file_sort_enabled,
|
||||
member_count=member_count,
|
||||
is_owner=is_owner,
|
||||
)
|
||||
|
|
@ -326,43 +325,6 @@ async def update_workspace_api_access(
|
|||
) from e
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/ai-sort")
|
||||
async def trigger_ai_sort(
|
||||
workspace_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
user = auth.user
|
||||
"""Trigger a full AI file sort for all documents in the workspace."""
|
||||
try:
|
||||
await check_permission(
|
||||
session,
|
||||
auth,
|
||||
workspace_id,
|
||||
Permission.SETTINGS_UPDATE.value,
|
||||
"You don't have permission to trigger AI sort on this workspace",
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(Workspace).filter(Workspace.id == workspace_id)
|
||||
)
|
||||
db_workspace = result.scalars().first()
|
||||
if not db_workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
from app.tasks.celery_tasks.document_tasks import ai_sort_workspace_task
|
||||
|
||||
ai_sort_workspace_task.delay(workspace_id, str(user.id))
|
||||
return {"message": "AI sort started"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger AI sort: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to trigger AI sort: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}", response_model=dict)
|
||||
async def delete_workspace(
|
||||
workspace_id: int,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ class WorkspaceUpdate(BaseModel):
|
|||
description: str | None = None
|
||||
citations_enabled: bool | None = None
|
||||
qna_custom_instructions: str | None = None
|
||||
ai_file_sort_enabled: bool | None = None
|
||||
|
||||
|
||||
class WorkspaceApiAccessUpdate(BaseModel):
|
||||
|
|
@ -36,7 +35,6 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel):
|
|||
api_access_enabled: bool = False
|
||||
qna_custom_instructions: str | None = None
|
||||
shared_memory_md: str | None = None
|
||||
ai_file_sort_enabled: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
"""AI File Sort Service: builds connector-type/date/category/subcategory folder paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import (
|
||||
Chunk,
|
||||
Document,
|
||||
DocumentType,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
)
|
||||
from app.services.folder_service import ensure_folder_hierarchy_with_depth_validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DOCTYPE_TO_CONNECTOR_LABEL: dict[str, str] = {
|
||||
DocumentType.EXTENSION: "Browser Extension",
|
||||
DocumentType.CRAWLED_URL: "Web Crawl",
|
||||
DocumentType.FILE: "File Upload",
|
||||
DocumentType.SLACK_CONNECTOR: "Slack",
|
||||
DocumentType.TEAMS_CONNECTOR: "Teams",
|
||||
DocumentType.ONEDRIVE_FILE: "OneDrive",
|
||||
DocumentType.NOTION_CONNECTOR: "Notion",
|
||||
DocumentType.YOUTUBE_VIDEO: "YouTube",
|
||||
DocumentType.GITHUB_CONNECTOR: "GitHub",
|
||||
DocumentType.LINEAR_CONNECTOR: "Linear",
|
||||
DocumentType.DISCORD_CONNECTOR: "Discord",
|
||||
DocumentType.JIRA_CONNECTOR: "Jira",
|
||||
DocumentType.CONFLUENCE_CONNECTOR: "Confluence",
|
||||
DocumentType.CLICKUP_CONNECTOR: "ClickUp",
|
||||
DocumentType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
DocumentType.GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
DocumentType.GOOGLE_DRIVE_FILE: "Google Drive",
|
||||
DocumentType.AIRTABLE_CONNECTOR: "Airtable",
|
||||
DocumentType.LUMA_CONNECTOR: "Luma",
|
||||
DocumentType.ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
DocumentType.BOOKSTACK_CONNECTOR: "BookStack",
|
||||
DocumentType.CIRCLEBACK: "Circleback",
|
||||
DocumentType.OBSIDIAN_CONNECTOR: "Obsidian",
|
||||
DocumentType.NOTE: "Notes",
|
||||
DocumentType.DROPBOX_FILE: "Dropbox",
|
||||
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)",
|
||||
DocumentType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)",
|
||||
DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)",
|
||||
DocumentType.LOCAL_FOLDER_FILE: "Local Folder",
|
||||
}
|
||||
|
||||
_CONNECTOR_TYPE_LABEL: dict[str, str] = {
|
||||
SearchSourceConnectorType.SERPER_API: "Serper Search",
|
||||
SearchSourceConnectorType.TAVILY_API: "Tavily Search",
|
||||
SearchSourceConnectorType.SEARXNG_API: "SearXNG Search",
|
||||
SearchSourceConnectorType.LINKUP_API: "Linkup Search",
|
||||
SearchSourceConnectorType.BAIDU_SEARCH_API: "Baidu Search",
|
||||
SearchSourceConnectorType.SLACK_CONNECTOR: "Slack",
|
||||
SearchSourceConnectorType.TEAMS_CONNECTOR: "Teams",
|
||||
SearchSourceConnectorType.ONEDRIVE_CONNECTOR: "OneDrive",
|
||||
SearchSourceConnectorType.NOTION_CONNECTOR: "Notion",
|
||||
SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub",
|
||||
SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear",
|
||||
SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord",
|
||||
SearchSourceConnectorType.JIRA_CONNECTOR: "Jira",
|
||||
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence",
|
||||
SearchSourceConnectorType.CLICKUP_CONNECTOR: "ClickUp",
|
||||
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable",
|
||||
SearchSourceConnectorType.LUMA_CONNECTOR: "Luma",
|
||||
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: "Web Crawl",
|
||||
SearchSourceConnectorType.BOOKSTACK_CONNECTOR: "BookStack",
|
||||
SearchSourceConnectorType.CIRCLEBACK_CONNECTOR: "Circleback",
|
||||
SearchSourceConnectorType.OBSIDIAN_CONNECTOR: "Obsidian",
|
||||
SearchSourceConnectorType.MCP_CONNECTOR: "MCP",
|
||||
SearchSourceConnectorType.DROPBOX_CONNECTOR: "Dropbox",
|
||||
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive (Composio)",
|
||||
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail (Composio)",
|
||||
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar (Composio)",
|
||||
}
|
||||
|
||||
_MAX_CONTENT_CHARS = 4000
|
||||
_MAX_CHUNKS_FOR_CONTEXT = 5
|
||||
|
||||
_CATEGORY_PROMPT = (
|
||||
"Based on the document information below, classify it into a broad category "
|
||||
"and a more specific subcategory.\n\n"
|
||||
"Rules:\n"
|
||||
"- category: 1-2 word broad theme (e.g. Science, Finance, Engineering, Communication, Media)\n"
|
||||
"- subcategory: 1-2 word specific topic within the category "
|
||||
"(e.g. Physics, Tax Reports, Backend, Team Updates)\n"
|
||||
"- Use nouns only. Do not include generic terms like 'General' or 'Miscellaneous'.\n\n"
|
||||
"Title: {title}\n\n"
|
||||
"Content: {summary}\n\n"
|
||||
'Respond with ONLY a JSON object: {{"category": "...", "subcategory": "..."}}'
|
||||
)
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9 _\-()]")
|
||||
_FALLBACK_CATEGORY = "Uncategorized"
|
||||
_FALLBACK_SUBCATEGORY = "General"
|
||||
|
||||
|
||||
def resolve_root_folder_label(
|
||||
document: Document, connector: SearchSourceConnector | None
|
||||
) -> str:
|
||||
if connector is not None:
|
||||
return _CONNECTOR_TYPE_LABEL.get(
|
||||
connector.connector_type, str(connector.connector_type)
|
||||
)
|
||||
return _DOCTYPE_TO_CONNECTOR_LABEL.get(
|
||||
document.document_type, str(document.document_type)
|
||||
)
|
||||
|
||||
|
||||
def resolve_date_folder(document: Document) -> str:
|
||||
ts = document.updated_at or document.created_at
|
||||
if ts is None:
|
||||
ts = datetime.now(UTC)
|
||||
return ts.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def sanitize_category_folder_name(
|
||||
value: str | None, fallback: str = _FALLBACK_CATEGORY
|
||||
) -> str:
|
||||
if not value or not value.strip():
|
||||
return fallback
|
||||
cleaned = _SAFE_NAME_RE.sub("", value.strip())
|
||||
cleaned = " ".join(cleaned.split())
|
||||
if not cleaned:
|
||||
return fallback
|
||||
return cleaned[:50]
|
||||
|
||||
|
||||
async def _resolve_document_text(
|
||||
session: AsyncSession,
|
||||
document: Document,
|
||||
) -> str:
|
||||
"""Build the best available text representation for taxonomy generation.
|
||||
|
||||
Prefers ``document.content``; falls back to joining the first few chunks
|
||||
when content is empty or too short to be useful.
|
||||
"""
|
||||
text = (document.content or "").strip()
|
||||
if len(text) >= 100:
|
||||
return text[:_MAX_CONTENT_CHARS]
|
||||
|
||||
stmt = (
|
||||
select(Chunk.content)
|
||||
.where(Chunk.document_id == document.id)
|
||||
.order_by(Chunk.position, Chunk.id)
|
||||
.limit(_MAX_CHUNKS_FOR_CONTEXT)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
chunk_texts = [row[0] for row in result.all() if row[0]]
|
||||
if chunk_texts:
|
||||
combined = "\n\n".join(chunk_texts)
|
||||
return combined[:_MAX_CONTENT_CHARS]
|
||||
|
||||
return text[:_MAX_CONTENT_CHARS]
|
||||
|
||||
|
||||
def _get_cached_taxonomy(document: Document) -> tuple[str, str] | None:
|
||||
"""Return (category, subcategory) from document metadata cache, or None."""
|
||||
meta = document.document_metadata
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
cat = meta.get("ai_sort_category")
|
||||
subcat = meta.get("ai_sort_subcategory")
|
||||
if cat and subcat and isinstance(cat, str) and isinstance(subcat, str):
|
||||
return cat, subcat
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached_taxonomy(document: Document, category: str, subcategory: str) -> None:
|
||||
"""Persist the AI taxonomy on document metadata for deterministic re-sorts."""
|
||||
meta = dict(document.document_metadata or {})
|
||||
meta["ai_sort_category"] = category
|
||||
meta["ai_sort_subcategory"] = subcategory
|
||||
document.document_metadata = meta
|
||||
|
||||
|
||||
async def generate_ai_taxonomy(
|
||||
title: str,
|
||||
summary_or_content: str,
|
||||
llm,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (category, subcategory) using a single structured LLM call."""
|
||||
text = (summary_or_content or "").strip()
|
||||
if not text:
|
||||
return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY
|
||||
|
||||
if len(text) > _MAX_CONTENT_CHARS:
|
||||
text = text[:_MAX_CONTENT_CHARS]
|
||||
|
||||
prompt = _CATEGORY_PROMPT.format(title=title or "Untitled", summary=text)
|
||||
try:
|
||||
result = await llm.ainvoke([HumanMessage(content=prompt)])
|
||||
raw = result.content.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
parsed = json.loads(raw)
|
||||
category = sanitize_category_folder_name(
|
||||
parsed.get("category"), _FALLBACK_CATEGORY
|
||||
)
|
||||
subcategory = sanitize_category_folder_name(
|
||||
parsed.get("subcategory"), _FALLBACK_SUBCATEGORY
|
||||
)
|
||||
return category, subcategory
|
||||
except Exception:
|
||||
logger.warning("AI taxonomy generation failed, using fallback", exc_info=True)
|
||||
return _FALLBACK_CATEGORY, _FALLBACK_SUBCATEGORY
|
||||
|
||||
|
||||
def _build_path_segments(
|
||||
root_label: str,
|
||||
date_label: str,
|
||||
category: str,
|
||||
subcategory: str,
|
||||
) -> list[dict]:
|
||||
return [
|
||||
{"name": root_label, "metadata": {"ai_sort": True, "ai_sort_level": 1}},
|
||||
{"name": date_label, "metadata": {"ai_sort": True, "ai_sort_level": 2}},
|
||||
{"name": category, "metadata": {"ai_sort": True, "ai_sort_level": 3}},
|
||||
{"name": subcategory, "metadata": {"ai_sort": True, "ai_sort_level": 4}},
|
||||
]
|
||||
|
||||
|
||||
async def _resolve_taxonomy(
|
||||
session: AsyncSession,
|
||||
document: Document,
|
||||
llm,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (category, subcategory), reusing cached values when available."""
|
||||
cached = _get_cached_taxonomy(document)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
content_text = await _resolve_document_text(session, document)
|
||||
category, subcategory = await generate_ai_taxonomy(
|
||||
document.title, content_text, llm
|
||||
)
|
||||
_set_cached_taxonomy(document, category, subcategory)
|
||||
return category, subcategory
|
||||
|
||||
|
||||
async def ai_sort_document(
|
||||
session: AsyncSession,
|
||||
document: Document,
|
||||
llm,
|
||||
) -> Document:
|
||||
"""Sort a single document into the 4-level AI folder hierarchy."""
|
||||
connector: SearchSourceConnector | None = None
|
||||
if document.connector_id is not None:
|
||||
connector = await session.get(SearchSourceConnector, document.connector_id)
|
||||
|
||||
root_label = resolve_root_folder_label(document, connector)
|
||||
date_label = resolve_date_folder(document)
|
||||
|
||||
category, subcategory = await _resolve_taxonomy(session, document, llm)
|
||||
|
||||
segments = _build_path_segments(root_label, date_label, category, subcategory)
|
||||
|
||||
leaf_folder = await ensure_folder_hierarchy_with_depth_validation(
|
||||
session,
|
||||
document.workspace_id,
|
||||
segments,
|
||||
)
|
||||
|
||||
document.folder_id = leaf_folder.id
|
||||
await session.flush()
|
||||
return document
|
||||
|
||||
|
||||
async def ai_sort_all_documents(
|
||||
session: AsyncSession,
|
||||
workspace_id: int,
|
||||
llm,
|
||||
) -> tuple[int, int]:
|
||||
"""Sort all documents in a workspace. Returns (sorted_count, failed_count)."""
|
||||
stmt = (
|
||||
select(Document)
|
||||
.where(Document.workspace_id == workspace_id)
|
||||
.options(selectinload(Document.connector))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
documents = list(result.scalars().all())
|
||||
|
||||
sorted_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for doc in documents:
|
||||
try:
|
||||
connector = doc.connector
|
||||
root_label = resolve_root_folder_label(doc, connector)
|
||||
date_label = resolve_date_folder(doc)
|
||||
|
||||
category, subcategory = await _resolve_taxonomy(session, doc, llm)
|
||||
segments = _build_path_segments(
|
||||
root_label, date_label, category, subcategory
|
||||
)
|
||||
|
||||
leaf_folder = await ensure_folder_hierarchy_with_depth_validation(
|
||||
session,
|
||||
workspace_id,
|
||||
segments,
|
||||
)
|
||||
doc.folder_id = leaf_folder.id
|
||||
sorted_count += 1
|
||||
except Exception:
|
||||
logger.error("Failed to AI-sort document %s", doc.id, exc_info=True)
|
||||
failed_count += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"AI sort complete for workspace=%d: sorted=%d, failed=%d",
|
||||
workspace_id,
|
||||
sorted_count,
|
||||
failed_count,
|
||||
)
|
||||
return sorted_count, failed_count
|
||||
|
|
@ -4,7 +4,6 @@ import asyncio
|
|||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.celery_app import celery_app
|
||||
|
|
@ -1395,108 +1394,3 @@ async def _index_uploaded_folder_files_async(
|
|||
if notification_id is not None:
|
||||
_stop_heartbeat(notification_id)
|
||||
|
||||
|
||||
# ===== AI File Sort tasks =====
|
||||
|
||||
AI_SORT_LOCK_TTL_SECONDS = 600 # 10 minutes
|
||||
_ai_sort_redis = None
|
||||
|
||||
|
||||
def _get_ai_sort_redis():
|
||||
import redis
|
||||
|
||||
global _ai_sort_redis
|
||||
if _ai_sort_redis is None:
|
||||
_ai_sort_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||
return _ai_sort_redis
|
||||
|
||||
|
||||
def _ai_sort_lock_key(workspace_id: int) -> str:
|
||||
return f"ai_sort:workspace:{workspace_id}:lock"
|
||||
|
||||
|
||||
@celery_app.task(name="ai_sort_search_space", bind=True, max_retries=1)
|
||||
def ai_sort_workspace_task(self, workspace_id: int, user_id: str):
|
||||
"""Full AI sort for all documents in a workspace."""
|
||||
return run_async_celery_task(
|
||||
lambda: _ai_sort_workspace_async(workspace_id, user_id)
|
||||
)
|
||||
|
||||
|
||||
async def _ai_sort_workspace_async(workspace_id: int, user_id: str):
|
||||
r = _get_ai_sort_redis()
|
||||
lock_key = _ai_sort_lock_key(workspace_id)
|
||||
|
||||
if not r.set(lock_key, "running", nx=True, ex=AI_SORT_LOCK_TTL_SECONDS):
|
||||
logger.info(
|
||||
"AI sort already running for workspace=%d, skipping",
|
||||
workspace_id,
|
||||
)
|
||||
return
|
||||
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
from app.services.ai_file_sort_service import ai_sort_all_documents
|
||||
from app.services.llm_service import get_agent_llm
|
||||
|
||||
async with get_celery_session_maker()() as session:
|
||||
llm = await get_agent_llm(session, workspace_id, disable_streaming=True)
|
||||
if llm is None:
|
||||
logger.warning(
|
||||
"No LLM configured for workspace=%d, skipping AI sort",
|
||||
workspace_id,
|
||||
)
|
||||
return
|
||||
|
||||
sorted_count, failed_count = await ai_sort_all_documents(
|
||||
session, workspace_id, llm
|
||||
)
|
||||
elapsed = time.perf_counter() - t_start
|
||||
logger.info(
|
||||
"AI sort workspace=%d done in %.1fs: sorted=%d failed=%d",
|
||||
workspace_id,
|
||||
elapsed,
|
||||
sorted_count,
|
||||
failed_count,
|
||||
)
|
||||
finally:
|
||||
r.delete(lock_key)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="ai_sort_document", bind=True, max_retries=2, default_retry_delay=10
|
||||
)
|
||||
def ai_sort_document_task(self, workspace_id: int, user_id: str, document_id: int):
|
||||
"""Incremental AI sort for a single document after indexing."""
|
||||
return run_async_celery_task(
|
||||
lambda: _ai_sort_document_async(workspace_id, user_id, document_id)
|
||||
)
|
||||
|
||||
|
||||
async def _ai_sort_document_async(workspace_id: int, user_id: str, document_id: int):
|
||||
from app.db import Document
|
||||
from app.services.ai_file_sort_service import ai_sort_document
|
||||
from app.services.llm_service import get_agent_llm
|
||||
|
||||
async with get_celery_session_maker()() as session:
|
||||
document = await session.get(Document, document_id)
|
||||
if document is None:
|
||||
logger.warning("Document %d not found, skipping AI sort", document_id)
|
||||
return
|
||||
|
||||
llm = await get_agent_llm(session, workspace_id, disable_streaming=True)
|
||||
if llm is None:
|
||||
logger.warning(
|
||||
"No LLM for workspace=%d, skipping AI sort of doc=%d",
|
||||
workspace_id,
|
||||
document_id,
|
||||
)
|
||||
return
|
||||
|
||||
await ai_sort_document(session, document, llm)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"AI sorted document=%d into workspace=%d",
|
||||
document_id,
|
||||
workspace_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ def _disable_otel(monkeypatch: pytest.MonkeyPatch):
|
|||
("cleanup_stale_indexing_notifications", "cleanup"),
|
||||
("reconcile_pending_stripe_credit_purchases", "reconcile"),
|
||||
("check_periodic_schedules", "check"),
|
||||
("ai_sort_search_space", "ai"),
|
||||
("index_notion_pages", "index"),
|
||||
("index_github_repos", "index"),
|
||||
("index_google_drive_files", "index"),
|
||||
|
|
|
|||
|
|
@ -1,275 +0,0 @@
|
|||
"""Unit tests for AI file sort service: folder label resolution, date extraction, category sanitization."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ── resolve_root_folder_label ──
|
||||
|
||||
|
||||
def _make_document(document_type: str, connector_id=None):
|
||||
doc = MagicMock()
|
||||
doc.document_type = document_type
|
||||
doc.connector_id = connector_id
|
||||
return doc
|
||||
|
||||
|
||||
def _make_connector(connector_type: str):
|
||||
conn = MagicMock()
|
||||
conn.connector_type = connector_type
|
||||
return conn
|
||||
|
||||
|
||||
def test_root_label_uses_connector_type_when_available():
|
||||
from app.services.ai_file_sort_service import resolve_root_folder_label
|
||||
|
||||
doc = _make_document("FILE", connector_id=1)
|
||||
conn = _make_connector("GOOGLE_DRIVE_CONNECTOR")
|
||||
assert resolve_root_folder_label(doc, conn) == "Google Drive"
|
||||
|
||||
|
||||
def test_root_label_falls_back_to_document_type():
|
||||
from app.services.ai_file_sort_service import resolve_root_folder_label
|
||||
|
||||
doc = _make_document("SLACK_CONNECTOR")
|
||||
assert resolve_root_folder_label(doc, None) == "Slack"
|
||||
|
||||
|
||||
def test_root_label_unknown_doctype_returns_raw_value():
|
||||
from app.services.ai_file_sort_service import resolve_root_folder_label
|
||||
|
||||
doc = _make_document("UNKNOWN_TYPE")
|
||||
assert resolve_root_folder_label(doc, None) == "UNKNOWN_TYPE"
|
||||
|
||||
|
||||
# ── resolve_date_folder ──
|
||||
|
||||
|
||||
def test_date_folder_from_updated_at():
|
||||
from app.services.ai_file_sort_service import resolve_date_folder
|
||||
|
||||
doc = MagicMock()
|
||||
doc.updated_at = datetime(2025, 3, 15, 10, 30, 0, tzinfo=UTC)
|
||||
doc.created_at = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
assert resolve_date_folder(doc) == "2025-03-15"
|
||||
|
||||
|
||||
def test_date_folder_falls_back_to_created_at():
|
||||
from app.services.ai_file_sort_service import resolve_date_folder
|
||||
|
||||
doc = MagicMock()
|
||||
doc.updated_at = None
|
||||
doc.created_at = datetime(2024, 12, 25, 23, 59, 0, tzinfo=UTC)
|
||||
assert resolve_date_folder(doc) == "2024-12-25"
|
||||
|
||||
|
||||
def test_date_folder_both_none_uses_today():
|
||||
from app.services.ai_file_sort_service import resolve_date_folder
|
||||
|
||||
doc = MagicMock()
|
||||
doc.updated_at = None
|
||||
doc.created_at = None
|
||||
result = resolve_date_folder(doc)
|
||||
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
assert result == today
|
||||
|
||||
|
||||
# ── sanitize_category_folder_name ──
|
||||
|
||||
|
||||
def test_sanitize_normal_value():
|
||||
from app.services.ai_file_sort_service import sanitize_category_folder_name
|
||||
|
||||
assert sanitize_category_folder_name("Machine Learning") == "Machine Learning"
|
||||
|
||||
|
||||
def test_sanitize_strips_special_chars():
|
||||
from app.services.ai_file_sort_service import sanitize_category_folder_name
|
||||
|
||||
assert sanitize_category_folder_name("Tax/Reports!") == "TaxReports"
|
||||
|
||||
|
||||
def test_sanitize_empty_returns_fallback():
|
||||
from app.services.ai_file_sort_service import sanitize_category_folder_name
|
||||
|
||||
assert sanitize_category_folder_name("") == "Uncategorized"
|
||||
assert sanitize_category_folder_name(None) == "Uncategorized"
|
||||
|
||||
|
||||
def test_sanitize_truncates_long_names():
|
||||
from app.services.ai_file_sort_service import sanitize_category_folder_name
|
||||
|
||||
long_name = "A" * 100
|
||||
result = sanitize_category_folder_name(long_name)
|
||||
assert len(result) <= 50
|
||||
|
||||
|
||||
# ── generate_ai_taxonomy ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_ai_taxonomy_parses_json():
|
||||
from app.services.ai_file_sort_service import generate_ai_taxonomy
|
||||
|
||||
mock_llm = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.content = '{"category": "Science", "subcategory": "Physics"}'
|
||||
mock_llm.ainvoke.return_value = mock_result
|
||||
|
||||
cat, sub = await generate_ai_taxonomy(
|
||||
"Physics Paper", "Some science document about physics", mock_llm
|
||||
)
|
||||
assert cat == "Science"
|
||||
assert sub == "Physics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_ai_taxonomy_handles_markdown_code_block():
|
||||
from app.services.ai_file_sort_service import generate_ai_taxonomy
|
||||
|
||||
mock_llm = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.content = (
|
||||
'```json\n{"category": "Finance", "subcategory": "Tax Reports"}\n```'
|
||||
)
|
||||
mock_llm.ainvoke.return_value = mock_result
|
||||
|
||||
cat, sub = await generate_ai_taxonomy("Tax Doc", "A tax report document", mock_llm)
|
||||
assert cat == "Finance"
|
||||
assert sub == "Tax Reports"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_ai_taxonomy_includes_title_in_prompt():
|
||||
from app.services.ai_file_sort_service import generate_ai_taxonomy
|
||||
|
||||
mock_llm = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.content = '{"category": "Engineering", "subcategory": "Backend"}'
|
||||
mock_llm.ainvoke.return_value = mock_result
|
||||
|
||||
await generate_ai_taxonomy("API Design Guide", "content about REST APIs", mock_llm)
|
||||
|
||||
prompt_text = mock_llm.ainvoke.call_args[0][0][0].content
|
||||
assert "API Design Guide" in prompt_text
|
||||
assert "content about REST APIs" in prompt_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_ai_taxonomy_fallback_on_error():
|
||||
from app.services.ai_file_sort_service import generate_ai_taxonomy
|
||||
|
||||
mock_llm = AsyncMock()
|
||||
mock_llm.ainvoke.side_effect = RuntimeError("LLM down")
|
||||
|
||||
cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm)
|
||||
assert cat == "Uncategorized"
|
||||
assert sub == "General"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_ai_taxonomy_fallback_on_empty_content():
|
||||
from app.services.ai_file_sort_service import generate_ai_taxonomy
|
||||
|
||||
mock_llm = AsyncMock()
|
||||
cat, sub = await generate_ai_taxonomy("Title", "", mock_llm)
|
||||
assert cat == "Uncategorized"
|
||||
assert sub == "General"
|
||||
mock_llm.ainvoke.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_ai_taxonomy_fallback_on_invalid_json():
|
||||
from app.services.ai_file_sort_service import generate_ai_taxonomy
|
||||
|
||||
mock_llm = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.content = "not valid json at all"
|
||||
mock_llm.ainvoke.return_value = mock_result
|
||||
|
||||
cat, sub = await generate_ai_taxonomy("Title", "some content", mock_llm)
|
||||
assert cat == "Uncategorized"
|
||||
assert sub == "General"
|
||||
|
||||
|
||||
# ── taxonomy caching ──
|
||||
|
||||
|
||||
def test_get_cached_taxonomy_returns_none_when_no_metadata():
|
||||
from app.services.ai_file_sort_service import _get_cached_taxonomy
|
||||
|
||||
doc = MagicMock()
|
||||
doc.document_metadata = None
|
||||
assert _get_cached_taxonomy(doc) is None
|
||||
|
||||
|
||||
def test_get_cached_taxonomy_returns_none_when_keys_missing():
|
||||
from app.services.ai_file_sort_service import _get_cached_taxonomy
|
||||
|
||||
doc = MagicMock()
|
||||
doc.document_metadata = {"some_other_key": "value"}
|
||||
assert _get_cached_taxonomy(doc) is None
|
||||
|
||||
|
||||
def test_get_cached_taxonomy_returns_cached_values():
|
||||
from app.services.ai_file_sort_service import _get_cached_taxonomy
|
||||
|
||||
doc = MagicMock()
|
||||
doc.document_metadata = {
|
||||
"ai_sort_category": "Finance",
|
||||
"ai_sort_subcategory": "Tax Reports",
|
||||
}
|
||||
assert _get_cached_taxonomy(doc) == ("Finance", "Tax Reports")
|
||||
|
||||
|
||||
def test_set_cached_taxonomy_persists_on_metadata():
|
||||
from app.services.ai_file_sort_service import _set_cached_taxonomy
|
||||
|
||||
doc = MagicMock()
|
||||
doc.document_metadata = {"existing_key": "keep_me"}
|
||||
_set_cached_taxonomy(doc, "Science", "Physics")
|
||||
assert doc.document_metadata["ai_sort_category"] == "Science"
|
||||
assert doc.document_metadata["ai_sort_subcategory"] == "Physics"
|
||||
assert doc.document_metadata["existing_key"] == "keep_me"
|
||||
|
||||
|
||||
def test_set_cached_taxonomy_creates_metadata_when_none():
|
||||
from app.services.ai_file_sort_service import _set_cached_taxonomy
|
||||
|
||||
doc = MagicMock()
|
||||
doc.document_metadata = None
|
||||
_set_cached_taxonomy(doc, "Engineering", "Backend")
|
||||
assert doc.document_metadata == {
|
||||
"ai_sort_category": "Engineering",
|
||||
"ai_sort_subcategory": "Backend",
|
||||
}
|
||||
|
||||
|
||||
# ── _build_path_segments ──
|
||||
|
||||
|
||||
def test_build_path_segments_structure():
|
||||
from app.services.ai_file_sort_service import _build_path_segments
|
||||
|
||||
segments = _build_path_segments("Google Drive", "2025-03-15", "Science", "Physics")
|
||||
assert len(segments) == 4
|
||||
assert segments[0] == {
|
||||
"name": "Google Drive",
|
||||
"metadata": {"ai_sort": True, "ai_sort_level": 1},
|
||||
}
|
||||
assert segments[1] == {
|
||||
"name": "2025-03-15",
|
||||
"metadata": {"ai_sort": True, "ai_sort_level": 2},
|
||||
}
|
||||
assert segments[2] == {
|
||||
"name": "Science",
|
||||
"metadata": {"ai_sort": True, "ai_sort_level": 3},
|
||||
}
|
||||
assert segments[3] == {
|
||||
"name": "Physics",
|
||||
"metadata": {"ai_sort": True, "ai_sort_level": 4},
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"""Unit tests for AI sort task Redis deduplication lock."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_lock_key_format():
|
||||
from app.tasks.celery_tasks.document_tasks import _ai_sort_lock_key
|
||||
|
||||
key = _ai_sort_lock_key(42)
|
||||
assert key == "ai_sort:workspace:42:lock"
|
||||
|
||||
|
||||
def test_lock_prevents_duplicate_run():
|
||||
"""When the Redis lock already exists, the task should skip execution."""
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = False # Lock already held
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.celery_tasks.document_tasks._get_ai_sort_redis",
|
||||
return_value=mock_redis,
|
||||
),
|
||||
patch(
|
||||
"app.tasks.celery_tasks.document_tasks.get_celery_session_maker"
|
||||
) as mock_session_maker,
|
||||
):
|
||||
import asyncio
|
||||
|
||||
from app.tasks.celery_tasks.document_tasks import _ai_sort_workspace_async
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(_ai_sort_workspace_async(1, "user-123"))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Session maker should never be called since lock was not acquired
|
||||
mock_session_maker.assert_not_called()
|
||||
|
|
@ -49,8 +49,8 @@ async def test_creates_missing_folders_in_chain():
|
|||
session.flush = mock_flush
|
||||
|
||||
segments = [
|
||||
{"name": "Slack", "metadata": {"ai_sort": True, "ai_sort_level": 1}},
|
||||
{"name": "2025-03-15", "metadata": {"ai_sort": True, "ai_sort_level": 2}},
|
||||
{"name": "Slack", "metadata": {"source": "slack"}},
|
||||
{"name": "2025-03-15", "metadata": {"source": "slack"}},
|
||||
]
|
||||
|
||||
result = await ensure_folder_hierarchy_with_depth_validation(
|
||||
|
|
|
|||
|
|
@ -112,12 +112,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
|||
}));
|
||||
|
||||
const token = await storage.get("token");
|
||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||
const workspace_id = parseInt(await storage.get("workspace_id"), 10);
|
||||
|
||||
const toSend = {
|
||||
document_type: "EXTENSION",
|
||||
content: content,
|
||||
search_space_id: search_space_id,
|
||||
workspace_id: workspace_id,
|
||||
};
|
||||
|
||||
console.log("toSend", toSend);
|
||||
|
|
|
|||
|
|
@ -106,12 +106,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
|||
}));
|
||||
|
||||
const token = await storage.get("token");
|
||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||
const workspace_id = parseInt(await storage.get("workspace_id"), 10);
|
||||
|
||||
const toSend = {
|
||||
document_type: "EXTENSION",
|
||||
content: content,
|
||||
search_space_id: search_space_id,
|
||||
workspace_id: workspace_id,
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
|
|
|
|||
7
surfsense_browser_extension/pnpm-workspace.yaml
Normal file
7
surfsense_browser_extension/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
'@swc/core': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
lmdb: set this to true or false
|
||||
msgpackr-extract: set this to true or false
|
||||
sharp: set this to true or false
|
||||
|
|
@ -33,6 +33,20 @@ import { getRenderedHtml } from "~utils/commons";
|
|||
import type { WebHistory } from "~utils/interfaces";
|
||||
import Loading from "./Loading";
|
||||
|
||||
// One-time migration: legacy persisted keys were `search_space` / `search_space_id`.
|
||||
// Copy them to the new `workspace` / `workspace_id` keys on first read so the
|
||||
// user's selected workspace survives the rename.
|
||||
async function migrateLegacyWorkspaceKeys(storage: Storage): Promise<void> {
|
||||
const legacyName = await storage.get("search_space");
|
||||
if (legacyName !== undefined && (await storage.get("workspace")) === undefined) {
|
||||
await storage.set("workspace", legacyName);
|
||||
}
|
||||
const legacyId = await storage.get("search_space_id");
|
||||
if (legacyId !== undefined && (await storage.get("workspace_id")) === undefined) {
|
||||
await storage.set("workspace_id", legacyId);
|
||||
}
|
||||
}
|
||||
|
||||
const HomePage = () => {
|
||||
const { toast } = useToast();
|
||||
const navigation = useNavigate();
|
||||
|
|
@ -40,11 +54,11 @@ const HomePage = () => {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState<string>("");
|
||||
const [searchspaces, setSearchSpaces] = useState([]);
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSearchSpaces = async () => {
|
||||
const checkWorkspaces = async () => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const token = await storage.get("token");
|
||||
|
||||
|
|
@ -55,7 +69,7 @@ const HomePage = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(await buildBackendUrl("/api/v1/searchspaces"), {
|
||||
const response = await fetch(await buildBackendUrl("/api/v1/workspaces"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
|
|
@ -66,7 +80,7 @@ const HomePage = () => {
|
|||
} else {
|
||||
const res = await response.json();
|
||||
console.log(res);
|
||||
setSearchSpaces(res);
|
||||
setWorkspaces(res);
|
||||
}
|
||||
} catch (error) {
|
||||
await storage.remove("token");
|
||||
|
|
@ -77,7 +91,7 @@ const HomePage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
checkSearchSpaces();
|
||||
checkWorkspaces();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -98,10 +112,11 @@ const HomePage = () => {
|
|||
});
|
||||
|
||||
const storage = new Storage({ area: "local" });
|
||||
const searchspace = await storage.get("search_space");
|
||||
await migrateLegacyWorkspaceKeys(storage);
|
||||
const workspace = await storage.get("workspace");
|
||||
|
||||
if (searchspace) {
|
||||
setValue(searchspace);
|
||||
if (workspace) {
|
||||
setValue(workspace);
|
||||
}
|
||||
|
||||
await storage.set("showShadowDom", true);
|
||||
|
|
@ -262,17 +277,17 @@ const HomePage = () => {
|
|||
const saveDatamessage = async () => {
|
||||
if (value === "") {
|
||||
toast({
|
||||
title: "Select a SearchSpace !",
|
||||
title: "Select a Workspace !",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = new Storage({ area: "local" });
|
||||
const search_space_id = await storage.get("search_space_id");
|
||||
const workspace_id = await storage.get("workspace_id");
|
||||
|
||||
if (!search_space_id) {
|
||||
if (!workspace_id) {
|
||||
toast({
|
||||
title: "Invalid SearchSpace selected!",
|
||||
title: "Invalid Workspace selected!",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
|
|
@ -319,15 +334,15 @@ const HomePage = () => {
|
|||
const storage = new Storage({ area: "local" });
|
||||
await storage.remove("token");
|
||||
await storage.remove("showShadowDom");
|
||||
await storage.remove("search_space");
|
||||
await storage.remove("search_space_id");
|
||||
await storage.remove("workspace");
|
||||
await storage.remove("workspace_id");
|
||||
navigation("/login");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return searchspaces.length === 0 ? (
|
||||
return workspaces.length === 0 ? (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
|
|
@ -337,7 +352,7 @@ const HomePage = () => {
|
|||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
|
||||
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
|
||||
<p className="text-sm">Please create a Search Space to continue</p>
|
||||
<p className="text-sm">Please create a Workspace to continue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -390,7 +405,7 @@ const HomePage = () => {
|
|||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
|
||||
<Label className="mb-2 block text-sm font-medium text-gray-300">Search Space</Label>
|
||||
<Label className="mb-2 block text-sm font-medium text-gray-300">Workspace</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -399,8 +414,8 @@ const HomePage = () => {
|
|||
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
||||
>
|
||||
{value
|
||||
? searchspaces.find((space) => space.name === value)?.name
|
||||
: "Select Search Space..."}
|
||||
? workspaces.find((space) => space.name === value)?.name
|
||||
: "Select Workspace..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -411,23 +426,23 @@ const HomePage = () => {
|
|||
className="border-gray-700 bg-gray-900 text-gray-200"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No search spaces found.</CommandEmpty>
|
||||
<CommandEmpty>No workspaces found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{searchspaces.map((space) => (
|
||||
{workspaces.map((space) => (
|
||||
<CommandItem
|
||||
key={space.name}
|
||||
value={space.name}
|
||||
onSelect={async (currentValue) => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
if (currentValue === value) {
|
||||
await storage.set("search_space", "");
|
||||
await storage.set("search_space_id", 0);
|
||||
await storage.set("workspace", "");
|
||||
await storage.set("workspace_id", 0);
|
||||
} else {
|
||||
const selectedSpace = searchspaces.find(
|
||||
const selectedSpace = workspaces.find(
|
||||
(space) => space.name === currentValue
|
||||
);
|
||||
await storage.set("search_space", currentValue);
|
||||
await storage.set("search_space_id", selectedSpace.id);
|
||||
await storage.set("workspace", currentValue);
|
||||
await storage.set("workspace_id", selectedSpace.id);
|
||||
}
|
||||
setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
|
|
|
|||
1
surfsense_browser_extension/tsconfig.tsbuildinfo
Normal file
1
surfsense_browser_extension/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -50,8 +50,8 @@ export const IPC_CHANNELS = {
|
|||
GET_SHORTCUTS: 'shortcuts:get',
|
||||
SET_SHORTCUTS: 'shortcuts:set',
|
||||
// Active search space
|
||||
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
|
||||
SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active',
|
||||
GET_ACTIVE_WORKSPACE: 'workspace:get-active',
|
||||
SET_ACTIVE_WORKSPACE: 'workspace:set-active',
|
||||
// Launch on system startup
|
||||
GET_AUTO_LAUNCH: 'auto-launch:get',
|
||||
SET_AUTO_LAUNCH: 'auto-launch:set',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
} from '../modules/folder-watcher';
|
||||
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
||||
import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch';
|
||||
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
|
||||
import { getActiveWorkspaceId, setActiveWorkspaceId } from '../modules/active-workspace';
|
||||
import { reregisterQuickAsk } from '../modules/quick-ask';
|
||||
import { reregisterGeneralAssist, reregisterScreenshotAssist } from '../modules/tray';
|
||||
import {
|
||||
|
|
@ -205,9 +205,9 @@ export function registerIpcHandlers(): void {
|
|||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
|
||||
async (_event, virtualPath: string, searchSpaceId?: number | null) => {
|
||||
async (_event, virtualPath: string, workspaceId?: number | null) => {
|
||||
try {
|
||||
const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
|
||||
const result = await readAgentLocalFileText(virtualPath, workspaceId);
|
||||
return { ok: true, path: result.path, content: result.content };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read local file';
|
||||
|
|
@ -218,9 +218,9 @@ export function registerIpcHandlers(): void {
|
|||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
|
||||
async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => {
|
||||
async (_event, virtualPath: string, content: string, workspaceId?: number | null) => {
|
||||
try {
|
||||
const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
|
||||
const result = await writeAgentLocalFileText(virtualPath, content, workspaceId);
|
||||
return { ok: true, path: result.path };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write local file';
|
||||
|
|
@ -321,10 +321,10 @@ export function registerIpcHandlers(): void {
|
|||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId());
|
||||
ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_WORKSPACE, () => getActiveWorkspaceId());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) =>
|
||||
setActiveSearchSpaceId(id)
|
||||
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, (_event, id: string) =>
|
||||
setActiveWorkspaceId(id)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
|
||||
|
|
@ -370,12 +370,12 @@ export function registerIpcHandlers(): void {
|
|||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) =>
|
||||
getAgentFilesystemSettings(searchSpaceId)
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, workspaceId?: number | null) =>
|
||||
getAgentFilesystemSettings(workspaceId)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
|
||||
getAgentFilesystemMounts(searchSpaceId)
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, workspaceId?: number | null) =>
|
||||
getAgentFilesystemMounts(workspaceId)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
|
|
@ -384,7 +384,7 @@ export function registerIpcHandlers(): void {
|
|||
_event,
|
||||
options: {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}
|
||||
|
|
@ -397,10 +397,10 @@ export function registerIpcHandlers(): void {
|
|||
(
|
||||
_event,
|
||||
payload: {
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null };
|
||||
}
|
||||
) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {})
|
||||
) => setAgentFilesystemSettings(payload?.workspaceId, payload?.settings ?? {})
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
|
||||
|
|
@ -415,7 +415,7 @@ export function registerIpcHandlers(): void {
|
|||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
|
||||
(_event, searchSpaceId?: number | null) =>
|
||||
stopAgentFilesystemTreeWatch(searchSpaceId)
|
||||
(_event, workspaceId?: number | null) =>
|
||||
stopAgentFilesystemTreeWatch(workspaceId)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
const STORE_KEY = 'activeSearchSpaceId';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let store: any = null;
|
||||
|
||||
async function getStore() {
|
||||
if (!store) {
|
||||
const { default: Store } = await import('electron-store');
|
||||
store = new Store({
|
||||
name: 'active-search-space',
|
||||
defaults: { [STORE_KEY]: null as string | null },
|
||||
});
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function getActiveSearchSpaceId(): Promise<string | null> {
|
||||
const s = await getStore();
|
||||
return (s.get(STORE_KEY) as string | null) ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveSearchSpaceId(id: string): Promise<void> {
|
||||
const s = await getStore();
|
||||
s.set(STORE_KEY, id);
|
||||
}
|
||||
33
surfsense_desktop/src/modules/active-workspace.ts
Normal file
33
surfsense_desktop/src/modules/active-workspace.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const STORE_KEY = 'activeWorkspaceId';
|
||||
let store: any = null;
|
||||
|
||||
async function getStore() {
|
||||
if (!store) {
|
||||
const { default: Store } = await import('electron-store');
|
||||
store = new Store({
|
||||
name: 'active-workspace',
|
||||
defaults: { [STORE_KEY]: null as string | null },
|
||||
});
|
||||
// One-time migration from the legacy `active-search-space` store so the
|
||||
// user's last-selected workspace survives the rename.
|
||||
if (store.get(STORE_KEY) == null) {
|
||||
const legacy: any = new Store({
|
||||
name: 'active-search-space',
|
||||
defaults: { activeSearchSpaceId: null as string | null },
|
||||
});
|
||||
const prev = legacy.get('activeSearchSpaceId') as string | null;
|
||||
if (prev != null) store.set(STORE_KEY, prev);
|
||||
}
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function getActiveWorkspaceId(): Promise<string | null> {
|
||||
const s = await getStore();
|
||||
return (s.get(STORE_KEY) as string | null) ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveWorkspaceId(id: string): Promise<void> {
|
||||
const s = await getStore();
|
||||
s.set(STORE_KEY, id);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ const SAFETY_POLL_MS = 60_000;
|
|||
const EVENT_DEBOUNCE_MS = 700;
|
||||
|
||||
export type AgentFilesystemTreeWatchOptions = {
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
rootPaths: string[];
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
|
|
@ -17,7 +17,7 @@ export type AgentFilesystemTreeWatchOptions = {
|
|||
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
|
||||
|
||||
type TreeDirtyEvent = {
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
reason: TreeDirtyReason;
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
|
|
@ -25,7 +25,7 @@ type TreeDirtyEvent = {
|
|||
};
|
||||
|
||||
type WatchSession = {
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
optionsSignature: string;
|
||||
rootPaths: string[];
|
||||
excludePatterns: string[];
|
||||
|
|
@ -40,15 +40,15 @@ type WatchSession = {
|
|||
|
||||
const sessions = new Map<string, WatchSession>();
|
||||
|
||||
function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
|
||||
if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
||||
return searchSpaceId;
|
||||
function normalizeWorkspaceId(workspaceId?: number | null): number | null {
|
||||
if (typeof workspaceId === 'number' && Number.isFinite(workspaceId) && workspaceId > 0) {
|
||||
return workspaceId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionKey(searchSpaceId?: number | null): string {
|
||||
const normalized = normalizeSearchSpaceId(searchSpaceId);
|
||||
function getSessionKey(workspaceId?: number | null): string {
|
||||
const normalized = normalizeWorkspaceId(workspaceId);
|
||||
return normalized === null ? 'default' : String(normalized);
|
||||
}
|
||||
|
||||
|
|
@ -71,13 +71,13 @@ function normalizeExtensions(value: string[] | null | undefined): string[] | nul
|
|||
}
|
||||
|
||||
function buildOptionsSignature(
|
||||
searchSpaceId: number | null,
|
||||
workspaceId: number | null,
|
||||
rootPaths: string[],
|
||||
excludePatterns: string[],
|
||||
fileExtensions: string[] | null
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
rootPaths: [...rootPaths].sort(),
|
||||
excludePatterns: [...excludePatterns].sort(),
|
||||
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
|
||||
|
|
@ -99,10 +99,10 @@ async function buildRootSnapshotSignature(
|
|||
rootPath: string
|
||||
): Promise<string> {
|
||||
let hash = 2166136261;
|
||||
hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash);
|
||||
hash = hashText(`space:${session.workspaceId ?? 'default'}|root:${rootPath}`, hash);
|
||||
const files = await listAgentFilesystemFiles({
|
||||
rootPath,
|
||||
searchSpaceId: session.searchSpaceId,
|
||||
workspaceId: session.workspaceId,
|
||||
excludePatterns: session.excludePatterns,
|
||||
fileExtensions: session.fileExtensions,
|
||||
});
|
||||
|
|
@ -118,13 +118,13 @@ async function buildRootSnapshotSignature(
|
|||
}
|
||||
|
||||
function sendTreeDirtyEvent(
|
||||
searchSpaceId: number | null,
|
||||
workspaceId: number | null,
|
||||
reason: TreeDirtyReason,
|
||||
rootPath: string,
|
||||
changedPath: string | null
|
||||
): void {
|
||||
const payload: TreeDirtyEvent = {
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
reason,
|
||||
rootPath,
|
||||
changedPath,
|
||||
|
|
@ -158,7 +158,7 @@ function scheduleDirtyEmit(
|
|||
session.pendingDirtyByRoot.clear();
|
||||
for (const [pendingRootPath, payload] of pending) {
|
||||
sendTreeDirtyEvent(
|
||||
session.searchSpaceId,
|
||||
session.workspaceId,
|
||||
payload.reason,
|
||||
pendingRootPath,
|
||||
payload.changedPath
|
||||
|
|
@ -183,21 +183,21 @@ async function closeSession(session: WatchSession): Promise<void> {
|
|||
export async function startAgentFilesystemTreeWatch(
|
||||
options: AgentFilesystemTreeWatchOptions
|
||||
): Promise<{ ok: true }> {
|
||||
const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
|
||||
const workspaceId = normalizeWorkspaceId(options.workspaceId);
|
||||
const rootPaths = Array.from(
|
||||
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
|
||||
);
|
||||
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
|
||||
const fileExtensions = normalizeExtensions(options.fileExtensions);
|
||||
const sessionKey = getSessionKey(searchSpaceId);
|
||||
const sessionKey = getSessionKey(workspaceId);
|
||||
|
||||
if (rootPaths.length === 0) {
|
||||
await stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||
await stopAgentFilesystemTreeWatch(workspaceId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const optionsSignature = buildOptionsSignature(
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
rootPaths,
|
||||
excludePatterns,
|
||||
fileExtensions
|
||||
|
|
@ -228,7 +228,7 @@ export async function startAgentFilesystemTreeWatch(
|
|||
);
|
||||
|
||||
const session: WatchSession = {
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
optionsSignature,
|
||||
rootPaths,
|
||||
excludePatterns,
|
||||
|
|
@ -291,9 +291,9 @@ export async function startAgentFilesystemTreeWatch(
|
|||
}
|
||||
|
||||
export async function stopAgentFilesystemTreeWatch(
|
||||
searchSpaceId?: number | null
|
||||
workspaceId?: number | null
|
||||
): Promise<{ ok: true }> {
|
||||
const sessionKey = getSessionKey(searchSpaceId);
|
||||
const sessionKey = getSessionKey(workspaceId);
|
||||
const session = sessions.get(sessionKey);
|
||||
if (!session) return { ok: true };
|
||||
sessions.delete(sessionKey);
|
||||
|
|
|
|||
|
|
@ -119,9 +119,9 @@ async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[
|
|||
return [...uniquePaths];
|
||||
}
|
||||
|
||||
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
|
||||
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
||||
return String(searchSpaceId);
|
||||
function normalizeWorkspaceKey(workspaceId?: number | null): string {
|
||||
if (typeof workspaceId === "number" && Number.isFinite(workspaceId) && workspaceId > 0) {
|
||||
return String(workspaceId);
|
||||
}
|
||||
return DEFAULT_SPACE_KEY;
|
||||
}
|
||||
|
|
@ -147,9 +147,9 @@ function getDefaultStore(): AgentFilesystemSettingsStore {
|
|||
|
||||
function getSettingsFromStore(
|
||||
store: AgentFilesystemSettingsStore,
|
||||
searchSpaceId?: number | null
|
||||
workspaceId?: number | null
|
||||
): AgentFilesystemSettings {
|
||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
||||
const key = normalizeWorkspaceKey(workspaceId);
|
||||
return store.spaces[key] ?? getDefaultSettings();
|
||||
}
|
||||
|
||||
|
|
@ -194,22 +194,22 @@ async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettin
|
|||
}
|
||||
|
||||
export async function getAgentFilesystemSettings(
|
||||
searchSpaceId?: number | null
|
||||
workspaceId?: number | null
|
||||
): Promise<AgentFilesystemSettings> {
|
||||
const store = await loadAgentFilesystemSettingsStore();
|
||||
return getSettingsFromStore(store, searchSpaceId);
|
||||
return getSettingsFromStore(store, workspaceId);
|
||||
}
|
||||
|
||||
export async function setAgentFilesystemSettings(
|
||||
searchSpaceId: number | null | undefined,
|
||||
workspaceId: number | null | undefined,
|
||||
settings: {
|
||||
mode?: AgentFilesystemMode;
|
||||
localRootPaths?: string[] | null;
|
||||
}
|
||||
): Promise<AgentFilesystemSettings> {
|
||||
const store = await loadAgentFilesystemSettingsStore();
|
||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
||||
const current = getSettingsFromStore(store, searchSpaceId);
|
||||
const key = normalizeWorkspaceKey(workspaceId);
|
||||
const current = getSettingsFromStore(store, workspaceId);
|
||||
const nextMode =
|
||||
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
|
||||
? settings.mode
|
||||
|
|
@ -291,7 +291,7 @@ export type LocalRootMount = {
|
|||
|
||||
export type AgentFilesystemListOptions = {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
};
|
||||
|
|
@ -332,9 +332,9 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
|
|||
}
|
||||
|
||||
export async function getAgentFilesystemMounts(
|
||||
searchSpaceId?: number | null
|
||||
workspaceId?: number | null
|
||||
): Promise<LocalRootMount[]> {
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const rootPaths = await resolveCurrentRootPaths(workspaceId);
|
||||
return buildRootMounts(rootPaths);
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +371,7 @@ function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<
|
|||
export async function listAgentFilesystemFiles(
|
||||
options: AgentFilesystemListOptions
|
||||
): Promise<AgentFilesystemFileEntry[]> {
|
||||
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
|
||||
const allowedRootPaths = await resolveCurrentRootPaths(options.workspaceId);
|
||||
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
|
||||
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
|
||||
const allowedRoots = new Set(
|
||||
|
|
@ -474,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
|
|||
return `/${mount}${relativePath}`;
|
||||
}
|
||||
|
||||
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
|
||||
const settings = await getAgentFilesystemSettings(searchSpaceId);
|
||||
async function resolveCurrentRootPaths(workspaceId?: number | null): Promise<string[]> {
|
||||
const settings = await getAgentFilesystemSettings(workspaceId);
|
||||
if (settings.localRootPaths.length === 0) {
|
||||
throw new Error("No local filesystem roots selected");
|
||||
}
|
||||
|
|
@ -484,9 +484,9 @@ async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<s
|
|||
|
||||
export async function readAgentLocalFileText(
|
||||
virtualPath: string,
|
||||
searchSpaceId?: number | null
|
||||
workspaceId?: number | null
|
||||
): Promise<{ path: string; content: string }> {
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const rootPaths = await resolveCurrentRootPaths(workspaceId);
|
||||
const mounts = buildRootMounts(rootPaths);
|
||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||
const rootMount = findMountByName(mounts, mount);
|
||||
|
|
@ -507,9 +507,9 @@ export async function readAgentLocalFileText(
|
|||
export async function writeAgentLocalFileText(
|
||||
virtualPath: string,
|
||||
content: string,
|
||||
searchSpaceId?: number | null
|
||||
workspaceId?: number | null
|
||||
): Promise<{ path: string }> {
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const rootPaths = await resolveCurrentRootPaths(workspaceId);
|
||||
const mounts = buildRootMounts(rootPaths);
|
||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||
const rootMount = findMountByName(mounts, mount);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as path from 'path';
|
|||
import * as fs from 'fs';
|
||||
import { IPC_CHANNELS } from '../ipc/channels';
|
||||
import { trackEvent } from './analytics';
|
||||
import { migrateWatchedFolderConfigs } from './migrate-watched-folders';
|
||||
|
||||
export interface WatchedFolderConfig {
|
||||
path: string;
|
||||
|
|
@ -12,7 +13,7 @@ export interface WatchedFolderConfig {
|
|||
excludePatterns: string[];
|
||||
fileExtensions: string[] | null;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -27,7 +28,7 @@ type FolderSyncAction = 'add' | 'change' | 'unlink';
|
|||
export interface FolderSyncFileChangedEvent {
|
||||
id: string;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
relativePath: string;
|
||||
|
|
@ -68,6 +69,12 @@ async function getStore() {
|
|||
[STORE_KEY]: [] as WatchedFolderConfig[],
|
||||
},
|
||||
});
|
||||
// One-time read-migration: legacy persisted configs stored the workspace as
|
||||
// `searchSpaceId`. Map it to `workspaceId` and write back once so existing
|
||||
// watched folders keep their sync target after the rename.
|
||||
const raw = store.get(STORE_KEY, []) as Array<Record<string, unknown>>;
|
||||
const { configs, migrated } = migrateWatchedFolderConfigs<WatchedFolderConfig>(raw);
|
||||
if (migrated) store.set(STORE_KEY, configs);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
|
@ -267,7 +274,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
|||
if (storedMtime === undefined) {
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
workspaceId: config.workspaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath: rel,
|
||||
|
|
@ -278,7 +285,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
|||
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
workspaceId: config.workspaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath: rel,
|
||||
|
|
@ -295,7 +302,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
|||
if (!(rel in currentMap)) {
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
workspaceId: config.workspaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath: rel,
|
||||
|
|
@ -346,7 +353,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
|||
|
||||
sendFileChangedEvent({
|
||||
rootFolderId: config.rootFolderId,
|
||||
searchSpaceId: config.searchSpaceId,
|
||||
workspaceId: config.workspaceId,
|
||||
folderPath: config.path,
|
||||
folderName: config.name,
|
||||
relativePath,
|
||||
|
|
@ -403,7 +410,7 @@ export async function addWatchedFolder(
|
|||
}
|
||||
|
||||
trackEvent('desktop_folder_watch_added', {
|
||||
search_space_id: config.searchSpaceId,
|
||||
workspace_id: config.workspaceId,
|
||||
root_folder_id: config.rootFolderId,
|
||||
active: config.active,
|
||||
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
|
||||
|
|
@ -431,7 +438,7 @@ export async function removeWatchedFolder(
|
|||
|
||||
if (removed) {
|
||||
trackEvent('desktop_folder_watch_removed', {
|
||||
search_space_id: removed.searchSpaceId,
|
||||
workspace_id: removed.workspaceId,
|
||||
root_folder_id: removed.rootFolderId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { migrateWatchedFolderConfigs } from "./migrate-watched-folders.ts";
|
||||
|
||||
// Run with: node --test src/modules/migrate-watched-folders.test.ts
|
||||
test("maps legacy searchSpaceId to workspaceId and flags migration", () => {
|
||||
const { configs, migrated } = migrateWatchedFolderConfigs<{ workspaceId?: number }>([
|
||||
{ path: "/tmp/a", searchSpaceId: 42 },
|
||||
]);
|
||||
assert.equal(migrated, true);
|
||||
assert.equal(configs[0].workspaceId, 42);
|
||||
assert.equal("searchSpaceId" in (configs[0] as object), false);
|
||||
});
|
||||
|
||||
test("leaves configs that already have workspaceId untouched", () => {
|
||||
const { configs, migrated } = migrateWatchedFolderConfigs<{ workspaceId?: number }>([
|
||||
{ path: "/tmp/b", workspaceId: 5 },
|
||||
]);
|
||||
assert.equal(migrated, false);
|
||||
assert.equal(configs[0].workspaceId, 5);
|
||||
});
|
||||
23
surfsense_desktop/src/modules/migrate-watched-folders.ts
Normal file
23
surfsense_desktop/src/modules/migrate-watched-folders.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* One-time read-migration for persisted watched-folder configs: legacy configs
|
||||
* stored the workspace as `searchSpaceId`. Map it to `workspaceId` so existing
|
||||
* watched folders keep their sync target after the rename. Pure + dependency-free
|
||||
* so it can be unit-checked without loading electron-store.
|
||||
*
|
||||
* Returns the migrated configs and whether anything changed (so callers can
|
||||
* write back only when needed).
|
||||
*/
|
||||
export function migrateWatchedFolderConfigs<T>(
|
||||
raw: Array<Record<string, unknown>>
|
||||
): { configs: T[]; migrated: boolean } {
|
||||
let migrated = false;
|
||||
const configs = raw.map((c) => {
|
||||
if (c.workspaceId === undefined && c.searchSpaceId !== undefined) {
|
||||
migrated = true;
|
||||
const { searchSpaceId, ...rest } = c;
|
||||
return { ...rest, workspaceId: searchSpaceId } as unknown as T;
|
||||
}
|
||||
return c as unknown as T;
|
||||
});
|
||||
return { configs, migrated };
|
||||
}
|
||||
|
|
@ -4,14 +4,14 @@ import { IPC_CHANNELS } from '../ipc/channels';
|
|||
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
|
||||
import { getServerOrigin } from './server';
|
||||
import { getShortcuts } from './shortcuts';
|
||||
import { getActiveSearchSpaceId } from './active-search-space';
|
||||
import { getActiveWorkspaceId } from './active-workspace';
|
||||
import { trackEvent } from './analytics';
|
||||
|
||||
let currentShortcut = '';
|
||||
let quickAskWindow: BrowserWindow | null = null;
|
||||
let pendingText = '';
|
||||
let pendingMode = '';
|
||||
let pendingSearchSpaceId: string | null = null;
|
||||
let pendingWorkspaceId: string | null = null;
|
||||
let sourceApp = '';
|
||||
let savedClipboard = '';
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
|||
skipTaskbar: true,
|
||||
});
|
||||
|
||||
const spaceId = pendingSearchSpaceId;
|
||||
const spaceId = pendingWorkspaceId;
|
||||
const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard';
|
||||
quickAskWindow.loadURL(`${getServerOrigin()}${route}?quickAssist=true`);
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
|||
async function openQuickAsk(text: string): Promise<void> {
|
||||
pendingText = text;
|
||||
pendingMode = 'quick-assist';
|
||||
pendingSearchSpaceId = await getActiveSearchSpaceId();
|
||||
pendingWorkspaceId = await getActiveWorkspaceId();
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||
createQuickAskWindow(pos.x, pos.y);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from 'path';
|
|||
import { trackEvent } from './analytics';
|
||||
import { showErrorDialog } from './errors';
|
||||
import { getServerOrigin, getServerPort } from './server';
|
||||
import { setActiveSearchSpaceId } from './active-search-space';
|
||||
import { setActiveWorkspaceId } from './active-workspace';
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
|
@ -140,14 +140,14 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
|||
});
|
||||
|
||||
// Auto-sync active search space from URL navigation
|
||||
const syncSearchSpace = (url: string) => {
|
||||
const syncWorkspace = (url: string) => {
|
||||
const match = url.match(/\/dashboard\/(\d+)/);
|
||||
if (match) {
|
||||
setActiveSearchSpaceId(match[1]);
|
||||
setActiveWorkspaceId(match[1]);
|
||||
}
|
||||
};
|
||||
mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url));
|
||||
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url));
|
||||
mainWindow.webContents.on('did-navigate', (_event, url) => syncWorkspace(url));
|
||||
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncWorkspace(url));
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
|
|
|||
|
|
@ -74,10 +74,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Browse files via native dialog
|
||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
||||
readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
|
||||
writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
|
||||
readAgentLocalFileText: (virtualPath: string, workspaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, workspaceId),
|
||||
writeAgentLocalFileText: (virtualPath: string, content: string, workspaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, workspaceId),
|
||||
|
||||
// Auth token sync across windows
|
||||
getAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACCESS_TOKEN),
|
||||
|
|
@ -104,9 +104,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }),
|
||||
|
||||
// Active search space
|
||||
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
|
||||
setActiveSearchSpace: (id: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
|
||||
getActiveWorkspace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_WORKSPACE),
|
||||
setActiveWorkspace: (id: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, id),
|
||||
|
||||
// Analytics bridge — lets posthog-js running inside the Next.js renderer
|
||||
// mirror identify/reset/capture into the Electron main-process PostHog
|
||||
|
|
@ -118,27 +118,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
|
||||
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
||||
// Agent filesystem mode
|
||||
getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
|
||||
getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
|
||||
getAgentFilesystemSettings: (workspaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, workspaceId),
|
||||
getAgentFilesystemMounts: (workspaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, workspaceId),
|
||||
listAgentFilesystemFiles: (options: {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
|
||||
startAgentFilesystemTreeWatch: (options: {
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
rootPaths: string[];
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
|
||||
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
|
||||
stopAgentFilesystemTreeWatch: (workspaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, workspaceId),
|
||||
onAgentFilesystemTreeDirty: (
|
||||
callback: (data: {
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
reason: 'watcher_event' | 'safety_poll';
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
|
|
@ -148,7 +148,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
const listener = (
|
||||
_event: unknown,
|
||||
data: {
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
reason: 'watcher_event' | 'safety_poll';
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
|
|
@ -163,7 +163,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
setAgentFilesystemSettings: (settings: {
|
||||
mode?: "cloud" | "desktop_local_folder";
|
||||
localRootPaths?: string[] | null;
|
||||
}, searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
|
||||
}, workspaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { workspaceId, settings }),
|
||||
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@
|
|||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "scripts"]
|
||||
"exclude": ["node_modules", "dist", "scripts", "src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Open **Settings → SurfSense** in Obsidian and fill in:
|
|||
| --- | --- |
|
||||
| Server URL | `https://surfsense.com` for SurfSense Cloud, or your self-hosted URL |
|
||||
| API token | Create a personal access token from the *Connectors → Obsidian* dialog or *User settings → API access* in the SurfSense web app |
|
||||
| Search space | Pick the search space this vault should sync into |
|
||||
| Search space | Pick the workspace this vault should sync into |
|
||||
| Vault name | Defaults to your Obsidian vault name; rename if you have multiple vaults |
|
||||
| Sync mode | *Auto* (recommended) or *Manual* |
|
||||
| Exclude patterns | Glob patterns of folders/files to skip (e.g. `.trash`, `_attachments`, `templates/**`) |
|
||||
|
|
@ -82,7 +82,7 @@ addendum.
|
|||
|
||||
- `vault_id`: a random UUID minted in the plugin's `data.json` on first run
|
||||
- `vault_name`: the Obsidian vault folder name
|
||||
- `search_space_id`: the SurfSense search space you picked
|
||||
- `workspace_id`: the SurfSense workspace you picked
|
||||
|
||||
**Sent per note on `/sync`, `/rename`, `/delete`:**
|
||||
|
||||
|
|
|
|||
3153
surfsense_obsidian/pnpm-lock.yaml
generated
Normal file
3153
surfsense_obsidian/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
2
surfsense_obsidian/pnpm-workspace.yaml
Normal file
2
surfsense_obsidian/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
NotePayload,
|
||||
RenameAck,
|
||||
RenameItem,
|
||||
SearchSpace,
|
||||
Workspace,
|
||||
SyncAck,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -94,14 +94,14 @@ export class SurfSenseApiClient {
|
|||
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
|
||||
}
|
||||
|
||||
async listSearchSpaces(): Promise<SearchSpace[]> {
|
||||
const resp = await this.request<SearchSpace[] | { items: SearchSpace[] }>(
|
||||
async listWorkspaces(): Promise<Workspace[]> {
|
||||
const resp = await this.request<Workspace[] | { items: Workspace[] }>(
|
||||
"GET",
|
||||
"/api/v1/searchspaces/"
|
||||
"/api/v1/workspaces/"
|
||||
);
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (resp && Array.isArray((resp as { items?: SearchSpace[] }).items)) {
|
||||
return (resp as { items: SearchSpace[] }).items;
|
||||
if (resp && Array.isArray((resp as { items?: Workspace[] }).items)) {
|
||||
return (resp as { items: Workspace[] }).items;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ export class SurfSenseApiClient {
|
|||
}
|
||||
|
||||
async connect(input: {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
vaultId: string;
|
||||
vaultName: string;
|
||||
vaultFingerprint: string;
|
||||
|
|
@ -125,7 +125,7 @@ export class SurfSenseApiClient {
|
|||
{
|
||||
vault_id: input.vaultId,
|
||||
vault_name: input.vaultName,
|
||||
search_space_id: input.searchSpaceId,
|
||||
workspace_id: input.workspaceId,
|
||||
vault_fingerprint: input.vaultFingerprint,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export default class SurfSensePlugin extends Plugin {
|
|||
name: "Sync current note",
|
||||
checkCallback: (checking) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file || file.extension.toLowerCase() !== "md") return false;
|
||||
if (file?.extension.toLowerCase() !== "md") return false;
|
||||
if (checking) return true;
|
||||
this.queue.enqueueUpsert(file.path);
|
||||
void this.engine.flushQueue();
|
||||
|
|
@ -186,7 +186,7 @@ export default class SurfSensePlugin extends Plugin {
|
|||
if (!changed) return;
|
||||
this.engine?.refreshStatus();
|
||||
this.notifyStatusChange();
|
||||
if (this.settings.searchSpaceId !== null) {
|
||||
if (this.settings.workspaceId !== null) {
|
||||
void this.engine.ensureConnected();
|
||||
}
|
||||
}
|
||||
|
|
@ -252,16 +252,24 @@ export default class SurfSensePlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings() {
|
||||
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
|
||||
// One-time migration: the workspace was previously persisted as `searchSpaceId`.
|
||||
// Destructure it out so the migrated value moves into `workspaceId` and the
|
||||
// dead key is not spread back in / re-persisted.
|
||||
const data = (await this.loadData()) as
|
||||
| (Partial<SurfsensePluginSettings> & { searchSpaceId?: number | null })
|
||||
| null;
|
||||
const { searchSpaceId: legacyWorkspaceId, ...persisted } = data ?? {};
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(data ?? {}),
|
||||
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
||||
tombstones: { ...(data?.tombstones ?? {}) },
|
||||
includeFolders: [...(data?.includeFolders ?? [])],
|
||||
excludeFolders: [...(data?.excludeFolders ?? [])],
|
||||
excludePatterns: data?.excludePatterns?.length
|
||||
? [...data.excludePatterns]
|
||||
...persisted,
|
||||
workspaceId:
|
||||
persisted.workspaceId ?? legacyWorkspaceId ?? DEFAULT_SETTINGS.workspaceId,
|
||||
queue: (persisted.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
||||
tombstones: { ...(persisted.tombstones ?? {}) },
|
||||
includeFolders: [...(persisted.includeFolders ?? [])],
|
||||
excludeFolders: [...(persisted.excludeFolders ?? [])],
|
||||
excludePatterns: persisted.excludePatterns?.length
|
||||
? [...persisted.excludePatterns]
|
||||
: [...DEFAULT_SETTINGS.excludePatterns],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ import { normalizeFolder, parseExcludePatterns } from "./excludes";
|
|||
import { FolderSuggestModal } from "./folder-suggest-modal";
|
||||
import type SurfSensePlugin from "./main";
|
||||
import { STATUS_VISUALS } from "./status-visuals";
|
||||
import type { SearchSpace } from "./types";
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
/** Plugin settings tab. */
|
||||
|
||||
export class SurfSenseSettingTab extends PluginSettingTab {
|
||||
private readonly plugin: SurfSensePlugin;
|
||||
private searchSpaces: SearchSpace[] = [];
|
||||
private workspaces: Workspace[] = [];
|
||||
private loadingSpaces = false;
|
||||
private connectionIndicator: HTMLElement | null = null;
|
||||
private readonly onStatusChange = (): void => this.updateConnectionIndicator();
|
||||
|
|
@ -51,7 +51,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
const next = value.trim();
|
||||
const previous = this.plugin.settings.serverUrl;
|
||||
if (previous !== "" && next !== previous) {
|
||||
this.plugin.settings.searchSpaceId = null;
|
||||
this.plugin.settings.workspaceId = null;
|
||||
this.plugin.settings.connectorId = null;
|
||||
}
|
||||
this.plugin.settings.serverUrl = next;
|
||||
|
|
@ -80,7 +80,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
const next = value.trim();
|
||||
const previous = this.plugin.settings.apiToken;
|
||||
if (previous !== "" && next !== previous) {
|
||||
this.plugin.settings.searchSpaceId = null;
|
||||
this.plugin.settings.workspaceId = null;
|
||||
this.plugin.settings.connectorId = null;
|
||||
}
|
||||
this.plugin.settings.apiToken = next;
|
||||
|
|
@ -102,7 +102,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
await this.plugin.api.verifyToken();
|
||||
new Notice("Surfsense: token verified.");
|
||||
this.plugin.engine.refreshStatus({ force: true });
|
||||
await this.refreshSearchSpaces();
|
||||
await this.refreshWorkspaces();
|
||||
this.display();
|
||||
} catch (err) {
|
||||
this.handleApiError(err);
|
||||
|
|
@ -115,21 +115,21 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl)
|
||||
.setName("Search space")
|
||||
.setDesc(
|
||||
"Which Surfsense search space this vault syncs into. Reload after changing your token.",
|
||||
"Which Surfsense workspace this vault syncs into. Reload after changing your token.",
|
||||
)
|
||||
.addDropdown((drop) => {
|
||||
drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a search space");
|
||||
for (const space of this.searchSpaces) {
|
||||
drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a workspace");
|
||||
for (const space of this.workspaces) {
|
||||
drop.addOption(String(space.id), space.name);
|
||||
}
|
||||
if (settings.searchSpaceId !== null) {
|
||||
drop.setValue(String(settings.searchSpaceId));
|
||||
if (settings.workspaceId !== null) {
|
||||
drop.setValue(String(settings.workspaceId));
|
||||
}
|
||||
drop.onChange(async (value) => {
|
||||
this.plugin.settings.searchSpaceId = value ? Number(value) : null;
|
||||
this.plugin.settings.workspaceId = value ? Number(value) : null;
|
||||
this.plugin.settings.connectorId = null;
|
||||
await this.plugin.saveSettings();
|
||||
if (this.plugin.settings.searchSpaceId !== null) {
|
||||
if (this.plugin.settings.workspaceId !== null) {
|
||||
try {
|
||||
await this.plugin.engine.ensureConnected();
|
||||
await this.plugin.engine.maybeReconcile(true);
|
||||
|
|
@ -144,9 +144,9 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
.addExtraButton((btn) =>
|
||||
btn
|
||||
.setIcon("refresh-ccw")
|
||||
.setTooltip("Reload search spaces")
|
||||
.setTooltip("Reload workspaces")
|
||||
.onClick(async () => {
|
||||
await this.refreshSearchSpaces();
|
||||
await this.refreshWorkspaces();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
|
@ -319,13 +319,13 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
indicator.setAttr("title", visual.label);
|
||||
}
|
||||
|
||||
private async refreshSearchSpaces(): Promise<void> {
|
||||
private async refreshWorkspaces(): Promise<void> {
|
||||
this.loadingSpaces = true;
|
||||
try {
|
||||
this.searchSpaces = await this.plugin.api.listSearchSpaces();
|
||||
this.workspaces = await this.plugin.api.listWorkspaces();
|
||||
} catch (err) {
|
||||
this.handleApiError(err);
|
||||
this.searchSpaces = [];
|
||||
this.workspaces = [];
|
||||
} finally {
|
||||
this.loadingSpaces = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export interface SyncEngineSettings {
|
|||
vaultId: string;
|
||||
apiToken: string;
|
||||
connectorId: number | null;
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
includeFolders: string[];
|
||||
excludeFolders: string[];
|
||||
excludePatterns: string[];
|
||||
|
|
@ -96,7 +96,7 @@ export class SyncEngine {
|
|||
this.setStatus("syncing", "Connecting to SurfSense…");
|
||||
|
||||
const settings = this.deps.getSettings();
|
||||
if (!settings.searchSpaceId) {
|
||||
if (!settings.workspaceId) {
|
||||
// No target yet — /health still surfaces auth/network errors.
|
||||
try {
|
||||
const health = await this.deps.apiClient.health();
|
||||
|
|
@ -124,7 +124,7 @@ export class SyncEngine {
|
|||
*/
|
||||
async ensureConnected(): Promise<boolean> {
|
||||
const settings = this.deps.getSettings();
|
||||
if (!settings.searchSpaceId) {
|
||||
if (!settings.workspaceId) {
|
||||
this.setStatus("idle");
|
||||
return false;
|
||||
}
|
||||
|
|
@ -132,7 +132,7 @@ export class SyncEngine {
|
|||
try {
|
||||
const fingerprint = await computeVaultFingerprint(this.deps.app);
|
||||
const resp = await this.deps.apiClient.connect({
|
||||
searchSpaceId: settings.searchSpaceId,
|
||||
workspaceId: settings.workspaceId,
|
||||
vaultId: settings.vaultId,
|
||||
vaultName: this.deps.app.vault.getName(),
|
||||
vaultFingerprint: fingerprint,
|
||||
|
|
@ -272,7 +272,7 @@ export class SyncEngine {
|
|||
this.refreshStatus({ force: true });
|
||||
return;
|
||||
}
|
||||
if (!settings.searchSpaceId) {
|
||||
if (!settings.workspaceId) {
|
||||
try {
|
||||
const health = await this.deps.apiClient.health();
|
||||
this.applyHealth(health);
|
||||
|
|
@ -543,7 +543,7 @@ export class SyncEngine {
|
|||
const isError =
|
||||
last === "auth-error" || last === "offline" || last === "error";
|
||||
const s = this.deps.getSettings();
|
||||
const setupComplete = !!(s.apiToken && s.searchSpaceId && s.connectorId);
|
||||
const setupComplete = !!(s.apiToken && s.workspaceId && s.connectorId);
|
||||
if (isError && setupComplete) return;
|
||||
}
|
||||
this.setStatus(this.queueStatusKind(), this.statusDetail());
|
||||
|
|
@ -571,7 +571,7 @@ export class SyncEngine {
|
|||
kind = "needs-setup";
|
||||
detail = this.setupHint(s);
|
||||
} else if (kind !== "auth-error" && kind !== "offline" && kind !== "error") {
|
||||
if (!s.searchSpaceId || !s.connectorId) {
|
||||
if (!s.workspaceId || !s.connectorId) {
|
||||
kind = "needs-setup";
|
||||
detail = this.setupHint(s);
|
||||
}
|
||||
|
|
@ -582,7 +582,7 @@ export class SyncEngine {
|
|||
|
||||
private setupHint(s: SyncEngineSettings): string {
|
||||
if (!s.apiToken) return "Paste your API token in settings.";
|
||||
if (!s.searchSpaceId) return "Pick a search space in settings.";
|
||||
if (!s.workspaceId) return "Pick a workspace in settings.";
|
||||
return "Connecting…";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
export interface SurfsensePluginSettings {
|
||||
serverUrl: string;
|
||||
apiToken: string;
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
connectorId: number | null;
|
||||
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
||||
vaultId: string;
|
||||
|
|
@ -25,7 +25,7 @@ export interface SurfsensePluginSettings {
|
|||
export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
|
||||
serverUrl: "https://surfsense.com",
|
||||
apiToken: "",
|
||||
searchSpaceId: null,
|
||||
workspaceId: null,
|
||||
connectorId: null,
|
||||
vaultId: "",
|
||||
syncIntervalMinutes: 10,
|
||||
|
|
@ -107,7 +107,7 @@ export interface HeadingRef {
|
|||
level: number;
|
||||
}
|
||||
|
||||
export interface SearchSpace {
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
|
@ -117,7 +117,7 @@ export interface SearchSpace {
|
|||
export interface ConnectResponse {
|
||||
connector_id: number;
|
||||
vault_id: string;
|
||||
search_space_id: number;
|
||||
workspace_id: number;
|
||||
capabilities: string[];
|
||||
server_time_utc: string;
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function AutomationDetailContent({
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to view automations in this search space.
|
||||
You don't have permission to view automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function AutomationEditContent({ workspaceId, automationId }: AutomationE
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to edit automations in this search space.
|
||||
You don't have permission to edit automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ interface AutomationsContentProps {
|
|||
|
||||
/**
|
||||
* Client orchestrator for the automations list page. Pulls the active
|
||||
* search space's first page (via ``useAutomations`` → ``automationsListAtom``)
|
||||
* workspace's first page (via ``useAutomations`` → ``automationsListAtom``)
|
||||
* and the user's permissions, then decides between empty / loading / table.
|
||||
*
|
||||
* Read access is mandatory; anything else is hidden behind RBAC. The
|
||||
|
|
@ -46,7 +46,7 @@ export function AutomationsContent({ workspaceId }: AutomationsContentProps) {
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to view automations in this search space.
|
||||
You don't have permission to view automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function AutomationsEmptyState({ workspaceId, canCreate }: AutomationsEmp
|
|||
</div>
|
||||
) : (
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
You don't have permission to create automations in this search space.
|
||||
You don't have permission to create automations in this workspace.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function AutomationBuilderForm({
|
|||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Eligible models + the search-space-seeded defaults. Models are chosen per
|
||||
// Eligible models + the workspace-seeded defaults. Models are chosen per
|
||||
// automation on create; in edit mode the backend preserves the captured
|
||||
// snapshot, so the picker is create-only.
|
||||
const eligibleModels = useAutomationEligibleModels();
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
|
|||
if (mention.kind === "folder") {
|
||||
return { id: mention.id, title: mention.title, kind: "folder" };
|
||||
}
|
||||
if (mention.kind === "thread") {
|
||||
return { id: mention.id, title: mention.title, kind: "thread" };
|
||||
}
|
||||
return {
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function DeleteAutomationDialog({
|
|||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteAutomation({ automationId, workspaceId });
|
||||
await deleteAutomation({ automationId, workspaceId: workspaceId });
|
||||
onDeleted?.();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function AutomationNewContent({ workspaceId }: AutomationNewContentProps)
|
|||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You don't have permission to create automations in this search space.
|
||||
You don't have permission to create automations in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
11
surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx
Normal file
11
surfsense_web/app/dashboard/[workspace_id]/chats/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { AllChatsWorkspaceContent } from "@/components/layout/ui/sidebar";
|
||||
|
||||
export default async function ChatsPage({ params }: { params: Promise<{ workspace_id: string }> }) {
|
||||
const { workspace_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full select-none">
|
||||
<AllChatsWorkspaceContent workspaceId={workspace_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -52,12 +52,12 @@ export function DashboardClientLayout({
|
|||
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
const isSearchSpaceReady = activeWorkspaceId === workspaceId;
|
||||
const isWorkspaceReady = activeWorkspaceId === workspaceId;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchSpaceReady) return;
|
||||
if (isWorkspaceReady) return;
|
||||
setHasCheckedOnboarding(false);
|
||||
}, [isSearchSpaceReady]);
|
||||
}, [isWorkspaceReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnboardingPage) {
|
||||
|
|
@ -66,7 +66,7 @@ export function DashboardClientLayout({
|
|||
}
|
||||
|
||||
if (
|
||||
isSearchSpaceReady &&
|
||||
isWorkspaceReady &&
|
||||
!loading &&
|
||||
!accessLoading &&
|
||||
!globalConfigsLoading &&
|
||||
|
|
@ -75,7 +75,7 @@ export function DashboardClientLayout({
|
|||
!hasCheckedOnboarding
|
||||
) {
|
||||
// Onboarding is only relevant when no operator-provided
|
||||
// global_llm_config.yaml exists. When it does, search spaces inherit
|
||||
// global_llm_config.yaml exists. When it does, workspaces inherit
|
||||
// the global config and should never be forced into onboarding.
|
||||
if (globalConfigStatus?.exists) {
|
||||
setHasCheckedOnboarding(true);
|
||||
|
|
@ -102,7 +102,7 @@ export function DashboardClientLayout({
|
|||
setHasCheckedOnboarding(true);
|
||||
}
|
||||
}, [
|
||||
isSearchSpaceReady,
|
||||
isWorkspaceReady,
|
||||
loading,
|
||||
accessLoading,
|
||||
globalConfigsLoading,
|
||||
|
|
@ -153,13 +153,13 @@ export function DashboardClientLayout({
|
|||
setActiveWorkspaceIdState(activeSeacrhSpaceId);
|
||||
|
||||
// Sync to Electron store if stored value is null (first navigation)
|
||||
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
|
||||
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
|
||||
if (electronAPI?.getActiveWorkspace && electronAPI.setActiveWorkspace) {
|
||||
const setActiveWorkspace = electronAPI.setActiveWorkspace;
|
||||
electronAPI
|
||||
.getActiveSearchSpace()
|
||||
.getActiveWorkspace()
|
||||
.then((stored: string | null) => {
|
||||
if (!stored) {
|
||||
setActiveSearchSpace(activeSeacrhSpaceId);
|
||||
setActiveWorkspace(activeSeacrhSpaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
|
@ -169,7 +169,7 @@ export function DashboardClientLayout({
|
|||
// Determine if we should show loading
|
||||
const shouldShowLoading =
|
||||
!hasCheckedOnboarding &&
|
||||
(!isSearchSpaceReady ||
|
||||
(!isWorkspaceReady ||
|
||||
loading ||
|
||||
accessLoading ||
|
||||
globalConfigsLoading ||
|
||||
|
|
|
|||
|
|
@ -579,7 +579,7 @@ export default function NewChatPage() {
|
|||
error,
|
||||
flow,
|
||||
context: {
|
||||
workspaceId,
|
||||
workspaceId: workspaceId,
|
||||
threadId,
|
||||
},
|
||||
});
|
||||
|
|
@ -717,7 +717,7 @@ export default function NewChatPage() {
|
|||
data: typeof threadMessagesQuery.data;
|
||||
}>({ threadId: null, data: undefined });
|
||||
|
||||
// Reset thread-local runtime state on route/search-space changes. Data fetching
|
||||
// Reset thread-local runtime state on route/workspace changes. Data fetching
|
||||
// is handled by React Query below so the chat shell can render immediately.
|
||||
useEffect(() => {
|
||||
const nextThreadId = urlChatId > 0 ? urlChatId : null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function SearchSpaceDashboardPage({
|
||||
export default async function WorkspaceDashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ import type { Role } from "@/contracts/types/roles.types";
|
|||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -229,7 +229,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
|||
useEffect(() => {
|
||||
if (members.length > 0 && !membersLoading) {
|
||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||
trackSearchSpaceUsersViewed(workspaceId, members.length, ownerCount);
|
||||
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
|
||||
}
|
||||
}, [members, membersLoading, workspaceId]);
|
||||
|
||||
|
|
@ -581,7 +581,7 @@ function MemberRow({
|
|||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove <span className="font-medium">{member.user_email}</span>{" "}
|
||||
from this search space. They will lose access to all resources.
|
||||
from this workspace. They will lose access to all resources.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -654,7 +654,7 @@ function CreateInviteDialog({
|
|||
setCreatedInvite(invite);
|
||||
|
||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
||||
trackSearchSpaceInviteSent(workspaceId, {
|
||||
trackWorkspaceInviteSent(workspaceId, {
|
||||
roleName,
|
||||
hasExpiry: !!expiresAt,
|
||||
hasMaxUses: !!maxUses,
|
||||
|
|
@ -709,7 +709,7 @@ function CreateInviteDialog({
|
|||
Invite Created!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share this link to invite people to your search space.
|
||||
Share this link to invite people to your workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
|
|
@ -748,7 +748,7 @@ function CreateInviteDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Invite Members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a link to invite people to this search space.
|
||||
Create a link to invite people to this workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { AgentStatusContent } from "../components/AgentStatusContent";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentStatusContent />;
|
||||
}
|
||||
|
|
@ -226,9 +226,7 @@ export function AgentPermissionsContent() {
|
|||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
|
||||
);
|
||||
return <p className="text-sm text-muted-foreground">Open a workspace to manage agent rules.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,321 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FlagKey = keyof AgentFeatureFlags;
|
||||
|
||||
interface FlagDef {
|
||||
key: FlagKey;
|
||||
label: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
}
|
||||
|
||||
interface FlagGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
flags: FlagDef[];
|
||||
}
|
||||
|
||||
const FLAG_GROUPS: FlagGroup[] = [
|
||||
{
|
||||
id: "tier1",
|
||||
title: "Tier 1 — Agent quality",
|
||||
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_context_editing",
|
||||
label: "Context editing",
|
||||
description: "Trim tool outputs and spill old text into backend storage.",
|
||||
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
|
||||
},
|
||||
{
|
||||
key: "enable_compaction_v2",
|
||||
label: "Compaction v2",
|
||||
description: "SurfSense-aware compaction replacing safe summarization.",
|
||||
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
|
||||
},
|
||||
{
|
||||
key: "enable_retry_after",
|
||||
label: "Retry-After",
|
||||
description: "Honour rate-limit retry-after headers automatically.",
|
||||
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
|
||||
},
|
||||
{
|
||||
key: "enable_model_fallback",
|
||||
label: "Model fallback",
|
||||
description: "Fail over to a backup model on persistent errors.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
|
||||
},
|
||||
{
|
||||
key: "enable_model_call_limit",
|
||||
label: "Model call limit",
|
||||
description: "Cap total model calls per turn to prevent budget run-aways.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_limit",
|
||||
label: "Tool call limit",
|
||||
description: "Cap total tool calls per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_repair",
|
||||
label: "Tool-call name repair",
|
||||
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
|
||||
},
|
||||
{
|
||||
key: "enable_doom_loop",
|
||||
label: "Doom-loop detection",
|
||||
description: "Detect repeated identical tool calls and ask the user to confirm.",
|
||||
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier2",
|
||||
title: "Tier 2 — Safety",
|
||||
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_permission",
|
||||
label: "Permission middleware",
|
||||
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
|
||||
envVar: "SURFSENSE_ENABLE_PERMISSION",
|
||||
},
|
||||
{
|
||||
key: "enable_busy_mutex",
|
||||
label: "Busy mutex",
|
||||
description: "Prevent two concurrent runs from corrupting the same thread.",
|
||||
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
|
||||
},
|
||||
{
|
||||
key: "enable_llm_tool_selector",
|
||||
label: "LLM tool selector",
|
||||
description: "Use a smaller model to pre-filter the tool list per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier4",
|
||||
title: "Tier 4 — Skills + subagents",
|
||||
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_skills",
|
||||
label: "Skills",
|
||||
description: "Load on-demand skill packs (kb-research, report-writing, …).",
|
||||
envVar: "SURFSENSE_ENABLE_SKILLS",
|
||||
},
|
||||
{
|
||||
key: "enable_specialized_subagents",
|
||||
label: "Specialized subagents",
|
||||
description: "Spin up explore / report_writer / connector_negotiator subagents.",
|
||||
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier5",
|
||||
title: "Tier 5 — Audit + revert",
|
||||
subtitle: "Action log + revert route used by the Agent Actions dialog.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_action_log",
|
||||
label: "Action log",
|
||||
description: "Persist every tool call to agent_action_log.",
|
||||
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
|
||||
},
|
||||
{
|
||||
key: "enable_revert_route",
|
||||
label: "Revert route",
|
||||
description: "Allow reverting reversible actions from the action log.",
|
||||
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier6",
|
||||
title: "Tier 6 — Plugins",
|
||||
subtitle: "Optional middleware loaded from entry points.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_plugin_loader",
|
||||
label: "Plugin loader",
|
||||
description: "Load surfsense.plugins entry-point middleware.",
|
||||
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "obs",
|
||||
title: "Observability",
|
||||
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_otel",
|
||||
label: "OpenTelemetry",
|
||||
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
|
||||
envVar: "SURFSENSE_ENABLE_OTEL",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "desktop",
|
||||
title: "Desktop",
|
||||
subtitle: "Desktop-only capabilities exposed by the backend deployment.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_desktop_local_filesystem",
|
||||
label: "Local filesystem",
|
||||
description: "Allow Desktop chat sessions to operate directly on selected local folders.",
|
||||
envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{def.envVar}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{def.description}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={value ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"shrink-0 gap-1",
|
||||
value
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
|
||||
{value ? "On" : "Off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStatusContent() {
|
||||
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
|
||||
|
||||
const enabledCount = useMemo(() => {
|
||||
if (!flags) return 0;
|
||||
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
|
||||
.length;
|
||||
}, [flags]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !flags) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load agent status</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const masterOff = flags.disable_new_agent_stack;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{masterOff ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Master kill-switch is on</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Showing that{" "}
|
||||
<code className="rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||
</code>
|
||||
, which forces every new middleware off, regardless of the individual flags below.
|
||||
Restart the backend after changing it.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
Agent stack
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
|
||||
>
|
||||
{enabledCount} on
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
|
||||
env var and restart the backend to change a value.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{FLAG_GROUPS.map((group, groupIdx) => {
|
||||
const allOff = group.flags.every((f) => !flags[f.key]);
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{groupIdx > 0 && <Separator className="my-4 bg-border" />}
|
||||
<div className="rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{group.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
||||
</div>
|
||||
{allOff && (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
all off
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="bg-border" />
|
||||
<div className="px-4">
|
||||
{group.flags.map((def, flagIdx) => (
|
||||
<Fragment key={def.key}>
|
||||
{flagIdx > 0 && <Separator className="bg-border" />}
|
||||
<FlagRow def={def} value={flags[def.key]} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,15 +13,15 @@ import {
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSpace } from "@/contracts/types/workspace.types";
|
||||
import type { Workspace } from "@/contracts/types/workspace.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
|
||||
export function DesktopContent() {
|
||||
const api = useElectronAPI();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
|
||||
|
|
@ -40,14 +40,14 @@ export function DesktopContent() {
|
|||
setAutoLaunchSupported(hasAutoLaunchApi);
|
||||
|
||||
Promise.all([
|
||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
api.getActiveWorkspace?.() ?? Promise.resolve(null),
|
||||
workspacesApiService.getWorkspaces(),
|
||||
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
||||
])
|
||||
.then(([spaceId, spaces, autoLaunch]) => {
|
||||
if (!mounted) return;
|
||||
setActiveSpaceId(spaceId);
|
||||
if (spaces) setSearchSpaces(spaces);
|
||||
if (spaces) setWorkspaces(spaces);
|
||||
if (autoLaunch) {
|
||||
setAutoLaunchEnabled(autoLaunch.enabled);
|
||||
setAutoLaunchHidden(autoLaunch.openAsHidden);
|
||||
|
|
@ -136,30 +136,30 @@ export function DesktopContent() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSearchSpaceChange = (value: string) => {
|
||||
const handleWorkspaceChange = (value: string) => {
|
||||
setActiveSpaceId(value);
|
||||
api.setActiveSearchSpace?.(value);
|
||||
toast.success("Default search space updated");
|
||||
api.setActiveWorkspace?.(value);
|
||||
toast.success("Default workspace updated");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<section>
|
||||
<div className="pb-2 md:pb-3">
|
||||
<h2 className="text-base md:text-lg font-semibold">Default Search Space</h2>
|
||||
<h2 className="text-base md:text-lg font-semibold">Default Workspace</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
|
||||
Choose which workspace General Assist, Screenshot Assist, and Quick Assist use by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{searchSpaces.length > 0 ? (
|
||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
||||
{workspaces.length > 0 ? (
|
||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleWorkspaceChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a search space" />
|
||||
<SelectValue placeholder="Select a workspace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchSpaces.map((space) => (
|
||||
{workspaces.map((space) => (
|
||||
<SelectItem key={space.id} value={String(space.id)}>
|
||||
{space.name}
|
||||
</SelectItem>
|
||||
|
|
@ -167,9 +167,7 @@ export function DesktopContent() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No search spaces found. Create one first.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">No workspaces found. Create one first.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { SearchSpace } from "@/contracts/types/workspace.types";
|
||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import type { Workspace } from "@/contracts/types/workspace.types";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -79,7 +79,7 @@ export function MessagingChannelsContent() {
|
|||
const workspaceId = Number(params.workspace_id);
|
||||
const [gatewayConfig, setGatewayConfig] = useState<GatewayConfigState>(null);
|
||||
const [connections, setConnections] = useState<GatewayConnection[]>([]);
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [pairing, setPairing] = useState<Pairing | null>(null);
|
||||
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
||||
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
||||
|
|
@ -114,11 +114,11 @@ export function MessagingChannelsContent() {
|
|||
const refresh = useCallback(async () => {
|
||||
const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
|
||||
fetchConnections(),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
workspacesApiService.getWorkspaces(),
|
||||
fetchGatewayConfig(),
|
||||
]);
|
||||
setConnections(nextConnections);
|
||||
setSearchSpaces(spaces);
|
||||
setWorkspaces(spaces);
|
||||
setGatewayConfig(nextGatewayConfig);
|
||||
}, [fetchConnections, fetchGatewayConfig]);
|
||||
|
||||
|
|
@ -210,10 +210,7 @@ export function MessagingChannelsContent() {
|
|||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
async function updateConnectionSearchSpace(
|
||||
connection: GatewayConnection,
|
||||
nextWorkspaceId: string
|
||||
) {
|
||||
async function updateConnectionWorkspace(connection: GatewayConnection, nextWorkspaceId: string) {
|
||||
const previousConnections = connections;
|
||||
const parsedWorkspaceId = Number(nextWorkspaceId);
|
||||
const targetKey = connectionKey(connection);
|
||||
|
|
@ -324,14 +321,14 @@ export function MessagingChannelsContent() {
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={String(connection.workspace_id)}
|
||||
onValueChange={(value) => updateConnectionSearchSpace(connection, value)}
|
||||
disabled={searchSpaces.length === 0}
|
||||
onValueChange={(value) => updateConnectionWorkspace(connection, value)}
|
||||
disabled={workspaces.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
|
||||
<SelectValue placeholder="Select search space" />
|
||||
<SelectValue placeholder="Select workspace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchSpaces.map((space) => (
|
||||
{workspaces.map((space) => (
|
||||
<SelectItem key={space.id} value={String(space.id)}>
|
||||
{space.name}
|
||||
</SelectItem>
|
||||
|
|
@ -409,7 +406,7 @@ export function MessagingChannelsContent() {
|
|||
<AlertDescription>
|
||||
<p>
|
||||
Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
|
||||
SurfSense agent so you can ask questions, route messages to search spaces, and get
|
||||
SurfSense agent so you can ask questions, route messages to workspaces, and get
|
||||
answers from your knowledge base without leaving your chat app.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
ReceiptText,
|
||||
ShieldCheck,
|
||||
WandSparkles,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
|
|
@ -27,7 +26,6 @@ export type UserSettingsTab =
|
|||
| "prompts"
|
||||
| "community-prompts"
|
||||
| "agent-permissions"
|
||||
| "agent-status"
|
||||
| "purchases"
|
||||
| "desktop"
|
||||
| "hotkeys"
|
||||
|
|
@ -80,11 +78,6 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
|||
label: "Agent Permissions",
|
||||
icon: <ShieldCheck className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-status" as const,
|
||||
label: "Agent Status",
|
||||
icon: <Workflow className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "messaging-channels" as const,
|
||||
label: "Messaging Channels",
|
||||
|
|
@ -122,7 +115,7 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
|||
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-10 md:flex-row">
|
||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
|
||||
<div className="md:w-[220px] md:shrink-0">
|
||||
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<nav className="hidden flex-col gap-0.5 md:flex">
|
||||
|
|
|
|||
|
|
@ -9,25 +9,20 @@ import { useCallback, useMemo, useState } from "react";
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SearchSpaceSettingsTab =
|
||||
| "general"
|
||||
| "models"
|
||||
| "team-roles"
|
||||
| "prompts"
|
||||
| "public-links";
|
||||
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
|
||||
|
||||
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
|
||||
const DEFAULT_TAB: WorkspaceSettingsTab = "general";
|
||||
|
||||
interface SearchSpaceSettingsLayoutShellProps {
|
||||
interface WorkspaceSettingsLayoutShellProps {
|
||||
workspaceId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SearchSpaceSettingsLayoutShell({
|
||||
export function WorkspaceSettingsLayoutShell({
|
||||
workspaceId,
|
||||
children,
|
||||
}: SearchSpaceSettingsLayoutShellProps) {
|
||||
const t = useTranslations("searchSpaceSettings");
|
||||
}: WorkspaceSettingsLayoutShellProps) {
|
||||
const t = useTranslations("workspaceSettings");
|
||||
const segment = useSelectedLayoutSegment();
|
||||
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||
|
||||
|
|
@ -69,17 +64,17 @@ export function SearchSpaceSettingsLayoutShell({
|
|||
[t]
|
||||
);
|
||||
|
||||
const activeTab: SearchSpaceSettingsTab =
|
||||
const activeTab: WorkspaceSettingsTab =
|
||||
segment && navItems.some((item) => item.value === segment)
|
||||
? (segment as SearchSpaceSettingsTab)
|
||||
? (segment as WorkspaceSettingsTab)
|
||||
: DEFAULT_TAB;
|
||||
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
||||
|
||||
const hrefFor = (tab: SearchSpaceSettingsTab) =>
|
||||
const hrefFor = (tab: WorkspaceSettingsTab) =>
|
||||
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-6 md:flex-row">
|
||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
|
||||
<div className="md:w-[220px] md:shrink-0">
|
||||
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<nav className="hidden flex-col gap-0.5 md:flex">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type React from "react";
|
||||
import { use } from "react";
|
||||
import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
|
||||
import { WorkspaceSettingsLayoutShell } from "./layout-shell";
|
||||
|
||||
export default function SearchSpaceSettingsLayout({
|
||||
export default function WorkspaceSettingsLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -12,8 +12,8 @@ export default function SearchSpaceSettingsLayout({
|
|||
const { workspace_id } = use(params);
|
||||
|
||||
return (
|
||||
<SearchSpaceSettingsLayoutShell workspaceId={workspace_id}>
|
||||
<WorkspaceSettingsLayoutShell workspaceId={workspace_id}>
|
||||
{children}
|
||||
</SearchSpaceSettingsLayoutShell>
|
||||
</WorkspaceSettingsLayoutShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function SearchSpaceSettingsPage({
|
||||
export default async function WorkspaceSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { motion } from "motion/react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { searchSpacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { CreateSearchSpaceDialog } from "@/components/layout";
|
||||
import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { CreateWorkspaceDialog } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
|
@ -42,7 +42,7 @@ function ErrorScreen({ message }: { message: string }) {
|
|||
}
|
||||
|
||||
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
||||
const t = useTranslations("searchSpace");
|
||||
const t = useTranslations("workspace");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
||||
|
|
@ -74,26 +74,26 @@ export default function DashboardPage() {
|
|||
const router = useRouter();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
||||
const { data: workspaces = [], isLoading, error } = useAtomValue(workspacesAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
if (searchSpaces.length > 0) {
|
||||
if (workspaces.length > 0) {
|
||||
// Read the query string at the time of redirect — no subscription needed.
|
||||
// (Vercel Best Practice: rerender-defer-reads 5.2)
|
||||
const query = window.location.search;
|
||||
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`);
|
||||
router.replace(`/dashboard/${workspaces[0].id}/new-chat${query}`);
|
||||
}
|
||||
}, [isLoading, searchSpaces, router]);
|
||||
}, [isLoading, workspaces, router]);
|
||||
|
||||
// Show loading while fetching or while we have spaces and are about to redirect
|
||||
const shouldShowLoading = isLoading || searchSpaces.length > 0;
|
||||
const shouldShowLoading = isLoading || workspaces.length > 0;
|
||||
|
||||
// Use global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(shouldShowLoading);
|
||||
|
||||
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
|
||||
if (error) return <ErrorScreen message={error?.message || "Failed to load workspaces"} />;
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return null;
|
||||
|
|
@ -102,7 +102,7 @@ export default function DashboardPage() {
|
|||
return (
|
||||
<>
|
||||
<EmptyState onCreateClick={() => setShowCreateDialog(true)} />
|
||||
<CreateSearchSpaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
|
||||
<CreateWorkspaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
|
||||
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||
|
|
@ -239,7 +239,7 @@ export default function DesktopLoginPage() {
|
|||
setIsGoogleRedirecting(true);
|
||||
try {
|
||||
await api?.startGoogleOAuth?.();
|
||||
await autoSetSearchSpace();
|
||||
await autoSetWorkspace();
|
||||
router.push(getPostLoginRedirectPath());
|
||||
} catch (error) {
|
||||
setIsGoogleRedirecting(false);
|
||||
|
|
@ -247,13 +247,13 @@ export default function DesktopLoginPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const autoSetSearchSpace = async () => {
|
||||
const autoSetWorkspace = async () => {
|
||||
try {
|
||||
const stored = await api?.getActiveSearchSpace?.();
|
||||
const stored = await api?.getActiveWorkspace?.();
|
||||
if (stored) return;
|
||||
const spaces = await searchSpacesApiService.getSearchSpaces();
|
||||
const spaces = await workspacesApiService.getWorkspaces();
|
||||
if (spaces?.length) {
|
||||
await api?.setActiveSearchSpace?.(String(spaces[0].id));
|
||||
await api?.setActiveWorkspace?.(String(spaces[0].id));
|
||||
}
|
||||
} catch {
|
||||
// non-critical — dashboard-sync will catch it later
|
||||
|
|
@ -272,7 +272,7 @@ export default function DesktopLoginPage() {
|
|||
}
|
||||
await api.loginPassword(email, password);
|
||||
|
||||
await autoSetSearchSpace();
|
||||
await autoSetWorkspace();
|
||||
|
||||
setTimeout(() => {
|
||||
router.push(getPostLoginRedirectPath());
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ import { useSession } from "@/hooks/use-session";
|
|||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { setRedirectPath } from "@/lib/auth-utils";
|
||||
import {
|
||||
trackSearchSpaceInviteAccepted,
|
||||
trackSearchSpaceInviteDeclined,
|
||||
trackSearchSpaceUserAdded,
|
||||
trackWorkspaceInviteAccepted,
|
||||
trackWorkspaceInviteDeclined,
|
||||
trackWorkspaceUserAdded,
|
||||
} from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
|
|
@ -97,12 +97,8 @@ export default function InviteAcceptPage() {
|
|||
setAcceptedData(result);
|
||||
|
||||
// Track invite accepted and user added events
|
||||
trackSearchSpaceInviteAccepted(
|
||||
result.workspace_id,
|
||||
result.workspace_name,
|
||||
result.role_name
|
||||
);
|
||||
trackSearchSpaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
||||
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
|
||||
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
|
|
@ -113,7 +109,7 @@ export default function InviteAcceptPage() {
|
|||
|
||||
const handleDecline = () => {
|
||||
// Track invite declined event
|
||||
trackSearchSpaceInviteDeclined(inviteInfo?.workspace_name);
|
||||
trackWorkspaceInviteDeclined(inviteInfo?.workspace_name);
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
|
|
@ -187,7 +183,7 @@ export default function InviteAcceptPage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{acceptedData.workspace_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
<p className="text-sm text-muted-foreground">Workspace</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -206,7 +202,7 @@ export default function InviteAcceptPage() {
|
|||
className="w-full gap-2"
|
||||
onClick={() => router.push(`/dashboard/${acceptedData.workspace_id}`)}
|
||||
>
|
||||
Go to Search Space
|
||||
Go to Workspace
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -256,7 +252,7 @@ export default function InviteAcceptPage() {
|
|||
</motion.div>
|
||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to join {inviteInfo?.workspace_name || "this search space"}
|
||||
Sign in to join {inviteInfo?.workspace_name || "this workspace"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -267,7 +263,7 @@ export default function InviteAcceptPage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo?.workspace_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
<p className="text-sm text-muted-foreground">Workspace</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteInfo?.role_name && (
|
||||
|
|
@ -303,7 +299,7 @@ export default function InviteAcceptPage() {
|
|||
</motion.div>
|
||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||
<CardDescription>
|
||||
Accept this invite to join {inviteInfo?.workspace_name || "this search space"}
|
||||
Accept this invite to join {inviteInfo?.workspace_name || "this workspace"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -314,7 +310,7 @@ export default function InviteAcceptPage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo?.workspace_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
<p className="text-sm text-muted-foreground">Workspace</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteInfo?.role_name && (
|
||||
|
|
|
|||
|
|
@ -12,78 +12,78 @@ export const agentToolsAtom = atomWithQuery((_get) => ({
|
|||
|
||||
const STORAGE_PREFIX = "surfsense-disabled-tools-";
|
||||
|
||||
function loadDisabledTools(searchSpaceId: string): string[] {
|
||||
function loadDisabledTools(workspaceId: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${searchSpaceId}`);
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${workspaceId}`);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDisabledTools(searchSpaceId: string, tools: string[]) {
|
||||
function saveDisabledTools(workspaceId: string, tools: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (tools.length === 0) {
|
||||
localStorage.removeItem(`${STORAGE_PREFIX}${searchSpaceId}`);
|
||||
localStorage.removeItem(`${STORAGE_PREFIX}${workspaceId}`);
|
||||
} else {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${searchSpaceId}`, JSON.stringify(tools));
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${workspaceId}`, JSON.stringify(tools));
|
||||
}
|
||||
}
|
||||
|
||||
const disabledToolsBaseAtom = atom<string[]>([]);
|
||||
|
||||
/** Tracks whether the atom has been hydrated from localStorage for the current search space */
|
||||
/** Tracks whether the atom has been hydrated from localStorage for the current workspace */
|
||||
const hydratedForAtom = atom<string | null>(null);
|
||||
|
||||
/**
|
||||
* Read/write atom for the set of disabled tool names.
|
||||
* Persists to localStorage keyed by search space ID.
|
||||
* Persists to localStorage keyed by workspace ID.
|
||||
*/
|
||||
export const disabledToolsAtom = atom(
|
||||
(get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const hydratedFor = get(hydratedForAtom);
|
||||
if (searchSpaceId && hydratedFor !== searchSpaceId) {
|
||||
return loadDisabledTools(searchSpaceId);
|
||||
if (workspaceId && hydratedFor !== workspaceId) {
|
||||
return loadDisabledTools(workspaceId);
|
||||
}
|
||||
return get(disabledToolsBaseAtom);
|
||||
},
|
||||
(get, set, update: string[] | ((prev: string[]) => string[])) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const prev = get(disabledToolsBaseAtom);
|
||||
const next = typeof update === "function" ? update(prev) : update;
|
||||
set(disabledToolsBaseAtom, next);
|
||||
set(hydratedForAtom, searchSpaceId);
|
||||
if (searchSpaceId) {
|
||||
saveDisabledTools(searchSpaceId, next);
|
||||
set(hydratedForAtom, workspaceId);
|
||||
if (workspaceId) {
|
||||
saveDisabledTools(workspaceId, next);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Hydrate disabled tools from localStorage when search space changes.
|
||||
* Call this from a useEffect in a component that has access to the search space.
|
||||
* Hydrate disabled tools from localStorage when workspace changes.
|
||||
* Call this from a useEffect in a component that has access to the workspace.
|
||||
*/
|
||||
export const hydrateDisabledToolsAtom = atom(null, (get, set) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
if (!searchSpaceId) return;
|
||||
const stored = loadDisabledTools(searchSpaceId);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
if (!workspaceId) return;
|
||||
const stored = loadDisabledTools(workspaceId);
|
||||
set(disabledToolsBaseAtom, stored);
|
||||
set(hydratedForAtom, searchSpaceId);
|
||||
set(hydratedForAtom, workspaceId);
|
||||
});
|
||||
|
||||
/** Toggle a single tool's enabled/disabled state */
|
||||
export const toggleToolAtom = atom(null, (get, set, toolName: string) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const current = get(disabledToolsBaseAtom);
|
||||
const next = current.includes(toolName)
|
||||
? current.filter((t) => t !== toolName)
|
||||
: [...current, toolName];
|
||||
set(disabledToolsBaseAtom, next);
|
||||
set(hydratedForAtom, searchSpaceId);
|
||||
if (searchSpaceId) {
|
||||
saveDisabledTools(searchSpaceId, next);
|
||||
set(hydratedForAtom, workspaceId);
|
||||
if (workspaceId) {
|
||||
saveDisabledTools(workspaceId, next);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,15 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
// Cache invalidation strategy:
|
||||
// - Automation writes invalidate the search-space list + the touched detail.
|
||||
// - Automation writes invalidate the workspace list + the touched detail.
|
||||
// - Trigger writes only invalidate the parent automation detail (triggers
|
||||
// come back inline in AutomationDetail).
|
||||
// We deliberately invalidate the whole "automations" prefix on the list side
|
||||
// because list is keyed by (searchSpaceId, limit, offset) and we don't track
|
||||
// because list is keyed by (workspaceId, limit, offset) and we don't track
|
||||
// the active pagination in this layer.
|
||||
|
||||
function invalidateList(searchSpaceId: number) {
|
||||
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
|
||||
function invalidateList(workspaceId: number) {
|
||||
queryClient.invalidateQueries({ queryKey: ["automations", "list", workspaceId] });
|
||||
}
|
||||
|
||||
function invalidateDetail(automationId: number) {
|
||||
|
|
@ -113,17 +113,17 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
|||
|
||||
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
|
||||
mutationFn: async (vars: { automationId: number; workspaceId: number }) => {
|
||||
await automationsApiService.deleteAutomation(vars.automationId);
|
||||
return vars;
|
||||
},
|
||||
onSuccess: (vars) => {
|
||||
invalidateList(vars.searchSpaceId);
|
||||
invalidateList(vars.workspaceId);
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Automation deleted");
|
||||
trackAutomationDeleted({
|
||||
automation_id: vars.automationId,
|
||||
workspace_id: vars.searchSpaceId,
|
||||
workspace_id: vars.workspaceId,
|
||||
});
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms"
|
|||
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
// First page of the active search space's automations.
|
||||
// First page of the active workspace's automations.
|
||||
// Detail + paginated/parameterized reads live in hooks (see use-automation.ts,
|
||||
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
|
||||
// proliferate atom families for every (id, limit, offset) tuple.
|
||||
|
|
@ -11,18 +11,18 @@ const DEFAULT_LIMIT = 50;
|
|||
const DEFAULT_OFFSET = 0;
|
||||
|
||||
export const automationsListAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.automations.list(Number(workspaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
if (!workspaceId) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
return automationsApiService.listAutomations({
|
||||
workspace_id: Number(searchSpaceId),
|
||||
workspace_id: Number(workspaceId),
|
||||
limit: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { reportPanelAtom } from "./report-panel.atom";
|
|||
|
||||
interface CurrentThreadState {
|
||||
id: number | null;
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
visibility: ChatVisibility | null;
|
||||
hasComments: boolean;
|
||||
}
|
||||
|
||||
interface CurrentThreadMetadataPatch {
|
||||
id: number | null;
|
||||
searchSpaceId?: number | null;
|
||||
workspaceId?: number | null;
|
||||
visibility?: ChatVisibility | null;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ interface CurrentThreadMetadataUpdate {
|
|||
|
||||
const initialState: CurrentThreadState = {
|
||||
id: null,
|
||||
searchSpaceId: null,
|
||||
workspaceId: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
};
|
||||
|
|
@ -44,11 +44,11 @@ export const setCurrentThreadMetadataAtom = atom(
|
|||
set(currentThreadAtom, {
|
||||
...current,
|
||||
id: metadata.id,
|
||||
searchSpaceId:
|
||||
"searchSpaceId" in metadata
|
||||
? (metadata.searchSpaceId ?? null)
|
||||
workspaceId:
|
||||
"workspaceId" in metadata
|
||||
? (metadata.workspaceId ?? null)
|
||||
: isSameThread
|
||||
? current.searchSpaceId
|
||||
? current.workspaceId
|
||||
: null,
|
||||
visibility:
|
||||
"visibility" in metadata
|
||||
|
|
|
|||
|
|
@ -13,38 +13,38 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
||||
|
||||
export const createConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: CreateConnectorRequest) => {
|
||||
return connectorsApiService.createConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId),
|
||||
queryKey: cacheKeys.connectors.all(workspaceId),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: UpdateConnectorRequest) => {
|
||||
return connectorsApiService.updateConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: UpdateConnectorRequest) => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId),
|
||||
queryKey: cacheKeys.connectors.all(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.byId(String(request.id)),
|
||||
|
|
@ -54,19 +54,19 @@ export const updateConnectorMutationAtom = atomWithMutation((get) => {
|
|||
});
|
||||
|
||||
export const deleteConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: DeleteConnectorRequest) => {
|
||||
return connectorsApiService.deleteConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: DeleteConnectorRequest) => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.connectors.all(searchSpaceId),
|
||||
cacheKeys.connectors.all(workspaceId),
|
||||
(oldData: GetConnectorsResponse | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((connector) => connector.id !== request.id);
|
||||
|
|
@ -80,19 +80,19 @@ export const deleteConnectorMutationAtom = atomWithMutation((get) => {
|
|||
});
|
||||
|
||||
export const indexConnectorMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.connectors.index(),
|
||||
enabled: !!searchSpaceId,
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: IndexConnectorRequest) => {
|
||||
return connectorsApiService.indexConnector(request);
|
||||
},
|
||||
|
||||
onSuccess: (response: IndexConnectorResponse) => {
|
||||
if (!searchSpaceId) return;
|
||||
if (!workspaceId) return;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId),
|
||||
queryKey: cacheKeys.connectors.all(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.connectors.byId(String(response.connector_id)),
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
||||
|
||||
export const connectorsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId!),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.connectors.all(workspaceId!),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
return connectorsApiService.getConnectors({
|
||||
queryParams: {
|
||||
workspace_id: searchSpaceId!,
|
||||
workspace_id: workspaceId!,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
import { globalDocumentsQueryParamsAtom } from "./ui.atoms";
|
||||
|
||||
export const createDocumentMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||
enabled: !!searchSpaceId,
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: CreateDocumentRequest) => {
|
||||
return documentsApiService.createDocument(request);
|
||||
},
|
||||
|
|
@ -34,12 +34,12 @@ export const createDocumentMutationAtom = atomWithMutation((get) => {
|
|||
});
|
||||
|
||||
export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||
enabled: !!searchSpaceId,
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: UploadDocumentRequest) => {
|
||||
return documentsApiService.uploadDocument(request);
|
||||
},
|
||||
|
|
@ -47,19 +47,19 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
|||
onSuccess: () => {
|
||||
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
||||
queryKey: cacheKeys.logs.summary(workspaceId ?? undefined),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateDocumentMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||
enabled: !!searchSpaceId,
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: UpdateDocumentRequest) => {
|
||||
return documentsApiService.updateDocument(request);
|
||||
},
|
||||
|
|
@ -77,12 +77,12 @@ export const updateDocumentMutationAtom = atomWithMutation((get) => {
|
|||
});
|
||||
|
||||
export const deleteDocumentMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||
enabled: !!searchSpaceId,
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: DeleteDocumentRequest) => {
|
||||
return documentsApiService.deleteDocument(request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { atom } from "jotai";
|
|||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
/**
|
||||
* Set of folder IDs that are currently expanded in the tree, keyed by search space ID.
|
||||
* Set of folder IDs that are currently expanded in the tree, keyed by workspace ID.
|
||||
* Persisted to localStorage so expand/collapse state survives page refreshes.
|
||||
*/
|
||||
export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
|
||||
|
|
@ -13,7 +13,7 @@ export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
|
|||
);
|
||||
|
||||
/**
|
||||
* Expanded folder keys for Local filesystem tree, keyed by search space ID.
|
||||
* Expanded folder keys for Local filesystem tree, keyed by workspace ID.
|
||||
* Persisted so local tree expansion survives remounts/reloads.
|
||||
*/
|
||||
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
|
|||
page: 0,
|
||||
});
|
||||
|
||||
export const documentsSidebarOpenAtom = atom(false);
|
||||
|
||||
export interface AgentCreatedDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
documentType: string;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
folderId: number | null;
|
||||
createdById: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ interface EditorPanelState {
|
|||
kind: "document" | "local_file" | "memory";
|
||||
documentId: number | null;
|
||||
localFilePath: string | null;
|
||||
searchSpaceId: number | null;
|
||||
workspaceId: number | null;
|
||||
memoryScope: "user" | "team" | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ const initialState: EditorPanelState = {
|
|||
kind: "document",
|
||||
documentId: null,
|
||||
localFilePath: null,
|
||||
searchSpaceId: null,
|
||||
workspaceId: null,
|
||||
memoryScope: null,
|
||||
title: null,
|
||||
};
|
||||
|
|
@ -33,18 +33,18 @@ export const openEditorPanelAtom = atom(
|
|||
get,
|
||||
set,
|
||||
payload:
|
||||
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
|
||||
| { documentId: number; workspaceId: number; title?: string; kind?: "document" }
|
||||
| {
|
||||
kind: "local_file";
|
||||
localFilePath: string;
|
||||
title?: string;
|
||||
searchSpaceId?: number;
|
||||
workspaceId?: number;
|
||||
}
|
||||
| {
|
||||
kind: "memory";
|
||||
memoryScope: "user" | "team";
|
||||
title?: string;
|
||||
searchSpaceId?: number;
|
||||
workspaceId?: number;
|
||||
}
|
||||
) => {
|
||||
if (!get(editorPanelAtom).isOpen) {
|
||||
|
|
@ -56,7 +56,7 @@ export const openEditorPanelAtom = atom(
|
|||
kind: "local_file",
|
||||
documentId: null,
|
||||
localFilePath: payload.localFilePath,
|
||||
searchSpaceId: payload.searchSpaceId ?? null,
|
||||
workspaceId: payload.workspaceId ?? null,
|
||||
memoryScope: null,
|
||||
title: payload.title ?? null,
|
||||
});
|
||||
|
|
@ -70,7 +70,7 @@ export const openEditorPanelAtom = atom(
|
|||
kind: "memory",
|
||||
documentId: null,
|
||||
localFilePath: null,
|
||||
searchSpaceId: payload.searchSpaceId ?? null,
|
||||
workspaceId: payload.workspaceId ?? null,
|
||||
memoryScope: payload.memoryScope,
|
||||
title: payload.title ?? null,
|
||||
});
|
||||
|
|
@ -83,7 +83,7 @@ export const openEditorPanelAtom = atom(
|
|||
kind: "document",
|
||||
documentId: payload.documentId,
|
||||
localFilePath: null,
|
||||
searchSpaceId: payload.searchSpaceId,
|
||||
workspaceId: payload.workspaceId,
|
||||
memoryScope: null,
|
||||
title: payload.title ?? null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const acceptInviteMutationAtom = atomWithMutation(() => ({
|
|||
return invitesApiService.acceptInvite(request);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all });
|
||||
queryClient.invalidateQueries({ queryKey: cacheKeys.workspaces.all });
|
||||
toast.success("Invite accepted successfully");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import { invitesApiService } from "@/lib/apis/invites-api.service";
|
|||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export const invitesAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.invites.all(searchSpaceId?.toString() ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.invites.all(workspaceId?.toString() ?? ""),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
if (!workspaceId) {
|
||||
return [];
|
||||
}
|
||||
return invitesApiService.getInvites({
|
||||
workspace_id: Number(searchSpaceId),
|
||||
workspace_id: Number(workspaceId),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
* Create Log Mutation
|
||||
*/
|
||||
export const createLogMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
return {
|
||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: CreateLogRequest) => logsApiService.createLog(request),
|
||||
onSuccess: () => {
|
||||
// Invalidate all log-related queries (list, summary, detail, withQueryParams)
|
||||
|
|
@ -29,10 +29,10 @@ export const createLogMutationAtom = atomWithMutation((get) => {
|
|||
* Update Log Mutation
|
||||
*/
|
||||
export const updateLogMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
return {
|
||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async ({ logId, data }: { logId: number; data: UpdateLogRequest }) =>
|
||||
logsApiService.updateLog(logId, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
|
|
@ -45,10 +45,10 @@ export const updateLogMutationAtom = atomWithMutation((get) => {
|
|||
* Delete Log Mutation
|
||||
*/
|
||||
export const deleteLogMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
return {
|
||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
||||
enabled: !!searchSpaceId,
|
||||
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
|
||||
enabled: !!workspaceId,
|
||||
mutationFn: async (request: DeleteLogRequest) => logsApiService.deleteLog(request),
|
||||
onSuccess: (_data, request) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { toast } from "sonner";
|
|||
import type {
|
||||
DeleteMembershipRequest,
|
||||
DeleteMembershipResponse,
|
||||
LeaveSearchSpaceRequest,
|
||||
LeaveSearchSpaceResponse,
|
||||
LeaveWorkspaceRequest,
|
||||
LeaveWorkspaceResponse,
|
||||
UpdateMembershipRequest,
|
||||
UpdateMembershipResponse,
|
||||
} from "@/contracts/types/members.types";
|
||||
|
|
@ -48,20 +48,20 @@ export const deleteMemberMutationAtom = atomWithMutation(() => {
|
|||
};
|
||||
});
|
||||
|
||||
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
|
||||
export const leaveWorkspaceMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: LeaveSearchSpaceRequest) => {
|
||||
return membersApiService.leaveSearchSpace(request);
|
||||
mutationFn: async (request: LeaveWorkspaceRequest) => {
|
||||
return membersApiService.leaveWorkspace(request);
|
||||
},
|
||||
onSuccess: (_: LeaveSearchSpaceResponse, request: LeaveSearchSpaceRequest) => {
|
||||
toast.success("Successfully left the search space");
|
||||
onSuccess: (_: LeaveWorkspaceResponse, request: LeaveWorkspaceRequest) => {
|
||||
toast.success("Successfully left the workspace");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.members.all(request.workspace_id.toString()),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to leave search space");
|
||||
toast.error("Failed to leave workspace");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,40 +1,41 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-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(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.members.all(workspaceId?.toString() ?? ""),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
||||
refetchInterval: 2 * 60 * 1000, // 2 minutes
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
if (!workspaceId) {
|
||||
return [];
|
||||
}
|
||||
return membersApiService.getMembers({
|
||||
workspace_id: Number(searchSpaceId),
|
||||
workspace_id: Number(workspaceId),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const myAccessAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.members.myAccess(searchSpaceId?.toString() ?? ""),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.members.myAccess(workspaceId?.toString() ?? ""),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return membersApiService.getMyAccess({
|
||||
workspace_id: Number(searchSpaceId),
|
||||
workspace_id: Number(workspaceId),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -71,6 +72,6 @@ export function canPerform(
|
|||
* const canManageMembers = usePermissionGate('manage_members');
|
||||
*/
|
||||
export function usePermissionGate(permission: string): boolean {
|
||||
const access = useAtomValue(myAccessAtom);
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
return canPerform(access, permission);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,18 +18,18 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
||||
|
||||
function invalidateModelConnections(searchSpaceId: number) {
|
||||
function invalidateModelConnections(workspaceId: number) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.all(searchSpaceId),
|
||||
queryKey: cacheKeys.modelConnections.all(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
|
||||
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
function upsertModelConnection(searchSpaceId: number, connection: ConnectionRead) {
|
||||
function upsertModelConnection(workspaceId: number, connection: ConnectionRead) {
|
||||
queryClient.setQueryData<ConnectionRead[]>(
|
||||
cacheKeys.modelConnections.all(searchSpaceId),
|
||||
cacheKeys.modelConnections.all(workspaceId),
|
||||
(current = []) => {
|
||||
if (current.some((item) => item.id === connection.id)) {
|
||||
return current.map((item) => (item.id === connection.id ? connection : item));
|
||||
|
|
@ -40,19 +40,19 @@ function upsertModelConnection(searchSpaceId: number, connection: ConnectionRead
|
|||
}
|
||||
|
||||
export const createModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["model-connections", "create"],
|
||||
mutationFn: (request: ConnectionCreateRequest) =>
|
||||
modelConnectionsApiService.createConnection(request),
|
||||
onSuccess: (connection: ConnectionRead, request: ConnectionCreateRequest) => {
|
||||
const resolvedSearchSpaceId = Number(
|
||||
request.workspace_id ?? connection.workspace_id ?? searchSpaceId
|
||||
const resolvedWorkspaceId = Number(
|
||||
request.workspace_id ?? connection.workspace_id ?? workspaceId
|
||||
);
|
||||
toast.success("Connection created");
|
||||
if (resolvedSearchSpaceId > 0) {
|
||||
upsertModelConnection(resolvedSearchSpaceId, connection);
|
||||
invalidateModelConnections(resolvedSearchSpaceId);
|
||||
if (resolvedWorkspaceId > 0) {
|
||||
upsertModelConnection(resolvedWorkspaceId, connection);
|
||||
invalidateModelConnections(resolvedWorkspaceId);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to create connection"),
|
||||
|
|
@ -60,34 +60,34 @@ export const createModelConnectionMutationAtom = atomWithMutation((get) => {
|
|||
});
|
||||
|
||||
export const updateModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["model-connections", "update"],
|
||||
mutationFn: ({ id, data }: { id: number; data: ConnectionUpdateRequest }) =>
|
||||
modelConnectionsApiService.updateConnection(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Connection updated");
|
||||
invalidateModelConnections(searchSpaceId);
|
||||
invalidateModelConnections(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to update connection"),
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["model-connections", "delete"],
|
||||
mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Connection deleted");
|
||||
invalidateModelConnections(searchSpaceId);
|
||||
invalidateModelConnections(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to delete connection"),
|
||||
};
|
||||
});
|
||||
|
||||
export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["model-connections", "verify"],
|
||||
mutationFn: (id: number) => modelConnectionsApiService.verifyConnection(id),
|
||||
|
|
@ -103,14 +103,14 @@ export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
|
|||
: "Couldn't list models. Chat may still work — add model IDs manually."
|
||||
);
|
||||
}
|
||||
invalidateModelConnections(searchSpaceId);
|
||||
invalidateModelConnections(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to verify connection"),
|
||||
};
|
||||
});
|
||||
|
||||
export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["model-connections", "discover"],
|
||||
mutationFn: (id: number) => modelConnectionsApiService.discoverModels(id),
|
||||
|
|
@ -118,7 +118,7 @@ export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
|
|||
toast.success(
|
||||
models.length ? `${models.length} models discovered` : "No models found for this connection"
|
||||
);
|
||||
invalidateModelConnections(searchSpaceId);
|
||||
invalidateModelConnections(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to discover models"),
|
||||
};
|
||||
|
|
@ -149,64 +149,64 @@ export const testPreviewModelMutationAtom = atomWithMutation(() => {
|
|||
});
|
||||
|
||||
export const addManualModelMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["models", "add-manual"],
|
||||
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
|
||||
modelConnectionsApiService.addManualModel(connectionId, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Model added");
|
||||
invalidateModelConnections(searchSpaceId);
|
||||
invalidateModelConnections(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to add model"),
|
||||
};
|
||||
});
|
||||
|
||||
export const updateModelMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["models", "update"],
|
||||
mutationFn: ({ id, data }: { id: number; data: ModelUpdateRequest }) =>
|
||||
modelConnectionsApiService.updateModel(id, data),
|
||||
onSuccess: () => invalidateModelConnections(searchSpaceId),
|
||||
onSuccess: () => invalidateModelConnections(workspaceId),
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to update model"),
|
||||
};
|
||||
});
|
||||
|
||||
export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["models", "bulk-update"],
|
||||
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) =>
|
||||
modelConnectionsApiService.bulkUpdateModels(connectionId, data),
|
||||
onSuccess: () => invalidateModelConnections(searchSpaceId),
|
||||
onSuccess: () => invalidateModelConnections(workspaceId),
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to update models"),
|
||||
};
|
||||
});
|
||||
|
||||
export const testModelMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["models", "test"],
|
||||
mutationFn: (id: number) => modelConnectionsApiService.testModel(id),
|
||||
onSuccess: (result: VerifyConnectionResponse) => {
|
||||
if (result.ok) toast.success("Model test succeeded");
|
||||
else toast.error(result.message || "Model test failed");
|
||||
invalidateModelConnections(searchSpaceId);
|
||||
invalidateModelConnections(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to test model"),
|
||||
};
|
||||
});
|
||||
|
||||
export const updateModelRolesMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
mutationKey: ["model-roles", "update"],
|
||||
mutationFn: (roles: ModelRoles) =>
|
||||
modelConnectionsApiService.updateModelRoles(searchSpaceId, roles),
|
||||
modelConnectionsApiService.updateModelRoles(workspaceId, roles),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
|
||||
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to update model roles"),
|
||||
|
|
|
|||
|
|
@ -26,21 +26,21 @@ export const modelProvidersAtom = atomWithQuery(() => ({
|
|||
}));
|
||||
|
||||
export const modelConnectionsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
queryKey: cacheKeys.modelConnections.all(searchSpaceId),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.modelConnections.all(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: () => modelConnectionsApiService.getConnections(searchSpaceId),
|
||||
queryFn: () => modelConnectionsApiService.getConnections(workspaceId),
|
||||
};
|
||||
});
|
||||
|
||||
export const modelRolesAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
||||
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||
return {
|
||||
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: () => modelConnectionsApiService.getModelRoles(searchSpaceId),
|
||||
queryFn: () => modelConnectionsApiService.getModelRoles(workspaceId),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
|
|||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export const publicChatSnapshotsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.publicChatSnapshots.bySearchSpace(Number(searchSpaceId) || 0),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.publicChatSnapshots.byWorkspace(Number(workspaceId) || 0),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!searchSpaceId) {
|
||||
if (!workspaceId) {
|
||||
return { snapshots: [] };
|
||||
}
|
||||
return chatThreadsApiService.listPublicChatSnapshotsForSearchSpace({
|
||||
workspace_id: Number(searchSpaceId),
|
||||
return chatThreadsApiService.listPublicChatSnapshotsForWorkspace({
|
||||
workspace_id: Number(workspaceId),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
21
surfsense_web/atoms/tabs/migrate-tabs.test.ts
Normal file
21
surfsense_web/atoms/tabs/migrate-tabs.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { migrateLegacyTabs } from "./migrate-tabs";
|
||||
|
||||
// Run with: pnpm exec tsx --test atoms/tabs/migrate-tabs.test.ts
|
||||
test("maps legacy searchSpaceId to workspaceId on read", () => {
|
||||
const migrated = migrateLegacyTabs({
|
||||
tabs: [{ id: "chat-new", type: "chat", searchSpaceId: 7 } as never],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
const tab = migrated.tabs[0] as { workspaceId?: number };
|
||||
assert.equal(tab.workspaceId, 7);
|
||||
});
|
||||
|
||||
test("leaves an already-migrated workspaceId untouched", () => {
|
||||
const migrated = migrateLegacyTabs({
|
||||
tabs: [{ id: "d1", type: "document", workspaceId: 3, searchSpaceId: 9 } as never],
|
||||
});
|
||||
const tab = migrated.tabs[0] as { workspaceId?: number };
|
||||
assert.equal(tab.workspaceId, 3);
|
||||
});
|
||||
19
surfsense_web/atoms/tabs/migrate-tabs.ts
Normal file
19
surfsense_web/atoms/tabs/migrate-tabs.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* One-time read-migration for persisted tabs: legacy state stored the workspace
|
||||
* as `searchSpaceId`. Map it to `workspaceId` on read so already-open tabs keep
|
||||
* their workspace association after the rename. Pure + dependency-free so it can
|
||||
* be unit-checked without loading the atom module.
|
||||
*/
|
||||
export function migrateLegacyTabs<T extends { tabs: Array<{ workspaceId?: number }> }>(
|
||||
state: T
|
||||
): T {
|
||||
return {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) => {
|
||||
const legacy = t as { workspaceId?: number; searchSpaceId?: number };
|
||||
return legacy.workspaceId === undefined && legacy.searchSpaceId !== undefined
|
||||
? { ...t, workspaceId: legacy.searchSpaceId }
|
||||
: t;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
import { migrateLegacyTabs } from "./migrate-tabs";
|
||||
|
||||
export type TabType = "chat" | "document";
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export interface Tab {
|
|||
hasComments?: boolean;
|
||||
/** For document tabs */
|
||||
documentId?: number;
|
||||
searchSpaceId?: number;
|
||||
workspaceId?: number;
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
|
|
@ -40,11 +41,17 @@ const initialState: TabsState = {
|
|||
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||
|
||||
// Persist tabs in localStorage so they survive a hard refresh and let the user
|
||||
// keep tabs open across multiple search spaces (browser-like behavior).
|
||||
// keep tabs open across multiple workspaces (browser-like behavior).
|
||||
const localStorageAdapter = createJSONStorage<TabsState>(
|
||||
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
|
||||
);
|
||||
|
||||
// Wrap getItem in place so the adapter keeps its original (sync) type while
|
||||
// migrating legacy persisted state on read.
|
||||
const baseGetItem = localStorageAdapter.getItem.bind(localStorageAdapter);
|
||||
localStorageAdapter.getItem = (key, initialValue) =>
|
||||
migrateLegacyTabs(baseGetItem(key, initialValue));
|
||||
|
||||
export const tabsStateAtom = atomWithStorage<TabsState>(
|
||||
"surfsense:tabs",
|
||||
initialState,
|
||||
|
|
@ -81,14 +88,14 @@ export const syncChatTabAtom = atom(
|
|||
chatId,
|
||||
title,
|
||||
chatUrl,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
visibility,
|
||||
hasComments,
|
||||
}: {
|
||||
chatId: number | null;
|
||||
title?: string;
|
||||
chatUrl?: string;
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
|
|
@ -111,7 +118,7 @@ export const syncChatTabAtom = atom(
|
|||
...t,
|
||||
title: title || t.title,
|
||||
chatUrl: chatUrl || t.chatUrl,
|
||||
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
|
||||
workspaceId: workspaceId ?? t.workspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
}
|
||||
|
|
@ -122,18 +129,18 @@ export const syncChatTabAtom = atom(
|
|||
}
|
||||
|
||||
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
|
||||
// scoped to the current search space.
|
||||
// scoped to the current workspace.
|
||||
if (!chatId) {
|
||||
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
|
||||
if (hasNewChatTab) {
|
||||
set(tabsStateAtom, {
|
||||
...state,
|
||||
activeTabId: "chat-new",
|
||||
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, searchSpaceId, chatUrl } : t)),
|
||||
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, workspaceId, chatUrl } : t)),
|
||||
});
|
||||
} else {
|
||||
set(tabsStateAtom, {
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
}
|
||||
|
|
@ -148,7 +155,7 @@ export const syncChatTabAtom = atom(
|
|||
title: title || "New Chat",
|
||||
chatId,
|
||||
chatUrl,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
};
|
||||
|
|
@ -197,11 +204,7 @@ export const openDocumentTabAtom = atom(
|
|||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
documentId,
|
||||
searchSpaceId,
|
||||
title,
|
||||
}: { documentId: number; searchSpaceId: number; title?: string }
|
||||
{ documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string }
|
||||
) => {
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeDocumentTabId(documentId);
|
||||
|
|
@ -221,7 +224,7 @@ export const openDocumentTabAtom = atom(
|
|||
type: "document",
|
||||
title: title || `Document ${documentId}`,
|
||||
documentId,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
set(tabsStateAtom, {
|
||||
|
|
@ -300,7 +303,7 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
|
|||
return remaining.find((t) => t.id === newActiveId) ?? null;
|
||||
});
|
||||
|
||||
/** Reset tabs when switching search spaces. */
|
||||
/** Reset tabs when switching workspaces. */
|
||||
export const resetTabsAtom = atom(null, (_get, set) => {
|
||||
set(tabsStateAtom, { ...initialState });
|
||||
set(deletedChatIdsAtom, new Set<number>());
|
||||
|
|
|
|||
|
|
@ -1,96 +1,96 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateSearchSpaceRequest,
|
||||
DeleteSearchSpaceRequest,
|
||||
UpdateSearchSpaceApiAccessRequest,
|
||||
UpdateSearchSpaceRequest,
|
||||
CreateWorkspaceRequest,
|
||||
DeleteWorkspaceRequest,
|
||||
UpdateWorkspaceApiAccessRequest,
|
||||
UpdateWorkspaceRequest,
|
||||
} from "@/contracts/types/workspace.types";
|
||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeWorkspaceIdAtom } from "./workspace-query.atoms";
|
||||
|
||||
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
|
||||
export const createWorkspaceMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
mutationKey: ["create-search-space"],
|
||||
mutationFn: async (request: CreateSearchSpaceRequest) => {
|
||||
return searchSpacesApiService.createSearchSpace(request);
|
||||
mutationKey: ["create-workspace"],
|
||||
mutationFn: async (request: CreateWorkspaceRequest) => {
|
||||
return workspacesApiService.createWorkspace(request);
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
toast.success("Search space created successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.all,
|
||||
queryKey: cacheKeys.workspaces.all,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateSearchSpaceMutationAtom = atomWithMutation((get) => {
|
||||
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
|
||||
export const updateWorkspaceMutationAtom = atomWithMutation((get) => {
|
||||
const activeWorkspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["update-search-space", activeSearchSpaceId],
|
||||
enabled: !!activeSearchSpaceId,
|
||||
mutationFn: async (request: UpdateSearchSpaceRequest) => {
|
||||
return searchSpacesApiService.updateSearchSpace(request);
|
||||
mutationKey: ["update-workspace", activeWorkspaceId],
|
||||
enabled: !!activeWorkspaceId,
|
||||
mutationFn: async (request: UpdateWorkspaceRequest) => {
|
||||
return workspacesApiService.updateWorkspace(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: UpdateSearchSpaceRequest) => {
|
||||
onSuccess: (_, request: UpdateWorkspaceRequest) => {
|
||||
toast.success("Search space updated successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.all,
|
||||
queryKey: cacheKeys.workspaces.all,
|
||||
});
|
||||
if (request.id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
|
||||
queryKey: cacheKeys.workspaces.detail(String(request.id)),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateSearchSpaceApiAccessMutationAtom = atomWithMutation((get) => {
|
||||
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
|
||||
export const updateWorkspaceApiAccessMutationAtom = atomWithMutation((get) => {
|
||||
const activeWorkspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["update-search-space-api-access", activeSearchSpaceId],
|
||||
enabled: !!activeSearchSpaceId,
|
||||
mutationFn: async (request: UpdateSearchSpaceApiAccessRequest) => {
|
||||
return searchSpacesApiService.updateSearchSpaceApiAccess(request);
|
||||
mutationKey: ["update-workspace-api-access", activeWorkspaceId],
|
||||
enabled: !!activeWorkspaceId,
|
||||
mutationFn: async (request: UpdateWorkspaceApiAccessRequest) => {
|
||||
return workspacesApiService.updateWorkspaceApiAccess(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: UpdateSearchSpaceApiAccessRequest) => {
|
||||
onSuccess: (_, request: UpdateWorkspaceApiAccessRequest) => {
|
||||
toast.success("API access updated successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.all,
|
||||
queryKey: cacheKeys.workspaces.all,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
|
||||
queryKey: cacheKeys.workspaces.detail(String(request.id)),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteSearchSpaceMutationAtom = atomWithMutation((get) => {
|
||||
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
|
||||
export const deleteWorkspaceMutationAtom = atomWithMutation((get) => {
|
||||
const activeWorkspaceId = get(activeWorkspaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["delete-search-space", activeSearchSpaceId],
|
||||
enabled: !!activeSearchSpaceId,
|
||||
mutationFn: async (request: DeleteSearchSpaceRequest) => {
|
||||
return searchSpacesApiService.deleteSearchSpace(request);
|
||||
mutationKey: ["delete-workspace", activeWorkspaceId],
|
||||
enabled: !!activeWorkspaceId,
|
||||
mutationFn: async (request: DeleteWorkspaceRequest) => {
|
||||
return workspacesApiService.deleteWorkspace(request);
|
||||
},
|
||||
|
||||
onSuccess: (_, request: DeleteSearchSpaceRequest) => {
|
||||
onSuccess: (_, request: DeleteWorkspaceRequest) => {
|
||||
toast.success("Search space deleted successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.searchSpaces.all,
|
||||
queryKey: cacheKeys.workspaces.all,
|
||||
});
|
||||
if (request.id) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
|
||||
queryKey: cacheKeys.workspaces.detail(String(request.id)),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import type { GetSearchSpacesRequest } from "@/contracts/types/workspace.types";
|
||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import type { GetWorkspacesRequest } from "@/contracts/types/workspace.types";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export const activeWorkspaceIdAtom = atom<string | null>(null);
|
||||
|
||||
export const searchSpacesQueryParamsAtom = atom<GetSearchSpacesRequest["queryParams"]>({
|
||||
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
owned_only: false,
|
||||
});
|
||||
|
||||
export const searchSpacesAtom = atomWithQuery((get) => {
|
||||
const queryParams = get(searchSpacesQueryParamsAtom);
|
||||
export const workspacesAtom = atomWithQuery((get) => {
|
||||
const queryParams = get(workspacesQueryParamsAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.searchSpaces.withQueryParams(queryParams),
|
||||
queryKey: cacheKeys.workspaces.withQueryParams(queryParams),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return searchSpacesApiService.getSearchSpaces({
|
||||
return workspacesApiService.getWorkspaces({
|
||||
queryParams,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ This release brings major improvements to **collaboration and user experience**.
|
|||
#### New Chat-First Interface
|
||||
|
||||
- **Dashboard Removed**: Users now land directly in a chat for faster access
|
||||
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
|
||||
- **Redesigned Workspaces**: Moved to a left column with color-coded icons and hover tooltips
|
||||
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
|
||||
- **Streamlined Settings**: Accessible through intuitive dropdown menus
|
||||
- **Mobile-Responsive Design**: Better experience on all devices
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ This update brings **public sharing, image generation**, a redesigned Documents
|
|||
#### Public Sharing
|
||||
|
||||
- **Public Chats**: Share snapshots of chats via public links.
|
||||
- **Sharing Permissions**: Search Space owners control who can create and manage public links.
|
||||
- **Link Management Page**: View and revoke all public chats from Search Space Settings.
|
||||
- **Sharing Permissions**: Workspace owners control who can create and manage public links.
|
||||
- **Link Management Page**: View and revoke all public chats from Workspace Settings.
|
||||
|
||||
#### Auto (Load Balanced) Mode
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ SurfSense now treats every sensitive AI action as an explicit, reviewable step.
|
|||
|
||||
- **Mobile-First Polish**: Mobile citation drawer, long-press actions in chats and documents, a responsive documents sidebar, and a mobile-friendly onboarding tour.
|
||||
- **Reworked Composer**: Tool actions are grouped into a cleaner menu with better icons, plus a helpful "connect tools" banner.
|
||||
- **Settings & Team**: New tabbed user settings page (profile + API keys), team roles management with pagination, and a search space settings dialog.
|
||||
- **Settings & Team**: New tabbed user settings page (profile + API keys), team roles management with pagination, and a workspace settings dialog.
|
||||
- **Right Panel & Docked Sidebar**: A tabbed Sources/Report panel with smooth transitions, plus an optional docked documents sidebar.
|
||||
- **Community Prompts**: Public prompt library with copy support, inline share toggles, and a see-more/less toggle for long prompts.
|
||||
- **New Homepage**: Smooth scrolling, a use-cases grid, an updated walkthrough hero, a GitHub stars badge, and a new carousel for AI-generated video.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ The SurfSense desktop app becomes a serious always-on **AI like ChatGPT** that a
|
|||
- **Multi-Suggestion UI**: The suggestion popup now offers up to **3 options** to pick from, with clean cards and quick-assist detection.
|
||||
- **Knowledge Base Grounding**: Suggestions are grounded in your connected knowledge base, not just generic model output.
|
||||
- **macOS Permission Onboarding**: A clearer onboarding page walks macOS users through the permissions the app needs, only when they're actually needed.
|
||||
- **Switch Search Spaces from the Overlay**: Change the active search space without opening the main app.
|
||||
- **Switch Workspaces from the Overlay**: Change the active workspace without opening the main app.
|
||||
- **General Assist**: A new general-purpose assist mode with cleaner shortcut icons and descriptions.
|
||||
- **Stay Signed In Everywhere**: Sign-in is now synchronized between the desktop app and the web app.
|
||||
- **Vision Model Settings**: A dedicated Vision Models tab in Settings lets you pick and manage vision models, including a dynamic model list from OpenRouter.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Turn your knowledge and profile details into export-ready resume documents.
|
|||
|
||||
- **Cross-Site Anonymous Chat Cookies**: Anonymous chat cookies now adapt their SameSite and Secure settings based on deployment context, making hosted and cross-domain setups more reliable.
|
||||
- **Better Anonymous Chat History**: Message history handling in anonymous chat is more dependable, especially when users move between public chat states.
|
||||
- **Safer Form Inputs**: Login, registration, profile, search space, and role forms now enforce sensible max-length limits directly in the UI.
|
||||
- **Safer Form Inputs**: Login, registration, profile, workspace, and role forms now enforce sensible max-length limits directly in the UI.
|
||||
- **Cleaner Page Landmarks**: Home and free-chat pages no longer nest main landmarks, improving HTML semantics and screen-reader navigation.
|
||||
- **SEO Metadata Refresh**: Titles and descriptions across key pages now better communicate SurfSense's open-source, privacy-focused positioning.
|
||||
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ export const AssistantMessage: FC = () => {
|
|||
const commentPanelRef = useRef<HTMLDivElement>(null);
|
||||
const commentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const dbMessageId = parseMessageId(messageId);
|
||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||
|
||||
|
|
@ -520,7 +520,7 @@ export const AssistantMessage: FC = () => {
|
|||
const commentCount = commentsData?.total_count ?? 0;
|
||||
const hasComments = commentCount > 0;
|
||||
|
||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
const showCommentTrigger = workspaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
|
||||
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ interface ConnectorIndicatorProps {
|
|||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
(_props, ref) => {
|
||||
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
|
||||
// Real-time document type counts via Zero (updates instantly as docs are indexed)
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
|
||||
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||
// instead of creating a duplicate useInbox("status") hook.
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
|
|
@ -124,7 +124,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsSync,
|
||||
} = useConnectorsSync(searchSpaceId);
|
||||
} = useConnectorsSync(workspaceId);
|
||||
|
||||
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
|
||||
|
|
@ -142,7 +142,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
inboxItems
|
||||
);
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
// Get document types that have documents in the workspace
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
: [];
|
||||
|
|
@ -163,7 +163,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
open: () => handleOpenChange(true),
|
||||
}));
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
if (!workspaceId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
|
|
@ -191,8 +191,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
>
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
{isYouTubeView && workspaceId ? (
|
||||
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
|
|
@ -253,7 +253,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
searchSpaceId={searchSpaceId?.toString()}
|
||||
workspaceId={workspaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
|
|
@ -346,7 +346,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
|
|
|
|||
|
|
@ -159,17 +159,17 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
|||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* Step 4 — Pick search space */}
|
||||
{/* Step 4 — Pick workspace */}
|
||||
<article>
|
||||
<header className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||
4
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Pick this search space</h3>
|
||||
<h3 className="text-sm font-medium sm:text-base">Pick this workspace</h3>
|
||||
</header>
|
||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||
In the plugin's <span className="font-medium">Search space</span> setting, choose the
|
||||
search space you want this vault to sync into. The connector will appear here
|
||||
workspace you want this vault to sync into. The connector will appear here
|
||||
automatically once the plugin makes its first sync.
|
||||
</p>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
LINEAR_CONNECTOR: [
|
||||
"Search through all your Linear issues and comments",
|
||||
"Access issue titles, descriptions, and full discussion threads",
|
||||
"Connect your team's project management directly to your search space",
|
||||
"Connect your team's project management directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Linear content",
|
||||
"Index your Linear issues for enhanced search capabilities",
|
||||
],
|
||||
|
|
@ -36,63 +36,63 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
SLACK_CONNECTOR: [
|
||||
"Search through all your Slack messages and conversations",
|
||||
"Access messages from public and private channels",
|
||||
"Connect your team's communications directly to your search space",
|
||||
"Connect your team's communications directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Slack content",
|
||||
"Index your Slack conversations for enhanced search capabilities",
|
||||
],
|
||||
DISCORD_CONNECTOR: [
|
||||
"Search through all your Discord messages and conversations",
|
||||
"Access messages from all accessible channels",
|
||||
"Connect your community's communications directly to your search space",
|
||||
"Connect your community's communications directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Discord content",
|
||||
"Index your Discord conversations for enhanced search capabilities",
|
||||
],
|
||||
NOTION_CONNECTOR: [
|
||||
"Search through all your Notion pages and databases",
|
||||
"Access page content, properties, and metadata",
|
||||
"Connect your knowledge base directly to your search space",
|
||||
"Connect your knowledge base directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Notion content",
|
||||
"Index your Notion workspace for enhanced search capabilities",
|
||||
],
|
||||
CONFLUENCE_CONNECTOR: [
|
||||
"Search through all your Confluence pages and spaces",
|
||||
"Access page content, comments, and attachments",
|
||||
"Connect your team's documentation directly to your search space",
|
||||
"Connect your team's documentation directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Confluence content",
|
||||
"Index your Confluence workspace for enhanced search capabilities",
|
||||
],
|
||||
BOOKSTACK_CONNECTOR: [
|
||||
"Search through all your BookStack pages and books",
|
||||
"Access page content, chapters, and documentation",
|
||||
"Connect your documentation directly to your search space",
|
||||
"Connect your documentation directly to your workspace",
|
||||
"Keep your search results up-to-date with latest BookStack content",
|
||||
"Index your BookStack instance for enhanced search capabilities",
|
||||
],
|
||||
GITHUB_CONNECTOR: [
|
||||
"Search through code, issues, and documentation from GitHub repositories",
|
||||
"Access repository content, pull requests, and discussions",
|
||||
"Connect your codebase directly to your search space",
|
||||
"Connect your codebase directly to your workspace",
|
||||
"Keep your search results up-to-date with latest GitHub content",
|
||||
"Index your GitHub repositories for enhanced search capabilities",
|
||||
],
|
||||
JIRA_CONNECTOR: [
|
||||
"Search through all your Jira issues and tickets",
|
||||
"Access issue descriptions, comments, and project data",
|
||||
"Connect your project management directly to your search space",
|
||||
"Connect your project management directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Jira content",
|
||||
"Index your Jira projects for enhanced search capabilities",
|
||||
],
|
||||
CLICKUP_CONNECTOR: [
|
||||
"Search through all your ClickUp tasks and projects",
|
||||
"Access task descriptions, comments, and project data",
|
||||
"Connect your task management directly to your search space",
|
||||
"Connect your task management directly to your workspace",
|
||||
"Keep your search results up-to-date with latest ClickUp content",
|
||||
"Index your ClickUp workspace for enhanced search capabilities",
|
||||
],
|
||||
LUMA_CONNECTOR: [
|
||||
"Search through all your Luma events",
|
||||
"Access event details, descriptions, and attendee information",
|
||||
"Connect your events directly to your search space",
|
||||
"Connect your events directly to your workspace",
|
||||
"Keep your search results up-to-date with latest Luma content",
|
||||
"Index your Luma events for enhanced search capabilities",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
|||
<AlertDescription>
|
||||
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
||||
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
||||
action items to this search space.
|
||||
action items to this workspace.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface ConnectorConfigProps {
|
|||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
searchSpaceId?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ interface ConnectorEditViewProps {
|
|||
isSaving: boolean;
|
||||
isDisconnecting: boolean;
|
||||
isIndexing?: boolean;
|
||||
searchSpaceId?: string;
|
||||
workspaceId?: string;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
|
|
@ -65,7 +65,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
isSaving,
|
||||
isDisconnecting,
|
||||
isIndexing = false,
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
|
|
@ -78,7 +78,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const searchSpaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
|
||||
const workspaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
|
||||
const isAuthExpired = connector.config?.auth_expired === true;
|
||||
const reauthEndpoint = getReauthEndpoint(connector);
|
||||
const [reauthing, setReauthing] = useState(false);
|
||||
|
|
@ -91,7 +91,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
(connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR);
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||
const spaceId = workspaceId ?? workspaceIdAtom;
|
||||
if (!spaceId || !reauthEndpoint) return;
|
||||
setReauthing(true);
|
||||
try {
|
||||
|
|
@ -119,7 +119,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
} finally {
|
||||
setReauthing(false);
|
||||
}
|
||||
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
|
||||
}, [workspaceId, workspaceIdAtom, reauthEndpoint, connector.id]);
|
||||
|
||||
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
||||
const ConnectorConfigComponent = useMemo(() => {
|
||||
|
|
@ -273,7 +273,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
connector={connector}
|
||||
onConfigChange={onConfigChange}
|
||||
onNameChange={onNameChange}
|
||||
searchSpaceId={searchSpaceId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue