mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
Merge pull request #1600 from AnishSarkar22/fix/onboarding
fix(onboarding): make LLM setup server-authoritative with recovery-aware gate
This commit is contained in:
commit
d5b5c9cda2
47 changed files with 954 additions and 386 deletions
|
|
@ -0,0 +1,30 @@
|
|||
"""Add workspaces.llm_setup_completed_at for first-run vs. recovery onboarding.
|
||||
|
||||
No backfill: NULL is correct for every existing row. Configured workspaces
|
||||
self-heal via lazy stamping on their next status read (which fires while still
|
||||
``ready``, before they could reach ``needs_setup``); a blanket
|
||||
``SET ... = created_at`` would misclassify abandoned and global-only workspaces.
|
||||
|
||||
Revision ID: 174
|
||||
Revises: 173
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "174"
|
||||
down_revision: str | None = "173"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE workspaces "
|
||||
"ADD COLUMN IF NOT EXISTS llm_setup_completed_at TIMESTAMPTZ"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE workspaces DROP COLUMN IF EXISTS llm_setup_completed_at")
|
||||
|
|
@ -773,7 +773,7 @@ def create_generate_report_tool(
|
|||
|
||||
if not llm:
|
||||
error_msg = (
|
||||
"No LLM configured. Please configure a language model in Settings."
|
||||
"No LLM configured. Please configure a chat model in Settings."
|
||||
)
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return _failed(
|
||||
|
|
|
|||
|
|
@ -585,7 +585,7 @@ def create_generate_resume_tool(
|
|||
|
||||
if not llm:
|
||||
error_msg = (
|
||||
"No LLM configured. Please configure a language model in Settings."
|
||||
"No LLM configured. Please configure a chat model in Settings."
|
||||
)
|
||||
report_id = await _save_failed_report(error_msg)
|
||||
return _failed(
|
||||
|
|
|
|||
|
|
@ -1732,6 +1732,11 @@ class Workspace(BaseModel, TimestampMixin):
|
|||
Integer, nullable=True, default=0, server_default="0"
|
||||
) # For vision/screenshot analysis, defaults to Auto mode
|
||||
|
||||
# First time this workspace went ready via its own model (source=="models").
|
||||
# NULL = never self-configured. Set once, never cleared; splits a needs_setup
|
||||
# verdict into first-run vs. recovery.
|
||||
llm_setup_completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
|
|
@ -16,11 +17,13 @@ from app.db import (
|
|||
Permission,
|
||||
Workspace,
|
||||
get_async_session,
|
||||
has_permission,
|
||||
)
|
||||
from app.schemas import (
|
||||
ConnectionCreate,
|
||||
ConnectionRead,
|
||||
ConnectionUpdate,
|
||||
LlmSetupStatusRead,
|
||||
ModelCreate,
|
||||
ModelPreviewRead,
|
||||
ModelProviderRead,
|
||||
|
|
@ -43,7 +46,7 @@ from app.services.model_connection_service import (
|
|||
)
|
||||
from app.services.provider_registry import REGISTRY
|
||||
from app.users import get_auth_context, require_session_context
|
||||
from app.utils.rbac import check_permission
|
||||
from app.utils.rbac import check_permission, get_user_permissions
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -355,7 +358,7 @@ async def list_connections(
|
|||
session,
|
||||
auth,
|
||||
workspace_id,
|
||||
Permission.LLM_CONFIGS_CREATE.value,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
"You don't have permission to view model connections in this workspace",
|
||||
)
|
||||
stmt = stmt.where(Connection.workspace_id == workspace_id)
|
||||
|
|
@ -760,7 +763,7 @@ async def get_model_roles(
|
|||
session,
|
||||
auth,
|
||||
workspace_id,
|
||||
Permission.LLM_CONFIGS_CREATE.value,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
"You don't have permission to view model roles in this workspace",
|
||||
)
|
||||
workspace = await _clear_invalid_roles(session, workspace_id)
|
||||
|
|
@ -831,3 +834,127 @@ async def update_model_roles(
|
|||
vision_model_id=workspace.vision_model_id,
|
||||
image_gen_model_id=workspace.image_gen_model_id,
|
||||
)
|
||||
|
||||
|
||||
def _global_catalog_has_usable_chat() -> bool:
|
||||
"""True when the operator global catalog exposes a usable chat model.
|
||||
|
||||
Checks usability (enabled connection + enabled, chat-capable model), not
|
||||
mere presence of ``global_llm_config.yaml`` — an empty or malformed file,
|
||||
or an OpenRouter-only config whose startup fetch failed, yields no models
|
||||
and must fall through to onboarding.
|
||||
"""
|
||||
enabled_connection_ids = {
|
||||
conn["id"] for conn in config.GLOBAL_CONNECTIONS if conn.get("enabled", True)
|
||||
}
|
||||
return any(
|
||||
model.get("connection_id") in enabled_connection_ids
|
||||
and model.get("enabled", True)
|
||||
and has_capability(model, "chat")
|
||||
for model in config.GLOBAL_MODELS
|
||||
)
|
||||
|
||||
|
||||
async def _workspace_has_enabled_chat_model(
|
||||
session: AsyncSession, workspace_id: int
|
||||
) -> bool:
|
||||
result = await session.execute(
|
||||
select(Connection)
|
||||
.options(selectinload(Connection.models))
|
||||
.where(
|
||||
Connection.workspace_id == workspace_id,
|
||||
Connection.enabled == True,
|
||||
)
|
||||
)
|
||||
return any(
|
||||
model.enabled and has_capability(model, "chat")
|
||||
for conn in result.scalars().all()
|
||||
for model in conn.models
|
||||
)
|
||||
|
||||
|
||||
async def compute_llm_setup_status(
|
||||
session: AsyncSession,
|
||||
auth: AuthContext,
|
||||
workspace_id: int,
|
||||
) -> LlmSetupStatusRead:
|
||||
"""Single source of truth for whether a workspace can chat.
|
||||
|
||||
"Needs onboarding" is derived, never persisted: a workspace is ``ready``
|
||||
exactly when a usable chat model resolves for it (operator global catalog,
|
||||
a valid pinned role, or an enabled chat-capable model in Auto mode).
|
||||
"""
|
||||
await check_permission(
|
||||
session,
|
||||
auth,
|
||||
workspace_id,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
"You don't have permission to view LLM setup status in this workspace",
|
||||
)
|
||||
permissions = await get_user_permissions(session, auth.user.id, workspace_id)
|
||||
can_configure = has_permission(permissions, Permission.LLM_CONFIGS_CREATE.value)
|
||||
|
||||
global_usable = _global_catalog_has_usable_chat()
|
||||
if config.GLOBAL_LLM_CONFIG_FILE_EXISTS and global_usable:
|
||||
# Global readiness is never stamped: it is not this workspace's own setup.
|
||||
return LlmSetupStatusRead(
|
||||
status="ready",
|
||||
source="global_config",
|
||||
can_configure=can_configure,
|
||||
stage="ready",
|
||||
)
|
||||
|
||||
# Heal dangling role pins first: a chat_model_id pointing at a deleted or
|
||||
# disabled model collapses to 0 (Auto) here, so the checks below see the
|
||||
# real state.
|
||||
workspace = await _clear_invalid_roles(session, workspace_id)
|
||||
await session.commit()
|
||||
await session.refresh(workspace)
|
||||
|
||||
async def _stamp_own_setup() -> None:
|
||||
# Record first own-model readiness once; the guard makes concurrent
|
||||
# stamps idempotent. (Writing on this GET is precedented above.)
|
||||
if workspace.llm_setup_completed_at is None:
|
||||
workspace.llm_setup_completed_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
chat_model_id = workspace.chat_model_id or 0
|
||||
if chat_model_id != 0:
|
||||
# Survived _clear_invalid_roles => valid, enabled, chat-capable.
|
||||
source = "global_config" if chat_model_id < 0 else "models"
|
||||
if source == "models":
|
||||
await _stamp_own_setup()
|
||||
return LlmSetupStatusRead(
|
||||
status="ready", source=source, can_configure=can_configure, stage="ready"
|
||||
)
|
||||
|
||||
if global_usable:
|
||||
return LlmSetupStatusRead(
|
||||
status="ready",
|
||||
source="global_config",
|
||||
can_configure=can_configure,
|
||||
stage="ready",
|
||||
)
|
||||
if await _workspace_has_enabled_chat_model(session, workspace_id):
|
||||
await _stamp_own_setup()
|
||||
return LlmSetupStatusRead(
|
||||
status="ready", source="models", can_configure=can_configure, stage="ready"
|
||||
)
|
||||
|
||||
# A set timestamp => self-configured before (recovery); NULL => never (first-run).
|
||||
stage = "recovery" if workspace.llm_setup_completed_at else "initial_setup"
|
||||
return LlmSetupStatusRead(
|
||||
status="needs_setup", source="none", can_configure=can_configure, stage=stage
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/workspaces/{workspace_id}/llm-setup-status",
|
||||
response_model=LlmSetupStatusRead,
|
||||
)
|
||||
async def llm_setup_status(
|
||||
workspace_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
return await compute_llm_setup_status(session, auth, workspace_id)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
get_default_roles_config,
|
||||
)
|
||||
from app.routes.model_connections_routes import compute_llm_setup_status
|
||||
from app.schemas import (
|
||||
WorkspaceApiAccessUpdate,
|
||||
WorkspaceCreate,
|
||||
|
|
@ -93,7 +94,12 @@ async def create_workspace(
|
|||
|
||||
await session.commit()
|
||||
await session.refresh(db_workspace)
|
||||
return db_workspace
|
||||
|
||||
response = WorkspaceRead.model_validate(db_workspace)
|
||||
response.llm_setup = await compute_llm_setup_status(
|
||||
session, auth, db_workspace.id
|
||||
)
|
||||
return response
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from .model_connections import (
|
|||
ConnectionCreate,
|
||||
ConnectionRead,
|
||||
ConnectionUpdate,
|
||||
LlmSetupStatusRead,
|
||||
ModelCreate,
|
||||
ModelPreviewRead,
|
||||
ModelProviderRead,
|
||||
|
|
@ -185,13 +186,14 @@ __all__ = [
|
|||
"InviteInfoResponse",
|
||||
"InviteRead",
|
||||
"InviteUpdate",
|
||||
# Auth schemas
|
||||
"LlmSetupStatusRead",
|
||||
# Log schemas
|
||||
"LogBase",
|
||||
"LogCreate",
|
||||
"LogFilter",
|
||||
"LogRead",
|
||||
"LogUpdate",
|
||||
# Auth schemas
|
||||
"LogoutAllResponse",
|
||||
"LogoutRequest",
|
||||
"LogoutResponse",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
|
@ -146,3 +146,18 @@ class ModelRolesUpdate(BaseModel):
|
|||
chat_model_id: int | None = None
|
||||
vision_model_id: int | None = None
|
||||
image_gen_model_id: int | None = None
|
||||
|
||||
|
||||
class LlmSetupStatusRead(BaseModel):
|
||||
"""Server-authoritative verdict for the per-workspace LLM onboarding gate.
|
||||
|
||||
``status`` is the only thing the frontend gate acts on; ``source`` is
|
||||
informational and ``can_configure`` selects the onboarding vs. blocked
|
||||
screen for members who cannot manage models. ``stage`` refines a
|
||||
``needs_setup`` verdict into first-run (``initial_setup``) vs. recovery.
|
||||
"""
|
||||
|
||||
status: Literal["ready", "needs_setup"]
|
||||
source: Literal["global_config", "models", "none"]
|
||||
can_configure: bool
|
||||
stage: Literal["initial_setup", "recovery", "ready"]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from datetime import datetime
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .base import IDModel, TimestampModel
|
||||
from .model_connections import LlmSetupStatusRead
|
||||
|
||||
|
||||
class WorkspaceBase(BaseModel):
|
||||
|
|
@ -35,6 +36,9 @@ class WorkspaceRead(WorkspaceBase, IDModel, TimestampModel):
|
|||
api_access_enabled: bool = False
|
||||
qna_custom_instructions: str | None = None
|
||||
shared_memory_md: str | None = None
|
||||
# Populated only by create_workspace so the client can route straight to
|
||||
# onboarding vs. new-chat on the first hop. Null everywhere else.
|
||||
llm_setup: LlmSetupStatusRead | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
318
surfsense_backend/tests/unit/routes/test_llm_setup_status.py
Normal file
318
surfsense_backend/tests/unit/routes/test_llm_setup_status.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
"""Unit tests for the server-authoritative LLM onboarding verdict.
|
||||
|
||||
``compute_llm_setup_status`` is the single source of truth for whether a
|
||||
workspace can chat. These tests cover the two pieces of genuinely new logic:
|
||||
|
||||
1. ``_global_catalog_has_usable_chat`` — a pure check over the operator
|
||||
global catalog (usable model, not mere file presence).
|
||||
2. The decision tree in ``compute_llm_setup_status`` — exercised by faking
|
||||
the DB-touching seams (``_clear_invalid_roles`` heals dangling pins,
|
||||
``_workspace_has_enabled_chat_model`` reports BYOK models) so the routing
|
||||
between ready / needs_setup / global_config / models is asserted directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.context import AuthContext
|
||||
from app.db import Permission
|
||||
from app.routes import model_connections_routes as mc
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeUser:
|
||||
id: str = "u1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeWorkspace:
|
||||
chat_model_id: int | None = 0
|
||||
vision_model_id: int | None = 0
|
||||
image_gen_model_id: int | None = 0
|
||||
llm_setup_completed_at: datetime | None = None
|
||||
|
||||
|
||||
def _global_model(
|
||||
*,
|
||||
model_id: int = -1,
|
||||
connection_id: int = -1,
|
||||
enabled: bool = True,
|
||||
supports_chat: bool = True,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": model_id,
|
||||
"connection_id": connection_id,
|
||||
"enabled": enabled,
|
||||
"supports_chat": supports_chat,
|
||||
"capabilities_override": {},
|
||||
}
|
||||
|
||||
|
||||
class TestGlobalCatalogHasUsableChat:
|
||||
"""Usability, not file existence, is what counts."""
|
||||
|
||||
def test_usable_when_enabled_connection_and_chat_model(self, monkeypatch):
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": True}])
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [_global_model()])
|
||||
assert mc._global_catalog_has_usable_chat() is True
|
||||
|
||||
def test_empty_catalog_is_not_usable(self, monkeypatch):
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [])
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [])
|
||||
assert mc._global_catalog_has_usable_chat() is False
|
||||
|
||||
def test_disabled_connection_is_not_usable(self, monkeypatch):
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": False}])
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [_global_model()])
|
||||
assert mc._global_catalog_has_usable_chat() is False
|
||||
|
||||
def test_disabled_model_is_not_usable(self, monkeypatch):
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": True}])
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_MODELS", [_global_model(enabled=False)])
|
||||
assert mc._global_catalog_has_usable_chat() is False
|
||||
|
||||
def test_non_chat_model_is_not_usable(self, monkeypatch):
|
||||
monkeypatch.setattr(mc.config, "GLOBAL_CONNECTIONS", [{"id": -1, "enabled": True}])
|
||||
monkeypatch.setattr(
|
||||
mc.config, "GLOBAL_MODELS", [_global_model(supports_chat=False)]
|
||||
)
|
||||
assert mc._global_catalog_has_usable_chat() is False
|
||||
|
||||
|
||||
async def _run_status(
|
||||
*,
|
||||
file_exists: bool,
|
||||
global_usable: bool,
|
||||
chat_model_id: int,
|
||||
ws_has_chat: bool = False,
|
||||
permissions: list[str] | None = None,
|
||||
workspace: _FakeWorkspace | None = None,
|
||||
):
|
||||
"""Drive the decision tree with DB-touching seams stubbed out.
|
||||
|
||||
Pass ``workspace`` to preset/inspect ``llm_setup_completed_at`` — it is the
|
||||
object ``_clear_invalid_roles`` returns, and the lazy stamp mutates it in
|
||||
place (``session.commit`` is a no-op AsyncMock).
|
||||
"""
|
||||
if permissions is None:
|
||||
permissions = [Permission.FULL_ACCESS.value]
|
||||
if workspace is None:
|
||||
workspace = _FakeWorkspace(chat_model_id=chat_model_id)
|
||||
else:
|
||||
workspace.chat_model_id = chat_model_id
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(mc.config, "GLOBAL_LLM_CONFIG_FILE_EXISTS", file_exists)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
mc, "_global_catalog_has_usable_chat", return_value=global_usable
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(mc, "check_permission", AsyncMock()))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
mc, "get_user_permissions", AsyncMock(return_value=permissions)
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
mc,
|
||||
"_clear_invalid_roles",
|
||||
AsyncMock(return_value=workspace),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
mc,
|
||||
"_workspace_has_enabled_chat_model",
|
||||
AsyncMock(return_value=ws_has_chat),
|
||||
)
|
||||
)
|
||||
return await mc.compute_llm_setup_status(
|
||||
AsyncMock(), AuthContext.session(_FakeUser()), 1
|
||||
)
|
||||
|
||||
|
||||
class TestComputeLlmSetupStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_yaml_no_models_needs_setup(self):
|
||||
result = await _run_status(
|
||||
file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=False
|
||||
)
|
||||
assert result.status == "needs_setup"
|
||||
assert result.source == "none"
|
||||
assert result.stage == "initial_setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usable_global_catalog_is_ready(self):
|
||||
ws = _FakeWorkspace()
|
||||
result = await _run_status(
|
||||
file_exists=True, global_usable=True, chat_model_id=0, workspace=ws
|
||||
)
|
||||
assert result.status == "ready"
|
||||
assert result.source == "global_config"
|
||||
assert result.stage == "ready"
|
||||
# Global readiness is never stamped as this workspace's own setup.
|
||||
assert ws.llm_setup_completed_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yaml_present_but_empty_catalog_falls_through(self):
|
||||
# File exists but no usable model AND no BYOK => onboarding, not a
|
||||
# dead composer. This is the empty/broken-YAML regression.
|
||||
result = await _run_status(
|
||||
file_exists=True, global_usable=False, chat_model_id=0, ws_has_chat=False
|
||||
)
|
||||
assert result.status == "needs_setup"
|
||||
assert result.source == "none"
|
||||
assert result.stage == "initial_setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_with_workspace_model_is_ready(self):
|
||||
ws = _FakeWorkspace()
|
||||
result = await _run_status(
|
||||
file_exists=False,
|
||||
global_usable=False,
|
||||
chat_model_id=0,
|
||||
ws_has_chat=True,
|
||||
workspace=ws,
|
||||
)
|
||||
assert result.status == "ready"
|
||||
assert result.source == "models"
|
||||
assert result.stage == "ready"
|
||||
assert ws.llm_setup_completed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_counts_global_catalog_without_file(self):
|
||||
result = await _run_status(
|
||||
file_exists=False, global_usable=True, chat_model_id=0, ws_has_chat=False
|
||||
)
|
||||
assert result.status == "ready"
|
||||
assert result.source == "global_config"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pinned_workspace_model_is_ready(self):
|
||||
# chat_model_id > 0 survived _clear_invalid_roles => valid + enabled.
|
||||
ws = _FakeWorkspace()
|
||||
result = await _run_status(
|
||||
file_exists=False, global_usable=False, chat_model_id=5, workspace=ws
|
||||
)
|
||||
assert result.status == "ready"
|
||||
assert result.source == "models"
|
||||
assert result.stage == "ready"
|
||||
assert ws.llm_setup_completed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pinned_global_model_is_ready(self):
|
||||
ws = _FakeWorkspace()
|
||||
result = await _run_status(
|
||||
file_exists=False, global_usable=False, chat_model_id=-3, workspace=ws
|
||||
)
|
||||
assert result.status == "ready"
|
||||
assert result.source == "global_config"
|
||||
assert result.stage == "ready"
|
||||
# A negative pin is global-config readiness, not own setup.
|
||||
assert ws.llm_setup_completed_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pinned_dead_model_healed_to_needs_setup(self):
|
||||
# A pin to a deleted/disabled model collapses to 0 in
|
||||
# _clear_invalid_roles; with no fallback model it is needs_setup.
|
||||
result = await _run_status(
|
||||
file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=False
|
||||
)
|
||||
assert result.status == "needs_setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_configure_owner(self):
|
||||
result = await _run_status(
|
||||
file_exists=True,
|
||||
global_usable=True,
|
||||
chat_model_id=0,
|
||||
permissions=[Permission.FULL_ACCESS.value],
|
||||
)
|
||||
assert result.can_configure is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_configure_editor(self):
|
||||
result = await _run_status(
|
||||
file_exists=True,
|
||||
global_usable=True,
|
||||
chat_model_id=0,
|
||||
permissions=[
|
||||
Permission.LLM_CONFIGS_CREATE.value,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
],
|
||||
)
|
||||
assert result.can_configure is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_configure_viewer_is_false(self):
|
||||
result = await _run_status(
|
||||
file_exists=True,
|
||||
global_usable=True,
|
||||
chat_model_id=0,
|
||||
permissions=[Permission.LLM_CONFIGS_READ.value],
|
||||
)
|
||||
assert result.can_configure is False
|
||||
|
||||
|
||||
class TestOnboardingStage:
|
||||
"""First-run vs. recovery: the durable timestamp splits needs_setup."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_workspace_is_initial_setup(self):
|
||||
result = await _run_status(
|
||||
file_exists=False, global_usable=False, chat_model_id=0, ws_has_chat=False
|
||||
)
|
||||
assert result.stage == "initial_setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_previously_configured_then_lost_is_recovery(self):
|
||||
# Configured before (timestamp set), then deleted: needs_setup but recovery.
|
||||
ws = _FakeWorkspace(llm_setup_completed_at=datetime.now(UTC))
|
||||
result = await _run_status(
|
||||
file_exists=False,
|
||||
global_usable=False,
|
||||
chat_model_id=0,
|
||||
ws_has_chat=False,
|
||||
workspace=ws,
|
||||
)
|
||||
assert result.status == "needs_setup"
|
||||
assert result.stage == "recovery"
|
||||
assert ws.llm_setup_completed_at is not None # preserved, never cleared
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamp_is_not_overwritten_on_subsequent_ready(self):
|
||||
original = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
ws = _FakeWorkspace(llm_setup_completed_at=original)
|
||||
result = await _run_status(
|
||||
file_exists=False,
|
||||
global_usable=False,
|
||||
chat_model_id=0,
|
||||
ws_has_chat=True,
|
||||
workspace=ws,
|
||||
)
|
||||
assert result.stage == "ready"
|
||||
assert ws.llm_setup_completed_at == original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_only_loss_is_initial_setup_not_recovery(self):
|
||||
# Rode global (never stamped), then global lost: a genuine first own-setup.
|
||||
ws = _FakeWorkspace()
|
||||
result = await _run_status(
|
||||
file_exists=False,
|
||||
global_usable=False,
|
||||
chat_model_id=0,
|
||||
ws_has_chat=False,
|
||||
workspace=ws,
|
||||
)
|
||||
assert result.status == "needs_setup"
|
||||
assert result.stage == "initial_setup"
|
||||
assert ws.llm_setup_completed_at is None
|
||||
|
|
@ -4,15 +4,9 @@ import { useAtomValue, useSetAtom } from "jotai";
|
|||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalLlmConfigStatusAtom,
|
||||
globalModelConnectionsAtom,
|
||||
modelConnectionsAtom,
|
||||
modelRolesAtom,
|
||||
} from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||
|
|
@ -22,14 +16,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { useFolderSync } from "@/hooks/use-folder-sync";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { isLlmOnboardingComplete } from "@/lib/onboarding";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
workspaceId,
|
||||
initialPlaygroundSidebarCollapsed,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
workspaceId: string;
|
||||
initialPlaygroundSidebarCollapsed: boolean;
|
||||
}) {
|
||||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
|
|
@ -39,85 +34,38 @@ export function DashboardClientLayout({
|
|||
const setActiveWorkspaceIdState = useSetAtom(activeWorkspaceIdAtom);
|
||||
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
|
||||
|
||||
const { data: modelRoles = {}, isLoading: loading, error } = useAtomValue(modelRolesAtom);
|
||||
const { data: globalConnections = [], isLoading: globalConfigsLoading } = useAtomValue(
|
||||
globalModelConnectionsAtom
|
||||
);
|
||||
const { data: modelConnections = [], isLoading: modelConnectionsLoading } =
|
||||
useAtomValue(modelConnectionsAtom);
|
||||
const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } =
|
||||
useAtomValue(globalLlmConfigStatusAtom);
|
||||
|
||||
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||
// Single source of truth for the onboarding gate. Keyed by the route
|
||||
// workspaceId so the verdict can never lag behind a workspace switch.
|
||||
const {
|
||||
data: setupStatus,
|
||||
isLoading: setupLoading,
|
||||
error: setupError,
|
||||
} = useAtomValue(llmSetupStatusAtomFamily(Number(workspaceId)));
|
||||
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
const isWorkspaceReady = activeWorkspaceId === workspaceId;
|
||||
|
||||
const isReady = setupStatus?.status === "ready";
|
||||
|
||||
// First-run (initial_setup) is the only not-ready state that redirects, so
|
||||
// recovery falls through to the inline composer notice and an established
|
||||
// user who lost their models is never re-onboarded. The other direction
|
||||
// leaves onboarding once the workspace can chat.
|
||||
useEffect(() => {
|
||||
if (isWorkspaceReady) return;
|
||||
setHasCheckedOnboarding(false);
|
||||
}, [isWorkspaceReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnboardingPage) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isWorkspaceReady &&
|
||||
!loading &&
|
||||
!accessLoading &&
|
||||
!globalConfigsLoading &&
|
||||
!globalConfigStatusLoading &&
|
||||
!modelConnectionsLoading &&
|
||||
!hasCheckedOnboarding
|
||||
) {
|
||||
// Onboarding is only relevant when no operator-provided
|
||||
// global_llm_config.yaml exists. When it does, workspaces inherit
|
||||
// the global config and should never be forced into onboarding.
|
||||
if (globalConfigStatus?.exists) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const onboardingComplete = isLlmOnboardingComplete(
|
||||
modelRoles.chat_model_id,
|
||||
globalConnections,
|
||||
modelConnections
|
||||
);
|
||||
|
||||
if (onboardingComplete) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/dashboard/${workspaceId}/onboard`);
|
||||
setHasCheckedOnboarding(true);
|
||||
if (setupLoading || setupError) return;
|
||||
if (setupStatus?.stage === "initial_setup" && !isOnboardingPage) {
|
||||
router.replace(`/dashboard/${workspaceId}/onboard`);
|
||||
} else if (isReady && isOnboardingPage) {
|
||||
router.replace(`/dashboard/${workspaceId}/new-chat`);
|
||||
}
|
||||
}, [
|
||||
isWorkspaceReady,
|
||||
loading,
|
||||
accessLoading,
|
||||
globalConfigsLoading,
|
||||
globalConfigStatusLoading,
|
||||
globalConfigStatus,
|
||||
modelConnectionsLoading,
|
||||
modelRoles.chat_model_id,
|
||||
globalConnections,
|
||||
modelConnections,
|
||||
setupLoading,
|
||||
setupError,
|
||||
setupStatus?.stage,
|
||||
isReady,
|
||||
isOnboardingPage,
|
||||
isOwner,
|
||||
router,
|
||||
workspaceId,
|
||||
hasCheckedOnboarding,
|
||||
]);
|
||||
|
||||
const electronAPI = useElectronAPI();
|
||||
|
|
@ -167,18 +115,19 @@ export function DashboardClientLayout({
|
|||
}
|
||||
}, [workspace_id, setActiveWorkspaceIdState, electronAPI]);
|
||||
|
||||
// Determine if we should show loading
|
||||
const shouldShowLoading =
|
||||
!hasCheckedOnboarding &&
|
||||
(!isWorkspaceReady ||
|
||||
loading ||
|
||||
accessLoading ||
|
||||
globalConfigsLoading ||
|
||||
globalConfigStatusLoading ||
|
||||
modelConnectionsLoading) &&
|
||||
!isOnboardingPage;
|
||||
// Suppress children during either pending redirect so neither /new-chat nor
|
||||
// /onboard flashes for a frame.
|
||||
const isLeavingOnboarding = isReady && isOnboardingPage;
|
||||
const isEnteringOnboarding = setupStatus?.stage === "initial_setup" && !isOnboardingPage;
|
||||
const isRedirecting =
|
||||
!setupLoading && !setupError && (isLeavingOnboarding || isEnteringOnboarding);
|
||||
|
||||
// Block children until the workspace is synced and the initial verdict is
|
||||
// in; afterwards the composer renders its own not-ready state in place, so
|
||||
// recovery (e.g. deleting the last model) never triggers a full-screen
|
||||
// loader or a redirect.
|
||||
const shouldShowLoading = !setupError && (!isWorkspaceReady || setupLoading || isRedirecting);
|
||||
|
||||
// Use global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(shouldShowLoading);
|
||||
|
||||
// Wire desktop app file watcher -> single-file re-index API
|
||||
|
|
@ -188,7 +137,7 @@ export function DashboardClientLayout({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
||||
if (setupError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
|
|
@ -200,7 +149,7 @@ export function DashboardClientLayout({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
{setupError instanceof Error ? setupError.message : String(setupError)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -215,7 +164,10 @@ export function DashboardClientLayout({
|
|||
return (
|
||||
<DocumentUploadDialogProvider>
|
||||
<OnboardingTour />
|
||||
<LayoutDataProvider workspaceId={workspaceId}>
|
||||
<LayoutDataProvider
|
||||
workspaceId={workspaceId}
|
||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
>
|
||||
{children}
|
||||
<ConnectorIndicator showTrigger={false} />
|
||||
</LayoutDataProvider>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
// Server component
|
||||
import type React from "react";
|
||||
import { use } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { DashboardClientLayout } from "./client-layout";
|
||||
|
||||
export default function DashboardLayout({
|
||||
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ workspace_id: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { workspace_id } = use(params);
|
||||
const [{ workspace_id }, cookieStore] = await Promise.all([params, cookies()]);
|
||||
const initialPlaygroundSidebarCollapsed =
|
||||
cookieStore.get(PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE)?.value === "true";
|
||||
|
||||
return <DashboardClientLayout workspaceId={workspace_id}>{children}</DashboardClientLayout>;
|
||||
return (
|
||||
<DashboardClientLayout
|
||||
workspaceId={workspace_id}
|
||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
>
|
||||
{children}
|
||||
</DashboardClientLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,66 +2,32 @@
|
|||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
globalLlmConfigStatusAtom,
|
||||
globalModelConnectionsAtom,
|
||||
llmSetupStatusAtomFamily,
|
||||
modelConnectionsAtom,
|
||||
modelRolesAtom,
|
||||
} from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { useSession } from "@/hooks/use-session";
|
||||
import { redirectToLogin } from "@/lib/auth-utils";
|
||||
import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding";
|
||||
|
||||
export default function OnboardPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const workspaceId = Number(params.workspace_id);
|
||||
const session = useSession();
|
||||
const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue(
|
||||
globalModelConnectionsAtom
|
||||
);
|
||||
const { data: connections = [] } = useAtomValue(modelConnectionsAtom);
|
||||
const { data: roles = {}, isLoading: rolesLoading } = useAtomValue(modelRolesAtom);
|
||||
const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } =
|
||||
useAtomValue(globalLlmConfigStatusAtom);
|
||||
const { data: setupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId));
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "unauthenticated") redirectToLogin();
|
||||
}, [session.status]);
|
||||
|
||||
const hasUsableChatModel = useMemo(
|
||||
() => hasEnabledChatModel([...globalConnections, ...connections]),
|
||||
[globalConnections, connections]
|
||||
);
|
||||
|
||||
const onboardingComplete = isLlmOnboardingComplete(
|
||||
roles.chat_model_id,
|
||||
globalConnections,
|
||||
connections
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
session.status === "loading" || globalLoading || rolesLoading || globalConfigStatusLoading;
|
||||
|
||||
// Onboarding only applies when no global_llm_config.yaml exists. If a global
|
||||
// config is present (or onboarding is already complete), leave this page.
|
||||
const shouldLeaveOnboarding =
|
||||
!isLoading && (Boolean(globalConfigStatus?.exists) || onboardingComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldLeaveOnboarding) {
|
||||
router.replace(`/dashboard/${workspaceId}/new-chat`);
|
||||
}
|
||||
}, [shouldLeaveOnboarding, router, workspaceId]);
|
||||
|
||||
useGlobalLoadingEffect(isLoading || shouldLeaveOnboarding);
|
||||
|
||||
if (isLoading || shouldLeaveOnboarding) return null;
|
||||
// Leaving onboarding is the layout gate's job; here we only enable the
|
||||
// explicit CTA once the workspace can chat.
|
||||
const isReady = setupStatus?.status === "ready";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen select-none flex-col items-center justify-center bg-main-panel p-4">
|
||||
|
|
@ -80,8 +46,8 @@ export default function OnboardPage() {
|
|||
footerAction={
|
||||
<Button
|
||||
className="min-w-[112px]"
|
||||
disabled={!onboardingComplete || !hasUsableChatModel}
|
||||
onClick={() => router.push(`/dashboard/${workspaceId}/new-chat`)}
|
||||
disabled={!isReady}
|
||||
onClick={() => router.replace(`/dashboard/${workspaceId}/new-chat`)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,27 @@ function invalidateModelConnections(workspaceId: number) {
|
|||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.setupStatus(workspaceId),
|
||||
});
|
||||
}
|
||||
|
||||
// After a mutation that can remove the workspace's last usable chat model,
|
||||
// surface immediate feedback. The composer's inline notice covers the state on
|
||||
// its own; this just makes the consequence obvious at the moment of the action.
|
||||
async function warnIfWorkspaceChatDisabled(workspaceId: number) {
|
||||
if (workspaceId <= 0) return;
|
||||
try {
|
||||
const status = await queryClient.fetchQuery({
|
||||
queryKey: cacheKeys.modelConnections.setupStatus(workspaceId),
|
||||
queryFn: () => modelConnectionsApiService.getLlmSetupStatus(workspaceId),
|
||||
});
|
||||
if (status?.status === "needs_setup") {
|
||||
toast.warning("Chat is now disabled. Connect a chat model to start chatting again.");
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: the inline composer notice still reflects the state.
|
||||
}
|
||||
}
|
||||
|
||||
function upsertModelConnection(workspaceId: number, connection: ConnectionRead) {
|
||||
|
|
@ -78,9 +99,10 @@ export const deleteModelConnectionMutationAtom = atomWithMutation((get) => {
|
|||
return {
|
||||
mutationKey: ["model-connections", "delete"],
|
||||
mutationFn: (id: number) => modelConnectionsApiService.deleteConnection(id),
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
toast.success("Connection deleted");
|
||||
invalidateModelConnections(workspaceId);
|
||||
await warnIfWorkspaceChatDisabled(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to delete connection"),
|
||||
};
|
||||
|
|
@ -179,7 +201,10 @@ export const bulkUpdateModelsMutationAtom = atomWithMutation((get) => {
|
|||
mutationKey: ["models", "bulk-update"],
|
||||
mutationFn: ({ connectionId, data }: { connectionId: number; data: ModelsBulkUpdateRequest }) =>
|
||||
modelConnectionsApiService.bulkUpdateModels(connectionId, data),
|
||||
onSuccess: () => invalidateModelConnections(workspaceId),
|
||||
onSuccess: async () => {
|
||||
invalidateModelConnections(workspaceId);
|
||||
await warnIfWorkspaceChatDisabled(workspaceId);
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to update models"),
|
||||
};
|
||||
});
|
||||
|
|
@ -208,6 +233,9 @@ export const updateModelRolesMutationAtom = atomWithMutation((get) => {
|
|||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.roles(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.modelConnections.setupStatus(workspaceId),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || "Failed to update model roles"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { atomFamily } from "jotai-family";
|
||||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { modelConnectionsApiService } from "@/lib/apis/model-connections-api.service";
|
||||
import { isAuthenticated } from "@/lib/auth-utils";
|
||||
|
|
@ -44,3 +45,18 @@ export const modelRolesAtom = atomWithQuery((get) => {
|
|||
queryFn: () => modelConnectionsApiService.getModelRoles(workspaceId),
|
||||
};
|
||||
});
|
||||
|
||||
// Keyed by the route workspaceId (not activeWorkspaceIdAtom) so the onboarding
|
||||
// gate's verdict can never be computed against a stale workspace during a
|
||||
// workspace switch.
|
||||
export const llmSetupStatusAtomFamily = atomFamily((workspaceId: number) =>
|
||||
atomWithQuery(() => ({
|
||||
queryKey: cacheKeys.modelConnections.setupStatus(workspaceId),
|
||||
enabled: workspaceId > 0 && isAuthenticated(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Recovery is event-driven: mutations invalidate this key; external fixes
|
||||
// are caught on window focus. No polling, so not-ready tabs cost nothing.
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: () => modelConnectionsApiService.getLlmSetupStatus(workspaceId),
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,14 +20,10 @@ import { useAnnouncements } from "@/hooks/use-announcements";
|
|||
* Behaviour:
|
||||
* - On load, the first active, audience-matched, unread spotlight announcement
|
||||
* is shown automatically.
|
||||
* - The user must explicitly acknowledge it ("Got it" or the CTA link), which
|
||||
* marks it as read so it never shows again.
|
||||
* - Closing via the X / Escape / outside-click only hides it for the current
|
||||
* session; it reappears on the next load until the user marks it as seen.
|
||||
* - Any dismissal marks it as read so it never shows again.
|
||||
*/
|
||||
export function AnnouncementSpotlight() {
|
||||
const { announcements, markRead } = useAnnouncements();
|
||||
const [sessionDismissed, setSessionDismissed] = useState<Set<string>>(() => new Set());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Short delay so the spotlight doesn't flash during initial hydration/layout.
|
||||
|
|
@ -37,11 +33,8 @@ export function AnnouncementSpotlight() {
|
|||
}, []);
|
||||
|
||||
const current = useMemo(
|
||||
() =>
|
||||
announcements.find(
|
||||
(a) => a.spotlight && a.isImportant && !a.isRead && !sessionDismissed.has(a.id)
|
||||
) ?? null,
|
||||
[announcements, sessionDismissed]
|
||||
() => announcements.find((a) => a.spotlight && a.isImportant && !a.isRead) ?? null,
|
||||
[announcements]
|
||||
);
|
||||
|
||||
if (!current) return null;
|
||||
|
|
@ -51,13 +44,7 @@ export function AnnouncementSpotlight() {
|
|||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setSessionDismissed((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.add(current.id);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
if (!next) markRead(current.id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -82,7 +69,7 @@ export function AnnouncementSpotlight() {
|
|||
</DialogDescription>
|
||||
<DialogFooter className="mt-2">
|
||||
{current.link && (
|
||||
<Button variant="outline" asChild className="gap-1.5" onClick={handleAcknowledge}>
|
||||
<Button variant="secondary" asChild className="gap-1.5" onClick={handleAcknowledge}>
|
||||
<Link
|
||||
href={current.link.url}
|
||||
target={current.link.url.startsWith("http") ? "_blank" : undefined}
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
}}
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Manage External MCP Connectors</DialogTitle>
|
||||
<DialogTitle className="sr-only">MCP Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && workspaceId ? (
|
||||
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
}
|
||||
|
||||
if (isDeprecatedForConnect) {
|
||||
return "Deprecated — no longer available to connect.";
|
||||
return "Deprecated. No longer available to connect.";
|
||||
}
|
||||
|
||||
return description;
|
||||
|
|
@ -102,7 +102,16 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
|
||||
)}
|
||||
>
|
||||
{connectorType ? (
|
||||
{isMCP ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-6 bg-current"
|
||||
style={{
|
||||
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
}}
|
||||
/>
|
||||
) : connectorType ? (
|
||||
getConnectorIcon(connectorType, "size-6")
|
||||
) : id === "youtube-crawler" ? (
|
||||
<IconBrandYoutube className="size-6" />
|
||||
|
|
@ -113,19 +122,12 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
||||
{deprecated ? (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Deprecated
|
||||
</span>
|
||||
) : (
|
||||
showWarnings &&
|
||||
status.status !== "active" && (
|
||||
<ConnectorStatusBadge
|
||||
status={status.status}
|
||||
statusMessage={statusMessage}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
)
|
||||
{showWarnings && status.status !== "active" && (
|
||||
<ConnectorStatusBadge
|
||||
status={status.status}
|
||||
statusMessage={statusMessage}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isIndexing ? (
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
|
|||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight">
|
||||
Manage External MCP Connectors
|
||||
MCP Connectors
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5">
|
||||
Connect Surfsense to your favorite tools and services.
|
||||
Connect external tools and services through MCP.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
agentToolsAtom,
|
||||
|
|
@ -47,11 +47,7 @@ import {
|
|||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalModelConnectionsAtom,
|
||||
modelConnectionsAtom,
|
||||
modelRolesAtom,
|
||||
} from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
|
|
@ -262,17 +258,17 @@ const ThreadWelcome: FC = () => {
|
|||
const greeting = useMemo(() => getTimeBasedGreeting(user), [user]);
|
||||
|
||||
return (
|
||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||
<div className="my-auto flex w-full flex-col items-center gap-6 py-6 sm:contents sm:my-0 sm:gap-0 sm:py-0">
|
||||
<div className="aui-thread-welcome-message flex flex-col items-center text-center sm:absolute sm:bottom-[calc(50%+5rem)] sm:left-0 sm:right-0">
|
||||
<div className="aui-thread-welcome-root flex min-h-0 flex-1">
|
||||
<section className="mx-auto grid w-full max-w-(--thread-max-width) content-center gap-6 pt-8 pb-[clamp(5rem,16vh,12rem)]">
|
||||
<div className="aui-thread-welcome-message flex flex-col items-center px-4 text-center">
|
||||
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full flex items-start justify-center sm:absolute sm:top-[calc(50%-3.5rem)] sm:left-0 sm:right-0">
|
||||
<div className="flex w-full items-start justify-center">
|
||||
<Composer />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -448,6 +444,52 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer
|
||||
* (same visual treatment as the "Connect your tools" tray below it).
|
||||
*
|
||||
* Replaces the old full-screen onboarding redirect: a workspace with no usable
|
||||
* chat model (fresh install, or an admin deleted the last model) renders the
|
||||
* composer in place with this notice instead of navigating away. Configurable
|
||||
* members get a CTA to connect one; everyone else is told to ask an admin. The
|
||||
* send button stays disabled independently, and the backend rejects any send
|
||||
* that slips through, so this is purely guidance.
|
||||
*
|
||||
* Presentational: visibility is decided by the parent so the composer can flatten
|
||||
* its top shadow for a seamless join.
|
||||
*/
|
||||
const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }> = ({
|
||||
workspaceId,
|
||||
canConfigure,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="relative z-0 -mb-5 flex min-w-0 items-center gap-2 rounded-t-3xl bg-popover px-4 pt-2 pb-6 shadow-sm shadow-black/5 dark:shadow-black/10">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[13px] font-normal text-muted-foreground select-none">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{canConfigure
|
||||
? "Connect a chat model to start chatting."
|
||||
: "No model available. Ask a workspace admin to connect a chat model."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1" />
|
||||
{canConfigure ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 cursor-pointer gap-2 rounded-md px-2.5 text-xs font-medium select-none"
|
||||
onClick={() => router.push(`/dashboard/${workspaceId}/workspace-settings/models`)}
|
||||
>
|
||||
<span className="sm:hidden">Connect</span>
|
||||
<span className="hidden sm:inline">Connect a model</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer: FC = () => {
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
|
||||
|
|
@ -487,6 +529,9 @@ const Composer: FC = () => {
|
|||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
|
||||
|
||||
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
|
||||
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
|
||||
|
||||
const currentPlaceholder = COMPOSER_PLACEHOLDER;
|
||||
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -934,10 +979,17 @@ const Composer: FC = () => {
|
|||
) : null}
|
||||
</Popover>
|
||||
<div className="relative flex w-full flex-col">
|
||||
{isChatUnavailable ? (
|
||||
<ChatUnavailableNotice
|
||||
workspaceId={workspaceId ?? 0}
|
||||
canConfigure={chatSetupStatus?.can_configure ?? false}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
|
||||
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none"
|
||||
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none",
|
||||
isChatUnavailable && "shadow-none dark:shadow-none"
|
||||
)}
|
||||
>
|
||||
<PendingScreenImageStrip />
|
||||
|
|
@ -1071,9 +1123,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
if (url) setPendingScreenImages((prev) => [...prev, url]);
|
||||
}, [electronAPI, setPendingScreenImages]);
|
||||
|
||||
const { data: globalModelConnections } = useAtomValue(globalModelConnectionsAtom);
|
||||
const { data: modelConnections } = useAtomValue(modelConnectionsAtom);
|
||||
const { data: modelRoles } = useAtomValue(modelRolesAtom);
|
||||
const { data: setupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId));
|
||||
|
||||
const { data: agentTools } = useAtomValue(agentToolsAtom);
|
||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||
|
|
@ -1154,21 +1204,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
hydrateDisabled();
|
||||
}, [hydrateDisabled]);
|
||||
|
||||
const hasModelConfigured = useMemo(() => {
|
||||
const chatModelId = modelRoles?.chat_model_id ?? 0;
|
||||
if (chatModelId === 0) {
|
||||
return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
|
||||
connection.models.some((model) => model.enabled && Boolean(model.supports_chat))
|
||||
);
|
||||
}
|
||||
return [...(globalModelConnections ?? []), ...(modelConnections ?? [])].some((connection) =>
|
||||
connection.models.some(
|
||||
(model) => model.id === chatModelId && model.enabled && Boolean(model.supports_chat)
|
||||
)
|
||||
);
|
||||
}, [modelRoles?.chat_model_id, globalModelConnections, modelConnections]);
|
||||
// A workspace with no usable chat model renders the composer with an inline
|
||||
// notice (see ChatUnavailableNotice) rather than being redirected away, so
|
||||
// the send button must stay disabled here. The backend also rejects any
|
||||
// send that lacks a resolvable model, making this defense-in-depth.
|
||||
const isWorkspaceChatReady = setupStatus?.status === "ready";
|
||||
|
||||
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||
const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
|
||||
|
|
@ -1194,7 +1236,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
|
||||
<Unplug className="size-4" />
|
||||
Manage External MCP Connectors
|
||||
MCP Connectors
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
|
||||
<Settings2 className="size-4" />
|
||||
|
|
@ -1414,7 +1456,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
|
||||
<Unplug className="h-4 w-4" />
|
||||
Manage External MCP Connectors
|
||||
MCP Connectors
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub
|
||||
open={toolsPopoverOpen}
|
||||
|
|
@ -1610,12 +1652,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
)}
|
||||
<ConnectedScraperIcons workspaceId={workspaceId} />
|
||||
</div>
|
||||
{!hasModelConfigured && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
|
||||
<ChatHeader
|
||||
workspaceId={workspaceId}
|
||||
|
|
@ -1628,11 +1664,9 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
tooltip={
|
||||
isBlockedByOtherUser
|
||||
? "Wait for AI to finish responding"
|
||||
: !hasModelConfigured
|
||||
? "Please select a model to start chatting"
|
||||
: isComposerEmpty
|
||||
? "Enter a message or add a screenshot to send"
|
||||
: "Send message"
|
||||
: isComposerEmpty
|
||||
? "Enter a message or add a screenshot to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarListItem } from "../layout/ui/sidebar/SidebarListItem";
|
||||
import { DND_TYPES } from "./FolderNode";
|
||||
|
|
@ -92,6 +93,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
const isMemoryDocument =
|
||||
doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
||||
const isSelectable = canMention && !isUnavailable;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
|
|
@ -164,9 +166,9 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<ContextMenuTrigger asChild>
|
||||
<SidebarListItem
|
||||
ref={attachRef}
|
||||
active={isMentioned}
|
||||
active={isMentioned || dropdownOpen}
|
||||
dragging={isDragging}
|
||||
className="gap-2.5 px-1"
|
||||
className="group/item relative gap-2.5 px-1"
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
role="button"
|
||||
tabIndex={isUnavailable ? -1 : 0}
|
||||
|
|
@ -214,32 +216,36 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{isMemoryDocument ? (
|
||||
<span aria-disabled="true" className="h-3.5 w-3.5 shrink-0 cursor-default">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
disabled
|
||||
aria-disabled
|
||||
className="h-3.5 w-3.5 pointer-events-none"
|
||||
/>
|
||||
</span>
|
||||
) : canMention ? (
|
||||
<span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center transition-opacity",
|
||||
canMention &&
|
||||
(isMentioned
|
||||
? "opacity-0"
|
||||
: "max-sm:opacity-0 group-hover/item:opacity-0")
|
||||
)}
|
||||
>
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
{canMention ? (
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
aria-label={`Select ${doc.title}`}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100",
|
||||
isMentioned
|
||||
? "opacity-100"
|
||||
: "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
|
@ -260,36 +266,30 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<span className="relative shrink-0 flex items-center justify-center h-6 w-6">
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
) && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center transition-opacity pointer-events-none",
|
||||
dropdownOpen ? "opacity-0" : "group-hover:opacity-0"
|
||||
)}
|
||||
>
|
||||
{getDocumentTypeIcon(
|
||||
doc.document_type as DocumentTypeEnum,
|
||||
"h-3.5 w-3.5 text-muted-foreground"
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-0 right-0 bottom-0 flex items-center rounded-r-md pr-1 pl-6",
|
||||
isMentioned || dropdownOpen
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: isMentioned || dropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen
|
||||
? "opacity-100 bg-accent hover:bg-accent"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
"pointer-events-auto hidden h-6 w-6 shrink-0 hover:bg-transparent sm:inline-flex",
|
||||
dropdownOpen && "bg-accent hover:bg-accent"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Document actions for ${doc.title}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
|
|
@ -341,7 +341,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
</SidebarListItem>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderSelectionState } from "./FolderTreeView";
|
||||
|
||||
|
|
@ -124,6 +125,7 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
onStopWatching,
|
||||
onExportFolder,
|
||||
}: FolderNodeProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [renameValue, setRenameValue] = useState(folder.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -261,8 +263,9 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
tabIndex={0}
|
||||
dragging={isDragging}
|
||||
className={cn(
|
||||
"relative gap-1 px-1",
|
||||
"group/item relative gap-1 px-1",
|
||||
isExpanded && "font-medium",
|
||||
dropdownOpen && "bg-accent text-accent-foreground",
|
||||
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
|
||||
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
|
||||
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
|
||||
|
|
@ -291,11 +294,11 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
</span>
|
||||
|
||||
{processingState !== "idle" && selectionState === "none" ? (
|
||||
<>
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{processingState !== "idle" && selectionState === "none" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center group-hover:hidden">
|
||||
<span className="flex h-3.5 w-3.5 items-center justify-center">
|
||||
{processingState === "processing" ? (
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
) : (
|
||||
|
|
@ -309,29 +312,37 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
: "Some files failed to process"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectionState === "all"
|
||||
? true
|
||||
: selectionState === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<FolderIcon
|
||||
className={cn(
|
||||
"absolute h-4 w-4 text-muted-foreground transition-opacity",
|
||||
selectionState === "none"
|
||||
? "max-sm:opacity-0 group-hover/item:opacity-0"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectionState === "all"
|
||||
? true
|
||||
: selectionState === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
aria-label={`Select ${folder.name}`}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 transition-opacity max-sm:pointer-events-auto max-sm:opacity-100",
|
||||
selectionState === "none"
|
||||
? "pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
|
||||
: "opacity-100"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
|
|
@ -350,23 +361,35 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent transition-opacity",
|
||||
dropdownOpen
|
||||
? "opacity-100 bg-accent hover:bg-accent"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-0 right-0 bottom-0 flex items-center rounded-r-md pr-1 pl-6",
|
||||
dropdownOpen
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: dropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"pointer-events-auto hidden h-6 w-6 shrink-0 hover:bg-transparent transition-opacity sm:inline-flex",
|
||||
dropdownOpen && "bg-accent hover:bg-accent"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Folder actions for ${folder.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{isWatched && onRescan && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
|
|
@ -436,8 +459,9 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</SidebarListItem>
|
||||
</ContextMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -57,10 +57,15 @@ import { LayoutShell } from "../ui/shell";
|
|||
|
||||
interface LayoutDataProviderProps {
|
||||
workspaceId: string;
|
||||
initialPlaygroundSidebarCollapsed: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
|
||||
export function LayoutDataProvider({
|
||||
workspaceId,
|
||||
initialPlaygroundSidebarCollapsed,
|
||||
children,
|
||||
}: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
|
@ -307,7 +312,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
{
|
||||
title: "Artifacts",
|
||||
url: `/dashboard/${workspaceId}/artifacts`,
|
||||
icon: Boxes,
|
||||
icon: Shapes,
|
||||
isActive: isArtifactsActive,
|
||||
},
|
||||
{
|
||||
|
|
@ -724,6 +729,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
onTabSwitch={handleTabSwitch}
|
||||
onTabPrefetch={handleTabPrefetch}
|
||||
playgroundSidebar={<PlaygroundSidebar workspaceId={workspaceId} />}
|
||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
>
|
||||
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||
</LayoutShell>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { trackWorkspaceCreated } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
|
|
@ -67,7 +69,22 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
|
|||
|
||||
trackWorkspaceCreated(result.id, values.name);
|
||||
|
||||
router.push(`/dashboard/${result.id}/new-chat`);
|
||||
// Seed the gate's query so it resolves without a loader flash, and
|
||||
// route straight to onboarding vs. new-chat on the first hop.
|
||||
if (result.llm_setup) {
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.modelConnections.setupStatus(result.id),
|
||||
result.llm_setup
|
||||
);
|
||||
}
|
||||
// A fresh workspace can never be recovery, so this matches the gate,
|
||||
// which is the authoritative net regardless.
|
||||
const isInitialSetup = result.llm_setup?.stage === "initial_setup";
|
||||
router.push(
|
||||
isInitialSetup
|
||||
? `/dashboard/${result.id}/onboard`
|
||||
: `/dashboard/${result.id}/new-chat`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to create workspace:", error);
|
||||
setIsSubmitting(false);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ const DocumentTabContent = dynamic(
|
|||
}
|
||||
);
|
||||
|
||||
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
|
||||
const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
function MacDesktopTitleBar({
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
|
|
@ -113,6 +116,7 @@ interface LayoutShellProps {
|
|||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
playgroundSidebar?: React.ReactNode;
|
||||
initialPlaygroundSidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
function MainContentPanel({
|
||||
|
|
@ -214,12 +218,15 @@ export function LayoutShell({
|
|||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
playgroundSidebar,
|
||||
initialPlaygroundSidebarCollapsed = false,
|
||||
}: LayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const electronAPI = useElectronAPI();
|
||||
const isMacDesktop = electronAPI?.versions.platform === "darwin";
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false);
|
||||
const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(
|
||||
initialPlaygroundSidebarCollapsed
|
||||
);
|
||||
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||
const {
|
||||
sidebarWidth,
|
||||
|
|
@ -233,7 +240,12 @@ export function LayoutShell({
|
|||
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
||||
);
|
||||
const handlePlaygroundSidebarToggle = () => {
|
||||
setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed);
|
||||
setIsPlaygroundSidebarCollapsed((collapsed) => {
|
||||
const nextCollapsed = !collapsed;
|
||||
const secureAttribute = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE}=${nextCollapsed}; Path=/; Max-Age=${PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secureAttribute}`;
|
||||
return nextCollapsed;
|
||||
});
|
||||
};
|
||||
|
||||
// Mobile layout
|
||||
|
|
|
|||
|
|
@ -30,18 +30,17 @@ function CopyButton({ text }: { text: string }) {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 h-7 gap-1.5 px-2 text-xs"
|
||||
className="absolute top-2 right-2 size-7 p-0"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy configuration"
|
||||
aria-label={copied ? "Configuration copied" : "Copy configuration"}
|
||||
>
|
||||
{copied ? <Check className="size-3.5 text-brand" /> : <Copy className="size-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const TRANSPORTS: { id: McpTransport; label: string; hint: string }[] = [
|
||||
{ id: "remote", label: "Hosted", hint: "mcp.surfsense.com — nothing to install" },
|
||||
{ id: "remote", label: "Hosted", hint: "mcp.surfsense.com, nothing to install" },
|
||||
{ id: "stdio", label: "Self-host", hint: "run the server against your own backend" },
|
||||
];
|
||||
|
||||
|
|
@ -64,7 +63,7 @@ export function AgentSetupTabs({ options }: { options?: Partial<McpSnippetOption
|
|||
const active = TRANSPORTS.find((t) => t.id === transport) ?? TRANSPORTS[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="inline-flex rounded-lg border bg-muted/40 p-0.5">
|
||||
{TRANSPORTS.map((t) => (
|
||||
|
|
@ -83,19 +82,21 @@ export function AgentSetupTabs({ options }: { options?: Partial<McpSnippetOption
|
|||
<span className="text-xs text-muted-foreground">{active.hint}</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={MCP_CLIENTS[0].id} className="w-full">
|
||||
<TabsList className="flex h-auto flex-wrap justify-start gap-1">
|
||||
{MCP_CLIENTS.map((client) => (
|
||||
<TabsTrigger key={client.id} value={client.id}>
|
||||
{client.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<Tabs defaultValue={MCP_CLIENTS[0].id} className="min-w-0 w-full">
|
||||
<div className="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<TabsList className="flex h-auto w-max min-w-full flex-nowrap justify-start gap-1">
|
||||
{MCP_CLIENTS.map((client) => (
|
||||
<TabsTrigger key={client.id} value={client.id}>
|
||||
{client.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
{MCP_CLIENTS.map((client) => {
|
||||
const snippet = client[transport];
|
||||
const config = snippet.build(resolved);
|
||||
return (
|
||||
<TabsContent key={client.id} value={client.id} className="space-y-3">
|
||||
<TabsContent key={client.id} value={client.id} className="min-w-0 space-y-3">
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm leading-relaxed text-muted-foreground">
|
||||
{snippet.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
|
|
|
|||
|
|
@ -41,13 +41,11 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
|
|||
<SidebarButtonBadge>New</SidebarButtonBadge>
|
||||
</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent className="max-h-[85vh] min-w-0 overflow-x-hidden overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect to Claude Code, Codex, OpenCode…</DialogTitle>
|
||||
<DialogDescription>
|
||||
The SurfSense MCP server gives any coding agent these scrapers and your knowledge base
|
||||
as native tools. You need an API key (create one under API Keys) — then pick your agent
|
||||
and paste its config.
|
||||
Give your coding agent access to SurfSense scrapers and your knowledge base. Create an API key under API Keys, choose your agent, then paste the config.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AgentSetupTabs options={{ baseUrl: BACKEND_URL || undefined }} />
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function ContextMenuSubContent({
|
|||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground border border-popover-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -83,7 +83,7 @@ function ContextMenuContent({
|
|||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground border border-popover-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ export const globalLlmConfigStatus = z.object({
|
|||
exists: z.boolean(),
|
||||
});
|
||||
|
||||
export const llmSetupStatus = z.object({
|
||||
status: z.enum(["ready", "needs_setup"]),
|
||||
source: z.enum(["global_config", "models", "none"]),
|
||||
can_configure: z.boolean(),
|
||||
stage: z.enum(["initial_setup", "recovery", "ready"]),
|
||||
});
|
||||
|
||||
export const modelProviderRead = z.object({
|
||||
provider: z.string(),
|
||||
transport: z.string(),
|
||||
|
|
@ -140,5 +147,6 @@ export type ModelUpdateRequest = z.infer<typeof modelUpdateRequest>;
|
|||
export type ModelsBulkUpdateRequest = z.infer<typeof modelsBulkUpdateRequest>;
|
||||
export type ModelRoles = z.infer<typeof modelRoles>;
|
||||
export type GlobalLlmConfigStatus = z.infer<typeof globalLlmConfigStatus>;
|
||||
export type LlmSetupStatus = z.infer<typeof llmSetupStatus>;
|
||||
export type VerifyConnectionResponse = z.infer<typeof verifyConnectionResponse>;
|
||||
export type ModelProviderRead = z.infer<typeof modelProviderRead>;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { paginationQueryParams } from ".";
|
||||
import { llmSetupStatus } from "./model-connections.types";
|
||||
|
||||
export const workspace = z.object({
|
||||
id: z.number(),
|
||||
|
|
@ -36,7 +37,9 @@ export const createWorkspaceRequest = workspace.pick({ name: true, description:
|
|||
qna_custom_instructions: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const createWorkspaceResponse = workspace.omit({ member_count: true, is_owner: true });
|
||||
export const createWorkspaceResponse = workspace
|
||||
.omit({ member_count: true, is_owner: true })
|
||||
.extend({ llm_setup: llmSetupStatus.nullable().optional() });
|
||||
|
||||
/**
|
||||
* Get workspace
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
connectionUpdateRequest,
|
||||
type GlobalLlmConfigStatus,
|
||||
globalLlmConfigStatus,
|
||||
type LlmSetupStatus,
|
||||
llmSetupStatus,
|
||||
type ModelCreateRequest,
|
||||
type ModelPreviewRead,
|
||||
type ModelProviderRead,
|
||||
|
|
@ -40,6 +42,13 @@ class ModelConnectionsApiService {
|
|||
return baseApiService.get(`/api/v1/global-llm-config-status`, globalLlmConfigStatus);
|
||||
};
|
||||
|
||||
getLlmSetupStatus = async (workspaceId: number): Promise<LlmSetupStatus> => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/workspaces/${workspaceId}/llm-setup-status`,
|
||||
llmSetupStatus
|
||||
);
|
||||
};
|
||||
|
||||
getModelProviders = async (): Promise<ModelProviderRead[]> => {
|
||||
return baseApiService.get(`/api/v1/model-providers`, modelProviderListResponse);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import type { ConnectionRead } from "@/contracts/types/model-connections.types";
|
||||
|
||||
export function hasEnabledChatModel(connections: ConnectionRead[]): boolean {
|
||||
return connections.some(
|
||||
(connection) =>
|
||||
connection.enabled &&
|
||||
connection.models.some((model) => model.enabled && Boolean(model.supports_chat))
|
||||
);
|
||||
}
|
||||
|
||||
export function isLlmOnboardingComplete(
|
||||
chatModelId: number | null | undefined,
|
||||
globalConnections: ConnectionRead[],
|
||||
workspaceConnections: ConnectionRead[]
|
||||
): boolean {
|
||||
const connections = [...globalConnections, ...workspaceConnections];
|
||||
const resolvedChatModelId = chatModelId ?? 0;
|
||||
|
||||
if (resolvedChatModelId === 0) {
|
||||
return hasEnabledChatModel(connections);
|
||||
}
|
||||
|
||||
return connections.some((connection) =>
|
||||
connection.models.some(
|
||||
(model) => model.id === resolvedChatModelId && model.enabled && Boolean(model.supports_chat)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ export const cacheKeys = {
|
|||
globalConfigStatus: () => ["model-connections", "global-config-status"] as const,
|
||||
providers: () => ["model-connections", "providers"] as const,
|
||||
roles: (workspaceId: number) => ["model-roles", workspaceId] as const,
|
||||
setupStatus: (workspaceId: number) => ["llm-setup-status", workspaceId] as const,
|
||||
},
|
||||
auth: {
|
||||
user: ["auth", "user"] as const,
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@
|
|||
"manage_documents": "Manage Documents",
|
||||
"connectors": "Connectors",
|
||||
"add_connector": "Add Connector",
|
||||
"manage_connectors": "Manage External MCP Connectors",
|
||||
"manage_connectors": "MCP Connectors",
|
||||
"logs": "Logs",
|
||||
"all_workspaces": "All Workspaces",
|
||||
"team": "Team"
|
||||
|
|
@ -587,7 +587,7 @@
|
|||
"add_provider_subtitle": "Configure your first model provider to get started",
|
||||
"add_provider_button": "Add Provider",
|
||||
"add_new_llm_provider": "Add New LLM Provider",
|
||||
"configure_new_provider": "Configure a new language model provider for your AI assistant",
|
||||
"configure_new_provider": "Configure a new chat model provider for your AI assistant",
|
||||
"config_name": "Configuration Name",
|
||||
"config_name_required": "Configuration Name *",
|
||||
"config_name_placeholder": "e.g., My OpenAI GPT-4",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
"fuzzy-search": "^3.2.1",
|
||||
"geist": "^1.4.2",
|
||||
"jotai": "^2.15.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"jotai-tanstack-query": "^0.11.0",
|
||||
"katex": "^0.16.28",
|
||||
"lenis": "^1.3.17",
|
||||
|
|
|
|||
13
surfsense_web/pnpm-lock.yaml
generated
13
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -254,6 +254,9 @@ importers:
|
|||
jotai:
|
||||
specifier: ^2.15.1
|
||||
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
|
||||
jotai-family:
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4))
|
||||
jotai-tanstack-query:
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -7032,6 +7035,12 @@ packages:
|
|||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
jotai-family@1.0.2:
|
||||
resolution: {integrity: sha512-U1aTMGxmsmz2Z8gaJD1/ljmMnsmG4/dqrcsfwGbjWV7p6boB9Vy3+75YwUpwPj7GbLNJk9O8rl1VNi8d7+6Rxw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
jotai: '>=2.9.0'
|
||||
|
||||
jotai-optics@0.4.0:
|
||||
resolution: {integrity: sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==}
|
||||
peerDependencies:
|
||||
|
|
@ -16336,6 +16345,10 @@ snapshots:
|
|||
|
||||
jose@5.10.0: {}
|
||||
|
||||
jotai-family@1.0.2(jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)):
|
||||
dependencies:
|
||||
jotai: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
|
||||
|
||||
jotai-optics@0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1):
|
||||
dependencies:
|
||||
jotai: 2.8.4(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ test.describe("ClickUp connector journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
await expect(connectorDialog.getByText("ClickUp")).toBeVisible();
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ test.describe("Composio Calendar journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
|
||||
const beforeChatDocs = await listDocuments(request, apiToken, workspace.id);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ test.describe("Composio Gmail journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
|
||||
const beforeChatDocs = await listDocuments(request, apiToken, workspace.id);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ test.describe("Confluence connector journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
await connectorDialog.getByPlaceholder("Search").fill("Confluence");
|
||||
await expect(connectorDialog.getByText("Confluence", { exact: true })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ test.describe("Native Google Calendar journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
|
||||
const beforeDocs = await listDocuments(request, apiToken, workspace.id);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ test.describe("Native Google Gmail journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
|
||||
const beforeDocs = await listDocuments(request, apiToken, workspace.id);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ test.describe("Jira connector journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
await connectorDialog.getByPlaceholder("Search").fill("Jira");
|
||||
await expect(connectorDialog.getByText("Jira", { exact: true })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ test.describe("Linear connector journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
|
||||
const beforeDocs = await listDocuments(request, apiToken, workspace.id);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ test.describe("Notion connector journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
await connectorDialog.getByPlaceholder("Search").fill("Notion");
|
||||
await expect(connectorDialog.getByText("Notion", { exact: true })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ test.describe("Slack connector journey", () => {
|
|||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openConnectorPopup(page);
|
||||
const connectorDialog = page.getByRole("dialog", { name: "Manage External MCP Connectors" });
|
||||
const connectorDialog = page.getByRole("dialog", { name: "MCP Connectors" });
|
||||
await expect(connectorDialog).toBeVisible();
|
||||
await connectorDialog.getByPlaceholder("Search").fill("Slack");
|
||||
await expect(connectorDialog.getByText("Slack", { exact: true })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export async function openConnectorPopup(page: Page): Promise<void> {
|
|||
await expect(trigger).toBeVisible({ timeout: 60_000 });
|
||||
await trigger.click();
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "Manage External MCP Connectors" })).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "MCP Connectors" })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue