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"
|
Integer, nullable=True, default=0, server_default="0"
|
||||||
) # For vision/screenshot analysis, defaults to Auto mode
|
) # For vision/screenshot analysis, defaults to Auto mode
|
||||||
|
|
||||||
ai_file_sort_enabled = Column(
|
|
||||||
Boolean, nullable=False, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id = Column(
|
user_id = Column(
|
||||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
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.
|
"""``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.
|
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)
|
log_index_success(ctx, chunk_count=chunk_count)
|
||||||
outcome_status = "success"
|
outcome_status = "success"
|
||||||
|
|
||||||
await self._enqueue_ai_sort_if_enabled(document)
|
|
||||||
|
|
||||||
except RETRYABLE_LLM_ERRORS as e:
|
except RETRYABLE_LLM_ERRORS as e:
|
||||||
ot.record_error(persist_span, e)
|
ot.record_error(persist_span, e)
|
||||||
log_retryable_llm_error(ctx, e)
|
log_retryable_llm_error(ctx, e)
|
||||||
|
|
@ -564,29 +562,6 @@ class IndexingPipelineService:
|
||||||
)
|
)
|
||||||
return len(new_texts)
|
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(
|
async def index_batch_parallel(
|
||||||
self,
|
self,
|
||||||
connector_docs: list[ConnectorDocument],
|
connector_docs: list[ConnectorDocument],
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,7 @@ def record_kb_search_duration(
|
||||||
_record(
|
_record(
|
||||||
_kb_search_duration(),
|
_kb_search_duration(),
|
||||||
duration_ms,
|
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."""
|
"""Span around knowledge-base search routines."""
|
||||||
attrs: dict[str, Any] = {}
|
attrs: dict[str, Any] = {}
|
||||||
if workspace_id is not None:
|
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:
|
if query_chars is not None:
|
||||||
attrs["query.chars"] = int(query_chars)
|
attrs["query.chars"] = int(query_chars)
|
||||||
if extra:
|
if extra:
|
||||||
|
|
@ -303,7 +303,7 @@ def chat_request_span(
|
||||||
if chat_id is not None:
|
if chat_id is not None:
|
||||||
attrs["chat.id"] = int(chat_id)
|
attrs["chat.id"] = int(chat_id)
|
||||||
if workspace_id is not None:
|
if workspace_id is not None:
|
||||||
attrs["search_space.id"] = int(workspace_id)
|
attrs["workspace.id"] = int(workspace_id)
|
||||||
if flow:
|
if flow:
|
||||||
attrs["chat.flow"] = flow
|
attrs["chat.flow"] = flow
|
||||||
if request_id:
|
if request_id:
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,6 @@ async def read_workspaces(
|
||||||
citations_enabled=space.citations_enabled,
|
citations_enabled=space.citations_enabled,
|
||||||
api_access_enabled=space.api_access_enabled,
|
api_access_enabled=space.api_access_enabled,
|
||||||
qna_custom_instructions=space.qna_custom_instructions,
|
qna_custom_instructions=space.qna_custom_instructions,
|
||||||
ai_file_sort_enabled=space.ai_file_sort_enabled,
|
|
||||||
member_count=member_count,
|
member_count=member_count,
|
||||||
is_owner=is_owner,
|
is_owner=is_owner,
|
||||||
)
|
)
|
||||||
|
|
@ -326,43 +325,6 @@ async def update_workspace_api_access(
|
||||||
) from e
|
) 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)
|
@router.delete("/workspaces/{workspace_id}", response_model=dict)
|
||||||
async def delete_workspace(
|
async def delete_workspace(
|
||||||
workspace_id: int,
|
workspace_id: int,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ class WorkspaceUpdate(BaseModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
citations_enabled: bool | None = None
|
citations_enabled: bool | None = None
|
||||||
qna_custom_instructions: str | None = None
|
qna_custom_instructions: str | None = None
|
||||||
ai_file_sort_enabled: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceApiAccessUpdate(BaseModel):
|
class WorkspaceApiAccessUpdate(BaseModel):
|
||||||
|
|
@ -36,7 +35,6 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel):
|
||||||
api_access_enabled: bool = False
|
api_access_enabled: bool = False
|
||||||
qna_custom_instructions: str | None = None
|
qna_custom_instructions: str | None = None
|
||||||
shared_memory_md: str | None = None
|
shared_memory_md: str | None = None
|
||||||
ai_file_sort_enabled: bool = False
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
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 contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
|
|
@ -1395,108 +1394,3 @@ async def _index_uploaded_folder_files_async(
|
||||||
if notification_id is not None:
|
if notification_id is not None:
|
||||||
_stop_heartbeat(notification_id)
|
_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"),
|
("cleanup_stale_indexing_notifications", "cleanup"),
|
||||||
("reconcile_pending_stripe_credit_purchases", "reconcile"),
|
("reconcile_pending_stripe_credit_purchases", "reconcile"),
|
||||||
("check_periodic_schedules", "check"),
|
("check_periodic_schedules", "check"),
|
||||||
("ai_sort_search_space", "ai"),
|
|
||||||
("index_notion_pages", "index"),
|
("index_notion_pages", "index"),
|
||||||
("index_github_repos", "index"),
|
("index_github_repos", "index"),
|
||||||
("index_google_drive_files", "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
|
session.flush = mock_flush
|
||||||
|
|
||||||
segments = [
|
segments = [
|
||||||
{"name": "Slack", "metadata": {"ai_sort": True, "ai_sort_level": 1}},
|
{"name": "Slack", "metadata": {"source": "slack"}},
|
||||||
{"name": "2025-03-15", "metadata": {"ai_sort": True, "ai_sort_level": 2}},
|
{"name": "2025-03-15", "metadata": {"source": "slack"}},
|
||||||
]
|
]
|
||||||
|
|
||||||
result = await ensure_folder_hierarchy_with_depth_validation(
|
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 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 = {
|
const toSend = {
|
||||||
document_type: "EXTENSION",
|
document_type: "EXTENSION",
|
||||||
content: content,
|
content: content,
|
||||||
search_space_id: search_space_id,
|
workspace_id: workspace_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("toSend", toSend);
|
console.log("toSend", toSend);
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,12 @@ const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const token = await storage.get("token");
|
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 = {
|
const toSend = {
|
||||||
document_type: "EXTENSION",
|
document_type: "EXTENSION",
|
||||||
content: content,
|
content: content,
|
||||||
search_space_id: search_space_id,
|
workspace_id: workspace_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestOptions = {
|
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 type { WebHistory } from "~utils/interfaces";
|
||||||
import Loading from "./Loading";
|
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 HomePage = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigation = useNavigate();
|
const navigation = useNavigate();
|
||||||
|
|
@ -40,11 +54,11 @@ const HomePage = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [value, setValue] = React.useState<string>("");
|
const [value, setValue] = React.useState<string>("");
|
||||||
const [searchspaces, setSearchSpaces] = useState([]);
|
const [workspaces, setWorkspaces] = useState([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSearchSpaces = async () => {
|
const checkWorkspaces = async () => {
|
||||||
const storage = new Storage({ area: "local" });
|
const storage = new Storage({ area: "local" });
|
||||||
const token = await storage.get("token");
|
const token = await storage.get("token");
|
||||||
|
|
||||||
|
|
@ -55,7 +69,7 @@ const HomePage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(await buildBackendUrl("/api/v1/searchspaces"), {
|
const response = await fetch(await buildBackendUrl("/api/v1/workspaces"), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +80,7 @@ const HomePage = () => {
|
||||||
} else {
|
} else {
|
||||||
const res = await response.json();
|
const res = await response.json();
|
||||||
console.log(res);
|
console.log(res);
|
||||||
setSearchSpaces(res);
|
setWorkspaces(res);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await storage.remove("token");
|
await storage.remove("token");
|
||||||
|
|
@ -77,7 +91,7 @@ const HomePage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkSearchSpaces();
|
checkWorkspaces();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -98,10 +112,11 @@ const HomePage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const storage = new Storage({ area: "local" });
|
const storage = new Storage({ area: "local" });
|
||||||
const searchspace = await storage.get("search_space");
|
await migrateLegacyWorkspaceKeys(storage);
|
||||||
|
const workspace = await storage.get("workspace");
|
||||||
|
|
||||||
if (searchspace) {
|
if (workspace) {
|
||||||
setValue(searchspace);
|
setValue(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.set("showShadowDom", true);
|
await storage.set("showShadowDom", true);
|
||||||
|
|
@ -262,17 +277,17 @@ const HomePage = () => {
|
||||||
const saveDatamessage = async () => {
|
const saveDatamessage = async () => {
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
toast({
|
toast({
|
||||||
title: "Select a SearchSpace !",
|
title: "Select a Workspace !",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new Storage({ area: "local" });
|
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({
|
toast({
|
||||||
title: "Invalid SearchSpace selected!",
|
title: "Invalid Workspace selected!",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -319,15 +334,15 @@ const HomePage = () => {
|
||||||
const storage = new Storage({ area: "local" });
|
const storage = new Storage({ area: "local" });
|
||||||
await storage.remove("token");
|
await storage.remove("token");
|
||||||
await storage.remove("showShadowDom");
|
await storage.remove("showShadowDom");
|
||||||
await storage.remove("search_space");
|
await storage.remove("workspace");
|
||||||
await storage.remove("search_space_id");
|
await storage.remove("workspace_id");
|
||||||
navigation("/login");
|
navigation("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
} else {
|
} 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 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="flex flex-1 items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
|
@ -337,7 +352,7 @@ const HomePage = () => {
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -390,7 +405,7 @@ const HomePage = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
|
<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}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -399,8 +414,8 @@ const HomePage = () => {
|
||||||
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
{value
|
{value
|
||||||
? searchspaces.find((space) => space.name === value)?.name
|
? workspaces.find((space) => space.name === value)?.name
|
||||||
: "Select Search Space..."}
|
: "Select Workspace..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -411,23 +426,23 @@ const HomePage = () => {
|
||||||
className="border-gray-700 bg-gray-900 text-gray-200"
|
className="border-gray-700 bg-gray-900 text-gray-200"
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No search spaces found.</CommandEmpty>
|
<CommandEmpty>No workspaces found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{searchspaces.map((space) => (
|
{workspaces.map((space) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={space.name}
|
key={space.name}
|
||||||
value={space.name}
|
value={space.name}
|
||||||
onSelect={async (currentValue) => {
|
onSelect={async (currentValue) => {
|
||||||
const storage = new Storage({ area: "local" });
|
const storage = new Storage({ area: "local" });
|
||||||
if (currentValue === value) {
|
if (currentValue === value) {
|
||||||
await storage.set("search_space", "");
|
await storage.set("workspace", "");
|
||||||
await storage.set("search_space_id", 0);
|
await storage.set("workspace_id", 0);
|
||||||
} else {
|
} else {
|
||||||
const selectedSpace = searchspaces.find(
|
const selectedSpace = workspaces.find(
|
||||||
(space) => space.name === currentValue
|
(space) => space.name === currentValue
|
||||||
);
|
);
|
||||||
await storage.set("search_space", currentValue);
|
await storage.set("workspace", currentValue);
|
||||||
await storage.set("search_space_id", selectedSpace.id);
|
await storage.set("workspace_id", selectedSpace.id);
|
||||||
}
|
}
|
||||||
setValue(currentValue === value ? "" : currentValue);
|
setValue(currentValue === value ? "" : currentValue);
|
||||||
setOpen(false);
|
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',
|
GET_SHORTCUTS: 'shortcuts:get',
|
||||||
SET_SHORTCUTS: 'shortcuts:set',
|
SET_SHORTCUTS: 'shortcuts:set',
|
||||||
// Active search space
|
// Active search space
|
||||||
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
|
GET_ACTIVE_WORKSPACE: 'workspace:get-active',
|
||||||
SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active',
|
SET_ACTIVE_WORKSPACE: 'workspace:set-active',
|
||||||
// Launch on system startup
|
// Launch on system startup
|
||||||
GET_AUTO_LAUNCH: 'auto-launch:get',
|
GET_AUTO_LAUNCH: 'auto-launch:get',
|
||||||
SET_AUTO_LAUNCH: 'auto-launch:set',
|
SET_AUTO_LAUNCH: 'auto-launch:set',
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
} from '../modules/folder-watcher';
|
} from '../modules/folder-watcher';
|
||||||
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
||||||
import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch';
|
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 { reregisterQuickAsk } from '../modules/quick-ask';
|
||||||
import { reregisterGeneralAssist, reregisterScreenshotAssist } from '../modules/tray';
|
import { reregisterGeneralAssist, reregisterScreenshotAssist } from '../modules/tray';
|
||||||
import {
|
import {
|
||||||
|
|
@ -205,9 +205,9 @@ export function registerIpcHandlers(): void {
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
|
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
|
||||||
async (_event, virtualPath: string, searchSpaceId?: number | null) => {
|
async (_event, virtualPath: string, workspaceId?: number | null) => {
|
||||||
try {
|
try {
|
||||||
const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
|
const result = await readAgentLocalFileText(virtualPath, workspaceId);
|
||||||
return { ok: true, path: result.path, content: result.content };
|
return { ok: true, path: result.path, content: result.content };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to read local file';
|
const message = error instanceof Error ? error.message : 'Failed to read local file';
|
||||||
|
|
@ -218,9 +218,9 @@ export function registerIpcHandlers(): void {
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
|
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 {
|
try {
|
||||||
const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
|
const result = await writeAgentLocalFileText(virtualPath, content, workspaceId);
|
||||||
return { ok: true, path: result.path };
|
return { ok: true, path: result.path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to write local file';
|
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) =>
|
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, (_event, id: string) =>
|
||||||
setActiveSearchSpaceId(id)
|
setActiveWorkspaceId(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
|
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) =>
|
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, workspaceId?: number | null) =>
|
||||||
getAgentFilesystemSettings(searchSpaceId)
|
getAgentFilesystemSettings(workspaceId)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
|
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, workspaceId?: number | null) =>
|
||||||
getAgentFilesystemMounts(searchSpaceId)
|
getAgentFilesystemMounts(workspaceId)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
|
|
@ -384,7 +384,7 @@ export function registerIpcHandlers(): void {
|
||||||
_event,
|
_event,
|
||||||
options: {
|
options: {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
excludePatterns?: string[] | null;
|
excludePatterns?: string[] | null;
|
||||||
fileExtensions?: string[] | null;
|
fileExtensions?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
@ -397,10 +397,10 @@ export function registerIpcHandlers(): void {
|
||||||
(
|
(
|
||||||
_event,
|
_event,
|
||||||
payload: {
|
payload: {
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | 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, () =>
|
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
|
||||||
|
|
@ -415,7 +415,7 @@ export function registerIpcHandlers(): void {
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
|
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
|
||||||
(_event, searchSpaceId?: number | null) =>
|
(_event, workspaceId?: number | null) =>
|
||||||
stopAgentFilesystemTreeWatch(searchSpaceId)
|
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;
|
const EVENT_DEBOUNCE_MS = 700;
|
||||||
|
|
||||||
export type AgentFilesystemTreeWatchOptions = {
|
export type AgentFilesystemTreeWatchOptions = {
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
rootPaths: string[];
|
rootPaths: string[];
|
||||||
excludePatterns?: string[] | null;
|
excludePatterns?: string[] | null;
|
||||||
fileExtensions?: string[] | null;
|
fileExtensions?: string[] | null;
|
||||||
|
|
@ -17,7 +17,7 @@ export type AgentFilesystemTreeWatchOptions = {
|
||||||
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
|
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
|
||||||
|
|
||||||
type TreeDirtyEvent = {
|
type TreeDirtyEvent = {
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
reason: TreeDirtyReason;
|
reason: TreeDirtyReason;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
changedPath: string | null;
|
changedPath: string | null;
|
||||||
|
|
@ -25,7 +25,7 @@ type TreeDirtyEvent = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type WatchSession = {
|
type WatchSession = {
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
optionsSignature: string;
|
optionsSignature: string;
|
||||||
rootPaths: string[];
|
rootPaths: string[];
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
|
|
@ -40,15 +40,15 @@ type WatchSession = {
|
||||||
|
|
||||||
const sessions = new Map<string, WatchSession>();
|
const sessions = new Map<string, WatchSession>();
|
||||||
|
|
||||||
function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
|
function normalizeWorkspaceId(workspaceId?: number | null): number | null {
|
||||||
if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
if (typeof workspaceId === 'number' && Number.isFinite(workspaceId) && workspaceId > 0) {
|
||||||
return searchSpaceId;
|
return workspaceId;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionKey(searchSpaceId?: number | null): string {
|
function getSessionKey(workspaceId?: number | null): string {
|
||||||
const normalized = normalizeSearchSpaceId(searchSpaceId);
|
const normalized = normalizeWorkspaceId(workspaceId);
|
||||||
return normalized === null ? 'default' : String(normalized);
|
return normalized === null ? 'default' : String(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,13 +71,13 @@ function normalizeExtensions(value: string[] | null | undefined): string[] | nul
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOptionsSignature(
|
function buildOptionsSignature(
|
||||||
searchSpaceId: number | null,
|
workspaceId: number | null,
|
||||||
rootPaths: string[],
|
rootPaths: string[],
|
||||||
excludePatterns: string[],
|
excludePatterns: string[],
|
||||||
fileExtensions: string[] | null
|
fileExtensions: string[] | null
|
||||||
): string {
|
): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
rootPaths: [...rootPaths].sort(),
|
rootPaths: [...rootPaths].sort(),
|
||||||
excludePatterns: [...excludePatterns].sort(),
|
excludePatterns: [...excludePatterns].sort(),
|
||||||
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
|
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
|
||||||
|
|
@ -99,10 +99,10 @@ async function buildRootSnapshotSignature(
|
||||||
rootPath: string
|
rootPath: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let hash = 2166136261;
|
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({
|
const files = await listAgentFilesystemFiles({
|
||||||
rootPath,
|
rootPath,
|
||||||
searchSpaceId: session.searchSpaceId,
|
workspaceId: session.workspaceId,
|
||||||
excludePatterns: session.excludePatterns,
|
excludePatterns: session.excludePatterns,
|
||||||
fileExtensions: session.fileExtensions,
|
fileExtensions: session.fileExtensions,
|
||||||
});
|
});
|
||||||
|
|
@ -118,13 +118,13 @@ async function buildRootSnapshotSignature(
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTreeDirtyEvent(
|
function sendTreeDirtyEvent(
|
||||||
searchSpaceId: number | null,
|
workspaceId: number | null,
|
||||||
reason: TreeDirtyReason,
|
reason: TreeDirtyReason,
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
changedPath: string | null
|
changedPath: string | null
|
||||||
): void {
|
): void {
|
||||||
const payload: TreeDirtyEvent = {
|
const payload: TreeDirtyEvent = {
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
reason,
|
reason,
|
||||||
rootPath,
|
rootPath,
|
||||||
changedPath,
|
changedPath,
|
||||||
|
|
@ -158,7 +158,7 @@ function scheduleDirtyEmit(
|
||||||
session.pendingDirtyByRoot.clear();
|
session.pendingDirtyByRoot.clear();
|
||||||
for (const [pendingRootPath, payload] of pending) {
|
for (const [pendingRootPath, payload] of pending) {
|
||||||
sendTreeDirtyEvent(
|
sendTreeDirtyEvent(
|
||||||
session.searchSpaceId,
|
session.workspaceId,
|
||||||
payload.reason,
|
payload.reason,
|
||||||
pendingRootPath,
|
pendingRootPath,
|
||||||
payload.changedPath
|
payload.changedPath
|
||||||
|
|
@ -183,21 +183,21 @@ async function closeSession(session: WatchSession): Promise<void> {
|
||||||
export async function startAgentFilesystemTreeWatch(
|
export async function startAgentFilesystemTreeWatch(
|
||||||
options: AgentFilesystemTreeWatchOptions
|
options: AgentFilesystemTreeWatchOptions
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
|
const workspaceId = normalizeWorkspaceId(options.workspaceId);
|
||||||
const rootPaths = Array.from(
|
const rootPaths = Array.from(
|
||||||
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
|
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
|
||||||
);
|
);
|
||||||
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
|
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
|
||||||
const fileExtensions = normalizeExtensions(options.fileExtensions);
|
const fileExtensions = normalizeExtensions(options.fileExtensions);
|
||||||
const sessionKey = getSessionKey(searchSpaceId);
|
const sessionKey = getSessionKey(workspaceId);
|
||||||
|
|
||||||
if (rootPaths.length === 0) {
|
if (rootPaths.length === 0) {
|
||||||
await stopAgentFilesystemTreeWatch(searchSpaceId);
|
await stopAgentFilesystemTreeWatch(workspaceId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionsSignature = buildOptionsSignature(
|
const optionsSignature = buildOptionsSignature(
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
rootPaths,
|
rootPaths,
|
||||||
excludePatterns,
|
excludePatterns,
|
||||||
fileExtensions
|
fileExtensions
|
||||||
|
|
@ -228,7 +228,7 @@ export async function startAgentFilesystemTreeWatch(
|
||||||
);
|
);
|
||||||
|
|
||||||
const session: WatchSession = {
|
const session: WatchSession = {
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
optionsSignature,
|
optionsSignature,
|
||||||
rootPaths,
|
rootPaths,
|
||||||
excludePatterns,
|
excludePatterns,
|
||||||
|
|
@ -291,9 +291,9 @@ export async function startAgentFilesystemTreeWatch(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopAgentFilesystemTreeWatch(
|
export async function stopAgentFilesystemTreeWatch(
|
||||||
searchSpaceId?: number | null
|
workspaceId?: number | null
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
const sessionKey = getSessionKey(searchSpaceId);
|
const sessionKey = getSessionKey(workspaceId);
|
||||||
const session = sessions.get(sessionKey);
|
const session = sessions.get(sessionKey);
|
||||||
if (!session) return { ok: true };
|
if (!session) return { ok: true };
|
||||||
sessions.delete(sessionKey);
|
sessions.delete(sessionKey);
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,9 @@ async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[
|
||||||
return [...uniquePaths];
|
return [...uniquePaths];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
|
function normalizeWorkspaceKey(workspaceId?: number | null): string {
|
||||||
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
if (typeof workspaceId === "number" && Number.isFinite(workspaceId) && workspaceId > 0) {
|
||||||
return String(searchSpaceId);
|
return String(workspaceId);
|
||||||
}
|
}
|
||||||
return DEFAULT_SPACE_KEY;
|
return DEFAULT_SPACE_KEY;
|
||||||
}
|
}
|
||||||
|
|
@ -147,9 +147,9 @@ function getDefaultStore(): AgentFilesystemSettingsStore {
|
||||||
|
|
||||||
function getSettingsFromStore(
|
function getSettingsFromStore(
|
||||||
store: AgentFilesystemSettingsStore,
|
store: AgentFilesystemSettingsStore,
|
||||||
searchSpaceId?: number | null
|
workspaceId?: number | null
|
||||||
): AgentFilesystemSettings {
|
): AgentFilesystemSettings {
|
||||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
const key = normalizeWorkspaceKey(workspaceId);
|
||||||
return store.spaces[key] ?? getDefaultSettings();
|
return store.spaces[key] ?? getDefaultSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,22 +194,22 @@ async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettin
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAgentFilesystemSettings(
|
export async function getAgentFilesystemSettings(
|
||||||
searchSpaceId?: number | null
|
workspaceId?: number | null
|
||||||
): Promise<AgentFilesystemSettings> {
|
): Promise<AgentFilesystemSettings> {
|
||||||
const store = await loadAgentFilesystemSettingsStore();
|
const store = await loadAgentFilesystemSettingsStore();
|
||||||
return getSettingsFromStore(store, searchSpaceId);
|
return getSettingsFromStore(store, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setAgentFilesystemSettings(
|
export async function setAgentFilesystemSettings(
|
||||||
searchSpaceId: number | null | undefined,
|
workspaceId: number | null | undefined,
|
||||||
settings: {
|
settings: {
|
||||||
mode?: AgentFilesystemMode;
|
mode?: AgentFilesystemMode;
|
||||||
localRootPaths?: string[] | null;
|
localRootPaths?: string[] | null;
|
||||||
}
|
}
|
||||||
): Promise<AgentFilesystemSettings> {
|
): Promise<AgentFilesystemSettings> {
|
||||||
const store = await loadAgentFilesystemSettingsStore();
|
const store = await loadAgentFilesystemSettingsStore();
|
||||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
const key = normalizeWorkspaceKey(workspaceId);
|
||||||
const current = getSettingsFromStore(store, searchSpaceId);
|
const current = getSettingsFromStore(store, workspaceId);
|
||||||
const nextMode =
|
const nextMode =
|
||||||
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
|
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
|
||||||
? settings.mode
|
? settings.mode
|
||||||
|
|
@ -291,7 +291,7 @@ export type LocalRootMount = {
|
||||||
|
|
||||||
export type AgentFilesystemListOptions = {
|
export type AgentFilesystemListOptions = {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
excludePatterns?: string[] | null;
|
excludePatterns?: string[] | null;
|
||||||
fileExtensions?: string[] | null;
|
fileExtensions?: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
@ -332,9 +332,9 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAgentFilesystemMounts(
|
export async function getAgentFilesystemMounts(
|
||||||
searchSpaceId?: number | null
|
workspaceId?: number | null
|
||||||
): Promise<LocalRootMount[]> {
|
): Promise<LocalRootMount[]> {
|
||||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
const rootPaths = await resolveCurrentRootPaths(workspaceId);
|
||||||
return buildRootMounts(rootPaths);
|
return buildRootMounts(rootPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +371,7 @@ function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<
|
||||||
export async function listAgentFilesystemFiles(
|
export async function listAgentFilesystemFiles(
|
||||||
options: AgentFilesystemListOptions
|
options: AgentFilesystemListOptions
|
||||||
): Promise<AgentFilesystemFileEntry[]> {
|
): Promise<AgentFilesystemFileEntry[]> {
|
||||||
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
|
const allowedRootPaths = await resolveCurrentRootPaths(options.workspaceId);
|
||||||
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
|
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
|
||||||
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
|
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
|
||||||
const allowedRoots = new Set(
|
const allowedRoots = new Set(
|
||||||
|
|
@ -474,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
|
||||||
return `/${mount}${relativePath}`;
|
return `/${mount}${relativePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
|
async function resolveCurrentRootPaths(workspaceId?: number | null): Promise<string[]> {
|
||||||
const settings = await getAgentFilesystemSettings(searchSpaceId);
|
const settings = await getAgentFilesystemSettings(workspaceId);
|
||||||
if (settings.localRootPaths.length === 0) {
|
if (settings.localRootPaths.length === 0) {
|
||||||
throw new Error("No local filesystem roots selected");
|
throw new Error("No local filesystem roots selected");
|
||||||
}
|
}
|
||||||
|
|
@ -484,9 +484,9 @@ async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<s
|
||||||
|
|
||||||
export async function readAgentLocalFileText(
|
export async function readAgentLocalFileText(
|
||||||
virtualPath: string,
|
virtualPath: string,
|
||||||
searchSpaceId?: number | null
|
workspaceId?: number | null
|
||||||
): Promise<{ path: string; content: string }> {
|
): Promise<{ path: string; content: string }> {
|
||||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
const rootPaths = await resolveCurrentRootPaths(workspaceId);
|
||||||
const mounts = buildRootMounts(rootPaths);
|
const mounts = buildRootMounts(rootPaths);
|
||||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||||
const rootMount = findMountByName(mounts, mount);
|
const rootMount = findMountByName(mounts, mount);
|
||||||
|
|
@ -507,9 +507,9 @@ export async function readAgentLocalFileText(
|
||||||
export async function writeAgentLocalFileText(
|
export async function writeAgentLocalFileText(
|
||||||
virtualPath: string,
|
virtualPath: string,
|
||||||
content: string,
|
content: string,
|
||||||
searchSpaceId?: number | null
|
workspaceId?: number | null
|
||||||
): Promise<{ path: string }> {
|
): Promise<{ path: string }> {
|
||||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
const rootPaths = await resolveCurrentRootPaths(workspaceId);
|
||||||
const mounts = buildRootMounts(rootPaths);
|
const mounts = buildRootMounts(rootPaths);
|
||||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||||
const rootMount = findMountByName(mounts, mount);
|
const rootMount = findMountByName(mounts, mount);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { IPC_CHANNELS } from '../ipc/channels';
|
import { IPC_CHANNELS } from '../ipc/channels';
|
||||||
import { trackEvent } from './analytics';
|
import { trackEvent } from './analytics';
|
||||||
|
import { migrateWatchedFolderConfigs } from './migrate-watched-folders';
|
||||||
|
|
||||||
export interface WatchedFolderConfig {
|
export interface WatchedFolderConfig {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -12,7 +13,7 @@ export interface WatchedFolderConfig {
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
fileExtensions: string[] | null;
|
fileExtensions: string[] | null;
|
||||||
rootFolderId: number | null;
|
rootFolderId: number | null;
|
||||||
searchSpaceId: number;
|
workspaceId: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,7 +28,7 @@ type FolderSyncAction = 'add' | 'change' | 'unlink';
|
||||||
export interface FolderSyncFileChangedEvent {
|
export interface FolderSyncFileChangedEvent {
|
||||||
id: string;
|
id: string;
|
||||||
rootFolderId: number | null;
|
rootFolderId: number | null;
|
||||||
searchSpaceId: number;
|
workspaceId: number;
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
folderName: string;
|
folderName: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
|
|
@ -68,6 +69,12 @@ async function getStore() {
|
||||||
[STORE_KEY]: [] as WatchedFolderConfig[],
|
[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;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +274,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
||||||
if (storedMtime === undefined) {
|
if (storedMtime === undefined) {
|
||||||
sendFileChangedEvent({
|
sendFileChangedEvent({
|
||||||
rootFolderId: config.rootFolderId,
|
rootFolderId: config.rootFolderId,
|
||||||
searchSpaceId: config.searchSpaceId,
|
workspaceId: config.workspaceId,
|
||||||
folderPath: config.path,
|
folderPath: config.path,
|
||||||
folderName: config.name,
|
folderName: config.name,
|
||||||
relativePath: rel,
|
relativePath: rel,
|
||||||
|
|
@ -278,7 +285,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
||||||
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
|
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
|
||||||
sendFileChangedEvent({
|
sendFileChangedEvent({
|
||||||
rootFolderId: config.rootFolderId,
|
rootFolderId: config.rootFolderId,
|
||||||
searchSpaceId: config.searchSpaceId,
|
workspaceId: config.workspaceId,
|
||||||
folderPath: config.path,
|
folderPath: config.path,
|
||||||
folderName: config.name,
|
folderName: config.name,
|
||||||
relativePath: rel,
|
relativePath: rel,
|
||||||
|
|
@ -295,7 +302,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
||||||
if (!(rel in currentMap)) {
|
if (!(rel in currentMap)) {
|
||||||
sendFileChangedEvent({
|
sendFileChangedEvent({
|
||||||
rootFolderId: config.rootFolderId,
|
rootFolderId: config.rootFolderId,
|
||||||
searchSpaceId: config.searchSpaceId,
|
workspaceId: config.workspaceId,
|
||||||
folderPath: config.path,
|
folderPath: config.path,
|
||||||
folderName: config.name,
|
folderName: config.name,
|
||||||
relativePath: rel,
|
relativePath: rel,
|
||||||
|
|
@ -346,7 +353,7 @@ async function startWatcher(config: WatchedFolderConfig) {
|
||||||
|
|
||||||
sendFileChangedEvent({
|
sendFileChangedEvent({
|
||||||
rootFolderId: config.rootFolderId,
|
rootFolderId: config.rootFolderId,
|
||||||
searchSpaceId: config.searchSpaceId,
|
workspaceId: config.workspaceId,
|
||||||
folderPath: config.path,
|
folderPath: config.path,
|
||||||
folderName: config.name,
|
folderName: config.name,
|
||||||
relativePath,
|
relativePath,
|
||||||
|
|
@ -403,7 +410,7 @@ export async function addWatchedFolder(
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('desktop_folder_watch_added', {
|
trackEvent('desktop_folder_watch_added', {
|
||||||
search_space_id: config.searchSpaceId,
|
workspace_id: config.workspaceId,
|
||||||
root_folder_id: config.rootFolderId,
|
root_folder_id: config.rootFolderId,
|
||||||
active: config.active,
|
active: config.active,
|
||||||
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
|
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
|
||||||
|
|
@ -431,7 +438,7 @@ export async function removeWatchedFolder(
|
||||||
|
|
||||||
if (removed) {
|
if (removed) {
|
||||||
trackEvent('desktop_folder_watch_removed', {
|
trackEvent('desktop_folder_watch_removed', {
|
||||||
search_space_id: removed.searchSpaceId,
|
workspace_id: removed.workspaceId,
|
||||||
root_folder_id: removed.rootFolderId,
|
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 { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
|
||||||
import { getServerOrigin } from './server';
|
import { getServerOrigin } from './server';
|
||||||
import { getShortcuts } from './shortcuts';
|
import { getShortcuts } from './shortcuts';
|
||||||
import { getActiveSearchSpaceId } from './active-search-space';
|
import { getActiveWorkspaceId } from './active-workspace';
|
||||||
import { trackEvent } from './analytics';
|
import { trackEvent } from './analytics';
|
||||||
|
|
||||||
let currentShortcut = '';
|
let currentShortcut = '';
|
||||||
let quickAskWindow: BrowserWindow | null = null;
|
let quickAskWindow: BrowserWindow | null = null;
|
||||||
let pendingText = '';
|
let pendingText = '';
|
||||||
let pendingMode = '';
|
let pendingMode = '';
|
||||||
let pendingSearchSpaceId: string | null = null;
|
let pendingWorkspaceId: string | null = null;
|
||||||
let sourceApp = '';
|
let sourceApp = '';
|
||||||
let savedClipboard = '';
|
let savedClipboard = '';
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const spaceId = pendingSearchSpaceId;
|
const spaceId = pendingWorkspaceId;
|
||||||
const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard';
|
const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard';
|
||||||
quickAskWindow.loadURL(`${getServerOrigin()}${route}?quickAssist=true`);
|
quickAskWindow.loadURL(`${getServerOrigin()}${route}?quickAssist=true`);
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
||||||
async function openQuickAsk(text: string): Promise<void> {
|
async function openQuickAsk(text: string): Promise<void> {
|
||||||
pendingText = text;
|
pendingText = text;
|
||||||
pendingMode = 'quick-assist';
|
pendingMode = 'quick-assist';
|
||||||
pendingSearchSpaceId = await getActiveSearchSpaceId();
|
pendingWorkspaceId = await getActiveWorkspaceId();
|
||||||
const cursor = screen.getCursorScreenPoint();
|
const cursor = screen.getCursorScreenPoint();
|
||||||
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||||
createQuickAskWindow(pos.x, pos.y);
|
createQuickAskWindow(pos.x, pos.y);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import path from 'path';
|
||||||
import { trackEvent } from './analytics';
|
import { trackEvent } from './analytics';
|
||||||
import { showErrorDialog } from './errors';
|
import { showErrorDialog } from './errors';
|
||||||
import { getServerOrigin, getServerPort } from './server';
|
import { getServerOrigin, getServerPort } from './server';
|
||||||
import { setActiveSearchSpaceId } from './active-search-space';
|
import { setActiveWorkspaceId } from './active-workspace';
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
|
|
@ -140,14 +140,14 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-sync active search space from URL navigation
|
// Auto-sync active search space from URL navigation
|
||||||
const syncSearchSpace = (url: string) => {
|
const syncWorkspace = (url: string) => {
|
||||||
const match = url.match(/\/dashboard\/(\d+)/);
|
const match = url.match(/\/dashboard\/(\d+)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
setActiveSearchSpaceId(match[1]);
|
setActiveWorkspaceId(match[1]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url));
|
mainWindow.webContents.on('did-navigate', (_event, url) => syncWorkspace(url));
|
||||||
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url));
|
mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncWorkspace(url));
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Browse files via native dialog
|
// Browse files via native dialog
|
||||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
||||||
readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
|
readAgentLocalFileText: (virtualPath: string, workspaceId?: number | null) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
|
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, workspaceId),
|
||||||
writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
|
writeAgentLocalFileText: (virtualPath: string, content: string, workspaceId?: number | null) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
|
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, workspaceId),
|
||||||
|
|
||||||
// Auth token sync across windows
|
// Auth token sync across windows
|
||||||
getAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACCESS_TOKEN),
|
getAccessToken: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACCESS_TOKEN),
|
||||||
|
|
@ -104,9 +104,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }),
|
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }),
|
||||||
|
|
||||||
// Active search space
|
// Active search space
|
||||||
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
|
getActiveWorkspace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_WORKSPACE),
|
||||||
setActiveSearchSpace: (id: string) =>
|
setActiveWorkspace: (id: string) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
|
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_WORKSPACE, id),
|
||||||
|
|
||||||
// Analytics bridge — lets posthog-js running inside the Next.js renderer
|
// Analytics bridge — lets posthog-js running inside the Next.js renderer
|
||||||
// mirror identify/reset/capture into the Electron main-process PostHog
|
// 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 }),
|
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
|
||||||
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
||||||
// Agent filesystem mode
|
// Agent filesystem mode
|
||||||
getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
|
getAgentFilesystemSettings: (workspaceId?: number | null) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
|
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, workspaceId),
|
||||||
getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
|
getAgentFilesystemMounts: (workspaceId?: number | null) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
|
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, workspaceId),
|
||||||
listAgentFilesystemFiles: (options: {
|
listAgentFilesystemFiles: (options: {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
excludePatterns?: string[] | null;
|
excludePatterns?: string[] | null;
|
||||||
fileExtensions?: string[] | null;
|
fileExtensions?: string[] | null;
|
||||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
|
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
|
||||||
startAgentFilesystemTreeWatch: (options: {
|
startAgentFilesystemTreeWatch: (options: {
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
rootPaths: string[];
|
rootPaths: string[];
|
||||||
excludePatterns?: string[] | null;
|
excludePatterns?: string[] | null;
|
||||||
fileExtensions?: string[] | null;
|
fileExtensions?: string[] | null;
|
||||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
|
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
|
||||||
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
|
stopAgentFilesystemTreeWatch: (workspaceId?: number | null) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
|
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, workspaceId),
|
||||||
onAgentFilesystemTreeDirty: (
|
onAgentFilesystemTreeDirty: (
|
||||||
callback: (data: {
|
callback: (data: {
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
reason: 'watcher_event' | 'safety_poll';
|
reason: 'watcher_event' | 'safety_poll';
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
changedPath: string | null;
|
changedPath: string | null;
|
||||||
|
|
@ -148,7 +148,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: unknown,
|
_event: unknown,
|
||||||
data: {
|
data: {
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
reason: 'watcher_event' | 'safety_poll';
|
reason: 'watcher_event' | 'safety_poll';
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
changedPath: string | null;
|
changedPath: string | null;
|
||||||
|
|
@ -163,7 +163,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
setAgentFilesystemSettings: (settings: {
|
setAgentFilesystemSettings: (settings: {
|
||||||
mode?: "cloud" | "desktop_local_folder";
|
mode?: "cloud" | "desktop_local_folder";
|
||||||
localRootPaths?: string[] | null;
|
localRootPaths?: string[] | null;
|
||||||
}, searchSpaceId?: number | null) =>
|
}, workspaceId?: number | null) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
|
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { workspaceId, settings }),
|
||||||
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
|
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,5 @@
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"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 |
|
| 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 |
|
| 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 |
|
| Vault name | Defaults to your Obsidian vault name; rename if you have multiple vaults |
|
||||||
| Sync mode | *Auto* (recommended) or *Manual* |
|
| Sync mode | *Auto* (recommended) or *Manual* |
|
||||||
| Exclude patterns | Glob patterns of folders/files to skip (e.g. `.trash`, `_attachments`, `templates/**`) |
|
| 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_id`: a random UUID minted in the plugin's `data.json` on first run
|
||||||
- `vault_name`: the Obsidian vault folder name
|
- `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`:**
|
**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,
|
NotePayload,
|
||||||
RenameAck,
|
RenameAck,
|
||||||
RenameItem,
|
RenameItem,
|
||||||
SearchSpace,
|
Workspace,
|
||||||
SyncAck,
|
SyncAck,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
|
@ -94,14 +94,14 @@ export class SurfSenseApiClient {
|
||||||
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
|
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSearchSpaces(): Promise<SearchSpace[]> {
|
async listWorkspaces(): Promise<Workspace[]> {
|
||||||
const resp = await this.request<SearchSpace[] | { items: SearchSpace[] }>(
|
const resp = await this.request<Workspace[] | { items: Workspace[] }>(
|
||||||
"GET",
|
"GET",
|
||||||
"/api/v1/searchspaces/"
|
"/api/v1/workspaces/"
|
||||||
);
|
);
|
||||||
if (Array.isArray(resp)) return resp;
|
if (Array.isArray(resp)) return resp;
|
||||||
if (resp && Array.isArray((resp as { items?: SearchSpace[] }).items)) {
|
if (resp && Array.isArray((resp as { items?: Workspace[] }).items)) {
|
||||||
return (resp as { items: SearchSpace[] }).items;
|
return (resp as { items: Workspace[] }).items;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ export class SurfSenseApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(input: {
|
async connect(input: {
|
||||||
searchSpaceId: number;
|
workspaceId: number;
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
vaultFingerprint: string;
|
vaultFingerprint: string;
|
||||||
|
|
@ -125,7 +125,7 @@ export class SurfSenseApiClient {
|
||||||
{
|
{
|
||||||
vault_id: input.vaultId,
|
vault_id: input.vaultId,
|
||||||
vault_name: input.vaultName,
|
vault_name: input.vaultName,
|
||||||
search_space_id: input.searchSpaceId,
|
workspace_id: input.workspaceId,
|
||||||
vault_fingerprint: input.vaultFingerprint,
|
vault_fingerprint: input.vaultFingerprint,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
name: "Sync current note",
|
name: "Sync current note",
|
||||||
checkCallback: (checking) => {
|
checkCallback: (checking) => {
|
||||||
const file = this.app.workspace.getActiveFile();
|
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;
|
if (checking) return true;
|
||||||
this.queue.enqueueUpsert(file.path);
|
this.queue.enqueueUpsert(file.path);
|
||||||
void this.engine.flushQueue();
|
void this.engine.flushQueue();
|
||||||
|
|
@ -186,7 +186,7 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
this.engine?.refreshStatus();
|
this.engine?.refreshStatus();
|
||||||
this.notifyStatusChange();
|
this.notifyStatusChange();
|
||||||
if (this.settings.searchSpaceId !== null) {
|
if (this.settings.workspaceId !== null) {
|
||||||
void this.engine.ensureConnected();
|
void this.engine.ensureConnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -252,16 +252,24 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
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 = {
|
this.settings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...(data ?? {}),
|
...persisted,
|
||||||
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
workspaceId:
|
||||||
tombstones: { ...(data?.tombstones ?? {}) },
|
persisted.workspaceId ?? legacyWorkspaceId ?? DEFAULT_SETTINGS.workspaceId,
|
||||||
includeFolders: [...(data?.includeFolders ?? [])],
|
queue: (persisted.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
||||||
excludeFolders: [...(data?.excludeFolders ?? [])],
|
tombstones: { ...(persisted.tombstones ?? {}) },
|
||||||
excludePatterns: data?.excludePatterns?.length
|
includeFolders: [...(persisted.includeFolders ?? [])],
|
||||||
? [...data.excludePatterns]
|
excludeFolders: [...(persisted.excludeFolders ?? [])],
|
||||||
|
excludePatterns: persisted.excludePatterns?.length
|
||||||
|
? [...persisted.excludePatterns]
|
||||||
: [...DEFAULT_SETTINGS.excludePatterns],
|
: [...DEFAULT_SETTINGS.excludePatterns],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ import { normalizeFolder, parseExcludePatterns } from "./excludes";
|
||||||
import { FolderSuggestModal } from "./folder-suggest-modal";
|
import { FolderSuggestModal } from "./folder-suggest-modal";
|
||||||
import type SurfSensePlugin from "./main";
|
import type SurfSensePlugin from "./main";
|
||||||
import { STATUS_VISUALS } from "./status-visuals";
|
import { STATUS_VISUALS } from "./status-visuals";
|
||||||
import type { SearchSpace } from "./types";
|
import type { Workspace } from "./types";
|
||||||
|
|
||||||
/** Plugin settings tab. */
|
/** Plugin settings tab. */
|
||||||
|
|
||||||
export class SurfSenseSettingTab extends PluginSettingTab {
|
export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
private readonly plugin: SurfSensePlugin;
|
private readonly plugin: SurfSensePlugin;
|
||||||
private searchSpaces: SearchSpace[] = [];
|
private workspaces: Workspace[] = [];
|
||||||
private loadingSpaces = false;
|
private loadingSpaces = false;
|
||||||
private connectionIndicator: HTMLElement | null = null;
|
private connectionIndicator: HTMLElement | null = null;
|
||||||
private readonly onStatusChange = (): void => this.updateConnectionIndicator();
|
private readonly onStatusChange = (): void => this.updateConnectionIndicator();
|
||||||
|
|
@ -51,7 +51,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
const next = value.trim();
|
const next = value.trim();
|
||||||
const previous = this.plugin.settings.serverUrl;
|
const previous = this.plugin.settings.serverUrl;
|
||||||
if (previous !== "" && next !== previous) {
|
if (previous !== "" && next !== previous) {
|
||||||
this.plugin.settings.searchSpaceId = null;
|
this.plugin.settings.workspaceId = null;
|
||||||
this.plugin.settings.connectorId = null;
|
this.plugin.settings.connectorId = null;
|
||||||
}
|
}
|
||||||
this.plugin.settings.serverUrl = next;
|
this.plugin.settings.serverUrl = next;
|
||||||
|
|
@ -80,7 +80,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
const next = value.trim();
|
const next = value.trim();
|
||||||
const previous = this.plugin.settings.apiToken;
|
const previous = this.plugin.settings.apiToken;
|
||||||
if (previous !== "" && next !== previous) {
|
if (previous !== "" && next !== previous) {
|
||||||
this.plugin.settings.searchSpaceId = null;
|
this.plugin.settings.workspaceId = null;
|
||||||
this.plugin.settings.connectorId = null;
|
this.plugin.settings.connectorId = null;
|
||||||
}
|
}
|
||||||
this.plugin.settings.apiToken = next;
|
this.plugin.settings.apiToken = next;
|
||||||
|
|
@ -102,7 +102,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
await this.plugin.api.verifyToken();
|
await this.plugin.api.verifyToken();
|
||||||
new Notice("Surfsense: token verified.");
|
new Notice("Surfsense: token verified.");
|
||||||
this.plugin.engine.refreshStatus({ force: true });
|
this.plugin.engine.refreshStatus({ force: true });
|
||||||
await this.refreshSearchSpaces();
|
await this.refreshWorkspaces();
|
||||||
this.display();
|
this.display();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleApiError(err);
|
this.handleApiError(err);
|
||||||
|
|
@ -115,21 +115,21 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Search space")
|
.setName("Search space")
|
||||||
.setDesc(
|
.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) => {
|
.addDropdown((drop) => {
|
||||||
drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a search space");
|
drop.addOption("", this.loadingSpaces ? "Loading…" : "Select a workspace");
|
||||||
for (const space of this.searchSpaces) {
|
for (const space of this.workspaces) {
|
||||||
drop.addOption(String(space.id), space.name);
|
drop.addOption(String(space.id), space.name);
|
||||||
}
|
}
|
||||||
if (settings.searchSpaceId !== null) {
|
if (settings.workspaceId !== null) {
|
||||||
drop.setValue(String(settings.searchSpaceId));
|
drop.setValue(String(settings.workspaceId));
|
||||||
}
|
}
|
||||||
drop.onChange(async (value) => {
|
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;
|
this.plugin.settings.connectorId = null;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
if (this.plugin.settings.searchSpaceId !== null) {
|
if (this.plugin.settings.workspaceId !== null) {
|
||||||
try {
|
try {
|
||||||
await this.plugin.engine.ensureConnected();
|
await this.plugin.engine.ensureConnected();
|
||||||
await this.plugin.engine.maybeReconcile(true);
|
await this.plugin.engine.maybeReconcile(true);
|
||||||
|
|
@ -144,9 +144,9 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
.addExtraButton((btn) =>
|
.addExtraButton((btn) =>
|
||||||
btn
|
btn
|
||||||
.setIcon("refresh-ccw")
|
.setIcon("refresh-ccw")
|
||||||
.setTooltip("Reload search spaces")
|
.setTooltip("Reload workspaces")
|
||||||
.onClick(async () => {
|
.onClick(async () => {
|
||||||
await this.refreshSearchSpaces();
|
await this.refreshWorkspaces();
|
||||||
this.display();
|
this.display();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -319,13 +319,13 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
indicator.setAttr("title", visual.label);
|
indicator.setAttr("title", visual.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshSearchSpaces(): Promise<void> {
|
private async refreshWorkspaces(): Promise<void> {
|
||||||
this.loadingSpaces = true;
|
this.loadingSpaces = true;
|
||||||
try {
|
try {
|
||||||
this.searchSpaces = await this.plugin.api.listSearchSpaces();
|
this.workspaces = await this.plugin.api.listWorkspaces();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleApiError(err);
|
this.handleApiError(err);
|
||||||
this.searchSpaces = [];
|
this.workspaces = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingSpaces = false;
|
this.loadingSpaces = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export interface SyncEngineSettings {
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
connectorId: number | null;
|
connectorId: number | null;
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
includeFolders: string[];
|
includeFolders: string[];
|
||||||
excludeFolders: string[];
|
excludeFolders: string[];
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
|
|
@ -96,7 +96,7 @@ export class SyncEngine {
|
||||||
this.setStatus("syncing", "Connecting to SurfSense…");
|
this.setStatus("syncing", "Connecting to SurfSense…");
|
||||||
|
|
||||||
const settings = this.deps.getSettings();
|
const settings = this.deps.getSettings();
|
||||||
if (!settings.searchSpaceId) {
|
if (!settings.workspaceId) {
|
||||||
// No target yet — /health still surfaces auth/network errors.
|
// No target yet — /health still surfaces auth/network errors.
|
||||||
try {
|
try {
|
||||||
const health = await this.deps.apiClient.health();
|
const health = await this.deps.apiClient.health();
|
||||||
|
|
@ -124,7 +124,7 @@ export class SyncEngine {
|
||||||
*/
|
*/
|
||||||
async ensureConnected(): Promise<boolean> {
|
async ensureConnected(): Promise<boolean> {
|
||||||
const settings = this.deps.getSettings();
|
const settings = this.deps.getSettings();
|
||||||
if (!settings.searchSpaceId) {
|
if (!settings.workspaceId) {
|
||||||
this.setStatus("idle");
|
this.setStatus("idle");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +132,7 @@ export class SyncEngine {
|
||||||
try {
|
try {
|
||||||
const fingerprint = await computeVaultFingerprint(this.deps.app);
|
const fingerprint = await computeVaultFingerprint(this.deps.app);
|
||||||
const resp = await this.deps.apiClient.connect({
|
const resp = await this.deps.apiClient.connect({
|
||||||
searchSpaceId: settings.searchSpaceId,
|
workspaceId: settings.workspaceId,
|
||||||
vaultId: settings.vaultId,
|
vaultId: settings.vaultId,
|
||||||
vaultName: this.deps.app.vault.getName(),
|
vaultName: this.deps.app.vault.getName(),
|
||||||
vaultFingerprint: fingerprint,
|
vaultFingerprint: fingerprint,
|
||||||
|
|
@ -272,7 +272,7 @@ export class SyncEngine {
|
||||||
this.refreshStatus({ force: true });
|
this.refreshStatus({ force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!settings.searchSpaceId) {
|
if (!settings.workspaceId) {
|
||||||
try {
|
try {
|
||||||
const health = await this.deps.apiClient.health();
|
const health = await this.deps.apiClient.health();
|
||||||
this.applyHealth(health);
|
this.applyHealth(health);
|
||||||
|
|
@ -543,7 +543,7 @@ export class SyncEngine {
|
||||||
const isError =
|
const isError =
|
||||||
last === "auth-error" || last === "offline" || last === "error";
|
last === "auth-error" || last === "offline" || last === "error";
|
||||||
const s = this.deps.getSettings();
|
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;
|
if (isError && setupComplete) return;
|
||||||
}
|
}
|
||||||
this.setStatus(this.queueStatusKind(), this.statusDetail());
|
this.setStatus(this.queueStatusKind(), this.statusDetail());
|
||||||
|
|
@ -571,7 +571,7 @@ export class SyncEngine {
|
||||||
kind = "needs-setup";
|
kind = "needs-setup";
|
||||||
detail = this.setupHint(s);
|
detail = this.setupHint(s);
|
||||||
} else if (kind !== "auth-error" && kind !== "offline" && kind !== "error") {
|
} else if (kind !== "auth-error" && kind !== "offline" && kind !== "error") {
|
||||||
if (!s.searchSpaceId || !s.connectorId) {
|
if (!s.workspaceId || !s.connectorId) {
|
||||||
kind = "needs-setup";
|
kind = "needs-setup";
|
||||||
detail = this.setupHint(s);
|
detail = this.setupHint(s);
|
||||||
}
|
}
|
||||||
|
|
@ -582,7 +582,7 @@ export class SyncEngine {
|
||||||
|
|
||||||
private setupHint(s: SyncEngineSettings): string {
|
private setupHint(s: SyncEngineSettings): string {
|
||||||
if (!s.apiToken) return "Paste your API token in settings.";
|
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…";
|
return "Connecting…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
export interface SurfsensePluginSettings {
|
export interface SurfsensePluginSettings {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
connectorId: number | null;
|
connectorId: number | null;
|
||||||
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
|
|
@ -25,7 +25,7 @@ export interface SurfsensePluginSettings {
|
||||||
export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
|
export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
|
||||||
serverUrl: "https://surfsense.com",
|
serverUrl: "https://surfsense.com",
|
||||||
apiToken: "",
|
apiToken: "",
|
||||||
searchSpaceId: null,
|
workspaceId: null,
|
||||||
connectorId: null,
|
connectorId: null,
|
||||||
vaultId: "",
|
vaultId: "",
|
||||||
syncIntervalMinutes: 10,
|
syncIntervalMinutes: 10,
|
||||||
|
|
@ -107,7 +107,7 @@ export interface HeadingRef {
|
||||||
level: number;
|
level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchSpace {
|
export interface Workspace {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -117,7 +117,7 @@ export interface SearchSpace {
|
||||||
export interface ConnectResponse {
|
export interface ConnectResponse {
|
||||||
connector_id: number;
|
connector_id: number;
|
||||||
vault_id: string;
|
vault_id: string;
|
||||||
search_space_id: number;
|
workspace_id: number;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
server_time_utc: string;
|
server_time_utc: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function AutomationDetailContent({
|
||||||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function AutomationEditContent({ workspaceId, automationId }: AutomationE
|
||||||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ interface AutomationsContentProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client orchestrator for the automations list page. Pulls the active
|
* 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.
|
* and the user's permissions, then decides between empty / loading / table.
|
||||||
*
|
*
|
||||||
* Read access is mandatory; anything else is hidden behind RBAC. The
|
* 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 />
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export function AutomationsEmptyState({ workspaceId, canCreate }: AutomationsEmp
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-6 text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export function AutomationBuilderForm({
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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
|
// automation on create; in edit mode the backend preserves the captured
|
||||||
// snapshot, so the picker is create-only.
|
// snapshot, so the picker is create-only.
|
||||||
const eligibleModels = useAutomationEligibleModels();
|
const eligibleModels = useAutomationEligibleModels();
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
|
||||||
if (mention.kind === "folder") {
|
if (mention.kind === "folder") {
|
||||||
return { id: mention.id, title: mention.title, 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 {
|
return {
|
||||||
id: mention.id,
|
id: mention.id,
|
||||||
title: mention.title,
|
title: mention.title,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function DeleteAutomationDialog({
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await deleteAutomation({ automationId, workspaceId });
|
await deleteAutomation({ automationId, workspaceId: workspaceId });
|
||||||
onDeleted?.();
|
onDeleted?.();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function AutomationNewContent({ workspaceId }: AutomationNewContentProps)
|
||||||
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</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 isOnboardingPage = pathname?.includes("/onboard");
|
||||||
const isOwner = access?.is_owner ?? false;
|
const isOwner = access?.is_owner ?? false;
|
||||||
const isSearchSpaceReady = activeWorkspaceId === workspaceId;
|
const isWorkspaceReady = activeWorkspaceId === workspaceId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSearchSpaceReady) return;
|
if (isWorkspaceReady) return;
|
||||||
setHasCheckedOnboarding(false);
|
setHasCheckedOnboarding(false);
|
||||||
}, [isSearchSpaceReady]);
|
}, [isWorkspaceReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOnboardingPage) {
|
if (isOnboardingPage) {
|
||||||
|
|
@ -66,7 +66,7 @@ export function DashboardClientLayout({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isSearchSpaceReady &&
|
isWorkspaceReady &&
|
||||||
!loading &&
|
!loading &&
|
||||||
!accessLoading &&
|
!accessLoading &&
|
||||||
!globalConfigsLoading &&
|
!globalConfigsLoading &&
|
||||||
|
|
@ -75,7 +75,7 @@ export function DashboardClientLayout({
|
||||||
!hasCheckedOnboarding
|
!hasCheckedOnboarding
|
||||||
) {
|
) {
|
||||||
// Onboarding is only relevant when no operator-provided
|
// 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.
|
// the global config and should never be forced into onboarding.
|
||||||
if (globalConfigStatus?.exists) {
|
if (globalConfigStatus?.exists) {
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
|
|
@ -102,7 +102,7 @@ export function DashboardClientLayout({
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isSearchSpaceReady,
|
isWorkspaceReady,
|
||||||
loading,
|
loading,
|
||||||
accessLoading,
|
accessLoading,
|
||||||
globalConfigsLoading,
|
globalConfigsLoading,
|
||||||
|
|
@ -153,13 +153,13 @@ export function DashboardClientLayout({
|
||||||
setActiveWorkspaceIdState(activeSeacrhSpaceId);
|
setActiveWorkspaceIdState(activeSeacrhSpaceId);
|
||||||
|
|
||||||
// Sync to Electron store if stored value is null (first navigation)
|
// Sync to Electron store if stored value is null (first navigation)
|
||||||
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
|
if (electronAPI?.getActiveWorkspace && electronAPI.setActiveWorkspace) {
|
||||||
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
|
const setActiveWorkspace = electronAPI.setActiveWorkspace;
|
||||||
electronAPI
|
electronAPI
|
||||||
.getActiveSearchSpace()
|
.getActiveWorkspace()
|
||||||
.then((stored: string | null) => {
|
.then((stored: string | null) => {
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
setActiveSearchSpace(activeSeacrhSpaceId);
|
setActiveWorkspace(activeSeacrhSpaceId);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
@ -169,7 +169,7 @@ export function DashboardClientLayout({
|
||||||
// Determine if we should show loading
|
// Determine if we should show loading
|
||||||
const shouldShowLoading =
|
const shouldShowLoading =
|
||||||
!hasCheckedOnboarding &&
|
!hasCheckedOnboarding &&
|
||||||
(!isSearchSpaceReady ||
|
(!isWorkspaceReady ||
|
||||||
loading ||
|
loading ||
|
||||||
accessLoading ||
|
accessLoading ||
|
||||||
globalConfigsLoading ||
|
globalConfigsLoading ||
|
||||||
|
|
|
||||||
|
|
@ -579,7 +579,7 @@ export default function NewChatPage() {
|
||||||
error,
|
error,
|
||||||
flow,
|
flow,
|
||||||
context: {
|
context: {
|
||||||
workspaceId,
|
workspaceId: workspaceId,
|
||||||
threadId,
|
threadId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -717,7 +717,7 @@ export default function NewChatPage() {
|
||||||
data: typeof threadMessagesQuery.data;
|
data: typeof threadMessagesQuery.data;
|
||||||
}>({ threadId: null, data: undefined });
|
}>({ 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.
|
// is handled by React Query below so the chat shell can render immediately.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextThreadId = urlChatId > 0 ? urlChatId : null;
|
const nextThreadId = urlChatId > 0 ? urlChatId : null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function SearchSpaceDashboardPage({
|
export default async function WorkspaceDashboardPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ workspace_id: string }>;
|
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 { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||||
import { formatRelativeDate } from "@/lib/format-date";
|
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 { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (members.length > 0 && !membersLoading) {
|
if (members.length > 0 && !membersLoading) {
|
||||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||||
trackSearchSpaceUsersViewed(workspaceId, members.length, ownerCount);
|
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
|
||||||
}
|
}
|
||||||
}, [members, membersLoading, workspaceId]);
|
}, [members, membersLoading, workspaceId]);
|
||||||
|
|
||||||
|
|
@ -581,7 +581,7 @@ function MemberRow({
|
||||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will remove <span className="font-medium">{member.user_email}</span>{" "}
|
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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -654,7 +654,7 @@ function CreateInviteDialog({
|
||||||
setCreatedInvite(invite);
|
setCreatedInvite(invite);
|
||||||
|
|
||||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
||||||
trackSearchSpaceInviteSent(workspaceId, {
|
trackWorkspaceInviteSent(workspaceId, {
|
||||||
roleName,
|
roleName,
|
||||||
hasExpiry: !!expiresAt,
|
hasExpiry: !!expiresAt,
|
||||||
hasMaxUses: !!maxUses,
|
hasMaxUses: !!maxUses,
|
||||||
|
|
@ -709,7 +709,7 @@ function CreateInviteDialog({
|
||||||
Invite Created!
|
Invite Created!
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Share this link to invite people to your search space.
|
Share this link to invite people to your workspace.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 py-2 md:py-4">
|
<div className="space-y-3 py-2 md:py-4">
|
||||||
|
|
@ -748,7 +748,7 @@ function CreateInviteDialog({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Invite Members</DialogTitle>
|
<DialogTitle>Invite Members</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a link to invite people to this search space.
|
Create a link to invite people to this workspace.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 py-2 md:py-4">
|
<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) {
|
if (!workspaceId) {
|
||||||
return (
|
return <p className="text-sm text-muted-foreground">Open a workspace to manage agent rules.</p>;
|
||||||
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||||
|
|
||||||
export function DesktopContent() {
|
export function DesktopContent() {
|
||||||
const api = useElectronAPI();
|
const api = useElectronAPI();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
|
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
|
||||||
|
|
@ -40,14 +40,14 @@ export function DesktopContent() {
|
||||||
setAutoLaunchSupported(hasAutoLaunchApi);
|
setAutoLaunchSupported(hasAutoLaunchApi);
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
api.getActiveWorkspace?.() ?? Promise.resolve(null),
|
||||||
searchSpacesApiService.getSearchSpaces(),
|
workspacesApiService.getWorkspaces(),
|
||||||
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
.then(([spaceId, spaces, autoLaunch]) => {
|
.then(([spaceId, spaces, autoLaunch]) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setActiveSpaceId(spaceId);
|
setActiveSpaceId(spaceId);
|
||||||
if (spaces) setSearchSpaces(spaces);
|
if (spaces) setWorkspaces(spaces);
|
||||||
if (autoLaunch) {
|
if (autoLaunch) {
|
||||||
setAutoLaunchEnabled(autoLaunch.enabled);
|
setAutoLaunchEnabled(autoLaunch.enabled);
|
||||||
setAutoLaunchHidden(autoLaunch.openAsHidden);
|
setAutoLaunchHidden(autoLaunch.openAsHidden);
|
||||||
|
|
@ -136,30 +136,30 @@ export function DesktopContent() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchSpaceChange = (value: string) => {
|
const handleWorkspaceChange = (value: string) => {
|
||||||
setActiveSpaceId(value);
|
setActiveSpaceId(value);
|
||||||
api.setActiveSearchSpace?.(value);
|
api.setActiveWorkspace?.(value);
|
||||||
toast.success("Default search space updated");
|
toast.success("Default workspace updated");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<section>
|
<section>
|
||||||
<div className="pb-2 md:pb-3">
|
<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">
|
<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.
|
default.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{searchSpaces.length > 0 ? (
|
{workspaces.length > 0 ? (
|
||||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
<Select value={activeSpaceId ?? undefined} onValueChange={handleWorkspaceChange}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select a search space" />
|
<SelectValue placeholder="Select a workspace" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{searchSpaces.map((space) => (
|
{workspaces.map((space) => (
|
||||||
<SelectItem key={space.id} value={String(space.id)}>
|
<SelectItem key={space.id} value={String(space.id)}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -167,9 +167,7 @@ export function DesktopContent() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">No workspaces found. Create one first.</p>
|
||||||
No search spaces found. Create one first.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import type { SearchSpace } from "@/contracts/types/workspace.types";
|
import type { Workspace } from "@/contracts/types/workspace.types";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||||
import { buildBackendUrl } from "@/lib/env-config";
|
import { buildBackendUrl } from "@/lib/env-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -79,7 +79,7 @@ export function MessagingChannelsContent() {
|
||||||
const workspaceId = Number(params.workspace_id);
|
const workspaceId = Number(params.workspace_id);
|
||||||
const [gatewayConfig, setGatewayConfig] = useState<GatewayConfigState>(null);
|
const [gatewayConfig, setGatewayConfig] = useState<GatewayConfigState>(null);
|
||||||
const [connections, setConnections] = useState<GatewayConnection[]>([]);
|
const [connections, setConnections] = useState<GatewayConnection[]>([]);
|
||||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
const [pairing, setPairing] = useState<Pairing | null>(null);
|
const [pairing, setPairing] = useState<Pairing | null>(null);
|
||||||
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
||||||
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
||||||
|
|
@ -114,11 +114,11 @@ export function MessagingChannelsContent() {
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
|
const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
|
||||||
fetchConnections(),
|
fetchConnections(),
|
||||||
searchSpacesApiService.getSearchSpaces(),
|
workspacesApiService.getWorkspaces(),
|
||||||
fetchGatewayConfig(),
|
fetchGatewayConfig(),
|
||||||
]);
|
]);
|
||||||
setConnections(nextConnections);
|
setConnections(nextConnections);
|
||||||
setSearchSpaces(spaces);
|
setWorkspaces(spaces);
|
||||||
setGatewayConfig(nextGatewayConfig);
|
setGatewayConfig(nextGatewayConfig);
|
||||||
}, [fetchConnections, fetchGatewayConfig]);
|
}, [fetchConnections, fetchGatewayConfig]);
|
||||||
|
|
||||||
|
|
@ -210,10 +210,7 @@ export function MessagingChannelsContent() {
|
||||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateConnectionSearchSpace(
|
async function updateConnectionWorkspace(connection: GatewayConnection, nextWorkspaceId: string) {
|
||||||
connection: GatewayConnection,
|
|
||||||
nextWorkspaceId: string
|
|
||||||
) {
|
|
||||||
const previousConnections = connections;
|
const previousConnections = connections;
|
||||||
const parsedWorkspaceId = Number(nextWorkspaceId);
|
const parsedWorkspaceId = Number(nextWorkspaceId);
|
||||||
const targetKey = connectionKey(connection);
|
const targetKey = connectionKey(connection);
|
||||||
|
|
@ -324,14 +321,14 @@ export function MessagingChannelsContent() {
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={String(connection.workspace_id)}
|
value={String(connection.workspace_id)}
|
||||||
onValueChange={(value) => updateConnectionSearchSpace(connection, value)}
|
onValueChange={(value) => updateConnectionWorkspace(connection, value)}
|
||||||
disabled={searchSpaces.length === 0}
|
disabled={workspaces.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
|
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
|
||||||
<SelectValue placeholder="Select search space" />
|
<SelectValue placeholder="Select workspace" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{searchSpaces.map((space) => (
|
{workspaces.map((space) => (
|
||||||
<SelectItem key={space.id} value={String(space.id)}>
|
<SelectItem key={space.id} value={String(space.id)}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -409,7 +406,7 @@ export function MessagingChannelsContent() {
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
|
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.
|
answers from your knowledge base without leaving your chat app.
|
||||||
</p>
|
</p>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
ReceiptText,
|
ReceiptText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
WandSparkles,
|
WandSparkles,
|
||||||
Workflow,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSelectedLayoutSegment } from "next/navigation";
|
import { useSelectedLayoutSegment } from "next/navigation";
|
||||||
|
|
@ -27,7 +26,6 @@ export type UserSettingsTab =
|
||||||
| "prompts"
|
| "prompts"
|
||||||
| "community-prompts"
|
| "community-prompts"
|
||||||
| "agent-permissions"
|
| "agent-permissions"
|
||||||
| "agent-status"
|
|
||||||
| "purchases"
|
| "purchases"
|
||||||
| "desktop"
|
| "desktop"
|
||||||
| "hotkeys"
|
| "hotkeys"
|
||||||
|
|
@ -80,11 +78,6 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
||||||
label: "Agent Permissions",
|
label: "Agent Permissions",
|
||||||
icon: <ShieldCheck className="h-4 w-4" />,
|
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,
|
value: "messaging-channels" as const,
|
||||||
label: "Messaging Channels",
|
label: "Messaging Channels",
|
||||||
|
|
@ -122,7 +115,7 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
||||||
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
|
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="md:w-[220px] md:shrink-0">
|
||||||
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
<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">
|
<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 { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type SearchSpaceSettingsTab =
|
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
|
||||||
| "general"
|
|
||||||
| "models"
|
|
||||||
| "team-roles"
|
|
||||||
| "prompts"
|
|
||||||
| "public-links";
|
|
||||||
|
|
||||||
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
|
const DEFAULT_TAB: WorkspaceSettingsTab = "general";
|
||||||
|
|
||||||
interface SearchSpaceSettingsLayoutShellProps {
|
interface WorkspaceSettingsLayoutShellProps {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchSpaceSettingsLayoutShell({
|
export function WorkspaceSettingsLayoutShell({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
children,
|
children,
|
||||||
}: SearchSpaceSettingsLayoutShellProps) {
|
}: WorkspaceSettingsLayoutShellProps) {
|
||||||
const t = useTranslations("searchSpaceSettings");
|
const t = useTranslations("workspaceSettings");
|
||||||
const segment = useSelectedLayoutSegment();
|
const segment = useSelectedLayoutSegment();
|
||||||
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||||
|
|
||||||
|
|
@ -69,17 +64,17 @@ export function SearchSpaceSettingsLayoutShell({
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTab: SearchSpaceSettingsTab =
|
const activeTab: WorkspaceSettingsTab =
|
||||||
segment && navItems.some((item) => item.value === segment)
|
segment && navItems.some((item) => item.value === segment)
|
||||||
? (segment as SearchSpaceSettingsTab)
|
? (segment as WorkspaceSettingsTab)
|
||||||
: DEFAULT_TAB;
|
: DEFAULT_TAB;
|
||||||
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
|
||||||
|
|
||||||
const hrefFor = (tab: SearchSpaceSettingsTab) =>
|
const hrefFor = (tab: WorkspaceSettingsTab) =>
|
||||||
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
|
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="md:w-[220px] md:shrink-0">
|
||||||
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
<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">
|
<nav className="hidden flex-col gap-0.5 md:flex">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { use } 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,
|
params,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -12,8 +12,8 @@ export default function SearchSpaceSettingsLayout({
|
||||||
const { workspace_id } = use(params);
|
const { workspace_id } = use(params);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchSpaceSettingsLayoutShell workspaceId={workspace_id}>
|
<WorkspaceSettingsLayoutShell workspaceId={workspace_id}>
|
||||||
{children}
|
{children}
|
||||||
</SearchSpaceSettingsLayoutShell>
|
</WorkspaceSettingsLayoutShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function SearchSpaceSettingsPage({
|
export default async function WorkspaceSettingsPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ workspace_id: string }>;
|
params: Promise<{ workspace_id: string }>;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { searchSpacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||||
import { CreateSearchSpaceDialog } from "@/components/layout";
|
import { CreateWorkspaceDialog } from "@/components/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
|
|
@ -42,7 +42,7 @@ function ErrorScreen({ message }: { message: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
||||||
const t = useTranslations("searchSpace");
|
const t = useTranslations("workspace");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
<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 router = useRouter();
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
|
||||||
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
const { data: workspaces = [], isLoading, error } = useAtomValue(workspacesAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
||||||
if (searchSpaces.length > 0) {
|
if (workspaces.length > 0) {
|
||||||
// Read the query string at the time of redirect — no subscription needed.
|
// Read the query string at the time of redirect — no subscription needed.
|
||||||
// (Vercel Best Practice: rerender-defer-reads 5.2)
|
// (Vercel Best Practice: rerender-defer-reads 5.2)
|
||||||
const query = window.location.search;
|
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
|
// 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
|
// Use global loading screen - spinner animation won't reset
|
||||||
useGlobalLoadingEffect(shouldShowLoading);
|
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) {
|
if (shouldShowLoading) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -102,7 +102,7 @@ export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EmptyState onCreateClick={() => setShowCreateDialog(true)} />
|
<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 { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
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";
|
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
|
||||||
|
|
||||||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||||
|
|
@ -239,7 +239,7 @@ export default function DesktopLoginPage() {
|
||||||
setIsGoogleRedirecting(true);
|
setIsGoogleRedirecting(true);
|
||||||
try {
|
try {
|
||||||
await api?.startGoogleOAuth?.();
|
await api?.startGoogleOAuth?.();
|
||||||
await autoSetSearchSpace();
|
await autoSetWorkspace();
|
||||||
router.push(getPostLoginRedirectPath());
|
router.push(getPostLoginRedirectPath());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsGoogleRedirecting(false);
|
setIsGoogleRedirecting(false);
|
||||||
|
|
@ -247,13 +247,13 @@ export default function DesktopLoginPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSetSearchSpace = async () => {
|
const autoSetWorkspace = async () => {
|
||||||
try {
|
try {
|
||||||
const stored = await api?.getActiveSearchSpace?.();
|
const stored = await api?.getActiveWorkspace?.();
|
||||||
if (stored) return;
|
if (stored) return;
|
||||||
const spaces = await searchSpacesApiService.getSearchSpaces();
|
const spaces = await workspacesApiService.getWorkspaces();
|
||||||
if (spaces?.length) {
|
if (spaces?.length) {
|
||||||
await api?.setActiveSearchSpace?.(String(spaces[0].id));
|
await api?.setActiveWorkspace?.(String(spaces[0].id));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// non-critical — dashboard-sync will catch it later
|
// non-critical — dashboard-sync will catch it later
|
||||||
|
|
@ -272,7 +272,7 @@ export default function DesktopLoginPage() {
|
||||||
}
|
}
|
||||||
await api.loginPassword(email, password);
|
await api.loginPassword(email, password);
|
||||||
|
|
||||||
await autoSetSearchSpace();
|
await autoSetWorkspace();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(getPostLoginRedirectPath());
|
router.push(getPostLoginRedirectPath());
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ import { useSession } from "@/hooks/use-session";
|
||||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { setRedirectPath } from "@/lib/auth-utils";
|
import { setRedirectPath } from "@/lib/auth-utils";
|
||||||
import {
|
import {
|
||||||
trackSearchSpaceInviteAccepted,
|
trackWorkspaceInviteAccepted,
|
||||||
trackSearchSpaceInviteDeclined,
|
trackWorkspaceInviteDeclined,
|
||||||
trackSearchSpaceUserAdded,
|
trackWorkspaceUserAdded,
|
||||||
} from "@/lib/posthog/events";
|
} from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
|
@ -97,12 +97,8 @@ export default function InviteAcceptPage() {
|
||||||
setAcceptedData(result);
|
setAcceptedData(result);
|
||||||
|
|
||||||
// Track invite accepted and user added events
|
// Track invite accepted and user added events
|
||||||
trackSearchSpaceInviteAccepted(
|
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
|
||||||
result.workspace_id,
|
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
||||||
result.workspace_name,
|
|
||||||
result.role_name
|
|
||||||
);
|
|
||||||
trackSearchSpaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to accept invite");
|
setError(err.message || "Failed to accept invite");
|
||||||
|
|
@ -113,7 +109,7 @@ export default function InviteAcceptPage() {
|
||||||
|
|
||||||
const handleDecline = () => {
|
const handleDecline = () => {
|
||||||
// Track invite declined event
|
// Track invite declined event
|
||||||
trackSearchSpaceInviteDeclined(inviteInfo?.workspace_name);
|
trackWorkspaceInviteDeclined(inviteInfo?.workspace_name);
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -187,7 +183,7 @@ export default function InviteAcceptPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{acceptedData.workspace_name}</p>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -206,7 +202,7 @@ export default function InviteAcceptPage() {
|
||||||
className="w-full gap-2"
|
className="w-full gap-2"
|
||||||
onClick={() => router.push(`/dashboard/${acceptedData.workspace_id}`)}
|
onClick={() => router.push(`/dashboard/${acceptedData.workspace_id}`)}
|
||||||
>
|
>
|
||||||
Go to Search Space
|
Go to Workspace
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|
@ -256,7 +252,7 @@ export default function InviteAcceptPage() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Sign in to join {inviteInfo?.workspace_name || "this search space"}
|
Sign in to join {inviteInfo?.workspace_name || "this workspace"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|
@ -267,7 +263,7 @@ export default function InviteAcceptPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{inviteInfo?.workspace_name}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{inviteInfo?.role_name && (
|
{inviteInfo?.role_name && (
|
||||||
|
|
@ -303,7 +299,7 @@ export default function InviteAcceptPage() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Accept this invite to join {inviteInfo?.workspace_name || "this search space"}
|
Accept this invite to join {inviteInfo?.workspace_name || "this workspace"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|
@ -314,7 +310,7 @@ export default function InviteAcceptPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{inviteInfo?.workspace_name}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{inviteInfo?.role_name && (
|
{inviteInfo?.role_name && (
|
||||||
|
|
|
||||||
|
|
@ -12,78 +12,78 @@ export const agentToolsAtom = atomWithQuery((_get) => ({
|
||||||
|
|
||||||
const STORAGE_PREFIX = "surfsense-disabled-tools-";
|
const STORAGE_PREFIX = "surfsense-disabled-tools-";
|
||||||
|
|
||||||
function loadDisabledTools(searchSpaceId: string): string[] {
|
function loadDisabledTools(workspaceId: string): string[] {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${searchSpaceId}`);
|
const raw = localStorage.getItem(`${STORAGE_PREFIX}${workspaceId}`);
|
||||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDisabledTools(searchSpaceId: string, tools: string[]) {
|
function saveDisabledTools(workspaceId: string, tools: string[]) {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (tools.length === 0) {
|
if (tools.length === 0) {
|
||||||
localStorage.removeItem(`${STORAGE_PREFIX}${searchSpaceId}`);
|
localStorage.removeItem(`${STORAGE_PREFIX}${workspaceId}`);
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem(`${STORAGE_PREFIX}${searchSpaceId}`, JSON.stringify(tools));
|
localStorage.setItem(`${STORAGE_PREFIX}${workspaceId}`, JSON.stringify(tools));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledToolsBaseAtom = atom<string[]>([]);
|
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);
|
const hydratedForAtom = atom<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read/write atom for the set of disabled tool names.
|
* 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(
|
export const disabledToolsAtom = atom(
|
||||||
(get) => {
|
(get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const hydratedFor = get(hydratedForAtom);
|
const hydratedFor = get(hydratedForAtom);
|
||||||
if (searchSpaceId && hydratedFor !== searchSpaceId) {
|
if (workspaceId && hydratedFor !== workspaceId) {
|
||||||
return loadDisabledTools(searchSpaceId);
|
return loadDisabledTools(workspaceId);
|
||||||
}
|
}
|
||||||
return get(disabledToolsBaseAtom);
|
return get(disabledToolsBaseAtom);
|
||||||
},
|
},
|
||||||
(get, set, update: string[] | ((prev: string[]) => string[])) => {
|
(get, set, update: string[] | ((prev: string[]) => string[])) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const prev = get(disabledToolsBaseAtom);
|
const prev = get(disabledToolsBaseAtom);
|
||||||
const next = typeof update === "function" ? update(prev) : update;
|
const next = typeof update === "function" ? update(prev) : update;
|
||||||
set(disabledToolsBaseAtom, next);
|
set(disabledToolsBaseAtom, next);
|
||||||
set(hydratedForAtom, searchSpaceId);
|
set(hydratedForAtom, workspaceId);
|
||||||
if (searchSpaceId) {
|
if (workspaceId) {
|
||||||
saveDisabledTools(searchSpaceId, next);
|
saveDisabledTools(workspaceId, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate disabled tools from localStorage when search space changes.
|
* Hydrate disabled tools from localStorage when workspace changes.
|
||||||
* Call this from a useEffect in a component that has access to the search space.
|
* Call this from a useEffect in a component that has access to the workspace.
|
||||||
*/
|
*/
|
||||||
export const hydrateDisabledToolsAtom = atom(null, (get, set) => {
|
export const hydrateDisabledToolsAtom = atom(null, (get, set) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
if (!searchSpaceId) return;
|
if (!workspaceId) return;
|
||||||
const stored = loadDisabledTools(searchSpaceId);
|
const stored = loadDisabledTools(workspaceId);
|
||||||
set(disabledToolsBaseAtom, stored);
|
set(disabledToolsBaseAtom, stored);
|
||||||
set(hydratedForAtom, searchSpaceId);
|
set(hydratedForAtom, workspaceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Toggle a single tool's enabled/disabled state */
|
/** Toggle a single tool's enabled/disabled state */
|
||||||
export const toggleToolAtom = atom(null, (get, set, toolName: string) => {
|
export const toggleToolAtom = atom(null, (get, set, toolName: string) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const current = get(disabledToolsBaseAtom);
|
const current = get(disabledToolsBaseAtom);
|
||||||
const next = current.includes(toolName)
|
const next = current.includes(toolName)
|
||||||
? current.filter((t) => t !== toolName)
|
? current.filter((t) => t !== toolName)
|
||||||
: [...current, toolName];
|
: [...current, toolName];
|
||||||
set(disabledToolsBaseAtom, next);
|
set(disabledToolsBaseAtom, next);
|
||||||
set(hydratedForAtom, searchSpaceId);
|
set(hydratedForAtom, workspaceId);
|
||||||
if (searchSpaceId) {
|
if (workspaceId) {
|
||||||
saveDisabledTools(searchSpaceId, next);
|
saveDisabledTools(workspaceId, next);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,15 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
// Cache invalidation strategy:
|
// 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
|
// - Trigger writes only invalidate the parent automation detail (triggers
|
||||||
// come back inline in AutomationDetail).
|
// come back inline in AutomationDetail).
|
||||||
// We deliberately invalidate the whole "automations" prefix on the list side
|
// 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.
|
// the active pagination in this layer.
|
||||||
|
|
||||||
function invalidateList(searchSpaceId: number) {
|
function invalidateList(workspaceId: number) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["automations", "list", workspaceId] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateDetail(automationId: number) {
|
function invalidateDetail(automationId: number) {
|
||||||
|
|
@ -113,17 +113,17 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
|
|
||||||
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
meta: { suppressGlobalErrorToast: true },
|
meta: { suppressGlobalErrorToast: true },
|
||||||
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
|
mutationFn: async (vars: { automationId: number; workspaceId: number }) => {
|
||||||
await automationsApiService.deleteAutomation(vars.automationId);
|
await automationsApiService.deleteAutomation(vars.automationId);
|
||||||
return vars;
|
return vars;
|
||||||
},
|
},
|
||||||
onSuccess: (vars) => {
|
onSuccess: (vars) => {
|
||||||
invalidateList(vars.searchSpaceId);
|
invalidateList(vars.workspaceId);
|
||||||
invalidateDetail(vars.automationId);
|
invalidateDetail(vars.automationId);
|
||||||
toast.success("Automation deleted");
|
toast.success("Automation deleted");
|
||||||
trackAutomationDeleted({
|
trackAutomationDeleted({
|
||||||
automation_id: vars.automationId,
|
automation_id: vars.automationId,
|
||||||
workspace_id: vars.searchSpaceId,
|
workspace_id: vars.workspaceId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: Error, vars) => {
|
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 { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
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,
|
// 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
|
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
|
||||||
// proliferate atom families for every (id, limit, offset) tuple.
|
// proliferate atom families for every (id, limit, offset) tuple.
|
||||||
|
|
@ -11,18 +11,18 @@ const DEFAULT_LIMIT = 50;
|
||||||
const DEFAULT_OFFSET = 0;
|
const DEFAULT_OFFSET = 0;
|
||||||
|
|
||||||
export const automationsListAtom = atomWithQuery((get) => {
|
export const automationsListAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
|
queryKey: cacheKeys.automations.list(Number(workspaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!workspaceId) {
|
||||||
return { items: [], total: 0 };
|
return { items: [], total: 0 };
|
||||||
}
|
}
|
||||||
return automationsApiService.listAutomations({
|
return automationsApiService.listAutomations({
|
||||||
workspace_id: Number(searchSpaceId),
|
workspace_id: Number(workspaceId),
|
||||||
limit: DEFAULT_LIMIT,
|
limit: DEFAULT_LIMIT,
|
||||||
offset: DEFAULT_OFFSET,
|
offset: DEFAULT_OFFSET,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { reportPanelAtom } from "./report-panel.atom";
|
||||||
|
|
||||||
interface CurrentThreadState {
|
interface CurrentThreadState {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
visibility: ChatVisibility | null;
|
visibility: ChatVisibility | null;
|
||||||
hasComments: boolean;
|
hasComments: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurrentThreadMetadataPatch {
|
interface CurrentThreadMetadataPatch {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
searchSpaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
visibility?: ChatVisibility | null;
|
visibility?: ChatVisibility | null;
|
||||||
hasComments?: boolean;
|
hasComments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ interface CurrentThreadMetadataUpdate {
|
||||||
|
|
||||||
const initialState: CurrentThreadState = {
|
const initialState: CurrentThreadState = {
|
||||||
id: null,
|
id: null,
|
||||||
searchSpaceId: null,
|
workspaceId: null,
|
||||||
visibility: null,
|
visibility: null,
|
||||||
hasComments: false,
|
hasComments: false,
|
||||||
};
|
};
|
||||||
|
|
@ -44,11 +44,11 @@ export const setCurrentThreadMetadataAtom = atom(
|
||||||
set(currentThreadAtom, {
|
set(currentThreadAtom, {
|
||||||
...current,
|
...current,
|
||||||
id: metadata.id,
|
id: metadata.id,
|
||||||
searchSpaceId:
|
workspaceId:
|
||||||
"searchSpaceId" in metadata
|
"workspaceId" in metadata
|
||||||
? (metadata.searchSpaceId ?? null)
|
? (metadata.workspaceId ?? null)
|
||||||
: isSameThread
|
: isSameThread
|
||||||
? current.searchSpaceId
|
? current.workspaceId
|
||||||
: null,
|
: null,
|
||||||
visibility:
|
visibility:
|
||||||
"visibility" in metadata
|
"visibility" in metadata
|
||||||
|
|
|
||||||
|
|
@ -13,38 +13,38 @@ import { queryClient } from "@/lib/query-client/client";
|
||||||
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
||||||
|
|
||||||
export const createConnectorMutationAtom = atomWithMutation((get) => {
|
export const createConnectorMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
|
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: CreateConnectorRequest) => {
|
mutationFn: async (request: CreateConnectorRequest) => {
|
||||||
return connectorsApiService.createConnector(request);
|
return connectorsApiService.createConnector(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (!searchSpaceId) return;
|
if (!workspaceId) return;
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.connectors.all(searchSpaceId),
|
queryKey: cacheKeys.connectors.all(workspaceId),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateConnectorMutationAtom = atomWithMutation((get) => {
|
export const updateConnectorMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
|
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: UpdateConnectorRequest) => {
|
mutationFn: async (request: UpdateConnectorRequest) => {
|
||||||
return connectorsApiService.updateConnector(request);
|
return connectorsApiService.updateConnector(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (_, request: UpdateConnectorRequest) => {
|
onSuccess: (_, request: UpdateConnectorRequest) => {
|
||||||
if (!searchSpaceId) return;
|
if (!workspaceId) return;
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.connectors.all(searchSpaceId),
|
queryKey: cacheKeys.connectors.all(workspaceId),
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.connectors.byId(String(request.id)),
|
queryKey: cacheKeys.connectors.byId(String(request.id)),
|
||||||
|
|
@ -54,19 +54,19 @@ export const updateConnectorMutationAtom = atomWithMutation((get) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteConnectorMutationAtom = atomWithMutation((get) => {
|
export const deleteConnectorMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.connectors.all(searchSpaceId ?? ""),
|
mutationKey: cacheKeys.connectors.all(workspaceId ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: DeleteConnectorRequest) => {
|
mutationFn: async (request: DeleteConnectorRequest) => {
|
||||||
return connectorsApiService.deleteConnector(request);
|
return connectorsApiService.deleteConnector(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (_, request: DeleteConnectorRequest) => {
|
onSuccess: (_, request: DeleteConnectorRequest) => {
|
||||||
if (!searchSpaceId) return;
|
if (!workspaceId) return;
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
cacheKeys.connectors.all(searchSpaceId),
|
cacheKeys.connectors.all(workspaceId),
|
||||||
(oldData: GetConnectorsResponse | undefined) => {
|
(oldData: GetConnectorsResponse | undefined) => {
|
||||||
if (!oldData) return oldData;
|
if (!oldData) return oldData;
|
||||||
return oldData.filter((connector) => connector.id !== request.id);
|
return oldData.filter((connector) => connector.id !== request.id);
|
||||||
|
|
@ -80,19 +80,19 @@ export const deleteConnectorMutationAtom = atomWithMutation((get) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const indexConnectorMutationAtom = atomWithMutation((get) => {
|
export const indexConnectorMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.connectors.index(),
|
mutationKey: cacheKeys.connectors.index(),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: IndexConnectorRequest) => {
|
mutationFn: async (request: IndexConnectorRequest) => {
|
||||||
return connectorsApiService.indexConnector(request);
|
return connectorsApiService.indexConnector(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (response: IndexConnectorResponse) => {
|
onSuccess: (response: IndexConnectorResponse) => {
|
||||||
if (!searchSpaceId) return;
|
if (!workspaceId) return;
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.connectors.all(searchSpaceId),
|
queryKey: cacheKeys.connectors.all(workspaceId),
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.connectors.byId(String(response.connector_id)),
|
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";
|
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
||||||
|
|
||||||
export const connectorsAtom = atomWithQuery((get) => {
|
export const connectorsAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.connectors.all(searchSpaceId!),
|
queryKey: cacheKeys.connectors.all(workspaceId!),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return connectorsApiService.getConnectors({
|
return connectorsApiService.getConnectors({
|
||||||
queryParams: {
|
queryParams: {
|
||||||
workspace_id: searchSpaceId!,
|
workspace_id: workspaceId!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ import { queryClient } from "@/lib/query-client/client";
|
||||||
import { globalDocumentsQueryParamsAtom } from "./ui.atoms";
|
import { globalDocumentsQueryParamsAtom } from "./ui.atoms";
|
||||||
|
|
||||||
export const createDocumentMutationAtom = atomWithMutation((get) => {
|
export const createDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: CreateDocumentRequest) => {
|
mutationFn: async (request: CreateDocumentRequest) => {
|
||||||
return documentsApiService.createDocument(request);
|
return documentsApiService.createDocument(request);
|
||||||
},
|
},
|
||||||
|
|
@ -34,12 +34,12 @@ export const createDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: UploadDocumentRequest) => {
|
mutationFn: async (request: UploadDocumentRequest) => {
|
||||||
return documentsApiService.uploadDocument(request);
|
return documentsApiService.uploadDocument(request);
|
||||||
},
|
},
|
||||||
|
|
@ -47,19 +47,19 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
// Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined),
|
queryKey: cacheKeys.logs.summary(workspaceId ?? undefined),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateDocumentMutationAtom = atomWithMutation((get) => {
|
export const updateDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: UpdateDocumentRequest) => {
|
mutationFn: async (request: UpdateDocumentRequest) => {
|
||||||
return documentsApiService.updateDocument(request);
|
return documentsApiService.updateDocument(request);
|
||||||
},
|
},
|
||||||
|
|
@ -77,12 +77,12 @@ export const updateDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteDocumentMutationAtom = atomWithMutation((get) => {
|
export const deleteDocumentMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
const documentsQueryParams = get(globalDocumentsQueryParamsAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
mutationKey: cacheKeys.documents.globalQueryParams(documentsQueryParams),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: DeleteDocumentRequest) => {
|
mutationFn: async (request: DeleteDocumentRequest) => {
|
||||||
return documentsApiService.deleteDocument(request);
|
return documentsApiService.deleteDocument(request);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
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.
|
* Persisted to localStorage so expand/collapse state survives page refreshes.
|
||||||
*/
|
*/
|
||||||
export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
|
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.
|
* Persisted so local tree expansion survives remounts/reloads.
|
||||||
*/
|
*/
|
||||||
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(
|
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,11 @@ export const globalDocumentsQueryParamsAtom = atom<GetDocumentsRequest["queryPar
|
||||||
page: 0,
|
page: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const documentsSidebarOpenAtom = atom(false);
|
|
||||||
|
|
||||||
export interface AgentCreatedDocument {
|
export interface AgentCreatedDocument {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
searchSpaceId: number;
|
workspaceId: number;
|
||||||
folderId: number | null;
|
folderId: number | null;
|
||||||
createdById: string | null;
|
createdById: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ interface EditorPanelState {
|
||||||
kind: "document" | "local_file" | "memory";
|
kind: "document" | "local_file" | "memory";
|
||||||
documentId: number | null;
|
documentId: number | null;
|
||||||
localFilePath: string | null;
|
localFilePath: string | null;
|
||||||
searchSpaceId: number | null;
|
workspaceId: number | null;
|
||||||
memoryScope: "user" | "team" | null;
|
memoryScope: "user" | "team" | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ const initialState: EditorPanelState = {
|
||||||
kind: "document",
|
kind: "document",
|
||||||
documentId: null,
|
documentId: null,
|
||||||
localFilePath: null,
|
localFilePath: null,
|
||||||
searchSpaceId: null,
|
workspaceId: null,
|
||||||
memoryScope: null,
|
memoryScope: null,
|
||||||
title: null,
|
title: null,
|
||||||
};
|
};
|
||||||
|
|
@ -33,18 +33,18 @@ export const openEditorPanelAtom = atom(
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
payload:
|
payload:
|
||||||
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
|
| { documentId: number; workspaceId: number; title?: string; kind?: "document" }
|
||||||
| {
|
| {
|
||||||
kind: "local_file";
|
kind: "local_file";
|
||||||
localFilePath: string;
|
localFilePath: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
searchSpaceId?: number;
|
workspaceId?: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "memory";
|
kind: "memory";
|
||||||
memoryScope: "user" | "team";
|
memoryScope: "user" | "team";
|
||||||
title?: string;
|
title?: string;
|
||||||
searchSpaceId?: number;
|
workspaceId?: number;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!get(editorPanelAtom).isOpen) {
|
if (!get(editorPanelAtom).isOpen) {
|
||||||
|
|
@ -56,7 +56,7 @@ export const openEditorPanelAtom = atom(
|
||||||
kind: "local_file",
|
kind: "local_file",
|
||||||
documentId: null,
|
documentId: null,
|
||||||
localFilePath: payload.localFilePath,
|
localFilePath: payload.localFilePath,
|
||||||
searchSpaceId: payload.searchSpaceId ?? null,
|
workspaceId: payload.workspaceId ?? null,
|
||||||
memoryScope: null,
|
memoryScope: null,
|
||||||
title: payload.title ?? null,
|
title: payload.title ?? null,
|
||||||
});
|
});
|
||||||
|
|
@ -70,7 +70,7 @@ export const openEditorPanelAtom = atom(
|
||||||
kind: "memory",
|
kind: "memory",
|
||||||
documentId: null,
|
documentId: null,
|
||||||
localFilePath: null,
|
localFilePath: null,
|
||||||
searchSpaceId: payload.searchSpaceId ?? null,
|
workspaceId: payload.workspaceId ?? null,
|
||||||
memoryScope: payload.memoryScope,
|
memoryScope: payload.memoryScope,
|
||||||
title: payload.title ?? null,
|
title: payload.title ?? null,
|
||||||
});
|
});
|
||||||
|
|
@ -83,7 +83,7 @@ export const openEditorPanelAtom = atom(
|
||||||
kind: "document",
|
kind: "document",
|
||||||
documentId: payload.documentId,
|
documentId: payload.documentId,
|
||||||
localFilePath: null,
|
localFilePath: null,
|
||||||
searchSpaceId: payload.searchSpaceId,
|
workspaceId: payload.workspaceId,
|
||||||
memoryScope: null,
|
memoryScope: null,
|
||||||
title: payload.title ?? null,
|
title: payload.title ?? null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export const acceptInviteMutationAtom = atomWithMutation(() => ({
|
||||||
return invitesApiService.acceptInvite(request);
|
return invitesApiService.acceptInvite(request);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all });
|
queryClient.invalidateQueries({ queryKey: cacheKeys.workspaces.all });
|
||||||
toast.success("Invite accepted successfully");
|
toast.success("Invite accepted successfully");
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,18 @@ import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export const invitesAtom = atomWithQuery((get) => {
|
export const invitesAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.invites.all(searchSpaceId?.toString() ?? ""),
|
queryKey: cacheKeys.invites.all(workspaceId?.toString() ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!workspaceId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return invitesApiService.getInvites({
|
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
|
* Create Log Mutation
|
||||||
*/
|
*/
|
||||||
export const createLogMutationAtom = atomWithMutation((get) => {
|
export const createLogMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: CreateLogRequest) => logsApiService.createLog(request),
|
mutationFn: async (request: CreateLogRequest) => logsApiService.createLog(request),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate all log-related queries (list, summary, detail, withQueryParams)
|
// Invalidate all log-related queries (list, summary, detail, withQueryParams)
|
||||||
|
|
@ -29,10 +29,10 @@ export const createLogMutationAtom = atomWithMutation((get) => {
|
||||||
* Update Log Mutation
|
* Update Log Mutation
|
||||||
*/
|
*/
|
||||||
export const updateLogMutationAtom = atomWithMutation((get) => {
|
export const updateLogMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async ({ logId, data }: { logId: number; data: UpdateLogRequest }) =>
|
mutationFn: async ({ logId, data }: { logId: number; data: UpdateLogRequest }) =>
|
||||||
logsApiService.updateLog(logId, data),
|
logsApiService.updateLog(logId, data),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
|
|
@ -45,10 +45,10 @@ export const updateLogMutationAtom = atomWithMutation((get) => {
|
||||||
* Delete Log Mutation
|
* Delete Log Mutation
|
||||||
*/
|
*/
|
||||||
export const deleteLogMutationAtom = atomWithMutation((get) => {
|
export const deleteLogMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
return {
|
return {
|
||||||
mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined),
|
mutationKey: cacheKeys.logs.list(workspaceId ?? undefined),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
mutationFn: async (request: DeleteLogRequest) => logsApiService.deleteLog(request),
|
mutationFn: async (request: DeleteLogRequest) => logsApiService.deleteLog(request),
|
||||||
onSuccess: (_data, request) => {
|
onSuccess: (_data, request) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { toast } from "sonner";
|
||||||
import type {
|
import type {
|
||||||
DeleteMembershipRequest,
|
DeleteMembershipRequest,
|
||||||
DeleteMembershipResponse,
|
DeleteMembershipResponse,
|
||||||
LeaveSearchSpaceRequest,
|
LeaveWorkspaceRequest,
|
||||||
LeaveSearchSpaceResponse,
|
LeaveWorkspaceResponse,
|
||||||
UpdateMembershipRequest,
|
UpdateMembershipRequest,
|
||||||
UpdateMembershipResponse,
|
UpdateMembershipResponse,
|
||||||
} from "@/contracts/types/members.types";
|
} from "@/contracts/types/members.types";
|
||||||
|
|
@ -48,20 +48,20 @@ export const deleteMemberMutationAtom = atomWithMutation(() => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
|
export const leaveWorkspaceMutationAtom = atomWithMutation(() => {
|
||||||
return {
|
return {
|
||||||
meta: { suppressGlobalErrorToast: true },
|
meta: { suppressGlobalErrorToast: true },
|
||||||
mutationFn: async (request: LeaveSearchSpaceRequest) => {
|
mutationFn: async (request: LeaveWorkspaceRequest) => {
|
||||||
return membersApiService.leaveSearchSpace(request);
|
return membersApiService.leaveWorkspace(request);
|
||||||
},
|
},
|
||||||
onSuccess: (_: LeaveSearchSpaceResponse, request: LeaveSearchSpaceRequest) => {
|
onSuccess: (_: LeaveWorkspaceResponse, request: LeaveWorkspaceRequest) => {
|
||||||
toast.success("Successfully left the search space");
|
toast.success("Successfully left the workspace");
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.members.all(request.workspace_id.toString()),
|
queryKey: cacheKeys.members.all(request.workspace_id.toString()),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
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 { atomWithQuery } from "jotai-tanstack-query";
|
||||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||||
import { membersApiService } from "@/lib/apis/members-api.service";
|
import { membersApiService } from "@/lib/apis/members-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export const membersAtom = atomWithQuery((get) => {
|
export const membersAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
queryKey: cacheKeys.members.all(workspaceId?.toString() ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
||||||
refetchInterval: 2 * 60 * 1000, // 2 minutes
|
refetchInterval: 2 * 60 * 1000, // 2 minutes
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!workspaceId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return membersApiService.getMembers({
|
return membersApiService.getMembers({
|
||||||
workspace_id: Number(searchSpaceId),
|
workspace_id: Number(workspaceId),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const myAccessAtom = atomWithQuery((get) => {
|
export const myAccessAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.members.myAccess(searchSpaceId?.toString() ?? ""),
|
queryKey: cacheKeys.members.myAccess(workspaceId?.toString() ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return membersApiService.getMyAccess({
|
return membersApiService.getMyAccess({
|
||||||
workspace_id: Number(searchSpaceId),
|
workspace_id: Number(workspaceId),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -71,6 +72,6 @@ export function canPerform(
|
||||||
* const canManageMembers = usePermissionGate('manage_members');
|
* const canManageMembers = usePermissionGate('manage_members');
|
||||||
*/
|
*/
|
||||||
export function usePermissionGate(permission: string): boolean {
|
export function usePermissionGate(permission: string): boolean {
|
||||||
const access = useAtomValue(myAccessAtom);
|
const { data: access } = useAtomValue(myAccessAtom);
|
||||||
return canPerform(access, permission);
|
return canPerform(access, permission);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,18 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
import { activeWorkspaceIdAtom } from "../workspaces/workspace-query.atoms";
|
||||||
|
|
||||||
function invalidateModelConnections(searchSpaceId: number) {
|
function invalidateModelConnections(workspaceId: number) {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.modelConnections.all(searchSpaceId),
|
queryKey: cacheKeys.modelConnections.all(workspaceId),
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
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[]>(
|
queryClient.setQueryData<ConnectionRead[]>(
|
||||||
cacheKeys.modelConnections.all(searchSpaceId),
|
cacheKeys.modelConnections.all(workspaceId),
|
||||||
(current = []) => {
|
(current = []) => {
|
||||||
if (current.some((item) => item.id === connection.id)) {
|
if (current.some((item) => item.id === connection.id)) {
|
||||||
return current.map((item) => (item.id === connection.id ? connection : item));
|
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) => {
|
export const createModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["model-connections", "create"],
|
mutationKey: ["model-connections", "create"],
|
||||||
mutationFn: (request: ConnectionCreateRequest) =>
|
mutationFn: (request: ConnectionCreateRequest) =>
|
||||||
modelConnectionsApiService.createConnection(request),
|
modelConnectionsApiService.createConnection(request),
|
||||||
onSuccess: (connection: ConnectionRead, request: ConnectionCreateRequest) => {
|
onSuccess: (connection: ConnectionRead, request: ConnectionCreateRequest) => {
|
||||||
const resolvedSearchSpaceId = Number(
|
const resolvedWorkspaceId = Number(
|
||||||
request.workspace_id ?? connection.workspace_id ?? searchSpaceId
|
request.workspace_id ?? connection.workspace_id ?? workspaceId
|
||||||
);
|
);
|
||||||
toast.success("Connection created");
|
toast.success("Connection created");
|
||||||
if (resolvedSearchSpaceId > 0) {
|
if (resolvedWorkspaceId > 0) {
|
||||||
upsertModelConnection(resolvedSearchSpaceId, connection);
|
upsertModelConnection(resolvedWorkspaceId, connection);
|
||||||
invalidateModelConnections(resolvedSearchSpaceId);
|
invalidateModelConnections(resolvedWorkspaceId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to create connection"),
|
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) => {
|
export const updateModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["model-connections", "update"],
|
mutationKey: ["model-connections", "update"],
|
||||||
mutationFn: ({ id, data }: { id: number; data: ConnectionUpdateRequest }) =>
|
mutationFn: ({ id, data }: { id: number; data: ConnectionUpdateRequest }) =>
|
||||||
modelConnectionsApiService.updateConnection(id, data),
|
modelConnectionsApiService.updateConnection(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Connection updated");
|
toast.success("Connection updated");
|
||||||
invalidateModelConnections(searchSpaceId);
|
invalidateModelConnections(workspaceId);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to update connection"),
|
onError: (error: Error) => toast.error(error.message || "Failed to update connection"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteModelConnectionMutationAtom = atomWithMutation((get) => {
|
export const deleteModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["model-connections", "delete"],
|
mutationKey: ["model-connections", "delete"],
|
||||||
mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id),
|
mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Connection deleted");
|
toast.success("Connection deleted");
|
||||||
invalidateModelConnections(searchSpaceId);
|
invalidateModelConnections(workspaceId);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to delete connection"),
|
onError: (error: Error) => toast.error(error.message || "Failed to delete connection"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
|
export const verifyModelConnectionMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["model-connections", "verify"],
|
mutationKey: ["model-connections", "verify"],
|
||||||
mutationFn: (id: number) => modelConnectionsApiService.verifyConnection(id),
|
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."
|
: "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"),
|
onError: (error: Error) => toast.error(error.message || "Failed to verify connection"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
|
export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["model-connections", "discover"],
|
mutationKey: ["model-connections", "discover"],
|
||||||
mutationFn: (id: number) => modelConnectionsApiService.discoverModels(id),
|
mutationFn: (id: number) => modelConnectionsApiService.discoverModels(id),
|
||||||
|
|
@ -118,7 +118,7 @@ export const discoverConnectionModelsMutationAtom = atomWithMutation((get) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
models.length ? `${models.length} models discovered` : "No models found for this connection"
|
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"),
|
onError: (error: Error) => toast.error(error.message || "Failed to discover models"),
|
||||||
};
|
};
|
||||||
|
|
@ -149,64 +149,64 @@ export const testPreviewModelMutationAtom = atomWithMutation(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addManualModelMutationAtom = atomWithMutation((get) => {
|
export const addManualModelMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["models", "add-manual"],
|
mutationKey: ["models", "add-manual"],
|
||||||
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
|
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelCreateRequest }) =>
|
||||||
modelConnectionsApiService.addManualModel(connectionId, data),
|
modelConnectionsApiService.addManualModel(connectionId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Model added");
|
toast.success("Model added");
|
||||||
invalidateModelConnections(searchSpaceId);
|
invalidateModelConnections(workspaceId);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to add model"),
|
onError: (error: Error) => toast.error(error.message || "Failed to add model"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateModelMutationAtom = atomWithMutation((get) => {
|
export const updateModelMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["models", "update"],
|
mutationKey: ["models", "update"],
|
||||||
mutationFn: ({ id, data }: { id: number; data: ModelUpdateRequest }) =>
|
mutationFn: ({ id, data }: { id: number; data: ModelUpdateRequest }) =>
|
||||||
modelConnectionsApiService.updateModel(id, data),
|
modelConnectionsApiService.updateModel(id, data),
|
||||||
onSuccess: () => invalidateModelConnections(searchSpaceId),
|
onSuccess: () => invalidateModelConnections(workspaceId),
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to update model"),
|
onError: (error: Error) => toast.error(error.message || "Failed to update model"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => {
|
export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["models", "bulk-update"],
|
mutationKey: ["models", "bulk-update"],
|
||||||
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) =>
|
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) =>
|
||||||
modelConnectionsApiService.bulkUpdateModels(connectionId, data),
|
modelConnectionsApiService.bulkUpdateModels(connectionId, data),
|
||||||
onSuccess: () => invalidateModelConnections(searchSpaceId),
|
onSuccess: () => invalidateModelConnections(workspaceId),
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to update models"),
|
onError: (error: Error) => toast.error(error.message || "Failed to update models"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testModelMutationAtom = atomWithMutation((get) => {
|
export const testModelMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["models", "test"],
|
mutationKey: ["models", "test"],
|
||||||
mutationFn: (id: number) => modelConnectionsApiService.testModel(id),
|
mutationFn: (id: number) => modelConnectionsApiService.testModel(id),
|
||||||
onSuccess: (result: VerifyConnectionResponse) => {
|
onSuccess: (result: VerifyConnectionResponse) => {
|
||||||
if (result.ok) toast.success("Model test succeeded");
|
if (result.ok) toast.success("Model test succeeded");
|
||||||
else toast.error(result.message || "Model test failed");
|
else toast.error(result.message || "Model test failed");
|
||||||
invalidateModelConnections(searchSpaceId);
|
invalidateModelConnections(workspaceId);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to test model"),
|
onError: (error: Error) => toast.error(error.message || "Failed to test model"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateModelRolesMutationAtom = atomWithMutation((get) => {
|
export const updateModelRolesMutationAtom = atomWithMutation((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
mutationKey: ["model-roles", "update"],
|
mutationKey: ["model-roles", "update"],
|
||||||
mutationFn: (roles: ModelRoles) =>
|
mutationFn: (roles: ModelRoles) =>
|
||||||
modelConnectionsApiService.updateModelRoles(searchSpaceId, roles),
|
modelConnectionsApiService.updateModelRoles(workspaceId, roles),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
|
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: Error) => toast.error(error.message || "Failed to update model roles"),
|
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) => {
|
export const modelConnectionsAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.modelConnections.all(searchSpaceId),
|
queryKey: cacheKeys.modelConnections.all(workspaceId),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: () => modelConnectionsApiService.getConnections(searchSpaceId),
|
queryFn: () => modelConnectionsApiService.getConnections(workspaceId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const modelRolesAtom = atomWithQuery((get) => {
|
export const modelRolesAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = Number(get(activeWorkspaceIdAtom));
|
const workspaceId = Number(get(activeWorkspaceIdAtom));
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.modelConnections.roles(searchSpaceId),
|
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
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";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export const publicChatSnapshotsAtom = atomWithQuery((get) => {
|
export const publicChatSnapshotsAtom = atomWithQuery((get) => {
|
||||||
const searchSpaceId = get(activeWorkspaceIdAtom);
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.publicChatSnapshots.bySearchSpace(Number(searchSpaceId) || 0),
|
queryKey: cacheKeys.publicChatSnapshots.byWorkspace(Number(workspaceId) || 0),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!workspaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!workspaceId) {
|
||||||
return { snapshots: [] };
|
return { snapshots: [] };
|
||||||
}
|
}
|
||||||
return chatThreadsApiService.listPublicChatSnapshotsForSearchSpace({
|
return chatThreadsApiService.listPublicChatSnapshotsForWorkspace({
|
||||||
workspace_id: Number(searchSpaceId),
|
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 { atom } from "jotai";
|
||||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||||
|
import { migrateLegacyTabs } from "./migrate-tabs";
|
||||||
|
|
||||||
export type TabType = "chat" | "document";
|
export type TabType = "chat" | "document";
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ export interface Tab {
|
||||||
hasComments?: boolean;
|
hasComments?: boolean;
|
||||||
/** For document tabs */
|
/** For document tabs */
|
||||||
documentId?: number;
|
documentId?: number;
|
||||||
searchSpaceId?: number;
|
workspaceId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabsState {
|
interface TabsState {
|
||||||
|
|
@ -40,11 +41,17 @@ const initialState: TabsState = {
|
||||||
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||||
|
|
||||||
// Persist tabs in localStorage so they survive a hard refresh and let the user
|
// 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>(
|
const localStorageAdapter = createJSONStorage<TabsState>(
|
||||||
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
|
() => (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>(
|
export const tabsStateAtom = atomWithStorage<TabsState>(
|
||||||
"surfsense:tabs",
|
"surfsense:tabs",
|
||||||
initialState,
|
initialState,
|
||||||
|
|
@ -81,14 +88,14 @@ export const syncChatTabAtom = atom(
|
||||||
chatId,
|
chatId,
|
||||||
title,
|
title,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
visibility,
|
visibility,
|
||||||
hasComments,
|
hasComments,
|
||||||
}: {
|
}: {
|
||||||
chatId: number | null;
|
chatId: number | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
chatUrl?: string;
|
chatUrl?: string;
|
||||||
searchSpaceId: number;
|
workspaceId: number;
|
||||||
visibility?: ChatVisibility;
|
visibility?: ChatVisibility;
|
||||||
hasComments?: boolean;
|
hasComments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +118,7 @@ export const syncChatTabAtom = atom(
|
||||||
...t,
|
...t,
|
||||||
title: title || t.title,
|
title: title || t.title,
|
||||||
chatUrl: chatUrl || t.chatUrl,
|
chatUrl: chatUrl || t.chatUrl,
|
||||||
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
|
workspaceId: workspaceId ?? t.workspaceId,
|
||||||
...(visibility !== undefined ? { visibility } : {}),
|
...(visibility !== undefined ? { visibility } : {}),
|
||||||
...(hasComments !== undefined ? { hasComments } : {}),
|
...(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
|
// 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) {
|
if (!chatId) {
|
||||||
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
|
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
|
||||||
if (hasNewChatTab) {
|
if (hasNewChatTab) {
|
||||||
set(tabsStateAtom, {
|
set(tabsStateAtom, {
|
||||||
...state,
|
...state,
|
||||||
activeTabId: "chat-new",
|
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 {
|
} else {
|
||||||
set(tabsStateAtom, {
|
set(tabsStateAtom, {
|
||||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
|
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }],
|
||||||
activeTabId: "chat-new",
|
activeTabId: "chat-new",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +155,7 @@ export const syncChatTabAtom = atom(
|
||||||
title: title || "New Chat",
|
title: title || "New Chat",
|
||||||
chatId,
|
chatId,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
...(visibility !== undefined ? { visibility } : {}),
|
...(visibility !== undefined ? { visibility } : {}),
|
||||||
...(hasComments !== undefined ? { hasComments } : {}),
|
...(hasComments !== undefined ? { hasComments } : {}),
|
||||||
};
|
};
|
||||||
|
|
@ -197,11 +204,7 @@ export const openDocumentTabAtom = atom(
|
||||||
(
|
(
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
{
|
{ documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string }
|
||||||
documentId,
|
|
||||||
searchSpaceId,
|
|
||||||
title,
|
|
||||||
}: { documentId: number; searchSpaceId: number; title?: string }
|
|
||||||
) => {
|
) => {
|
||||||
const state = get(tabsStateAtom);
|
const state = get(tabsStateAtom);
|
||||||
const tabId = makeDocumentTabId(documentId);
|
const tabId = makeDocumentTabId(documentId);
|
||||||
|
|
@ -221,7 +224,7 @@ export const openDocumentTabAtom = atom(
|
||||||
type: "document",
|
type: "document",
|
||||||
title: title || `Document ${documentId}`,
|
title: title || `Document ${documentId}`,
|
||||||
documentId,
|
documentId,
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
set(tabsStateAtom, {
|
set(tabsStateAtom, {
|
||||||
|
|
@ -300,7 +303,7 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
|
||||||
return remaining.find((t) => t.id === newActiveId) ?? null;
|
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) => {
|
export const resetTabsAtom = atom(null, (_get, set) => {
|
||||||
set(tabsStateAtom, { ...initialState });
|
set(tabsStateAtom, { ...initialState });
|
||||||
set(deletedChatIdsAtom, new Set<number>());
|
set(deletedChatIdsAtom, new Set<number>());
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,96 @@
|
||||||
import { atomWithMutation } from "jotai-tanstack-query";
|
import { atomWithMutation } from "jotai-tanstack-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type {
|
import type {
|
||||||
CreateSearchSpaceRequest,
|
CreateWorkspaceRequest,
|
||||||
DeleteSearchSpaceRequest,
|
DeleteWorkspaceRequest,
|
||||||
UpdateSearchSpaceApiAccessRequest,
|
UpdateWorkspaceApiAccessRequest,
|
||||||
UpdateSearchSpaceRequest,
|
UpdateWorkspaceRequest,
|
||||||
} from "@/contracts/types/workspace.types";
|
} 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 { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
import { activeWorkspaceIdAtom } from "./workspace-query.atoms";
|
import { activeWorkspaceIdAtom } from "./workspace-query.atoms";
|
||||||
|
|
||||||
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
|
export const createWorkspaceMutationAtom = atomWithMutation(() => {
|
||||||
return {
|
return {
|
||||||
mutationKey: ["create-search-space"],
|
mutationKey: ["create-workspace"],
|
||||||
mutationFn: async (request: CreateSearchSpaceRequest) => {
|
mutationFn: async (request: CreateWorkspaceRequest) => {
|
||||||
return searchSpacesApiService.createSearchSpace(request);
|
return workspacesApiService.createWorkspace(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Search space created successfully");
|
toast.success("Search space created successfully");
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.searchSpaces.all,
|
queryKey: cacheKeys.workspaces.all,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateSearchSpaceMutationAtom = atomWithMutation((get) => {
|
export const updateWorkspaceMutationAtom = atomWithMutation((get) => {
|
||||||
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
|
const activeWorkspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: ["update-search-space", activeSearchSpaceId],
|
mutationKey: ["update-workspace", activeWorkspaceId],
|
||||||
enabled: !!activeSearchSpaceId,
|
enabled: !!activeWorkspaceId,
|
||||||
mutationFn: async (request: UpdateSearchSpaceRequest) => {
|
mutationFn: async (request: UpdateWorkspaceRequest) => {
|
||||||
return searchSpacesApiService.updateSearchSpace(request);
|
return workspacesApiService.updateWorkspace(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (_, request: UpdateSearchSpaceRequest) => {
|
onSuccess: (_, request: UpdateWorkspaceRequest) => {
|
||||||
toast.success("Search space updated successfully");
|
toast.success("Search space updated successfully");
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.searchSpaces.all,
|
queryKey: cacheKeys.workspaces.all,
|
||||||
});
|
});
|
||||||
if (request.id) {
|
if (request.id) {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
|
queryKey: cacheKeys.workspaces.detail(String(request.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateSearchSpaceApiAccessMutationAtom = atomWithMutation((get) => {
|
export const updateWorkspaceApiAccessMutationAtom = atomWithMutation((get) => {
|
||||||
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
|
const activeWorkspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: ["update-search-space-api-access", activeSearchSpaceId],
|
mutationKey: ["update-workspace-api-access", activeWorkspaceId],
|
||||||
enabled: !!activeSearchSpaceId,
|
enabled: !!activeWorkspaceId,
|
||||||
mutationFn: async (request: UpdateSearchSpaceApiAccessRequest) => {
|
mutationFn: async (request: UpdateWorkspaceApiAccessRequest) => {
|
||||||
return searchSpacesApiService.updateSearchSpaceApiAccess(request);
|
return workspacesApiService.updateWorkspaceApiAccess(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (_, request: UpdateSearchSpaceApiAccessRequest) => {
|
onSuccess: (_, request: UpdateWorkspaceApiAccessRequest) => {
|
||||||
toast.success("API access updated successfully");
|
toast.success("API access updated successfully");
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.searchSpaces.all,
|
queryKey: cacheKeys.workspaces.all,
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.searchSpaces.detail(String(request.id)),
|
queryKey: cacheKeys.workspaces.detail(String(request.id)),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteSearchSpaceMutationAtom = atomWithMutation((get) => {
|
export const deleteWorkspaceMutationAtom = atomWithMutation((get) => {
|
||||||
const activeSearchSpaceId = get(activeWorkspaceIdAtom);
|
const activeWorkspaceId = get(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutationKey: ["delete-search-space", activeSearchSpaceId],
|
mutationKey: ["delete-workspace", activeWorkspaceId],
|
||||||
enabled: !!activeSearchSpaceId,
|
enabled: !!activeWorkspaceId,
|
||||||
mutationFn: async (request: DeleteSearchSpaceRequest) => {
|
mutationFn: async (request: DeleteWorkspaceRequest) => {
|
||||||
return searchSpacesApiService.deleteSearchSpace(request);
|
return workspacesApiService.deleteWorkspace(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: (_, request: DeleteSearchSpaceRequest) => {
|
onSuccess: (_, request: DeleteWorkspaceRequest) => {
|
||||||
toast.success("Search space deleted successfully");
|
toast.success("Search space deleted successfully");
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.searchSpaces.all,
|
queryKey: cacheKeys.workspaces.all,
|
||||||
});
|
});
|
||||||
if (request.id) {
|
if (request.id) {
|
||||||
queryClient.removeQueries({
|
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 { atom } from "jotai";
|
||||||
import { atomWithQuery } from "jotai-tanstack-query";
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
import type { GetSearchSpacesRequest } from "@/contracts/types/workspace.types";
|
import type { GetWorkspacesRequest } 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 { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export const activeWorkspaceIdAtom = atom<string | null>(null);
|
export const activeWorkspaceIdAtom = atom<string | null>(null);
|
||||||
|
|
||||||
export const searchSpacesQueryParamsAtom = atom<GetSearchSpacesRequest["queryParams"]>({
|
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
|
||||||
skip: 0,
|
skip: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
owned_only: false,
|
owned_only: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searchSpacesAtom = atomWithQuery((get) => {
|
export const workspacesAtom = atomWithQuery((get) => {
|
||||||
const queryParams = get(searchSpacesQueryParamsAtom);
|
const queryParams = get(workspacesQueryParamsAtom);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.searchSpaces.withQueryParams(queryParams),
|
queryKey: cacheKeys.workspaces.withQueryParams(queryParams),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return searchSpacesApiService.getSearchSpaces({
|
return workspacesApiService.getWorkspaces({
|
||||||
queryParams,
|
queryParams,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ This release brings major improvements to **collaboration and user experience**.
|
||||||
#### New Chat-First Interface
|
#### New Chat-First Interface
|
||||||
|
|
||||||
- **Dashboard Removed**: Users now land directly in a chat for faster access
|
- **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
|
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
|
||||||
- **Streamlined Settings**: Accessible through intuitive dropdown menus
|
- **Streamlined Settings**: Accessible through intuitive dropdown menus
|
||||||
- **Mobile-Responsive Design**: Better experience on all devices
|
- **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 Sharing
|
||||||
|
|
||||||
- **Public Chats**: Share snapshots of chats via public links.
|
- **Public Chats**: Share snapshots of chats via public links.
|
||||||
- **Sharing Permissions**: Search Space owners control who can create and manage public links.
|
- **Sharing Permissions**: Workspace owners control who can create and manage public links.
|
||||||
- **Link Management Page**: View and revoke all public chats from Search Space Settings.
|
- **Link Management Page**: View and revoke all public chats from Workspace Settings.
|
||||||
|
|
||||||
#### Auto (Load Balanced) Mode
|
#### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 commentPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const commentTriggerRef = useRef<HTMLButtonElement>(null);
|
const commentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const messageId = useAuiState(({ message }) => message?.id);
|
const messageId = useAuiState(({ message }) => message?.id);
|
||||||
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
|
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||||
const dbMessageId = parseMessageId(messageId);
|
const dbMessageId = parseMessageId(messageId);
|
||||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||||
|
|
||||||
|
|
@ -520,7 +520,7 @@ export const AssistantMessage: FC = () => {
|
||||||
const commentCount = commentsData?.total_count ?? 0;
|
const commentCount = commentsData?.total_count ?? 0;
|
||||||
const hasComments = commentCount > 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)
|
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ interface ConnectorIndicatorProps {
|
||||||
|
|
||||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||||
(_props, ref) => {
|
(_props, ref) => {
|
||||||
const searchSpaceId = useAtomValue(activeWorkspaceIdAtom);
|
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||||
|
|
||||||
// Real-time document type counts via Zero (updates instantly as docs are indexed)
|
// 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)
|
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||||
// instead of creating a duplicate useInbox("status") hook.
|
// instead of creating a duplicate useInbox("status") hook.
|
||||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||||
|
|
@ -124,7 +124,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
loading: connectorsLoading,
|
loading: connectorsLoading,
|
||||||
error: connectorsError,
|
error: connectorsError,
|
||||||
refreshConnectors: refreshConnectorsSync,
|
refreshConnectors: refreshConnectorsSync,
|
||||||
} = useConnectorsSync(searchSpaceId);
|
} = useConnectorsSync(workspaceId);
|
||||||
|
|
||||||
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
||||||
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
|
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
|
||||||
|
|
@ -142,7 +142,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
inboxItems
|
inboxItems
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get document types that have documents in the search space
|
// Get document types that have documents in the workspace
|
||||||
const activeDocumentTypes = documentTypeCounts
|
const activeDocumentTypes = documentTypeCounts
|
||||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||||
: [];
|
: [];
|
||||||
|
|
@ -163,7 +163,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
open: () => handleOpenChange(true),
|
open: () => handleOpenChange(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!searchSpaceId) return null;
|
if (!workspaceId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||||
|
|
@ -191,8 +191,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||||
{isYouTubeView && searchSpaceId ? (
|
{isYouTubeView && workspaceId ? (
|
||||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
|
||||||
) : viewingMCPList ? (
|
) : viewingMCPList ? (
|
||||||
<ConnectorAccountsListView
|
<ConnectorAccountsListView
|
||||||
connectorType="MCP_CONNECTOR"
|
connectorType="MCP_CONNECTOR"
|
||||||
|
|
@ -253,7 +253,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
isDisconnecting={isDisconnecting}
|
isDisconnecting={isDisconnecting}
|
||||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||||
searchSpaceId={searchSpaceId?.toString()}
|
workspaceId={workspaceId?.toString()}
|
||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||||
|
|
@ -346,7 +346,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
<TabsContent value="all" className="m-0">
|
<TabsContent value="all" className="m-0">
|
||||||
<AllConnectorsTab
|
<AllConnectorsTab
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
searchSpaceId={searchSpaceId}
|
workspaceId={workspaceId}
|
||||||
connectedTypes={connectedTypes}
|
connectedTypes={connectedTypes}
|
||||||
connectingId={connectingId}
|
connectingId={connectingId}
|
||||||
allConnectors={connectors}
|
allConnectors={connectors}
|
||||||
|
|
|
||||||
|
|
@ -159,17 +159,17 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
<div className="h-px bg-border/60" />
|
||||||
|
|
||||||
{/* Step 4 — Pick search space */}
|
{/* Step 4 — Pick workspace */}
|
||||||
<article>
|
<article>
|
||||||
<header className="mb-3 flex items-center gap-2">
|
<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">
|
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||||
4
|
4
|
||||||
</div>
|
</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>
|
</header>
|
||||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||||
In the plugin's <span className="font-medium">Search space</span> setting, choose the
|
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.
|
automatically once the plugin makes its first sync.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
||||||
LINEAR_CONNECTOR: [
|
LINEAR_CONNECTOR: [
|
||||||
"Search through all your Linear issues and comments",
|
"Search through all your Linear issues and comments",
|
||||||
"Access issue titles, descriptions, and full discussion threads",
|
"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",
|
"Keep your search results up-to-date with latest Linear content",
|
||||||
"Index your Linear issues for enhanced search capabilities",
|
"Index your Linear issues for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
|
|
@ -36,63 +36,63 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
||||||
SLACK_CONNECTOR: [
|
SLACK_CONNECTOR: [
|
||||||
"Search through all your Slack messages and conversations",
|
"Search through all your Slack messages and conversations",
|
||||||
"Access messages from public and private channels",
|
"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",
|
"Keep your search results up-to-date with latest Slack content",
|
||||||
"Index your Slack conversations for enhanced search capabilities",
|
"Index your Slack conversations for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
DISCORD_CONNECTOR: [
|
DISCORD_CONNECTOR: [
|
||||||
"Search through all your Discord messages and conversations",
|
"Search through all your Discord messages and conversations",
|
||||||
"Access messages from all accessible channels",
|
"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",
|
"Keep your search results up-to-date with latest Discord content",
|
||||||
"Index your Discord conversations for enhanced search capabilities",
|
"Index your Discord conversations for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
NOTION_CONNECTOR: [
|
NOTION_CONNECTOR: [
|
||||||
"Search through all your Notion pages and databases",
|
"Search through all your Notion pages and databases",
|
||||||
"Access page content, properties, and metadata",
|
"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",
|
"Keep your search results up-to-date with latest Notion content",
|
||||||
"Index your Notion workspace for enhanced search capabilities",
|
"Index your Notion workspace for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
CONFLUENCE_CONNECTOR: [
|
CONFLUENCE_CONNECTOR: [
|
||||||
"Search through all your Confluence pages and spaces",
|
"Search through all your Confluence pages and spaces",
|
||||||
"Access page content, comments, and attachments",
|
"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",
|
"Keep your search results up-to-date with latest Confluence content",
|
||||||
"Index your Confluence workspace for enhanced search capabilities",
|
"Index your Confluence workspace for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
BOOKSTACK_CONNECTOR: [
|
BOOKSTACK_CONNECTOR: [
|
||||||
"Search through all your BookStack pages and books",
|
"Search through all your BookStack pages and books",
|
||||||
"Access page content, chapters, and documentation",
|
"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",
|
"Keep your search results up-to-date with latest BookStack content",
|
||||||
"Index your BookStack instance for enhanced search capabilities",
|
"Index your BookStack instance for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
GITHUB_CONNECTOR: [
|
GITHUB_CONNECTOR: [
|
||||||
"Search through code, issues, and documentation from GitHub repositories",
|
"Search through code, issues, and documentation from GitHub repositories",
|
||||||
"Access repository content, pull requests, and discussions",
|
"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",
|
"Keep your search results up-to-date with latest GitHub content",
|
||||||
"Index your GitHub repositories for enhanced search capabilities",
|
"Index your GitHub repositories for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
JIRA_CONNECTOR: [
|
JIRA_CONNECTOR: [
|
||||||
"Search through all your Jira issues and tickets",
|
"Search through all your Jira issues and tickets",
|
||||||
"Access issue descriptions, comments, and project data",
|
"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",
|
"Keep your search results up-to-date with latest Jira content",
|
||||||
"Index your Jira projects for enhanced search capabilities",
|
"Index your Jira projects for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
CLICKUP_CONNECTOR: [
|
CLICKUP_CONNECTOR: [
|
||||||
"Search through all your ClickUp tasks and projects",
|
"Search through all your ClickUp tasks and projects",
|
||||||
"Access task descriptions, comments, and project data",
|
"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",
|
"Keep your search results up-to-date with latest ClickUp content",
|
||||||
"Index your ClickUp workspace for enhanced search capabilities",
|
"Index your ClickUp workspace for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
LUMA_CONNECTOR: [
|
LUMA_CONNECTOR: [
|
||||||
"Search through all your Luma events",
|
"Search through all your Luma events",
|
||||||
"Access event details, descriptions, and attendee information",
|
"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",
|
"Keep your search results up-to-date with latest Luma content",
|
||||||
"Index your Luma events for enhanced search capabilities",
|
"Index your Luma events for enhanced search capabilities",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
||||||
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
||||||
action items to this search space.
|
action items to this workspace.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export interface ConnectorConfigProps {
|
||||||
connector: SearchSourceConnector;
|
connector: SearchSourceConnector;
|
||||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||||
onNameChange?: (name: string) => void;
|
onNameChange?: (name: string) => void;
|
||||||
searchSpaceId?: string;
|
workspaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
|
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ interface ConnectorEditViewProps {
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
isDisconnecting: boolean;
|
isDisconnecting: boolean;
|
||||||
isIndexing?: boolean;
|
isIndexing?: boolean;
|
||||||
searchSpaceId?: string;
|
workspaceId?: string;
|
||||||
onStartDateChange: (date: Date | undefined) => void;
|
onStartDateChange: (date: Date | undefined) => void;
|
||||||
onEndDateChange: (date: Date | undefined) => void;
|
onEndDateChange: (date: Date | undefined) => void;
|
||||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||||
|
|
@ -65,7 +65,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
isSaving,
|
isSaving,
|
||||||
isDisconnecting,
|
isDisconnecting,
|
||||||
isIndexing = false,
|
isIndexing = false,
|
||||||
searchSpaceId,
|
workspaceId,
|
||||||
onStartDateChange,
|
onStartDateChange,
|
||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
onPeriodicEnabledChange,
|
onPeriodicEnabledChange,
|
||||||
|
|
@ -78,7 +78,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
}) => {
|
}) => {
|
||||||
const searchSpaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
|
const workspaceIdAtom = useAtomValue(activeWorkspaceIdAtom);
|
||||||
const isAuthExpired = connector.config?.auth_expired === true;
|
const isAuthExpired = connector.config?.auth_expired === true;
|
||||||
const reauthEndpoint = getReauthEndpoint(connector);
|
const reauthEndpoint = getReauthEndpoint(connector);
|
||||||
const [reauthing, setReauthing] = useState(false);
|
const [reauthing, setReauthing] = useState(false);
|
||||||
|
|
@ -91,7 +91,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
(connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR);
|
(connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR);
|
||||||
|
|
||||||
const handleReauth = useCallback(async () => {
|
const handleReauth = useCallback(async () => {
|
||||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
const spaceId = workspaceId ?? workspaceIdAtom;
|
||||||
if (!spaceId || !reauthEndpoint) return;
|
if (!spaceId || !reauthEndpoint) return;
|
||||||
setReauthing(true);
|
setReauthing(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -119,7 +119,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setReauthing(false);
|
setReauthing(false);
|
||||||
}
|
}
|
||||||
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
|
}, [workspaceId, workspaceIdAtom, reauthEndpoint, connector.id]);
|
||||||
|
|
||||||
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
||||||
const ConnectorConfigComponent = useMemo(() => {
|
const ConnectorConfigComponent = useMemo(() => {
|
||||||
|
|
@ -273,7 +273,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
connector={connector}
|
connector={connector}
|
||||||
onConfigChange={onConfigChange}
|
onConfigChange={onConfigChange}
|
||||||
onNameChange={onNameChange}
|
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