diff --git a/surfsense_backend/.gitignore b/surfsense_backend/.gitignore index e9b62fbbd..13a523310 100644 --- a/surfsense_backend/.gitignore +++ b/surfsense_backend/.gitignore @@ -11,3 +11,4 @@ celerybeat-schedule* celerybeat-schedule.* celerybeat-schedule.dir celerybeat-schedule.bak +global_llm_config.yaml \ No newline at end of file diff --git a/surfsense_backend/alembic/versions/36_remove_fk_constraints_for_global_llm_configs.py b/surfsense_backend/alembic/versions/36_remove_fk_constraints_for_global_llm_configs.py new file mode 100644 index 000000000..fa4c929ce --- /dev/null +++ b/surfsense_backend/alembic/versions/36_remove_fk_constraints_for_global_llm_configs.py @@ -0,0 +1,73 @@ +"""remove_fk_constraints_for_global_llm_configs + +Revision ID: 36 +Revises: 35 +Create Date: 2025-11-13 23:20:12.912741 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "36" +down_revision: str | None = "35" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """ + Remove foreign key constraints on LLM preference columns to allow global configs (negative IDs). + + Global LLM configs use negative IDs and don't exist in the llm_configs table, + so we need to remove the foreign key constraints that were preventing their use. + """ + # Drop the foreign key constraints + op.drop_constraint( + "user_search_space_preferences_long_context_llm_id_fkey", + "user_search_space_preferences", + type_="foreignkey", + ) + op.drop_constraint( + "user_search_space_preferences_fast_llm_id_fkey", + "user_search_space_preferences", + type_="foreignkey", + ) + op.drop_constraint( + "user_search_space_preferences_strategic_llm_id_fkey", + "user_search_space_preferences", + type_="foreignkey", + ) + + +def downgrade() -> None: + """ + Re-add foreign key constraints (will fail if any negative IDs exist in the table). + """ + # Re-add the foreign key constraints + op.create_foreign_key( + "user_search_space_preferences_long_context_llm_id_fkey", + "user_search_space_preferences", + "llm_configs", + ["long_context_llm_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "user_search_space_preferences_fast_llm_id_fkey", + "user_search_space_preferences", + "llm_configs", + ["fast_llm_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "user_search_space_preferences_strategic_llm_id_fkey", + "user_search_space_preferences", + "llm_configs", + ["strategic_llm_id"], + ["id"], + ondelete="SET NULL", + ) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index b8c35d347..7d06643e1 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -3,6 +3,7 @@ import shutil from pathlib import Path from typing import Any +import yaml from chonkie import AutoEmbeddings, CodeChunker, RecursiveChunker from chonkie.embeddings.azure_openai import AzureOpenAIEmbeddings from chonkie.embeddings.registry import EmbeddingsRegistry @@ -80,6 +81,36 @@ def is_ffmpeg_installed(): return shutil.which("ffmpeg") is not None +def load_global_llm_configs(): + """ + Load global LLM configurations from YAML file. + Falls back to example file if main file doesn't exist. + + Returns: + list: List of global LLM config dictionaries, or empty list if file doesn't exist + """ + # Try main config file first + global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.yaml" + + # Fall back to example file for testing + # if not global_config_file.exists(): + # global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.example.yaml" + # if global_config_file.exists(): + # print("Info: Using global_llm_config.example.yaml (copy to global_llm_config.yaml for production)") + + if not global_config_file.exists(): + # No global configs available + return [] + + try: + with open(global_config_file, encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get("global_llm_configs", []) + except Exception as e: + print(f"Warning: Failed to load global LLM configs: {e}") + return [] + + class Config: # Check if ffmpeg is installed if not is_ffmpeg_installed(): @@ -122,6 +153,11 @@ class Config: # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations + # Global LLM Configurations (optional) + # Load from global_llm_config.yaml if available + # These can be used as default options for users + GLOBAL_LLM_CONFIGS = load_global_llm_configs() + # Chonkie Configuration | Edit this to your needs EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL") # Azure OpenAI credentials from environment variables diff --git a/surfsense_backend/app/config/global_llm_config.example.yaml b/surfsense_backend/app/config/global_llm_config.example.yaml new file mode 100644 index 000000000..bd574515a --- /dev/null +++ b/surfsense_backend/app/config/global_llm_config.example.yaml @@ -0,0 +1,80 @@ +# Global LLM Configuration +# +# SETUP INSTRUCTIONS: +# 1. For production: Copy this file to global_llm_config.yaml and add your real API keys +# 2. For testing: The system will use this example file automatically if global_llm_config.yaml doesn't exist +# +# NOTE: The example API keys below are placeholders and won't work. +# Replace them with your actual API keys to enable global configurations. +# +# These configurations will be available to all users as a convenient option +# Users can choose to use these global configs or add their own + +global_llm_configs: + # Example: OpenAI GPT-4 Turbo + - id: -1 + name: "Global GPT-4 Turbo" + provider: "OPENAI" + model_name: "gpt-4-turbo-preview" + api_key: "sk-your-openai-api-key-here" + api_base: "" + language: "English" + litellm_params: + temperature: 0.7 + max_tokens: 4000 + + # Example: Anthropic Claude 3 Opus + - id: -2 + name: "Global Claude 3 Opus" + provider: "ANTHROPIC" + model_name: "claude-3-opus-20240229" + api_key: "sk-ant-your-anthropic-api-key-here" + api_base: "" + language: "English" + litellm_params: + temperature: 0.7 + max_tokens: 4000 + + # Example: Fast model - GPT-3.5 Turbo + - id: -3 + name: "Global GPT-3.5 Turbo" + provider: "OPENAI" + model_name: "gpt-3.5-turbo" + api_key: "sk-your-openai-api-key-here" + api_base: "" + language: "English" + litellm_params: + temperature: 0.5 + max_tokens: 2000 + + # Example: Chinese LLM - DeepSeek + - id: -4 + name: "Global DeepSeek Chat" + provider: "DEEPSEEK" + model_name: "deepseek-chat" + api_key: "your-deepseek-api-key-here" + api_base: "https://api.deepseek.com/v1" + language: "Chinese" + litellm_params: + temperature: 0.7 + max_tokens: 4000 + + # Example: Groq - Fast inference + - id: -5 + name: "Global Groq Llama 3" + provider: "GROQ" + model_name: "llama3-70b-8192" + api_key: "your-groq-api-key-here" + api_base: "" + language: "English" + litellm_params: + temperature: 0.7 + max_tokens: 8000 + +# Notes: +# - Use negative IDs to distinguish global configs from user configs +# - IDs should be unique and sequential (e.g., -1, -2, -3, etc.) +# - The 'api_key' field will not be exposed to users via API +# - Users can select these configs for their long_context, fast, or strategic LLM roles +# - All standard LiteLLM providers are supported + diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 78512c671..c23c0d13e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -348,15 +348,11 @@ class UserSearchSpacePreference(BaseModel, TimestampMixin): ) # User-specific LLM preferences for this search space - long_context_llm_id = Column( - Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True - ) - fast_llm_id = Column( - Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True - ) - strategic_llm_id = Column( - Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True - ) + # Note: These can be negative IDs for global configs (from YAML) or positive IDs for custom configs (from DB) + # Foreign keys removed to support global configs with negative IDs + long_context_llm_id = Column(Integer, nullable=True) + fast_llm_id = Column(Integer, nullable=True) + strategic_llm_id = Column(Integer, nullable=True) # Future RBAC fields can be added here # role = Column(String(50), nullable=True) # e.g., 'owner', 'editor', 'viewer' @@ -365,13 +361,12 @@ class UserSearchSpacePreference(BaseModel, TimestampMixin): user = relationship("User", back_populates="search_space_preferences") search_space = relationship("SearchSpace", back_populates="user_preferences") - long_context_llm = relationship( - "LLMConfig", foreign_keys=[long_context_llm_id], post_update=True - ) - fast_llm = relationship("LLMConfig", foreign_keys=[fast_llm_id], post_update=True) - strategic_llm = relationship( - "LLMConfig", foreign_keys=[strategic_llm_id], post_update=True - ) + # Note: Relationships removed because foreign keys no longer exist + # Global configs (negative IDs) don't exist in llm_configs table + # Application code manually fetches configs when needed + # long_context_llm = relationship("LLMConfig", foreign_keys=[long_context_llm_id], post_update=True) + # fast_llm = relationship("LLMConfig", foreign_keys=[fast_llm_id], post_update=True) + # strategic_llm = relationship("LLMConfig", foreign_keys=[strategic_llm_id], post_update=True) class Log(BaseModel, TimestampMixin): diff --git a/surfsense_backend/app/routes/chats_routes.py b/surfsense_backend/app/routes/chats_routes.py index ed8911260..05360cee0 100644 --- a/surfsense_backend/app/routes/chats_routes.py +++ b/surfsense_backend/app/routes/chats_routes.py @@ -68,9 +68,9 @@ async def handle_chat_data( selectinload(UserSearchSpacePreference.search_space).selectinload( SearchSpace.llm_configs ), - selectinload(UserSearchSpacePreference.long_context_llm), - selectinload(UserSearchSpacePreference.fast_llm), - selectinload(UserSearchSpacePreference.strategic_llm), + # Note: Removed selectinload for LLM relationships as they no longer exist + # Global configs (negative IDs) don't have foreign keys + # LLM configs are now fetched manually when needed ) .filter( UserSearchSpacePreference.search_space_id == search_space_id, @@ -81,6 +81,8 @@ async def handle_chat_data( # print("UserSearchSpacePreference:", user_preference) language = None + llm_configs = [] # Initialize to empty list + if ( user_preference and user_preference.search_space @@ -88,16 +90,36 @@ async def handle_chat_data( ): llm_configs = user_preference.search_space.llm_configs - for preferred_llm in [ - user_preference.fast_llm, - user_preference.long_context_llm, - user_preference.strategic_llm, - ]: - if preferred_llm and getattr(preferred_llm, "language", None): - language = preferred_llm.language - break + # Manually fetch LLM configs since relationships no longer exist + # Check fast_llm, long_context_llm, and strategic_llm IDs + from app.config import config as app_config - if not language: + for llm_id in [ + user_preference.fast_llm_id, + user_preference.long_context_llm_id, + user_preference.strategic_llm_id, + ]: + if llm_id is not None: + # Check if it's a global config (negative ID) + if llm_id < 0: + # Look in global configs + for global_cfg in app_config.GLOBAL_LLM_CONFIGS: + if global_cfg.get("id") == llm_id: + language = global_cfg.get("language") + if language: + break + else: + # Look in custom configs + for llm_config in llm_configs: + if llm_config.id == llm_id and getattr( + llm_config, "language", None + ): + language = llm_config.language + break + if language: + break + + if not language and llm_configs: first_llm_config = llm_configs[0] language = getattr(first_llm_config, "language", None) diff --git a/surfsense_backend/app/routes/llm_config_routes.py b/surfsense_backend/app/routes/llm_config_routes.py index a10c7997e..35c3ce574 100644 --- a/surfsense_backend/app/routes/llm_config_routes.py +++ b/surfsense_backend/app/routes/llm_config_routes.py @@ -1,9 +1,11 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select -from sqlalchemy.orm import selectinload +from app.config import config from app.db import ( LLMConfig, SearchSpace, @@ -16,6 +18,7 @@ from app.services.llm_service import validate_llm_config from app.users import current_active_user router = APIRouter() +logger = logging.getLogger(__name__) # Helper function to check search space access @@ -43,16 +46,11 @@ async def get_or_create_user_preference( ) -> UserSearchSpacePreference: """Get or create user preference for a search space""" result = await session.execute( - select(UserSearchSpacePreference) - .filter( + select(UserSearchSpacePreference).filter( UserSearchSpacePreference.user_id == user_id, UserSearchSpacePreference.search_space_id == search_space_id, ) - .options( - selectinload(UserSearchSpacePreference.long_context_llm), - selectinload(UserSearchSpacePreference.fast_llm), - selectinload(UserSearchSpacePreference.strategic_llm), - ) + # Removed selectinload options since relationships no longer exist ) preference = result.scalars().first() @@ -88,6 +86,58 @@ class LLMPreferencesRead(BaseModel): strategic_llm: LLMConfigRead | None = None +class GlobalLLMConfigRead(BaseModel): + """Schema for reading global LLM configs (without API key)""" + + id: int + name: str + provider: str + custom_provider: str | None = None + model_name: str + api_base: str | None = None + language: str | None = None + litellm_params: dict | None = None + is_global: bool = True + + +# Global LLM Config endpoints + + +@router.get("/global-llm-configs", response_model=list[GlobalLLMConfigRead]) +async def get_global_llm_configs( + user: User = Depends(current_active_user), +): + """ + Get all available global LLM configurations. + These are pre-configured by the system administrator and available to all users. + API keys are not exposed through this endpoint. + """ + try: + global_configs = config.GLOBAL_LLM_CONFIGS + + # Remove API keys from response + safe_configs = [] + for cfg in global_configs: + safe_config = { + "id": cfg.get("id"), + "name": cfg.get("name"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_base": cfg.get("api_base"), + "language": cfg.get("language"), + "litellm_params": cfg.get("litellm_params", {}), + "is_global": True, + } + safe_configs.append(safe_config) + + return safe_configs + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to fetch global LLM configs: {e!s}" + ) from e + + @router.post("/llm-configs", response_model=LLMConfigRead) async def create_llm_config( llm_config: LLMConfigCreate, @@ -309,13 +359,49 @@ async def get_user_llm_preferences( session, user.id, search_space_id ) + # Helper function to get config (global or custom) + async def get_config_for_id(config_id): + if config_id is None: + return None + + # Check if it's a global config (negative ID) + if config_id < 0: + for cfg in config.GLOBAL_LLM_CONFIGS: + if cfg.get("id") == config_id: + # Return as LLMConfigRead-compatible dict + return { + "id": cfg.get("id"), + "name": cfg.get("name"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_key": "***GLOBAL***", # Don't expose the actual key + "api_base": cfg.get("api_base"), + "language": cfg.get("language"), + "litellm_params": cfg.get("litellm_params"), + "created_at": None, + "search_space_id": search_space_id, + } + return None + + # It's a custom config, fetch from database + result = await session.execute( + select(LLMConfig).filter(LLMConfig.id == config_id) + ) + return result.scalars().first() + + # Get the configs (from DB for custom, or constructed for global) + long_context_llm = await get_config_for_id(preference.long_context_llm_id) + fast_llm = await get_config_for_id(preference.fast_llm_id) + strategic_llm = await get_config_for_id(preference.strategic_llm_id) + return { "long_context_llm_id": preference.long_context_llm_id, "fast_llm_id": preference.fast_llm_id, "strategic_llm_id": preference.strategic_llm_id, - "long_context_llm": preference.long_context_llm, - "fast_llm": preference.fast_llm, - "strategic_llm": preference.strategic_llm, + "long_context_llm": long_context_llm, + "fast_llm": fast_llm, + "strategic_llm": strategic_llm, } except HTTPException: raise @@ -353,29 +439,57 @@ async def update_user_llm_preferences( for _key, llm_config_id in update_data.items(): if llm_config_id is not None: - # Verify the LLM config belongs to the search space - result = await session.execute( - select(LLMConfig).filter( - LLMConfig.id == llm_config_id, - LLMConfig.search_space_id == search_space_id, - ) - ) - llm_config = result.scalars().first() - if not llm_config: - raise HTTPException( - status_code=404, - detail=f"LLM configuration {llm_config_id} not found in this search space", - ) + # Check if this is a global config (negative ID) + if llm_config_id < 0: + # Validate global config exists + global_config = None + for cfg in config.GLOBAL_LLM_CONFIGS: + if cfg.get("id") == llm_config_id: + global_config = cfg + break - # Collect language for consistency check - languages.add(llm_config.language) + if not global_config: + raise HTTPException( + status_code=404, + detail=f"Global LLM configuration {llm_config_id} not found", + ) - # Check if all selected LLM configs have the same language + # Collect language for consistency check (if explicitly set) + lang = global_config.get("language") + if lang and lang.strip(): # Only add non-empty languages + languages.add(lang.strip()) + else: + # Verify the LLM config belongs to the search space (custom config) + result = await session.execute( + select(LLMConfig).filter( + LLMConfig.id == llm_config_id, + LLMConfig.search_space_id == search_space_id, + ) + ) + llm_config = result.scalars().first() + if not llm_config: + raise HTTPException( + status_code=404, + detail=f"LLM configuration {llm_config_id} not found in this search space", + ) + + # Collect language for consistency check (if explicitly set) + if llm_config.language and llm_config.language.strip(): + languages.add(llm_config.language.strip()) + + # Language consistency check - only warn if there are multiple explicit languages + # Allow mixing configs with and without language settings if len(languages) > 1: - raise HTTPException( - status_code=400, - detail="All selected LLM configurations must have the same language setting", + # Log warning but allow the operation + logger.warning( + f"Multiple languages detected in LLM selection for search_space {search_space_id}: {languages}. " + "This may affect response quality." ) + # Don't raise an exception - allow users to proceed + # raise HTTPException( + # status_code=400, + # detail="All selected LLM configurations must have the same language setting", + # ) # Update user preferences for key, value in update_data.items(): @@ -384,19 +498,50 @@ async def update_user_llm_preferences( await session.commit() await session.refresh(preference) - # Reload relationships - await session.refresh( - preference, ["long_context_llm", "fast_llm", "strategic_llm"] - ) + # Helper function to get config (global or custom) + async def get_config_for_id(config_id): + if config_id is None: + return None + + # Check if it's a global config (negative ID) + if config_id < 0: + for cfg in config.GLOBAL_LLM_CONFIGS: + if cfg.get("id") == config_id: + # Return as LLMConfigRead-compatible dict + return { + "id": cfg.get("id"), + "name": cfg.get("name"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_key": "***GLOBAL***", # Don't expose the actual key + "api_base": cfg.get("api_base"), + "language": cfg.get("language"), + "litellm_params": cfg.get("litellm_params"), + "created_at": None, + "search_space_id": search_space_id, + } + return None + + # It's a custom config, fetch from database + result = await session.execute( + select(LLMConfig).filter(LLMConfig.id == config_id) + ) + return result.scalars().first() + + # Get the configs (from DB for custom, or constructed for global) + long_context_llm = await get_config_for_id(preference.long_context_llm_id) + fast_llm = await get_config_for_id(preference.fast_llm_id) + strategic_llm = await get_config_for_id(preference.strategic_llm_id) # Return updated preferences return { "long_context_llm_id": preference.long_context_llm_id, "fast_llm_id": preference.fast_llm_id, "strategic_llm_id": preference.strategic_llm_id, - "long_context_llm": preference.long_context_llm, - "fast_llm": preference.fast_llm, - "strategic_llm": preference.strategic_llm, + "long_context_llm": long_context_llm, + "fast_llm": fast_llm, + "strategic_llm": strategic_llm, } except HTTPException: raise diff --git a/surfsense_backend/app/schemas/llm_config.py b/surfsense_backend/app/schemas/llm_config.py index 285c15665..27f3736b5 100644 --- a/surfsense_backend/app/schemas/llm_config.py +++ b/surfsense_backend/app/schemas/llm_config.py @@ -62,7 +62,11 @@ class LLMConfigUpdate(BaseModel): class LLMConfigRead(LLMConfigBase, IDModel, TimestampModel): id: int - created_at: datetime - search_space_id: int + created_at: datetime | None = Field( + None, description="Creation timestamp (None for global configs)" + ) + search_space_id: int | None = Field( + None, description="Search space ID (None for global configs)" + ) model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_backend/app/services/llm_service.py b/surfsense_backend/app/services/llm_service.py index f63228ba6..ea9140f8e 100644 --- a/surfsense_backend/app/services/llm_service.py +++ b/surfsense_backend/app/services/llm_service.py @@ -6,6 +6,7 @@ from langchain_litellm import ChatLiteLLM from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from app.config import config from app.db import LLMConfig, UserSearchSpacePreference # Configure litellm to automatically drop unsupported parameters @@ -20,6 +21,27 @@ class LLMRole: STRATEGIC = "strategic" +def get_global_llm_config(llm_config_id: int) -> dict | None: + """ + Get a global LLM configuration by ID. + Global configs have negative IDs. + + Args: + llm_config_id: The ID of the global config (should be negative) + + Returns: + dict: Global config dictionary or None if not found + """ + if llm_config_id >= 0: + return None + + for cfg in config.GLOBAL_LLM_CONFIGS: + if cfg.get("id") == llm_config_id: + return cfg + + return None + + async def validate_llm_config( provider: str, model_name: str, @@ -171,7 +193,70 @@ async def get_user_llm_instance( ) return None - # Get the LLM configuration + # Check if this is a global config (negative ID) + if llm_config_id < 0: + global_config = get_global_llm_config(llm_config_id) + if not global_config: + logger.error(f"Global LLM config {llm_config_id} not found") + return None + + # Build model string for global config + if global_config.get("custom_provider"): + model_string = ( + f"{global_config['custom_provider']}/{global_config['model_name']}" + ) + else: + provider_map = { + "OPENAI": "openai", + "ANTHROPIC": "anthropic", + "GROQ": "groq", + "COHERE": "cohere", + "GOOGLE": "gemini", + "OLLAMA": "ollama", + "MISTRAL": "mistral", + "AZURE_OPENAI": "azure", + "OPENROUTER": "openrouter", + "COMETAPI": "cometapi", + "XAI": "xai", + "BEDROCK": "bedrock", + "AWS_BEDROCK": "bedrock", + "VERTEX_AI": "vertex_ai", + "TOGETHER_AI": "together_ai", + "FIREWORKS_AI": "fireworks_ai", + "REPLICATE": "replicate", + "PERPLEXITY": "perplexity", + "ANYSCALE": "anyscale", + "DEEPINFRA": "deepinfra", + "CEREBRAS": "cerebras", + "SAMBANOVA": "sambanova", + "AI21": "ai21", + "CLOUDFLARE": "cloudflare", + "DATABRICKS": "databricks", + "DEEPSEEK": "openai", + "ALIBABA_QWEN": "openai", + "MOONSHOT": "openai", + "ZHIPU": "openai", + } + provider_prefix = provider_map.get( + global_config["provider"], global_config["provider"].lower() + ) + model_string = f"{provider_prefix}/{global_config['model_name']}" + + # Create ChatLiteLLM instance from global config + litellm_kwargs = { + "model": model_string, + "api_key": global_config["api_key"], + } + + if global_config.get("api_base"): + litellm_kwargs["api_base"] = global_config["api_base"] + + if global_config.get("litellm_params"): + litellm_kwargs.update(global_config["litellm_params"]) + + return ChatLiteLLM(**litellm_kwargs) + + # Get the LLM configuration from database (user-specific config) result = await session.execute( select(LLMConfig).where( LLMConfig.id == llm_config_id, diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index eceb7ecd4..d5c08b797 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -282,7 +282,7 @@ export function DashboardClientLayout({ -
{children}
+
{children}
{/* Only render chat panel on researcher page */} {isResearcherPage && } diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx index 0dca983fb..bfbae3b99 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -12,7 +12,7 @@ import { CompletionStep } from "@/components/onboard/completion-step"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; +import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; const TOTAL_STEPS = 3; @@ -23,6 +23,7 @@ const OnboardPage = () => { const searchSpaceId = Number(params.search_space_id); const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs(searchSpaceId); + const { globalConfigs, loading: globalConfigsLoading } = useGlobalLLMConfigs(); const { preferences, loading: preferencesLoading, @@ -51,7 +52,13 @@ const OnboardPage = () => { // Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load) // But only check once to avoid redirect loops useEffect(() => { - if (!preferencesLoading && !configsLoading && isOnboardingComplete() && !hasUserProgressed) { + if ( + !preferencesLoading && + !configsLoading && + !globalConfigsLoading && + isOnboardingComplete() && + !hasUserProgressed + ) { // Small delay to ensure the check is stable const timer = setTimeout(() => { router.push(`/dashboard/${searchSpaceId}`); @@ -61,6 +68,7 @@ const OnboardPage = () => { }, [ preferencesLoading, configsLoading, + globalConfigsLoading, isOnboardingComplete, hasUserProgressed, router, @@ -77,7 +85,10 @@ const OnboardPage = () => { t("all_set"), ]; - const canProceedToStep2 = !configsLoading && llmConfigs.length > 0; + // User can proceed to step 2 if they have either custom configs OR global configs available + const canProceedToStep2 = + !configsLoading && !globalConfigsLoading && (llmConfigs.length > 0 || globalConfigs.length > 0); + const canProceedToStep3 = !preferencesLoading && preferences.long_context_llm_id && @@ -100,7 +111,7 @@ const OnboardPage = () => { router.push(`/dashboard/${searchSpaceId}/documents`); }; - if (configsLoading || preferencesLoading) { + if (configsLoading || preferencesLoading || globalConfigsLoading) { return (
diff --git a/surfsense_web/components/chat/AnimatedEmptyState.tsx b/surfsense_web/components/chat/AnimatedEmptyState.tsx index b43532d4e..d04708aa5 100644 --- a/surfsense_web/components/chat/AnimatedEmptyState.tsx +++ b/surfsense_web/components/chat/AnimatedEmptyState.tsx @@ -131,7 +131,7 @@ export function AnimatedEmptyState() { }, [layoutStable, isInView]); return ( -
+

diff --git a/surfsense_web/components/chat/ChatInputGroup.tsx b/surfsense_web/components/chat/ChatInputGroup.tsx index eaf33014a..2995a30b8 100644 --- a/surfsense_web/components/chat/ChatInputGroup.tsx +++ b/surfsense_web/components/chat/ChatInputGroup.tsx @@ -27,7 +27,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { useDocumentTypes } from "@/hooks/use-document-types"; import type { Document } from "@/hooks/use-documents"; -import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; +import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; const DocumentSelector = React.memo( @@ -567,19 +567,29 @@ const LLMSelector = React.memo(() => { const searchSpaceId = Number(search_space_id); const { llmConfigs, loading: llmLoading, error } = useLLMConfigs(searchSpaceId); + const { + globalConfigs, + loading: globalConfigsLoading, + error: globalConfigsError, + } = useGlobalLLMConfigs(); const { preferences, updatePreferences, loading: preferencesLoading, } = useLLMPreferences(searchSpaceId); - const isLoading = llmLoading || preferencesLoading; + const isLoading = llmLoading || preferencesLoading || globalConfigsLoading; + + // Combine global and custom configs + const allConfigs = React.useMemo(() => { + return [...globalConfigs.map((config) => ({ ...config, is_global: true })), ...llmConfigs]; + }, [globalConfigs, llmConfigs]); // Memoize the selected config to avoid repeated lookups const selectedConfig = React.useMemo(() => { - if (!preferences.fast_llm_id || !llmConfigs.length) return null; - return llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null; - }, [preferences.fast_llm_id, llmConfigs]); + if (!preferences.fast_llm_id || !allConfigs.length) return null; + return allConfigs.find((config) => config.id === preferences.fast_llm_id) || null; + }, [preferences.fast_llm_id, allConfigs]); // Memoize the display value for the trigger const displayValue = React.useMemo(() => { @@ -591,6 +601,7 @@ const LLMSelector = React.memo(() => { {selectedConfig.name} + {selectedConfig.is_global && 🌐}

); }, [selectedConfig]); @@ -616,7 +627,7 @@ const LLMSelector = React.memo(() => { } // Error state - if (error) { + if (error || globalConfigsError) { return (
- {llmConfigs.length === 0 ? ( + {allConfigs.length === 0 ? (
@@ -675,32 +686,87 @@ const LLMSelector = React.memo(() => {
) : (
- {llmConfigs.map((config) => ( - -
-
-
- -
-
-
- {config.name} - - {config.provider} - -
-

- {config.model_name} -

-
-
+ {/* Global Configurations */} + {globalConfigs.length > 0 && ( + <> +
+ Global Configurations
- - ))} + {globalConfigs.map((config) => ( + +
+
+
+ +
+
+
+ {config.name} + + {config.provider} + + + 🌐 Global + +
+

+ {config.model_name} +

+
+
+
+
+ ))} + + )} + + {/* Custom Configurations */} + {llmConfigs.length > 0 && ( + <> +
+ Your Configurations +
+ {llmConfigs.map((config) => ( + +
+
+
+ +
+
+
+ {config.name} + + {config.provider} + +
+

+ {config.model_name} +

+
+
+
+
+ ))} + + )}
)} @@ -787,7 +853,7 @@ export const ChatInputUI = React.memo( onTopKChange?: (topK: number) => void; }) => { return ( - + diff --git a/surfsense_web/components/chat/ChatInterface.tsx b/surfsense_web/components/chat/ChatInterface.tsx index 9b518017a..56632f1a2 100644 --- a/surfsense_web/components/chat/ChatInterface.tsx +++ b/surfsense_web/components/chat/ChatInterface.tsx @@ -43,10 +43,10 @@ export default function ChatInterface({ }, [chat_id, search_space_id]); return ( - +
-
+
- + {messages.map((message, index) => ( -
- +
) : ( diff --git a/surfsense_web/components/onboard/add-provider-step.tsx b/surfsense_web/components/onboard/add-provider-step.tsx index 1086d7dc0..e63287a6a 100644 --- a/surfsense_web/components/onboard/add-provider-step.tsx +++ b/surfsense_web/components/onboard/add-provider-step.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertCircle, Bot, Check, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { AlertCircle, Bot, Check, CheckCircle, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; import { motion } from "motion/react"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -30,7 +30,7 @@ import { import { LANGUAGES } from "@/contracts/enums/languages"; import { getModelsByProvider } from "@/contracts/enums/llm-models"; import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; -import { type CreateLLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs"; +import { type CreateLLMConfig, useGlobalLLMConfigs, useLLMConfigs } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; import InferenceParamsEditor from "../inference-params-editor"; @@ -48,6 +48,7 @@ export function AddProviderStep({ }: AddProviderStepProps) { const t = useTranslations("onboard"); const { llmConfigs, createLLMConfig, deleteLLMConfig } = useLLMConfigs(searchSpaceId); + const { globalConfigs } = useGlobalLLMConfigs(); const [isAddingNew, setIsAddingNew] = useState(false); const [formData, setFormData] = useState({ name: "", @@ -117,6 +118,19 @@ export function AddProviderStep({ {t("add_provider_instruction")} + {/* Global Configs Notice */} + {globalConfigs.length > 0 && ( + + + + {globalConfigs.length} global configuration(s) available! +
+ You can skip adding your own LLM provider and use our pre-configured models in the next + step. Or continue here to add your own custom configurations. +
+
+ )} + {/* Existing Configurations */} {llmConfigs.length > 0 && (
diff --git a/surfsense_web/components/onboard/assign-roles-step.tsx b/surfsense_web/components/onboard/assign-roles-step.tsx index 1a1557a3c..094b46cf9 100644 --- a/surfsense_web/components/onboard/assign-roles-step.tsx +++ b/surfsense_web/components/onboard/assign-roles-step.tsx @@ -15,7 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; +import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; interface AssignRolesStepProps { searchSpaceId: number; @@ -25,8 +25,12 @@ interface AssignRolesStepProps { export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignRolesStepProps) { const t = useTranslations("onboard"); const { llmConfigs } = useLLMConfigs(searchSpaceId); + const { globalConfigs } = useGlobalLLMConfigs(); const { preferences, updatePreferences } = useLLMPreferences(searchSpaceId); + // Combine global and user-specific configs + const allConfigs = [...globalConfigs, ...llmConfigs]; + const ROLE_DESCRIPTIONS = { long_context: { icon: Brain, @@ -107,7 +111,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR const isAssignmentComplete = assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id; - if (llmConfigs.length === 0) { + if (allConfigs.length === 0) { return (
@@ -130,7 +134,7 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => { const IconComponent = role.icon; const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments]; - const assignedConfig = llmConfigs.find((config) => config.id === currentAssignment); + const assignedConfig = allConfigs.find((config) => config.id === currentAssignment); return ( + {globalConfigs.length > 0 && ( +
+ {t("global_configs") || "Global Configurations"} +
+ )} + {globalConfigs + .filter((config) => config.id && config.id.toString().trim() !== "") + .map((config) => ( + +
+ + 🌐 Global + + + {config.provider} + + {config.name} + ({config.model_name}) +
+
+ ))} + {llmConfigs.length > 0 && globalConfigs.length > 0 && ( +
+ {t("your_configs") || "Your Configurations"} +
+ )} {llmConfigs .filter((config) => config.id && config.id.toString().trim() !== "") .map((config) => ( @@ -193,6 +223,11 @@ export function AssignRolesStep({ searchSpaceId, onPreferencesUpdated }: AssignR
{t("assigned")}: + {assignedConfig.is_global && ( + + 🌐 Global + + )} {assignedConfig.provider} {assignedConfig.name}
diff --git a/surfsense_web/components/onboard/completion-step.tsx b/surfsense_web/components/onboard/completion-step.tsx index 1bf0f7a2e..83595a956 100644 --- a/surfsense_web/components/onboard/completion-step.tsx +++ b/surfsense_web/components/onboard/completion-step.tsx @@ -4,7 +4,7 @@ import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react import { motion } from "motion/react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; +import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; const ROLE_ICONS = { long_context: Brain, @@ -18,12 +18,16 @@ interface CompletionStepProps { export function CompletionStep({ searchSpaceId }: CompletionStepProps) { const { llmConfigs } = useLLMConfigs(searchSpaceId); + const { globalConfigs } = useGlobalLLMConfigs(); const { preferences } = useLLMPreferences(searchSpaceId); + // Combine global and user-specific configs + const allConfigs = [...globalConfigs, ...llmConfigs]; + const assignedConfigs = { - long_context: llmConfigs.find((c) => c.id === preferences.long_context_llm_id), - fast: llmConfigs.find((c) => c.id === preferences.fast_llm_id), - strategic: llmConfigs.find((c) => c.id === preferences.strategic_llm_id), + long_context: allConfigs.find((c) => c.id === preferences.long_context_llm_id), + fast: allConfigs.find((c) => c.id === preferences.fast_llm_id), + strategic: allConfigs.find((c) => c.id === preferences.strategic_llm_id), }; return ( @@ -86,6 +90,11 @@ export function CompletionStep({ searchSpaceId }: CompletionStepProps) {
+ {config.is_global && ( + + 🌐 Global + + )} {config.provider} {config.model_name}
@@ -115,8 +124,14 @@ export function CompletionStep({ searchSpaceId }: CompletionStepProps) {

- ✓ {llmConfigs.length} LLM provider{llmConfigs.length > 1 ? "s" : ""} configured + ✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available + {globalConfigs.length > 0 && ( + ✓ {globalConfigs.length} Global config(s) + )} + {llmConfigs.length > 0 && ( + ✓ {llmConfigs.length} Custom config(s) + )} ✓ All roles assigned ✓ Ready to use
diff --git a/surfsense_web/components/settings/llm-role-manager.tsx b/surfsense_web/components/settings/llm-role-manager.tsx index 63b002a24..4f5fc9369 100644 --- a/surfsense_web/components/settings/llm-role-manager.tsx +++ b/surfsense_web/components/settings/llm-role-manager.tsx @@ -27,7 +27,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; +import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; const ROLE_DESCRIPTIONS = { long_context: { @@ -67,6 +67,12 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) { error: configsError, refreshConfigs, } = useLLMConfigs(searchSpaceId); + const { + globalConfigs, + loading: globalConfigsLoading, + error: globalConfigsError, + refreshGlobalConfigs, + } = useGlobalLLMConfigs(); const { preferences, loading: preferencesLoading, @@ -164,12 +170,17 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) { const isAssignmentComplete = assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id; const assignedConfigIds = Object.values(assignments).filter((id) => id !== ""); - const availableConfigs = llmConfigs.filter( - (config) => config.id && config.id.toString().trim() !== "" - ); - const isLoading = configsLoading || preferencesLoading; - const hasError = configsError || preferencesError; + // Combine global and custom configs + const allConfigs = [ + ...globalConfigs.map((config) => ({ ...config, is_global: true })), + ...llmConfigs.filter((config) => config.id && config.id.toString().trim() !== ""), + ]; + + const availableConfigs = allConfigs; + + const isLoading = configsLoading || preferencesLoading || globalConfigsLoading; + const hasError = configsError || preferencesError || globalConfigsError; return (
@@ -218,7 +229,9 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) { {hasError && ( - {configsError || preferencesError} + + {configsError || preferencesError || globalConfigsError} + )} @@ -249,6 +262,10 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {

{availableConfigs.length}

Available Models

+
+ 🌐 {globalConfigs.length} Global + • {llmConfigs.length} Custom +
@@ -422,30 +439,73 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) { Unassigned - {availableConfigs.map((config) => ( - -
- - {config.provider} - - {config.name} - - ({config.model_name}) - + + {/* Global Configurations */} + {globalConfigs.length > 0 && ( + <> +
+ Global Configurations
- - ))} + {globalConfigs.map((config) => ( + +
+ + {config.provider} + + {config.name} + + ({config.model_name}) + + + 🌐 Global + +
+
+ ))} + + )} + + {/* Custom Configurations */} + {llmConfigs.length > 0 && ( + <> +
+ Your Configurations +
+ {llmConfigs + .filter( + (config) => config.id && config.id.toString().trim() !== "" + ) + .map((config) => ( + +
+ + {config.provider} + + {config.name} + + ({config.model_name}) + +
+
+ ))} + + )}
{assignedConfig && (
-
+
Assigned: {assignedConfig.provider} {assignedConfig.name} + {assignedConfig.is_global && ( + + 🌐 Global + + )}
Model: {assignedConfig.model_name} diff --git a/surfsense_web/components/settings/model-config-manager.tsx b/surfsense_web/components/settings/model-config-manager.tsx index c0fa1af7c..16bd57e71 100644 --- a/surfsense_web/components/settings/model-config-manager.tsx +++ b/surfsense_web/components/settings/model-config-manager.tsx @@ -51,7 +51,12 @@ import { import { LANGUAGES } from "@/contracts/enums/languages"; import { getModelsByProvider } from "@/contracts/enums/llm-models"; import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; -import { type CreateLLMConfig, type LLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs"; +import { + type CreateLLMConfig, + type LLMConfig, + useGlobalLLMConfigs, + useLLMConfigs, +} from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; import InferenceParamsEditor from "../inference-params-editor"; @@ -69,6 +74,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { deleteLLMConfig, refreshConfigs, } = useLLMConfigs(searchSpaceId); + const { globalConfigs } = useGlobalLLMConfigs(); const [isAddingNew, setIsAddingNew] = useState(false); const [editingConfig, setEditingConfig] = useState(null); const [showApiKey, setShowApiKey] = useState>({}); @@ -224,6 +230,20 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { )} + {/* Global Configs Info Alert */} + {!loading && !error && globalConfigs.length > 0 && ( + + + + + {globalConfigs.length} global configuration{globalConfigs.length > 1 ? "s" : ""} + {" "} + available for use. You can assign them in the LLM Roles tab without adding your own API + keys. + + + )} + {/* Loading State */} {loading && ( @@ -310,8 +330,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {

No Configurations Yet

- Get started by adding your first LLM provider configuration to begin using the - system. + Add your own LLM provider configurations.