diff --git a/Dockerfile.allinone b/Dockerfile.allinone index 0765deb15..12eee5c90 100644 --- a/Dockerfile.allinone +++ b/Dockerfile.allinone @@ -28,12 +28,15 @@ COPY surfsense_web/package.json surfsense_web/pnpm-lock.yaml* ./ COPY surfsense_web/source.config.ts ./ COPY surfsense_web/content ./content -# Install dependencies -RUN pnpm install --frozen-lockfile +# Install dependencies (skip postinstall which requires all source files) +RUN pnpm install --frozen-lockfile --ignore-scripts # Copy source COPY surfsense_web/ ./ +# Run fumadocs-mdx postinstall now that source files are available +RUN pnpm fumadocs-mdx + # Build args for frontend ARG NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 ARG NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL diff --git a/surfsense_backend/alembic/versions/51_add_new_llm_config_table.py b/surfsense_backend/alembic/versions/51_add_new_llm_config_table.py new file mode 100644 index 000000000..89a5c1246 --- /dev/null +++ b/surfsense_backend/alembic/versions/51_add_new_llm_config_table.py @@ -0,0 +1,114 @@ +"""Add NewLLMConfig table for configurable LLM + prompt settings + +Revision ID: 51 +Revises: 50 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "51" +down_revision: str | None = "50" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """ + Add the new_llm_configs table that combines LLM model settings with prompt configuration. + + This table includes: + - LLM model configuration (provider, model_name, api_key, etc.) + - Configurable system instructions + - Citation toggle + """ + # Create new_llm_configs table only if it doesn't already exist + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'new_llm_configs' + ) THEN + CREATE TABLE new_llm_configs ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + -- Basic info + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + + -- LLM Model Configuration (same as llm_configs, excluding language) + provider litellmprovider NOT NULL, + custom_provider VARCHAR(100), + model_name VARCHAR(100) NOT NULL, + api_key TEXT NOT NULL, + api_base VARCHAR(500), + litellm_params JSONB DEFAULT '{}', + + -- Prompt Configuration + system_instructions TEXT NOT NULL DEFAULT '', + use_default_system_instructions BOOLEAN NOT NULL DEFAULT TRUE, + citations_enabled BOOLEAN NOT NULL DEFAULT TRUE, + + -- Default flag + is_default BOOLEAN NOT NULL DEFAULT FALSE, + + -- Foreign key to search space + search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE + ); + END IF; + END$$; + """ + ) + + # Create indexes if they don't exist + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_id' + ) THEN + CREATE INDEX ix_new_llm_configs_id ON new_llm_configs(id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_created_at' + ) THEN + CREATE INDEX ix_new_llm_configs_created_at ON new_llm_configs(created_at); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_name' + ) THEN + CREATE INDEX ix_new_llm_configs_name ON new_llm_configs(name); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_search_space_id' + ) THEN + CREATE INDEX ix_new_llm_configs_search_space_id ON new_llm_configs(search_space_id); + END IF; + END$$; + """ + ) + + +def downgrade() -> None: + """Remove the new_llm_configs table.""" + # Drop indexes + op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_search_space_id") + op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_name") + op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_created_at") + op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_id") + + # Drop table + op.execute("DROP TABLE IF EXISTS new_llm_configs") diff --git a/surfsense_backend/alembic/versions/52_rename_llm_preference_columns.py b/surfsense_backend/alembic/versions/52_rename_llm_preference_columns.py new file mode 100644 index 000000000..cd1a1dbbc --- /dev/null +++ b/surfsense_backend/alembic/versions/52_rename_llm_preference_columns.py @@ -0,0 +1,130 @@ +"""Rename LLM preference columns in searchspaces table + +Revision ID: 52 +Revises: 51 +Create Date: 2024-12-22 + +This migration renames the LLM preference columns: +- fast_llm_id -> agent_llm_id +- long_context_llm_id -> document_summary_llm_id +- strategic_llm_id is removed (data migrated to document_summary_llm_id) +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "52" +down_revision = "51" +branch_labels = None +depends_on = None + + +def upgrade(): + # First, migrate any strategic_llm_id values to document_summary_llm_id + # (only if document_summary_llm_id/long_context_llm_id is NULL) + # Use IF EXISTS check to handle case where column might not exist + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'strategic_llm_id' + ) THEN + UPDATE searchspaces + SET long_context_llm_id = strategic_llm_id + WHERE long_context_llm_id IS NULL AND strategic_llm_id IS NOT NULL; + END IF; + END$$; + """ + ) + + # Rename columns (only if they exist with old names) + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'fast_llm_id' + ) THEN + ALTER TABLE searchspaces RENAME COLUMN fast_llm_id TO agent_llm_id; + END IF; + END$$; + """ + ) + + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'long_context_llm_id' + ) THEN + ALTER TABLE searchspaces RENAME COLUMN long_context_llm_id TO document_summary_llm_id; + END IF; + END$$; + """ + ) + + # Drop the strategic_llm_id column if it exists + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'strategic_llm_id' + ) THEN + ALTER TABLE searchspaces DROP COLUMN strategic_llm_id; + END IF; + END$$; + """ + ) + + +def downgrade(): + # Add back the strategic_llm_id column + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'strategic_llm_id' + ) THEN + ALTER TABLE searchspaces ADD COLUMN strategic_llm_id INTEGER; + END IF; + END$$; + """ + ) + + # Rename columns back + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'agent_llm_id' + ) THEN + ALTER TABLE searchspaces RENAME COLUMN agent_llm_id TO fast_llm_id; + END IF; + END$$; + """ + ) + + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'searchspaces' AND column_name = 'document_summary_llm_id' + ) THEN + ALTER TABLE searchspaces RENAME COLUMN document_summary_llm_id TO long_context_llm_id; + END IF; + END$$; + """ + ) diff --git a/surfsense_backend/alembic/versions/53_cleanup_old_llm_configs.py b/surfsense_backend/alembic/versions/53_cleanup_old_llm_configs.py new file mode 100644 index 000000000..16f5779be --- /dev/null +++ b/surfsense_backend/alembic/versions/53_cleanup_old_llm_configs.py @@ -0,0 +1,244 @@ +"""Migrate data from old llm_configs to new_llm_configs and cleanup + +Revision ID: 53 +Revises: 52 +Create Date: 2024-12-22 + +This migration: +1. Migrates data from old llm_configs table to new_llm_configs (preserving user configs) +2. Drops the old llm_configs table (no longer used) +3. Removes the is_default column from new_llm_configs (roles now determine which config to use) +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "53" +down_revision = "52" +branch_labels = None +depends_on = None + + +def upgrade(): + # STEP 1: Migrate data from old llm_configs to new_llm_configs + # This preserves any user-created configurations + op.execute( + """ + DO $$ + BEGIN + -- Only migrate if both tables exist + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'llm_configs' + ) AND EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'new_llm_configs' + ) THEN + -- Insert old configs into new table (skipping duplicates by name+search_space_id) + INSERT INTO new_llm_configs ( + name, + description, + provider, + custom_provider, + model_name, + api_key, + api_base, + litellm_params, + system_instructions, + use_default_system_instructions, + citations_enabled, + is_default, + search_space_id, + created_at + ) + SELECT + lc.name, + NULL as description, -- Old table didn't have description + lc.provider, + lc.custom_provider, + lc.model_name, + lc.api_key, + lc.api_base, + COALESCE(lc.litellm_params, '{}'::jsonb), + '' as system_instructions, -- Use defaults + TRUE as use_default_system_instructions, + TRUE as citations_enabled, + FALSE as is_default, + lc.search_space_id, + COALESCE(lc.created_at, NOW()) + FROM llm_configs lc + WHERE lc.search_space_id IS NOT NULL + AND NOT EXISTS ( + -- Skip if a config with same name already exists in new_llm_configs for this search space + SELECT 1 FROM new_llm_configs nlc + WHERE nlc.name = lc.name + AND nlc.search_space_id = lc.search_space_id + ); + + -- Log how many configs were migrated + RAISE NOTICE 'Migrated % configs from llm_configs to new_llm_configs', + (SELECT COUNT(*) FROM llm_configs WHERE search_space_id IS NOT NULL); + END IF; + END$$; + """ + ) + + # STEP 2: Update searchspaces to point to new_llm_configs for their agent LLM + # If a search space had an agent_llm_id pointing to old llm_configs, + # try to find the corresponding config in new_llm_configs + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'llm_configs' + ) THEN + -- Update agent_llm_id to point to migrated config in new_llm_configs + UPDATE searchspaces ss + SET agent_llm_id = ( + SELECT nlc.id + FROM new_llm_configs nlc + JOIN llm_configs lc ON lc.name = nlc.name AND lc.search_space_id = nlc.search_space_id + WHERE lc.id = ss.agent_llm_id + AND nlc.search_space_id = ss.id + LIMIT 1 + ) + WHERE ss.agent_llm_id IS NOT NULL + AND ss.agent_llm_id > 0 -- Only positive IDs (not global configs) + AND EXISTS ( + SELECT 1 FROM llm_configs lc WHERE lc.id = ss.agent_llm_id + ); + + -- Update document_summary_llm_id similarly + UPDATE searchspaces ss + SET document_summary_llm_id = ( + SELECT nlc.id + FROM new_llm_configs nlc + JOIN llm_configs lc ON lc.name = nlc.name AND lc.search_space_id = nlc.search_space_id + WHERE lc.id = ss.document_summary_llm_id + AND nlc.search_space_id = ss.id + LIMIT 1 + ) + WHERE ss.document_summary_llm_id IS NOT NULL + AND ss.document_summary_llm_id > 0 -- Only positive IDs (not global configs) + AND EXISTS ( + SELECT 1 FROM llm_configs lc WHERE lc.id = ss.document_summary_llm_id + ); + END IF; + END$$; + """ + ) + + # STEP 3: Drop the is_default column from new_llm_configs + # (role assignments now determine which config to use) + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_llm_configs' AND column_name = 'is_default' + ) THEN + ALTER TABLE new_llm_configs DROP COLUMN is_default; + END IF; + END$$; + """ + ) + + # STEP 4: Drop the old llm_configs table (data has been migrated) + op.execute("DROP TABLE IF EXISTS llm_configs CASCADE") + + +def downgrade(): + # Recreate the old llm_configs table + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'llm_configs' + ) THEN + CREATE TABLE llm_configs ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + provider litellmprovider NOT NULL, + custom_provider VARCHAR(100), + model_name VARCHAR(100) NOT NULL, + api_key TEXT NOT NULL, + api_base VARCHAR(500), + language VARCHAR(50), + litellm_params JSONB DEFAULT '{}', + search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE + ); + + -- Create indexes + CREATE INDEX IF NOT EXISTS ix_llm_configs_id ON llm_configs(id); + CREATE INDEX IF NOT EXISTS ix_llm_configs_name ON llm_configs(name); + CREATE INDEX IF NOT EXISTS ix_llm_configs_created_at ON llm_configs(created_at); + END IF; + END$$; + """ + ) + + # Migrate data back from new_llm_configs to llm_configs + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'new_llm_configs' + ) THEN + INSERT INTO llm_configs ( + name, + provider, + custom_provider, + model_name, + api_key, + api_base, + language, + litellm_params, + search_space_id, + created_at + ) + SELECT + nlc.name, + nlc.provider, + nlc.custom_provider, + nlc.model_name, + nlc.api_key, + nlc.api_base, + 'English' as language, -- Default language + COALESCE(nlc.litellm_params, '{}'::jsonb), + nlc.search_space_id, + nlc.created_at + FROM new_llm_configs nlc + WHERE nlc.search_space_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM llm_configs lc + WHERE lc.name = nlc.name + AND lc.search_space_id = nlc.search_space_id + ); + END IF; + END$$; + """ + ) + + # Add back the is_default column to new_llm_configs + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_llm_configs' AND column_name = 'is_default' + ) THEN + ALTER TABLE new_llm_configs ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE; + END IF; + END$$; + """ + ) diff --git a/surfsense_backend/app/agents/new_chat/__init__.py b/surfsense_backend/app/agents/new_chat/__init__.py index 2ee5fe1d5..eccb7a5c3 100644 --- a/surfsense_backend/app/agents/new_chat/__init__.py +++ b/surfsense_backend/app/agents/new_chat/__init__.py @@ -31,56 +31,50 @@ from .system_prompt import ( ) # Tools - registry exports +# Tools - factory exports (for direct use) +# Tools - knowledge base utilities from .tools import ( BUILTIN_TOOLS, ToolDefinition, build_tools, - get_all_tool_names, - get_default_enabled_tools, - get_tool_by_name, -) - -# Tools - factory exports (for direct use) -from .tools import ( create_display_image_tool, create_generate_podcast_tool, create_link_preview_tool, create_scrape_webpage_tool, create_search_knowledge_base_tool, -) - -# Tools - knowledge base utilities -from .tools import ( format_documents_for_context, + get_all_tool_names, + get_default_enabled_tools, + get_tool_by_name, search_knowledge_base_async, ) __all__ = [ - # Agent factory - "create_surfsense_deep_agent", - # Context - "SurfSenseContextSchema", - # LLM config - "create_chat_litellm_from_config", - "load_llm_config_from_yaml", + # Tools registry + "BUILTIN_TOOLS", # System prompt "SURFSENSE_CITATION_INSTRUCTIONS", "SURFSENSE_SYSTEM_PROMPT", - "build_surfsense_system_prompt", - # Tools registry - "BUILTIN_TOOLS", + # Context + "SurfSenseContextSchema", "ToolDefinition", + "build_surfsense_system_prompt", "build_tools", - "get_all_tool_names", - "get_default_enabled_tools", - "get_tool_by_name", + # LLM config + "create_chat_litellm_from_config", # Tool factories "create_display_image_tool", "create_generate_podcast_tool", "create_link_preview_tool", "create_scrape_webpage_tool", "create_search_knowledge_base_tool", + # Agent factory + "create_surfsense_deep_agent", # Knowledge base utilities "format_documents_for_context", + "get_all_tool_names", + "get_default_enabled_tools", + "get_tool_by_name", + "load_llm_config_from_yaml", "search_knowledge_base_async", ] diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index b2bcc008c..8fd5f3b71 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -2,7 +2,8 @@ SurfSense deep agent implementation. This module provides the factory function for creating SurfSense deep agents -with configurable tools via the tools registry. +with configurable tools via the tools registry and configurable prompts +via NewLLMConfig. """ from collections.abc import Sequence @@ -14,7 +15,11 @@ from langgraph.types import Checkpointer from sqlalchemy.ext.asyncio import AsyncSession from app.agents.new_chat.context import SurfSenseContextSchema -from app.agents.new_chat.system_prompt import build_surfsense_system_prompt +from app.agents.new_chat.llm_config import AgentConfig +from app.agents.new_chat.system_prompt import ( + build_configurable_system_prompt, + build_surfsense_system_prompt, +) from app.agents.new_chat.tools import build_tools from app.services.connector_service import ConnectorService @@ -29,13 +34,14 @@ def create_surfsense_deep_agent( db_session: AsyncSession, connector_service: ConnectorService, checkpointer: Checkpointer, + agent_config: AgentConfig | None = None, enabled_tools: list[str] | None = None, disabled_tools: list[str] | None = None, additional_tools: Sequence[BaseTool] | None = None, firecrawl_api_key: str | None = None, ): """ - Create a SurfSense deep agent with configurable tools. + Create a SurfSense deep agent with configurable tools and prompts. The agent comes with built-in tools that can be configured: - search_knowledge_base: Search the user's personal knowledge base @@ -44,6 +50,10 @@ def create_surfsense_deep_agent( - display_image: Display images in chat - scrape_webpage: Extract content from webpages + The system prompt can be configured via agent_config: + - Custom system instructions (or use defaults) + - Citation toggle (enable/disable citation requirements) + Args: llm: ChatLiteLLM instance for the agent's language model search_space_id: The user's search space ID @@ -51,6 +61,8 @@ def create_surfsense_deep_agent( connector_service: Initialized connector service for knowledge base search checkpointer: LangGraph checkpointer for conversation state persistence. Use AsyncPostgresSaver for production or MemorySaver for testing. + agent_config: Optional AgentConfig from NewLLMConfig for prompt configuration. + If None, uses default system prompt with citations enabled. enabled_tools: Explicit list of tool names to enable. If None, all default tools are enabled. Use this to limit which tools are available. disabled_tools: List of tool names to disable. Applied after enabled_tools. @@ -64,9 +76,21 @@ def create_surfsense_deep_agent( CompiledStateGraph: The configured deep agent Examples: - # Create agent with all default tools + # Create agent with all default tools and default prompt agent = create_surfsense_deep_agent(llm, search_space_id, db_session, ...) + # Create agent with custom prompt configuration + agent = create_surfsense_deep_agent( + llm, search_space_id, db_session, ..., + agent_config=AgentConfig( + provider="OPENAI", + model_name="gpt-4", + api_key="...", + system_instructions="Custom instructions...", + citations_enabled=False, + ) + ) + # Create agent with only specific tools agent = create_surfsense_deep_agent( llm, search_space_id, db_session, ..., @@ -101,11 +125,23 @@ def create_surfsense_deep_agent( additional_tools=list(additional_tools) if additional_tools else None, ) + # Build system prompt based on agent_config + if agent_config is not None: + # Use configurable prompt with settings from NewLLMConfig + system_prompt = build_configurable_system_prompt( + custom_system_instructions=agent_config.system_instructions, + use_default_system_instructions=agent_config.use_default_system_instructions, + citations_enabled=agent_config.citations_enabled, + ) + else: + # Use default prompt (with citations enabled) + system_prompt = build_surfsense_system_prompt() + # Create the deep agent with system prompt and checkpointer agent = create_deep_agent( model=llm, tools=tools, - system_prompt=build_surfsense_system_prompt(), + system_prompt=system_prompt, context_schema=SurfSenseContextSchema, checkpointer=checkpointer, ) diff --git a/surfsense_backend/app/agents/new_chat/llm_config.py b/surfsense_backend/app/agents/new_chat/llm_config.py index b6b406a38..a55ed79d3 100644 --- a/surfsense_backend/app/agents/new_chat/llm_config.py +++ b/surfsense_backend/app/agents/new_chat/llm_config.py @@ -1,14 +1,144 @@ """ LLM configuration utilities for SurfSense agents. -This module provides functions for loading LLM configurations from YAML files -and creating ChatLiteLLM instances from configuration dictionaries. +This module provides functions for loading LLM configurations from: +1. YAML files (global configs with negative IDs) +2. Database NewLLMConfig table (user-created configs with positive IDs) + +It also provides utilities for creating ChatLiteLLM instances and +managing prompt configurations. """ +from dataclasses import dataclass from pathlib import Path import yaml from langchain_litellm import ChatLiteLLM +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +# Provider mapping for LiteLLM model string construction +PROVIDER_MAP = { + "OPENAI": "openai", + "ANTHROPIC": "anthropic", + "GROQ": "groq", + "COHERE": "cohere", + "GOOGLE": "gemini", + "OLLAMA": "ollama", + "MISTRAL": "mistral", + "AZURE_OPENAI": "azure", + "OPENROUTER": "openrouter", + "XAI": "xai", + "BEDROCK": "bedrock", + "VERTEX_AI": "vertex_ai", + "TOGETHER_AI": "together_ai", + "FIREWORKS_AI": "fireworks_ai", + "DEEPSEEK": "openai", + "ALIBABA_QWEN": "openai", + "MOONSHOT": "openai", + "ZHIPU": "openai", + "REPLICATE": "replicate", + "PERPLEXITY": "perplexity", + "ANYSCALE": "anyscale", + "DEEPINFRA": "deepinfra", + "CEREBRAS": "cerebras", + "SAMBANOVA": "sambanova", + "AI21": "ai21", + "CLOUDFLARE": "cloudflare", + "DATABRICKS": "databricks", + "COMETAPI": "cometapi", + "HUGGINGFACE": "huggingface", + "CUSTOM": "custom", +} + + +@dataclass +class AgentConfig: + """ + Complete configuration for the SurfSense agent. + + This combines LLM settings with prompt configuration from NewLLMConfig. + """ + + # LLM Model Settings + provider: str + model_name: str + api_key: str + api_base: str | None = None + custom_provider: str | None = None + litellm_params: dict | None = None + + # Prompt Configuration + system_instructions: str | None = None + use_default_system_instructions: bool = True + citations_enabled: bool = True + + # Metadata + config_id: int | None = None + config_name: str | None = None + + @classmethod + def from_new_llm_config(cls, config) -> "AgentConfig": + """ + Create an AgentConfig from a NewLLMConfig database model. + + Args: + config: NewLLMConfig database model instance + + Returns: + AgentConfig instance + """ + return cls( + provider=config.provider.value + if hasattr(config.provider, "value") + else str(config.provider), + model_name=config.model_name, + api_key=config.api_key, + api_base=config.api_base, + custom_provider=config.custom_provider, + litellm_params=config.litellm_params, + system_instructions=config.system_instructions, + use_default_system_instructions=config.use_default_system_instructions, + citations_enabled=config.citations_enabled, + config_id=config.id, + config_name=config.name, + ) + + @classmethod + def from_yaml_config(cls, yaml_config: dict) -> "AgentConfig": + """ + Create an AgentConfig from a YAML configuration dictionary. + + YAML configs now support the same prompt configuration fields as NewLLMConfig: + - system_instructions: Custom system instructions (empty string uses defaults) + - use_default_system_instructions: Whether to use default instructions + - citations_enabled: Whether citations are enabled + + Args: + yaml_config: Configuration dictionary from YAML file + + Returns: + AgentConfig instance + """ + # Get system instructions from YAML, default to empty string + system_instructions = yaml_config.get("system_instructions", "") + + return cls( + provider=yaml_config.get("provider", "").upper(), + model_name=yaml_config.get("model_name", ""), + api_key=yaml_config.get("api_key", ""), + api_base=yaml_config.get("api_base"), + custom_provider=yaml_config.get("custom_provider"), + litellm_params=yaml_config.get("litellm_params"), + # Prompt configuration from YAML (with defaults for backwards compatibility) + system_instructions=system_instructions if system_instructions else None, + use_default_system_instructions=yaml_config.get( + "use_default_system_instructions", True + ), + citations_enabled=yaml_config.get("citations_enabled", True), + config_id=yaml_config.get("id"), + config_name=yaml_config.get("name"), + ) def load_llm_config_from_yaml(llm_config_id: int = -1) -> dict | None: @@ -47,9 +177,118 @@ def load_llm_config_from_yaml(llm_config_id: int = -1) -> dict | None: return None +async def load_new_llm_config_from_db( + session: AsyncSession, + config_id: int, +) -> "AgentConfig | None": + """ + Load a NewLLMConfig from the database by ID. + + Args: + session: AsyncSession for database access + config_id: The ID of the NewLLMConfig to load + + Returns: + AgentConfig instance or None if not found + """ + # Import here to avoid circular imports + from app.db import NewLLMConfig + + try: + result = await session.execute( + select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + ) + config = result.scalars().first() + + if not config: + print(f"Error: NewLLMConfig with id {config_id} not found") + return None + + return AgentConfig.from_new_llm_config(config) + except Exception as e: + print(f"Error loading NewLLMConfig from database: {e}") + return None + + +async def load_agent_llm_config_for_search_space( + session: AsyncSession, + search_space_id: int, +) -> "AgentConfig | None": + """ + Load the agent LLM configuration for a search space. + + This loads the LLM config based on the search space's agent_llm_id setting: + - Positive ID: Load from NewLLMConfig database table + - Negative ID: Load from YAML global configs + - None: Falls back to first global config (id=-1) + + Args: + session: AsyncSession for database access + search_space_id: The search space ID + + Returns: + AgentConfig instance or None if not found + """ + # Import here to avoid circular imports + from app.db import SearchSpace + + try: + # Get the search space to check its agent_llm_id preference + result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + + if not search_space: + print(f"Error: SearchSpace with id {search_space_id} not found") + return None + + # Use agent_llm_id from search space, fallback to -1 (first global config) + config_id = ( + search_space.agent_llm_id if search_space.agent_llm_id is not None else -1 + ) + + # Load the config using the unified loader + return await load_agent_config(session, config_id, search_space_id) + except Exception as e: + print(f"Error loading agent LLM config for search space {search_space_id}: {e}") + return None + + +async def load_agent_config( + session: AsyncSession, + config_id: int, + search_space_id: int | None = None, +) -> "AgentConfig | None": + """ + Load an agent configuration, supporting both YAML (negative IDs) and database (positive IDs) configs. + + This is the main entry point for loading configurations: + - Negative IDs: Load from YAML file (global configs) + - Positive IDs: Load from NewLLMConfig database table + + Args: + session: AsyncSession for database access + config_id: The config ID (negative for YAML, positive for database) + search_space_id: Optional search space ID for context + + Returns: + AgentConfig instance or None if not found + """ + if config_id < 0: + # Load from YAML (global configs have negative IDs) + yaml_config = load_llm_config_from_yaml(config_id) + if yaml_config: + return AgentConfig.from_yaml_config(yaml_config) + return None + else: + # Load from database (NewLLMConfig) + return await load_new_llm_config_from_db(session, config_id) + + def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None: """ - Create a ChatLiteLLM instance from a global LLM config. + Create a ChatLiteLLM instance from a global LLM config dictionary. Args: llm_config: LLM configuration dictionary from YAML @@ -57,34 +296,12 @@ def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None: Returns: ChatLiteLLM instance or None on error """ - # Provider mapping (same as in llm_service.py) - provider_map = { - "OPENAI": "openai", - "ANTHROPIC": "anthropic", - "GROQ": "groq", - "COHERE": "cohere", - "GOOGLE": "gemini", - "OLLAMA": "ollama", - "MISTRAL": "mistral", - "AZURE_OPENAI": "azure", - "OPENROUTER": "openrouter", - "XAI": "xai", - "BEDROCK": "bedrock", - "VERTEX_AI": "vertex_ai", - "TOGETHER_AI": "together_ai", - "FIREWORKS_AI": "fireworks_ai", - "DEEPSEEK": "openai", - "ALIBABA_QWEN": "openai", - "MOONSHOT": "openai", - "ZHIPU": "openai", - } - # Build the model string if llm_config.get("custom_provider"): model_string = f"{llm_config['custom_provider']}/{llm_config['model_name']}" else: provider = llm_config.get("provider", "").upper() - provider_prefix = provider_map.get(provider, provider.lower()) + provider_prefix = PROVIDER_MAP.get(provider, provider.lower()) model_string = f"{provider_prefix}/{llm_config['model_name']}" # Create ChatLiteLLM instance with streaming enabled @@ -103,3 +320,42 @@ def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None: litellm_kwargs.update(llm_config["litellm_params"]) return ChatLiteLLM(**litellm_kwargs) + + +def create_chat_litellm_from_agent_config( + agent_config: AgentConfig, +) -> ChatLiteLLM | None: + """ + Create a ChatLiteLLM instance from an AgentConfig. + + Args: + agent_config: AgentConfig instance + + Returns: + ChatLiteLLM instance or None on error + """ + # Build the model string + if agent_config.custom_provider: + model_string = f"{agent_config.custom_provider}/{agent_config.model_name}" + else: + provider_prefix = PROVIDER_MAP.get( + agent_config.provider, agent_config.provider.lower() + ) + model_string = f"{provider_prefix}/{agent_config.model_name}" + + # Create ChatLiteLLM instance with streaming enabled + litellm_kwargs = { + "model": model_string, + "api_key": agent_config.api_key, + "streaming": True, # Enable streaming for real-time token streaming + } + + # Add optional parameters + if agent_config.api_base: + litellm_kwargs["api_base"] = agent_config.api_base + + # Add any additional litellm parameters + if agent_config.litellm_params: + litellm_kwargs.update(agent_config.litellm_params) + + return ChatLiteLLM(**litellm_kwargs) diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index c0b9bb091..91b4eee08 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -3,10 +3,16 @@ System prompt building for SurfSense agents. This module provides functions and constants for building the SurfSense system prompt with configurable user instructions and citation support. + +The prompt is composed of three parts: +1. System Instructions (configurable via NewLLMConfig) +2. Tools Instructions (always included, not configurable) +3. Citation Instructions (toggleable via NewLLMConfig.citations_enabled) """ from datetime import UTC, datetime +# Default system instructions - can be overridden via NewLLMConfig.system_instructions SURFSENSE_SYSTEM_INSTRUCTIONS = """ You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base. @@ -219,12 +225,38 @@ However, from your video learning, it's important to note that asyncio is not su """ +# Anti-citation prompt - used when citations are disabled +# This explicitly tells the model NOT to include citations +SURFSENSE_NO_CITATION_INSTRUCTIONS = """ + +IMPORTANT: Citations are DISABLED for this configuration. + +DO NOT include any citations in your responses. Specifically: +1. Do NOT use the [citation:chunk_id] format anywhere in your response. +2. Do NOT reference document IDs, chunk IDs, or source IDs. +3. Simply provide the information naturally without any citation markers. +4. Write your response as if you're having a normal conversation, incorporating the information from your knowledge seamlessly. + +When answering questions based on documents from the knowledge base: +- Present the information directly and confidently +- Do not mention that information comes from specific documents or chunks +- Integrate facts naturally into your response without attribution markers + +Your goal is to provide helpful, informative answers in a clean, readable format without any citation notation. + +""" + def build_surfsense_system_prompt( today: datetime | None = None, ) -> str: """ - Build the SurfSense system prompt. + Build the SurfSense system prompt with default settings. + + This is a convenience function that builds the prompt with: + - Default system instructions + - Tools instructions (always included) + - Citation instructions enabled Args: today: Optional datetime for today's date (defaults to current UTC date) @@ -241,4 +273,74 @@ def build_surfsense_system_prompt( ) +def build_configurable_system_prompt( + custom_system_instructions: str | None = None, + use_default_system_instructions: bool = True, + citations_enabled: bool = True, + today: datetime | None = None, +) -> str: + """ + Build a configurable SurfSense system prompt based on NewLLMConfig settings. + + The prompt is composed of three parts: + 1. System Instructions - either custom or default SURFSENSE_SYSTEM_INSTRUCTIONS + 2. Tools Instructions - always included (SURFSENSE_TOOLS_INSTRUCTIONS) + 3. Citation Instructions - either SURFSENSE_CITATION_INSTRUCTIONS or SURFSENSE_NO_CITATION_INSTRUCTIONS + + Args: + custom_system_instructions: Custom system instructions to use. If empty/None and + use_default_system_instructions is True, defaults to + SURFSENSE_SYSTEM_INSTRUCTIONS. + use_default_system_instructions: Whether to use default instructions when + custom_system_instructions is empty/None. + citations_enabled: Whether to include citation instructions (True) or + anti-citation instructions (False). + today: Optional datetime for today's date (defaults to current UTC date) + + Returns: + Complete system prompt string + """ + resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat() + + # Determine system instructions + if custom_system_instructions and custom_system_instructions.strip(): + # Use custom instructions, injecting the date placeholder if present + system_instructions = custom_system_instructions.format( + resolved_today=resolved_today + ) + elif use_default_system_instructions: + # Use default instructions + system_instructions = SURFSENSE_SYSTEM_INSTRUCTIONS.format( + resolved_today=resolved_today + ) + else: + # No system instructions (edge case) + system_instructions = "" + + # Tools instructions are always included + tools_instructions = SURFSENSE_TOOLS_INSTRUCTIONS + + # Citation instructions based on toggle + citation_instructions = ( + SURFSENSE_CITATION_INSTRUCTIONS + if citations_enabled + else SURFSENSE_NO_CITATION_INSTRUCTIONS + ) + + return system_instructions + tools_instructions + citation_instructions + + +def get_default_system_instructions() -> str: + """ + Get the default system instructions template. + + This is useful for populating the UI with the default value when + creating a new NewLLMConfig. + + Returns: + Default system instructions string (with {resolved_today} placeholder) + """ + return SURFSENSE_SYSTEM_INSTRUCTIONS.strip() + + SURFSENSE_SYSTEM_PROMPT = build_surfsense_system_prompt() diff --git a/surfsense_backend/app/agents/new_chat/tools/__init__.py b/surfsense_backend/app/agents/new_chat/tools/__init__.py index ad75cda16..b89988327 100644 --- a/surfsense_backend/app/agents/new_chat/tools/__init__.py +++ b/surfsense_backend/app/agents/new_chat/tools/__init__.py @@ -13,15 +13,6 @@ Available tools: """ # Registry exports -from .registry import ( - BUILTIN_TOOLS, - ToolDefinition, - build_tools, - get_all_tool_names, - get_default_enabled_tools, - get_tool_by_name, -) - # Tool factory exports (for direct use) from .display_image import create_display_image_tool from .knowledge_base import ( @@ -31,6 +22,14 @@ from .knowledge_base import ( ) from .link_preview import create_link_preview_tool from .podcast import create_generate_podcast_tool +from .registry import ( + BUILTIN_TOOLS, + ToolDefinition, + build_tools, + get_all_tool_names, + get_default_enabled_tools, + get_tool_by_name, +) from .scrape_webpage import create_scrape_webpage_tool __all__ = [ @@ -38,9 +37,6 @@ __all__ = [ "BUILTIN_TOOLS", "ToolDefinition", "build_tools", - "get_all_tool_names", - "get_default_enabled_tools", - "get_tool_by_name", # Tool factories "create_display_image_tool", "create_generate_podcast_tool", @@ -49,6 +45,8 @@ __all__ = [ "create_search_knowledge_base_tool", # Knowledge base utilities "format_documents_for_context", + "get_all_tool_names", + "get_default_enabled_tools", + "get_tool_by_name", "search_knowledge_base_async", ] - diff --git a/surfsense_backend/app/agents/new_chat/tools/display_image.py b/surfsense_backend/app/agents/new_chat/tools/display_image.py index 1580568ec..5eb846063 100644 --- a/surfsense_backend/app/agents/new_chat/tools/display_image.py +++ b/surfsense_backend/app/agents/new_chat/tools/display_image.py @@ -86,7 +86,9 @@ def create_display_image_tool(): ratio = "16:9" # Default if "unsplash.com" in src or "pexels.com" in src: ratio = "16:9" - elif "imgur.com" in src or "github.com" in src or "githubusercontent.com" in src: + elif ( + "imgur.com" in src or "github.com" in src or "githubusercontent.com" in src + ): ratio = "auto" return { @@ -101,4 +103,3 @@ def create_display_image_tool(): } return display_image - diff --git a/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py b/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py index 2d818557d..6c3dfd34b 100644 --- a/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py +++ b/surfsense_backend/app/agents/new_chat/tools/knowledge_base.py @@ -605,4 +605,3 @@ def create_search_knowledge_base_tool( ) return search_knowledge_base - diff --git a/surfsense_backend/app/agents/new_chat/tools/link_preview.py b/surfsense_backend/app/agents/new_chat/tools/link_preview.py index 466df2034..188863015 100644 --- a/surfsense_backend/app/agents/new_chat/tools/link_preview.py +++ b/surfsense_backend/app/agents/new_chat/tools/link_preview.py @@ -46,13 +46,17 @@ def extract_og_content(html: str, property_name: str) -> str | None: def extract_twitter_content(html: str, name: str) -> str | None: """Extract Twitter Card meta content from HTML.""" - pattern = rf']+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']' + pattern = ( + rf']+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']' + ) match = re.search(pattern, html, re.IGNORECASE) if match: return match.group(1) # Try content before name - pattern = rf']+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:{name}["\']' + pattern = ( + rf']+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:{name}["\']' + ) match = re.search(pattern, html, re.IGNORECASE) if match: return match.group(1) @@ -289,4 +293,3 @@ def create_link_preview_tool(): } return link_preview - diff --git a/surfsense_backend/app/agents/new_chat/tools/podcast.py b/surfsense_backend/app/agents/new_chat/tools/podcast.py index 01a36d381..ff567bf73 100644 --- a/surfsense_backend/app/agents/new_chat/tools/podcast.py +++ b/surfsense_backend/app/agents/new_chat/tools/podcast.py @@ -171,4 +171,3 @@ def create_generate_podcast_tool( } return generate_podcast - diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 6c6469f33..3b0c2ddac 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -37,11 +37,18 @@ Example of adding a new tool: ), """ +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable +from typing import Any from langchain_core.tools import BaseTool +from .display_image import create_display_image_tool +from .knowledge_base import create_search_knowledge_base_tool +from .link_preview import create_link_preview_tool +from .podcast import create_generate_podcast_tool +from .scrape_webpage import create_scrape_webpage_tool + # ============================================================================= # Tool Definition # ============================================================================= @@ -71,13 +78,6 @@ class ToolDefinition: # Built-in Tools Registry # ============================================================================= -# Import tool factory functions -from .display_image import create_display_image_tool -from .knowledge_base import create_search_knowledge_base_tool -from .link_preview import create_link_preview_tool -from .podcast import create_generate_podcast_tool -from .scrape_webpage import create_scrape_webpage_tool - # Registry of all built-in tools # Contributors: Add your new tools here! BUILTIN_TOOLS: list[ToolDefinition] = [ @@ -228,4 +228,3 @@ def build_tools( tools.extend(additional_tools) return tools - diff --git a/surfsense_backend/app/agents/new_chat/tools/scrape_webpage.py b/surfsense_backend/app/agents/new_chat/tools/scrape_webpage.py index a4928d0c7..24f15edba 100644 --- a/surfsense_backend/app/agents/new_chat/tools/scrape_webpage.py +++ b/surfsense_backend/app/agents/new_chat/tools/scrape_webpage.py @@ -156,7 +156,9 @@ def create_scrape_webpage_tool(firecrawl_api_key: str | None = None): if not description and content: # Use first paragraph as description first_para = content.split("\n\n")[0] if content else "" - description = first_para[:300] + "..." if len(first_para) > 300 else first_para + description = ( + first_para[:300] + "..." if len(first_para) > 300 else first_para + ) # Truncate content if needed content, was_truncated = truncate_content(content, max_length) @@ -194,4 +196,3 @@ def create_scrape_webpage_tool(firecrawl_api_key: str | None = None): } return scrape_webpage - diff --git a/surfsense_backend/app/agents/podcaster/nodes.py b/surfsense_backend/app/agents/podcaster/nodes.py index 1353d2c66..3f908737a 100644 --- a/surfsense_backend/app/agents/podcaster/nodes.py +++ b/surfsense_backend/app/agents/podcaster/nodes.py @@ -12,7 +12,7 @@ from litellm import aspeech from app.config import config as app_config from app.services.kokoro_tts_service import get_kokoro_tts_service -from app.services.llm_service import get_long_context_llm +from app.services.llm_service import get_document_summary_llm from .configuration import Configuration from .prompts import get_podcast_generation_prompt @@ -30,10 +30,12 @@ async def create_podcast_transcript( search_space_id = configuration.search_space_id user_prompt = configuration.user_prompt - # Get search space's long context LLM - llm = await get_long_context_llm(state.db_session, search_space_id) + # Get search space's document summary LLM + llm = await get_document_summary_llm(state.db_session, search_space_id) if not llm: - error_message = f"No long context LLM configured for search space {search_space_id}" + error_message = ( + f"No document summary LLM configured for search space {search_space_id}" + ) print(error_message) raise RuntimeError(error_message) diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index bffe4f606..08be26de1 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -35,12 +35,6 @@ def load_global_llm_configs(): # 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 [] diff --git a/surfsense_backend/app/config/global_llm_config.example.yaml b/surfsense_backend/app/config/global_llm_config.example.yaml index bd574515a..14a18c99a 100644 --- a/surfsense_backend/app/config/global_llm_config.example.yaml +++ b/surfsense_backend/app/config/global_llm_config.example.yaml @@ -9,72 +9,101 @@ # # These configurations will be available to all users as a convenient option # Users can choose to use these global configs or add their own +# +# Structure matches NewLLMConfig: +# - LLM model configuration (provider, model_name, api_key, etc.) +# - Prompt configuration (system_instructions, citations_enabled) global_llm_configs: - # Example: OpenAI GPT-4 Turbo + # Example: OpenAI GPT-4 Turbo with citations enabled - id: -1 name: "Global GPT-4 Turbo" + description: "OpenAI's GPT-4 Turbo with default prompts and citations" 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 + # Prompt Configuration + system_instructions: "" # Empty = use default SURFSENSE_SYSTEM_INSTRUCTIONS + use_default_system_instructions: true + citations_enabled: true # Example: Anthropic Claude 3 Opus - id: -2 name: "Global Claude 3 Opus" + description: "Anthropic's most capable model with citations" 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 + system_instructions: "" + use_default_system_instructions: true + citations_enabled: true - # Example: Fast model - GPT-3.5 Turbo + # Example: Fast model - GPT-3.5 Turbo (citations disabled for speed) - id: -3 - name: "Global GPT-3.5 Turbo" + name: "Global GPT-3.5 Turbo (Fast)" + description: "Fast responses without citations for quick queries" 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 + system_instructions: "" + use_default_system_instructions: true + citations_enabled: false # Disabled for faster responses - # Example: Chinese LLM - DeepSeek + # Example: Chinese LLM - DeepSeek with custom instructions - id: -4 - name: "Global DeepSeek Chat" + name: "Global DeepSeek Chat (Chinese)" + description: "DeepSeek optimized for Chinese language responses" 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 + # Custom system instructions for Chinese responses + system_instructions: | + + You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base. + + Today's date (UTC): {resolved_today} + + IMPORTANT: Please respond in Chinese (简体中文) unless the user specifically requests another language. + + use_default_system_instructions: false + citations_enabled: true # Example: Groq - Fast inference - id: -5 name: "Global Groq Llama 3" + description: "Ultra-fast Llama 3 70B via Groq" 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 + system_instructions: "" + use_default_system_instructions: true + citations_enabled: true # Notes: -# - Use negative IDs to distinguish global configs from user configs +# - Use negative IDs to distinguish global configs from user configs (NewLLMConfig in DB) # - 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 +# - system_instructions: Custom prompt or empty string to use defaults +# - use_default_system_instructions: true = use SURFSENSE_SYSTEM_INSTRUCTIONS when system_instructions is empty +# - citations_enabled: true = include citation instructions, false = include anti-citation instructions # - All standard LiteLLM providers are supported - diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 815115640..a2a424c26 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -452,9 +452,10 @@ class SearchSpace(BaseModel, TimestampMixin): # Search space-level LLM preferences (shared by all members) # Note: These can be negative IDs for global configs (from YAML) or positive IDs for custom configs (from DB) - long_context_llm_id = Column(Integer, nullable=True) - fast_llm_id = Column(Integer, nullable=True) - strategic_llm_id = Column(Integer, nullable=True) + agent_llm_id = Column(Integer, nullable=True) # For agent/chat operations + document_summary_llm_id = Column( + Integer, nullable=True + ) # For document summarization user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False @@ -491,10 +492,10 @@ class SearchSpace(BaseModel, TimestampMixin): order_by="SearchSourceConnector.id", cascade="all, delete-orphan", ) - llm_configs = relationship( - "LLMConfig", + new_llm_configs = relationship( + "NewLLMConfig", back_populates="search_space", - order_by="LLMConfig.id", + order_by="NewLLMConfig.id", cascade="all, delete-orphan", ) @@ -553,10 +554,24 @@ class SearchSourceConnector(BaseModel, TimestampMixin): ) -class LLMConfig(BaseModel, TimestampMixin): - __tablename__ = "llm_configs" +class NewLLMConfig(BaseModel, TimestampMixin): + """ + New LLM configuration table that combines model settings with prompt configuration. + + This table provides: + - LLM model configuration (provider, model_name, api_key, etc.) + - Configurable system instructions (defaults to SURFSENSE_SYSTEM_INSTRUCTIONS) + - Citation toggle (enable/disable citation instructions) + + Note: SURFSENSE_TOOLS_INSTRUCTIONS is always used and not configurable. + """ + + __tablename__ = "new_llm_configs" name = Column(String(100), nullable=False, index=True) + description = Column(String(500), nullable=True) + + # === LLM Model Configuration (from original LLMConfig, excluding 'language') === # Provider from the enum provider = Column(SQLAlchemyEnum(LiteLLMProvider), nullable=False) # Custom provider name when provider is CUSTOM @@ -566,16 +581,29 @@ class LLMConfig(BaseModel, TimestampMixin): # API Key should be encrypted before storing api_key = Column(String, nullable=False) api_base = Column(String(500), nullable=True) - - language = Column(String(50), nullable=True, default="English") - # For any other parameters that litellm supports litellm_params = Column(JSON, nullable=True, default={}) + # === Prompt Configuration === + # Configurable system instructions (defaults to SURFSENSE_SYSTEM_INSTRUCTIONS) + # Users can customize this from the UI + system_instructions = Column( + Text, + nullable=False, + default="", # Empty string means use default SURFSENSE_SYSTEM_INSTRUCTIONS + ) + # Whether to use the default system instructions when system_instructions is empty + use_default_system_instructions = Column(Boolean, nullable=False, default=True) + + # Citation toggle - when enabled, SURFSENSE_CITATION_INSTRUCTIONS is injected + # When disabled, an anti-citation prompt is injected instead + citations_enabled = Column(Boolean, nullable=False, default=True) + + # === Relationships === search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False ) - search_space = relationship("SearchSpace", back_populates="llm_configs") + search_space = relationship("SearchSpace", back_populates="new_llm_configs") class Log(BaseModel, TimestampMixin): diff --git a/surfsense_backend/app/prompts/public_search_space_prompts.yaml b/surfsense_backend/app/prompts/public_search_space_prompts.yaml deleted file mode 100644 index 69b2de036..000000000 --- a/surfsense_backend/app/prompts/public_search_space_prompts.yaml +++ /dev/null @@ -1,190 +0,0 @@ -prompts: - # Developer-focused prompts - - key: ethereum_developer - value: "Imagine you are an experienced Ethereum developer tasked with creating a smart contract for a blockchain messenger. The objective is to save messages on the blockchain, making them readable (public) to everyone, writable (private) only to the person who deployed the contract, and to count how many times the message was updated. Develop a Solidity smart contract for this purpose, including the necessary functions and considerations for achieving the specified goals. Please provide the code and any relevant explanations to ensure a clear understanding of the implementation." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: linux_terminal - value: "I want you to act as a linux terminal. I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so. when i need to tell you something in english, i will do so by putting text inside curly brackets {like this}." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: javascript_console - value: "I want you to act as a javascript console. I will type commands and you will reply with what the javascript console should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so. when i need to tell you something in english, i will do so by putting text inside curly brackets {like this}." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: fullstack_developer - value: "I want you to act as a software developer. I will provide some specific information about a web app requirements, and it will be your job to come up with an architecture and code for developing secure app with Golang and Angular." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: regex_generator - value: "I want you to act as a regex generator. Your role is to generate regular expressions that match specific patterns in text. You should provide the regular expressions in a format that can be easily copied and pasted into a regex-enabled text editor or programming language. Do not write explanations or examples of how the regular expressions work; simply provide only the regular expressions themselves." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: senior_frontend_developer - value: "I want you to act as a Senior Frontend developer. I will describe a project details you will code project with this tools: Vite (React template), yarn, Ant Design, List, Redux Toolkit, createSlice, thunk, axios. You should merge files in single index.js file and nothing else. Do not write explanations." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: code_reviewer - value: "I want you to act as a Code reviewer who is experienced developer in the given code language. I will provide you with the code block or methods or code file along with the code language name, and I would like you to review the code and share the feedback, suggestions and alternative recommended approaches. Please write explanations behind the feedback or suggestions or alternative approaches." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: machine_learning_engineer - value: "I want you to act as a machine learning engineer. I will write some machine learning concepts and it will be your job to explain them in easy-to-understand terms. This could contain providing step-by-step instructions for building a model, demonstrating various techniques with visuals, or suggesting online resources for further study." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: sql_terminal - value: "I want you to act as a SQL terminal in front of an example database. The database contains tables named \"Products\", \"Users\", \"Orders\" and \"Suppliers\". I will type queries and you will reply with what the terminal would show. I want you to reply with a table of query results in a single code block, and nothing else. Do not write explanations. Do not type commands unless I instruct you to do so. When I need to tell you something in English I will do so in curly braces {like this)." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: python_interpreter - value: "Act as a Python interpreter. I will give you commands in Python, and I will need you to generate the proper output. Only say the output. But if there is none, say nothing, and don't give me an explanation. If I need to say something, I will do so through comments." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: devops_engineer - value: "You are a Senior DevOps engineer working at a Big Company. Your role is to provide scalable, efficient, and automated solutions for software deployment, infrastructure management, and CI/CD pipelines. Suggest the best DevOps practices, including infrastructure setup, deployment strategies, automation tools, and cost-effective scaling solutions." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - - key: cyber_security_specialist - value: "I want you to act as a cyber security specialist. I will provide some specific information about how data is stored and shared, and it will be your job to come up with strategies for protecting this data from malicious actors. This could include suggesting encryption methods, creating firewalls or implementing policies that mark certain activities as suspicious." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "developer" - - # General productivity prompts - - key: english_translator - value: "I want you to act as an English translator, spelling corrector and improver. I will speak to you in any language and you will detect the language, translate it and answer in the corrected and improved version of my text, in English. I want you to replace my simplified A0-level words and sentences with more beautiful and elegant, upper level English words and sentences. Keep the meaning same, but make them more literary. I want you to only reply the correction, the improvements and nothing else, do not write explanations." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: proofreader - value: "I want you act as a proofreader. I will provide you texts and I would like you to review them for any spelling, grammar, or punctuation errors. Once you have finished reviewing the text, provide me with any necessary corrections or suggestions for improve the text." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: note_taking_assistant - value: "I want you to act as a note-taking assistant for a lecture. Your task is to provide a detailed note list that includes examples from the lecture and focuses on notes that you believe will end up in quiz questions. Additionally, please make a separate list for notes that have numbers and data in them and another separated list for the examples that included in this lecture. The notes should be concise and easy to read." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: essay_writer - value: "I want you to act as an essay writer. You will need to research a given topic, formulate a thesis statement, and create a persuasive piece of work that is both informative and engaging." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: career_counselor - value: "I want you to act as a career counselor. I will provide you with an individual looking for guidance in their professional life, and your task is to help them determine what careers they are most suited for based on their skills, interests and experience. You should also conduct research into the various options available, explain the job market trends in different industries and advice on which qualifications would be beneficial for pursuing particular fields." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: life_coach - value: "I want you to act as a life coach. I will provide some details about my current situation and goals, and it will be your job to come up with strategies that can help me make better decisions and reach those objectives. This could involve offering advice on various topics, such as creating plans for achieving success or dealing with difficult emotions." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: motivational_coach - value: "I want you to act as a motivational coach. I will provide you with some information about someone's goals and challenges, and it will be your job to come up with strategies that can help this person achieve their goals. This could involve providing positive affirmations, giving helpful advice or suggesting activities they can do to reach their end goal." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - - key: travel_guide - value: "I want you to act as a travel guide. I will write you my location and you will suggest a place to visit near my location. In some cases, I will also give you the type of places I will visit. You will also suggest me places of similar type that are close to my first location." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "general" - - # Creative prompts - - key: storyteller - value: "I want you to act as a storyteller. You will come up with entertaining stories that are engaging, imaginative and captivating for the audience. It can be fairy tales, educational stories or any other type of stories which has the potential to capture people's attention and imagination. Depending on the target audience, you may choose specific themes or topics for your storytelling session e.g., if it's children then you can talk about animals; If it's adults then history-based tales might engage them better etc." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "creative" - - - key: screenwriter - value: "I want you to act as a screenwriter. You will develop an engaging and creative script for either a feature length film, or a Web Series that can captivate its viewers. Start with coming up with interesting characters, the setting of the story, dialogues between the characters etc. Once your character development is complete - create an exciting storyline filled with twists and turns that keeps the viewers in suspense until the end." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "creative" - - - key: novelist - value: "I want you to act as a novelist. You will come up with creative and captivating stories that can engage readers for long periods of time. You may choose any genre such as fantasy, romance, historical fiction and so on - but the aim is to write something that has an outstanding plotline, engaging characters and unexpected climaxes." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "creative" - - - key: poet - value: "I want you to act as a poet. You will create poems that evoke emotions and have the power to stir people's soul. Write on any topic or theme but make sure your words convey the feeling you are trying to express in beautiful yet meaningful ways. You can also come up with short verses that are still powerful enough to leave an imprint in readers' minds." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "creative" - - - key: rapper - value: "I want you to act as a rapper. You will come up with powerful and meaningful lyrics, beats and rhythm that can 'wow' the audience. Your lyrics should have an intriguing meaning and message which people can relate too. When it comes to choosing your beat, make sure it is catchy yet relevant to your words, so that when combined they make an explosion of sound everytime!" - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "creative" - - - key: composer - value: "I want you to act as a composer. I will provide the lyrics to a song and you will create music for it. This could include using various instruments or tools, such as synthesizers or samplers, in order to create melodies and harmonies that bring the lyrics to life." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "creative" - - # Educational prompts - - key: math_teacher - value: "I want you to act as a math teacher. I will provide some mathematical equations or concepts, and it will be your job to explain them in easy-to-understand terms. This could include providing step-by-step instructions for solving a problem, demonstrating various techniques with visuals or suggesting online resources for further study." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "educational" - - - key: philosophy_teacher - value: "I want you to act as a philosophy teacher. I will provide some topics related to the study of philosophy, and it will be your job to explain these concepts in an easy-to-understand manner. This could include providing examples, posing questions or breaking down complex ideas into smaller pieces that are easier to comprehend." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "educational" - - - key: historian - value: "I want you to act as a historian. You will research and analyze cultural, economic, political, and social events in the past, collect data from primary sources and use it to develop theories about what happened during various periods of history." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "educational" - - - key: debater - value: "I want you to act as a debater. I will provide you with some topics related to current events and your task is to research both sides of the debates, present valid arguments for each side, refute opposing points of view, and draw persuasive conclusions based on evidence. Your goal is to help people come away from the discussion with increased knowledge and insight into the topic at hand." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "educational" - - - key: explainer_with_analogies - value: "I want you to act as an explainer who uses analogies to clarify complex topics. When I give you a subject (technical, philosophical or scientific), you'll follow this structure: 1. Ask me 1-2 quick questions to assess my current level of understanding. 2. Based on my answer, create three analogies to explain the topic: one that a 10-year-old would understand, one for a high-school student, and one for a college-level person. 3. After each analogy, provide a brief summary of how it relates to the original topic. 4. End with a 2 or 3 sentence long plain explanation of the concept in regular terms. Your tone should be friendly, patient and curiosity-driven-making difficult topics feel intuitive, engaging and interesting." - author: "awesome-chatgpt-prompts" - link: "https://github.com/f/awesome-chatgpt-prompts" - category: "educational" diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 6ad87d975..a055bf549 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -11,10 +11,10 @@ from .google_calendar_add_connector_route import ( from .google_gmail_add_connector_route import ( router as google_gmail_add_connector_router, ) -from .llm_config_routes import router as llm_config_router from .logs_routes import router as logs_router from .luma_add_connector_route import router as luma_add_connector_router from .new_chat_routes import router as new_chat_router +from .new_llm_config_routes import router as new_llm_config_router from .notes_routes import router as notes_router from .podcasts_routes import router as podcasts_router from .rbac_routes import router as rbac_router @@ -35,5 +35,5 @@ router.include_router(google_calendar_add_connector_router) router.include_router(google_gmail_add_connector_router) router.include_router(airtable_add_connector_router) router.include_router(luma_add_connector_router) -router.include_router(llm_config_router) +router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) diff --git a/surfsense_backend/app/routes/llm_config_routes.py b/surfsense_backend/app/routes/llm_config_routes.py deleted file mode 100644 index 31c7200f5..000000000 --- a/surfsense_backend/app/routes/llm_config_routes.py +++ /dev/null @@ -1,576 +0,0 @@ -import logging - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select - -from app.config import config -from app.db import ( - LLMConfig, - Permission, - SearchSpace, - User, - get_async_session, -) -from app.schemas import LLMConfigCreate, LLMConfigRead, LLMConfigUpdate -from app.services.llm_service import validate_llm_config -from app.users import current_active_user -from app.utils.rbac import check_permission - -router = APIRouter() -logger = logging.getLogger(__name__) - - -class LLMPreferencesUpdate(BaseModel): - """Schema for updating search space LLM preferences""" - - long_context_llm_id: int | None = None - fast_llm_id: int | None = None - strategic_llm_id: int | None = None - - -class LLMPreferencesRead(BaseModel): - """Schema for reading search space LLM preferences""" - - long_context_llm_id: int | None = None - fast_llm_id: int | None = None - strategic_llm_id: int | None = None - long_context_llm: LLMConfigRead | None = None - fast_llm: LLMConfigRead | None = None - 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, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Create a new LLM configuration for a search space. - Requires LLM_CONFIGS_CREATE permission. - """ - try: - # Verify user has permission to create LLM configs - await check_permission( - session, - user, - llm_config.search_space_id, - Permission.LLM_CONFIGS_CREATE.value, - "You don't have permission to create LLM configurations in this search space", - ) - - # Validate the LLM configuration by making a test API call - is_valid, error_message = await validate_llm_config( - provider=llm_config.provider.value, - model_name=llm_config.model_name, - api_key=llm_config.api_key, - api_base=llm_config.api_base, - custom_provider=llm_config.custom_provider, - litellm_params=llm_config.litellm_params, - ) - - if not is_valid: - raise HTTPException( - status_code=400, - detail=f"Invalid LLM configuration: {error_message}", - ) - - db_llm_config = LLMConfig(**llm_config.model_dump()) - session.add(db_llm_config) - await session.commit() - await session.refresh(db_llm_config) - return db_llm_config - except HTTPException: - raise - except Exception as e: - await session.rollback() - raise HTTPException( - status_code=500, detail=f"Failed to create LLM configuration: {e!s}" - ) from e - - -@router.get("/llm-configs", response_model=list[LLMConfigRead]) -async def read_llm_configs( - search_space_id: int, - skip: int = 0, - limit: int = 200, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Get all LLM configurations for a search space. - Requires LLM_CONFIGS_READ permission. - """ - try: - # Verify user has permission to read LLM configs - await check_permission( - session, - user, - search_space_id, - Permission.LLM_CONFIGS_READ.value, - "You don't have permission to view LLM configurations in this search space", - ) - - result = await session.execute( - select(LLMConfig) - .filter(LLMConfig.search_space_id == search_space_id) - .offset(skip) - .limit(limit) - ) - return result.scalars().all() - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Failed to fetch LLM configurations: {e!s}" - ) from e - - -@router.get("/llm-configs/{llm_config_id}", response_model=LLMConfigRead) -async def read_llm_config( - llm_config_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Get a specific LLM configuration by ID. - Requires LLM_CONFIGS_READ permission. - """ - try: - # Get the LLM config - result = await session.execute( - select(LLMConfig).filter(LLMConfig.id == llm_config_id) - ) - llm_config = result.scalars().first() - - if not llm_config: - raise HTTPException(status_code=404, detail="LLM configuration not found") - - # Verify user has permission to read LLM configs - await check_permission( - session, - user, - llm_config.search_space_id, - Permission.LLM_CONFIGS_READ.value, - "You don't have permission to view LLM configurations in this search space", - ) - - return llm_config - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Failed to fetch LLM configuration: {e!s}" - ) from e - - -@router.put("/llm-configs/{llm_config_id}", response_model=LLMConfigRead) -async def update_llm_config( - llm_config_id: int, - llm_config_update: LLMConfigUpdate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Update an existing LLM configuration. - Requires LLM_CONFIGS_UPDATE permission. - """ - try: - # Get the LLM config - result = await session.execute( - select(LLMConfig).filter(LLMConfig.id == llm_config_id) - ) - db_llm_config = result.scalars().first() - - if not db_llm_config: - raise HTTPException(status_code=404, detail="LLM configuration not found") - - # Verify user has permission to update LLM configs - await check_permission( - session, - user, - db_llm_config.search_space_id, - Permission.LLM_CONFIGS_UPDATE.value, - "You don't have permission to update LLM configurations in this search space", - ) - - update_data = llm_config_update.model_dump(exclude_unset=True) - - # Apply updates to a temporary copy for validation - temp_config = { - "provider": update_data.get("provider", db_llm_config.provider.value), - "model_name": update_data.get("model_name", db_llm_config.model_name), - "api_key": update_data.get("api_key", db_llm_config.api_key), - "api_base": update_data.get("api_base", db_llm_config.api_base), - "custom_provider": update_data.get( - "custom_provider", db_llm_config.custom_provider - ), - "litellm_params": update_data.get( - "litellm_params", db_llm_config.litellm_params - ), - } - - # Validate the updated configuration - is_valid, error_message = await validate_llm_config( - provider=temp_config["provider"], - model_name=temp_config["model_name"], - api_key=temp_config["api_key"], - api_base=temp_config["api_base"], - custom_provider=temp_config["custom_provider"], - litellm_params=temp_config["litellm_params"], - ) - - if not is_valid: - raise HTTPException( - status_code=400, - detail=f"Invalid LLM configuration: {error_message}", - ) - - # Apply updates to the database object - for key, value in update_data.items(): - setattr(db_llm_config, key, value) - - await session.commit() - await session.refresh(db_llm_config) - return db_llm_config - except HTTPException: - raise - except Exception as e: - await session.rollback() - raise HTTPException( - status_code=500, detail=f"Failed to update LLM configuration: {e!s}" - ) from e - - -@router.delete("/llm-configs/{llm_config_id}", response_model=dict) -async def delete_llm_config( - llm_config_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Delete an LLM configuration. - Requires LLM_CONFIGS_DELETE permission. - """ - try: - # Get the LLM config - result = await session.execute( - select(LLMConfig).filter(LLMConfig.id == llm_config_id) - ) - db_llm_config = result.scalars().first() - - if not db_llm_config: - raise HTTPException(status_code=404, detail="LLM configuration not found") - - # Verify user has permission to delete LLM configs - await check_permission( - session, - user, - db_llm_config.search_space_id, - Permission.LLM_CONFIGS_DELETE.value, - "You don't have permission to delete LLM configurations in this search space", - ) - - await session.delete(db_llm_config) - await session.commit() - return {"message": "LLM configuration deleted successfully"} - except HTTPException: - raise - except Exception as e: - await session.rollback() - raise HTTPException( - status_code=500, detail=f"Failed to delete LLM configuration: {e!s}" - ) from e - - -# Search Space LLM Preferences endpoints - - -@router.get( - "/search-spaces/{search_space_id}/llm-preferences", - response_model=LLMPreferencesRead, -) -async def get_llm_preferences( - search_space_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Get the LLM preferences for a specific search space. - LLM preferences are shared by all members of the search space. - Requires LLM_CONFIGS_READ permission. - """ - try: - # Verify user has permission to read LLM configs - await check_permission( - session, - user, - search_space_id, - Permission.LLM_CONFIGS_READ.value, - "You don't have permission to view LLM preferences in this search space", - ) - - # Get the search space - result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) - ) - search_space = result.scalars().first() - - if not search_space: - raise HTTPException(status_code=404, detail="Search space not found") - - # 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(search_space.long_context_llm_id) - fast_llm = await get_config_for_id(search_space.fast_llm_id) - strategic_llm = await get_config_for_id(search_space.strategic_llm_id) - - return { - "long_context_llm_id": search_space.long_context_llm_id, - "fast_llm_id": search_space.fast_llm_id, - "strategic_llm_id": search_space.strategic_llm_id, - "long_context_llm": long_context_llm, - "fast_llm": fast_llm, - "strategic_llm": strategic_llm, - } - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Failed to fetch LLM preferences: {e!s}" - ) from e - - -@router.put( - "/search-spaces/{search_space_id}/llm-preferences", - response_model=LLMPreferencesRead, -) -async def update_llm_preferences( - search_space_id: int, - preferences: LLMPreferencesUpdate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Update the LLM preferences for a specific search space. - LLM preferences are shared by all members of the search space. - Requires SETTINGS_UPDATE permission (only users with settings access can change). - """ - try: - # Verify user has permission to update settings (not just LLM configs) - # This ensures only users with settings access can change shared LLM preferences - await check_permission( - session, - user, - search_space_id, - Permission.SETTINGS_UPDATE.value, - "You don't have permission to update LLM preferences in this search space", - ) - - # Get the search space - result = await session.execute( - select(SearchSpace).filter(SearchSpace.id == search_space_id) - ) - search_space = result.scalars().first() - - if not search_space: - raise HTTPException(status_code=404, detail="Search space not found") - - # Validate that all provided LLM config IDs belong to the search space - update_data = preferences.model_dump(exclude_unset=True) - - # Store language from configs to validate consistency - languages = set() - - for _key, llm_config_id in update_data.items(): - if llm_config_id is not None: - # 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 - - if not global_config: - raise HTTPException( - status_code=404, - detail=f"Global LLM configuration {llm_config_id} not found", - ) - - # 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: - # 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." - ) - - # Update search space LLM preferences - for key, value in update_data.items(): - setattr(search_space, key, value) - - await session.commit() - await session.refresh(search_space) - - # 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(search_space.long_context_llm_id) - fast_llm = await get_config_for_id(search_space.fast_llm_id) - strategic_llm = await get_config_for_id(search_space.strategic_llm_id) - - # Return updated preferences - return { - "long_context_llm_id": search_space.long_context_llm_id, - "fast_llm_id": search_space.fast_llm_id, - "strategic_llm_id": search_space.strategic_llm_id, - "long_context_llm": long_context_llm, - "fast_llm": fast_llm, - "strategic_llm": strategic_llm, - } - except HTTPException: - raise - except Exception as e: - await session.rollback() - raise HTTPException( - status_code=500, detail=f"Failed to update LLM preferences: {e!s}" - ) from e diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 81477ac6d..476ff2935 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -685,8 +685,16 @@ async def handle_new_chat( ) search_space = search_space_result.scalars().first() - # TODO: Add new llm config arch then complete this - llm_config_id = -4 + if not search_space: + raise HTTPException(status_code=404, detail="Search space not found") + + # Use agent_llm_id from search space for chat operations + # Positive IDs load from NewLLMConfig database table + # Negative IDs load from YAML global configs + # Falls back to -1 (first global config) if not configured + llm_config_id = ( + search_space.agent_llm_id if search_space.agent_llm_id is not None else -1 + ) # Return streaming response return StreamingResponse( @@ -696,7 +704,6 @@ async def handle_new_chat( chat_id=request.chat_id, session=session, llm_config_id=llm_config_id, - messages=request.messages, attachments=request.attachments, mentioned_document_ids=request.mentioned_document_ids, ), diff --git a/surfsense_backend/app/routes/new_llm_config_routes.py b/surfsense_backend/app/routes/new_llm_config_routes.py new file mode 100644 index 000000000..d54b95bad --- /dev/null +++ b/surfsense_backend/app/routes/new_llm_config_routes.py @@ -0,0 +1,376 @@ +""" +API routes for NewLLMConfig CRUD operations. + +NewLLMConfig combines LLM model settings with prompt configuration: +- LLM provider, model, API key, etc. +- Configurable system instructions +- Citation toggle +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.agents.new_chat.system_prompt import get_default_system_instructions +from app.config import config +from app.db import ( + NewLLMConfig, + Permission, + User, + get_async_session, +) +from app.schemas import ( + DefaultSystemInstructionsResponse, + GlobalNewLLMConfigRead, + NewLLMConfigCreate, + NewLLMConfigRead, + NewLLMConfigUpdate, +) +from app.services.llm_service import validate_llm_config +from app.users import current_active_user +from app.utils.rbac import check_permission + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Global Configs Routes +# ============================================================================= + + +@router.get("/global-new-llm-configs", response_model=list[GlobalNewLLMConfigRead]) +async def get_global_new_llm_configs( + user: User = Depends(current_active_user), +): + """ + Get all available global NewLLMConfig configurations. + These are pre-configured by the system administrator and available to all users. + API keys are not exposed through this endpoint. + + Global configs have negative IDs to distinguish from user-created configs. + """ + try: + global_configs = config.GLOBAL_LLM_CONFIGS + + # Transform to new structure, hiding API keys + safe_configs = [] + for cfg in global_configs: + safe_config = { + "id": cfg.get("id"), + "name": cfg.get("name"), + "description": cfg.get("description"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_base": cfg.get("api_base") or None, + "litellm_params": cfg.get("litellm_params", {}), + # New prompt configuration fields + "system_instructions": cfg.get("system_instructions", ""), + "use_default_system_instructions": cfg.get( + "use_default_system_instructions", True + ), + "citations_enabled": cfg.get("citations_enabled", True), + "is_global": True, + } + safe_configs.append(safe_config) + + return safe_configs + except Exception as e: + logger.exception("Failed to fetch global NewLLMConfigs") + raise HTTPException( + status_code=500, detail=f"Failed to fetch global configurations: {e!s}" + ) from e + + +# ============================================================================= +# CRUD Routes +# ============================================================================= + + +@router.post("/new-llm-configs", response_model=NewLLMConfigRead) +async def create_new_llm_config( + config_data: NewLLMConfigCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Create a new NewLLMConfig for a search space. + Requires LLM_CONFIGS_CREATE permission. + """ + try: + # Verify user has permission + await check_permission( + session, + user, + config_data.search_space_id, + Permission.LLM_CONFIGS_CREATE.value, + "You don't have permission to create LLM configurations in this search space", + ) + + # Validate the LLM configuration by making a test API call + is_valid, error_message = await validate_llm_config( + provider=config_data.provider.value, + model_name=config_data.model_name, + api_key=config_data.api_key, + api_base=config_data.api_base, + custom_provider=config_data.custom_provider, + litellm_params=config_data.litellm_params, + ) + + if not is_valid: + raise HTTPException( + status_code=400, + detail=f"Invalid LLM configuration: {error_message}", + ) + + # Create the config + db_config = NewLLMConfig(**config_data.model_dump()) + session.add(db_config) + await session.commit() + await session.refresh(db_config) + + return db_config + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to create NewLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to create configuration: {e!s}" + ) from e + + +@router.get("/new-llm-configs", response_model=list[NewLLMConfigRead]) +async def list_new_llm_configs( + search_space_id: int, + skip: int = 0, + limit: int = 100, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Get all NewLLMConfigs for a search space. + Requires LLM_CONFIGS_READ permission. + """ + try: + # Verify user has permission + await check_permission( + session, + user, + search_space_id, + Permission.LLM_CONFIGS_READ.value, + "You don't have permission to view LLM configurations in this search space", + ) + + result = await session.execute( + select(NewLLMConfig) + .filter(NewLLMConfig.search_space_id == search_space_id) + .order_by(NewLLMConfig.created_at.desc()) + .offset(skip) + .limit(limit) + ) + + return result.scalars().all() + + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to list NewLLMConfigs") + raise HTTPException( + status_code=500, detail=f"Failed to fetch configurations: {e!s}" + ) from e + + +@router.get( + "/new-llm-configs/default-system-instructions", + response_model=DefaultSystemInstructionsResponse, +) +async def get_default_system_instructions_endpoint( + user: User = Depends(current_active_user), +): + """ + Get the default SURFSENSE_SYSTEM_INSTRUCTIONS template. + Useful for pre-populating the UI when creating a new configuration. + """ + return DefaultSystemInstructionsResponse( + default_system_instructions=get_default_system_instructions() + ) + + +@router.get("/new-llm-configs/{config_id}", response_model=NewLLMConfigRead) +async def get_new_llm_config( + config_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Get a specific NewLLMConfig by ID. + Requires LLM_CONFIGS_READ permission. + """ + try: + result = await session.execute( + select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + ) + config = result.scalars().first() + + if not config: + raise HTTPException(status_code=404, detail="Configuration not found") + + # Verify user has permission + await check_permission( + session, + user, + config.search_space_id, + Permission.LLM_CONFIGS_READ.value, + "You don't have permission to view LLM configurations in this search space", + ) + + return config + + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to get NewLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to fetch configuration: {e!s}" + ) from e + + +@router.put("/new-llm-configs/{config_id}", response_model=NewLLMConfigRead) +async def update_new_llm_config( + config_id: int, + update_data: NewLLMConfigUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Update an existing NewLLMConfig. + Requires LLM_CONFIGS_UPDATE permission. + """ + try: + result = await session.execute( + select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + ) + config = result.scalars().first() + + if not config: + raise HTTPException(status_code=404, detail="Configuration not found") + + # Verify user has permission + await check_permission( + session, + user, + config.search_space_id, + Permission.LLM_CONFIGS_UPDATE.value, + "You don't have permission to update LLM configurations in this search space", + ) + + update_dict = update_data.model_dump(exclude_unset=True) + + # If updating LLM settings, validate them + if any( + key in update_dict + for key in [ + "provider", + "model_name", + "api_key", + "api_base", + "custom_provider", + "litellm_params", + ] + ): + # Build the validation config from existing + updates + validation_config = { + "provider": update_dict.get("provider", config.provider).value + if hasattr(update_dict.get("provider", config.provider), "value") + else update_dict.get("provider", config.provider.value), + "model_name": update_dict.get("model_name", config.model_name), + "api_key": update_dict.get("api_key", config.api_key), + "api_base": update_dict.get("api_base", config.api_base), + "custom_provider": update_dict.get( + "custom_provider", config.custom_provider + ), + "litellm_params": update_dict.get( + "litellm_params", config.litellm_params + ), + } + + is_valid, error_message = await validate_llm_config( + provider=validation_config["provider"], + model_name=validation_config["model_name"], + api_key=validation_config["api_key"], + api_base=validation_config["api_base"], + custom_provider=validation_config["custom_provider"], + litellm_params=validation_config["litellm_params"], + ) + + if not is_valid: + raise HTTPException( + status_code=400, + detail=f"Invalid LLM configuration: {error_message}", + ) + + # Apply updates + for key, value in update_dict.items(): + setattr(config, key, value) + + await session.commit() + await session.refresh(config) + + return config + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to update NewLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to update configuration: {e!s}" + ) from e + + +@router.delete("/new-llm-configs/{config_id}", response_model=dict) +async def delete_new_llm_config( + config_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Delete a NewLLMConfig. + Requires LLM_CONFIGS_DELETE permission. + """ + try: + result = await session.execute( + select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + ) + config = result.scalars().first() + + if not config: + raise HTTPException(status_code=404, detail="Configuration not found") + + # Verify user has permission + await check_permission( + session, + user, + config.search_space_id, + Permission.LLM_CONFIGS_DELETE.value, + "You don't have permission to delete LLM configurations in this search space", + ) + + await session.delete(config) + await session.commit() + + return {"message": "Configuration deleted successfully", "id": config_id} + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to delete NewLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to delete configuration: {e!s}" + ) from e diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index d04cf11ce..bc52a52b1 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -1,13 +1,13 @@ import logging -from pathlib import Path -import yaml from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from app.config import config from app.db import ( + NewLLMConfig, Permission, SearchSpace, SearchSpaceMembership, @@ -17,6 +17,8 @@ from app.db import ( get_default_roles_config, ) from app.schemas import ( + LLMPreferencesRead, + LLMPreferencesUpdate, SearchSpaceCreate, SearchSpaceRead, SearchSpaceUpdate, @@ -184,37 +186,6 @@ async def read_search_spaces( ) from e -@router.get("/searchspaces/prompts/community") -async def get_community_prompts(): - """ - Get community-curated prompts for SearchSpace System Instructions. - This endpoint does not require authentication as it serves public prompts. - """ - try: - # Get the path to the prompts YAML file - prompts_file = ( - Path(__file__).parent.parent - / "prompts" - / "public_search_space_prompts.yaml" - ) - - if not prompts_file.exists(): - raise HTTPException( - status_code=404, detail="Community prompts file not found" - ) - - with open(prompts_file, encoding="utf-8") as f: - data = yaml.safe_load(f) - - return data.get("prompts", []) - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Failed to load community prompts: {e!s}" - ) from e - - @router.get("/searchspaces/{search_space_id}", response_model=SearchSpaceRead) async def read_search_space( search_space_id: int, @@ -329,3 +300,184 @@ async def delete_search_space( raise HTTPException( status_code=500, detail=f"Failed to delete search space: {e!s}" ) from e + + +# ============================================================================= +# LLM Preferences Routes +# ============================================================================= + + +async def _get_llm_config_by_id( + session: AsyncSession, config_id: int | None +) -> dict | None: + """ + Get an LLM config by ID as a dictionary. Returns database config for positive IDs, + global config for negative IDs, or None if ID is None. + """ + if config_id is None: + return None + + if config_id < 0: + # Global config - find from YAML + global_configs = config.GLOBAL_LLM_CONFIGS + for cfg in global_configs: + if cfg.get("id") == config_id: + return { + "id": cfg.get("id"), + "name": cfg.get("name"), + "description": cfg.get("description"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_base": cfg.get("api_base"), + "litellm_params": cfg.get("litellm_params", {}), + "system_instructions": cfg.get("system_instructions", ""), + "use_default_system_instructions": cfg.get( + "use_default_system_instructions", True + ), + "citations_enabled": cfg.get("citations_enabled", True), + "is_global": True, + } + return None + else: + # Database config - convert to dict + result = await session.execute( + select(NewLLMConfig).filter(NewLLMConfig.id == config_id) + ) + db_config = result.scalars().first() + if db_config: + return { + "id": db_config.id, + "name": db_config.name, + "description": db_config.description, + "provider": db_config.provider.value if db_config.provider else None, + "custom_provider": db_config.custom_provider, + "model_name": db_config.model_name, + "api_key": db_config.api_key, + "api_base": db_config.api_base, + "litellm_params": db_config.litellm_params or {}, + "system_instructions": db_config.system_instructions or "", + "use_default_system_instructions": db_config.use_default_system_instructions, + "citations_enabled": db_config.citations_enabled, + "created_at": db_config.created_at.isoformat() + if db_config.created_at + else None, + "search_space_id": db_config.search_space_id, + } + return None + + +@router.get( + "/search-spaces/{search_space_id}/llm-preferences", + response_model=LLMPreferencesRead, +) +async def get_llm_preferences( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Get LLM preferences (role assignments) for a search space. + Requires LLM_CONFIGS_READ permission. + """ + try: + # Check permission + await check_permission( + session, + user, + search_space_id, + Permission.LLM_CONFIGS_READ.value, + "You don't have permission to view LLM preferences", + ) + + result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + + if not search_space: + raise HTTPException(status_code=404, detail="Search space not found") + + # Get full config objects for each role + agent_llm = await _get_llm_config_by_id(session, search_space.agent_llm_id) + document_summary_llm = await _get_llm_config_by_id( + session, search_space.document_summary_llm_id + ) + + return LLMPreferencesRead( + agent_llm_id=search_space.agent_llm_id, + document_summary_llm_id=search_space.document_summary_llm_id, + agent_llm=agent_llm, + document_summary_llm=document_summary_llm, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to get LLM preferences") + raise HTTPException( + status_code=500, detail=f"Failed to get LLM preferences: {e!s}" + ) from e + + +@router.put( + "/search-spaces/{search_space_id}/llm-preferences", + response_model=LLMPreferencesRead, +) +async def update_llm_preferences( + search_space_id: int, + preferences: LLMPreferencesUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Update LLM preferences (role assignments) for a search space. + Requires LLM_CONFIGS_UPDATE permission. + """ + try: + # Check permission + await check_permission( + session, + user, + search_space_id, + Permission.LLM_CONFIGS_UPDATE.value, + "You don't have permission to update LLM preferences", + ) + + result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + + if not search_space: + raise HTTPException(status_code=404, detail="Search space not found") + + # Update preferences + update_data = preferences.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(search_space, key, value) + + await session.commit() + await session.refresh(search_space) + + # Get full config objects for response + agent_llm = await _get_llm_config_by_id(session, search_space.agent_llm_id) + document_summary_llm = await _get_llm_config_by_id( + session, search_space.document_summary_llm_id + ) + + return LLMPreferencesRead( + agent_llm_id=search_space.agent_llm_id, + document_summary_llm_id=search_space.document_summary_llm_id, + agent_llm=agent_llm, + document_summary_llm=document_summary_llm, + ) + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to update LLM preferences") + raise HTTPException( + status_code=500, detail=f"Failed to update LLM preferences: {e!s}" + ) from e diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 9ec639e7b..f5ae65e9d 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -10,7 +10,6 @@ from .documents import ( ExtensionDocumentMetadata, PaginatedResponse, ) -from .llm_config import LLMConfigBase, LLMConfigCreate, LLMConfigRead, LLMConfigUpdate from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate from .new_chat import ( ChatMessage, @@ -26,6 +25,16 @@ from .new_chat import ( ThreadListItem, ThreadListResponse, ) +from .new_llm_config import ( + DefaultSystemInstructionsResponse, + GlobalNewLLMConfigRead, + LLMPreferencesRead, + LLMPreferencesUpdate, + NewLLMConfigCreate, + NewLLMConfigPublic, + NewLLMConfigRead, + NewLLMConfigUpdate, +) from .podcasts import PodcastBase, PodcastCreate, PodcastRead, PodcastUpdate from .rbac_schemas import ( InviteAcceptRequest, @@ -67,6 +76,7 @@ __all__ = [ "ChunkCreate", "ChunkRead", "ChunkUpdate", + "DefaultSystemInstructionsResponse", # Document schemas "DocumentBase", "DocumentRead", @@ -75,6 +85,7 @@ __all__ = [ "DocumentsCreate", "ExtensionDocumentContent", "ExtensionDocumentMetadata", + "GlobalNewLLMConfigRead", # Base schemas "IDModel", # RBAC schemas @@ -84,11 +95,9 @@ __all__ = [ "InviteInfoResponse", "InviteRead", "InviteUpdate", - # LLM Config schemas - "LLMConfigBase", - "LLMConfigCreate", - "LLMConfigRead", - "LLMConfigUpdate", + # LLM Preferences schemas + "LLMPreferencesRead", + "LLMPreferencesUpdate", # Log schemas "LogBase", "LogCreate", @@ -106,6 +115,11 @@ __all__ = [ "NewChatThreadRead", "NewChatThreadUpdate", "NewChatThreadWithMessages", + # NewLLMConfig schemas + "NewLLMConfigCreate", + "NewLLMConfigPublic", + "NewLLMConfigRead", + "NewLLMConfigUpdate", "PaginatedResponse", "PermissionInfo", "PermissionsListResponse", diff --git a/surfsense_backend/app/schemas/llm_config.py b/surfsense_backend/app/schemas/llm_config.py deleted file mode 100644 index 27f3736b5..000000000 --- a/surfsense_backend/app/schemas/llm_config.py +++ /dev/null @@ -1,72 +0,0 @@ -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -from app.db import LiteLLMProvider - -from .base import IDModel, TimestampModel - - -class LLMConfigBase(BaseModel): - name: str = Field( - ..., max_length=100, description="User-friendly name for the LLM configuration" - ) - provider: LiteLLMProvider = Field(..., description="LiteLLM provider type") - custom_provider: str | None = Field( - None, max_length=100, description="Custom provider name when provider is CUSTOM" - ) - model_name: str = Field( - ..., max_length=100, description="Model name without provider prefix" - ) - api_key: str = Field(..., description="API key for the provider") - api_base: str | None = Field( - None, max_length=500, description="Optional API base URL" - ) - litellm_params: dict[str, Any] | None = Field( - default=None, description="Additional LiteLLM parameters" - ) - language: str | None = Field( - default="English", max_length=50, description="Language for the LLM" - ) - - -class LLMConfigCreate(LLMConfigBase): - search_space_id: int = Field( - ..., description="Search space ID to associate the LLM config with" - ) - - -class LLMConfigUpdate(BaseModel): - name: str | None = Field( - None, max_length=100, description="User-friendly name for the LLM configuration" - ) - provider: LiteLLMProvider | None = Field(None, description="LiteLLM provider type") - custom_provider: str | None = Field( - None, max_length=100, description="Custom provider name when provider is CUSTOM" - ) - model_name: str | None = Field( - None, max_length=100, description="Model name without provider prefix" - ) - api_key: str | None = Field(None, description="API key for the provider") - api_base: str | None = Field( - None, max_length=500, description="Optional API base URL" - ) - language: str | None = Field( - None, max_length=50, description="Language for the LLM" - ) - litellm_params: dict[str, Any] | None = Field( - None, description="Additional LiteLLM parameters" - ) - - -class LLMConfigRead(LLMConfigBase, IDModel, TimestampModel): - 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/schemas/new_llm_config.py b/surfsense_backend/app/schemas/new_llm_config.py new file mode 100644 index 000000000..67979f176 --- /dev/null +++ b/surfsense_backend/app/schemas/new_llm_config.py @@ -0,0 +1,191 @@ +""" +Pydantic schemas for the NewLLMConfig API. + +NewLLMConfig combines LLM model settings with prompt configuration: +- LLM provider, model, API key, etc. +- Configurable system instructions +- Citation toggle +""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.db import LiteLLMProvider + + +class NewLLMConfigBase(BaseModel): + """Base schema with common fields for NewLLMConfig.""" + + name: str = Field( + ..., max_length=100, description="User-friendly name for the configuration" + ) + description: str | None = Field( + None, max_length=500, description="Optional description" + ) + + # LLM Model Configuration + provider: LiteLLMProvider = Field(..., description="LiteLLM provider type") + custom_provider: str | None = Field( + None, max_length=100, description="Custom provider name when provider is CUSTOM" + ) + model_name: str = Field( + ..., max_length=100, description="Model name without provider prefix" + ) + api_key: str = Field(..., description="API key for the provider") + api_base: str | None = Field( + None, max_length=500, description="Optional API base URL" + ) + litellm_params: dict[str, Any] | None = Field( + default=None, description="Additional LiteLLM parameters" + ) + + # Prompt Configuration + system_instructions: str = Field( + default="", + description="Custom system instructions. Empty string uses default SURFSENSE_SYSTEM_INSTRUCTIONS.", + ) + use_default_system_instructions: bool = Field( + default=True, + description="Whether to use default instructions when system_instructions is empty", + ) + citations_enabled: bool = Field( + default=True, + description="Whether to include citation instructions in the system prompt", + ) + + +class NewLLMConfigCreate(NewLLMConfigBase): + """Schema for creating a new NewLLMConfig.""" + + search_space_id: int = Field( + ..., description="Search space ID to associate the config with" + ) + + +class NewLLMConfigUpdate(BaseModel): + """Schema for updating an existing NewLLMConfig. All fields are optional.""" + + name: str | None = Field(None, max_length=100) + description: str | None = Field(None, max_length=500) + + # LLM Model Configuration + provider: LiteLLMProvider | None = None + custom_provider: str | None = Field(None, max_length=100) + model_name: str | None = Field(None, max_length=100) + api_key: str | None = None + api_base: str | None = Field(None, max_length=500) + litellm_params: dict[str, Any] | None = None + + # Prompt Configuration + system_instructions: str | None = None + use_default_system_instructions: bool | None = None + citations_enabled: bool | None = None + + +class NewLLMConfigRead(NewLLMConfigBase): + """Schema for reading a NewLLMConfig (includes id and timestamps).""" + + id: int + created_at: datetime + search_space_id: int + + model_config = ConfigDict(from_attributes=True) + + +class NewLLMConfigPublic(BaseModel): + """ + Public schema for NewLLMConfig that hides the API key. + Used when returning configs in list views or to users who shouldn't see keys. + """ + + id: int + name: str + description: str | None = None + + # LLM Model Configuration (no api_key) + provider: LiteLLMProvider + custom_provider: str | None = None + model_name: str + api_base: str | None = None + litellm_params: dict[str, Any] | None = None + + # Prompt Configuration + system_instructions: str + use_default_system_instructions: bool + citations_enabled: bool + + created_at: datetime + search_space_id: int + + model_config = ConfigDict(from_attributes=True) + + +class DefaultSystemInstructionsResponse(BaseModel): + """Response schema for getting default system instructions.""" + + default_system_instructions: str = Field( + ..., description="The default SURFSENSE_SYSTEM_INSTRUCTIONS template" + ) + + +class GlobalNewLLMConfigRead(BaseModel): + """ + Schema for reading global LLM configs from YAML. + Global configs have negative IDs and no search_space_id. + API key is hidden for security. + """ + + id: int = Field(..., description="Negative ID for global configs") + name: str + description: str | None = None + + # LLM Model Configuration (no api_key) + provider: str # String because YAML doesn't enforce enum + custom_provider: str | None = None + model_name: str + api_base: str | None = None + litellm_params: dict[str, Any] | None = None + + # Prompt Configuration + system_instructions: str = "" + use_default_system_instructions: bool = True + citations_enabled: bool = True + + is_global: bool = True # Always true for global configs + + +# ============================================================================= +# LLM Preferences Schemas (for role assignments) +# ============================================================================= + + +class LLMPreferencesRead(BaseModel): + """Schema for reading LLM preferences (role assignments) for a search space.""" + + agent_llm_id: int | None = Field( + None, description="ID of the LLM config to use for agent/chat tasks" + ) + document_summary_llm_id: int | None = Field( + None, description="ID of the LLM config to use for document summarization" + ) + agent_llm: dict[str, Any] | None = Field( + None, description="Full config for agent LLM" + ) + document_summary_llm: dict[str, Any] | None = Field( + None, description="Full config for document summary LLM" + ) + + model_config = ConfigDict(from_attributes=True) + + +class LLMPreferencesUpdate(BaseModel): + """Schema for updating LLM preferences.""" + + agent_llm_id: int | None = Field( + None, description="ID of the LLM config to use for agent/chat tasks" + ) + document_summary_llm_id: int | None = Field( + None, description="ID of the LLM config to use for document summarization" + ) diff --git a/surfsense_backend/app/services/llm_service.py b/surfsense_backend/app/services/llm_service.py index c3270b59e..68dd167b5 100644 --- a/surfsense_backend/app/services/llm_service.py +++ b/surfsense_backend/app/services/llm_service.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.config import config -from app.db import LLMConfig, SearchSpace +from app.db import NewLLMConfig, SearchSpace # Configure litellm to automatically drop unsupported parameters litellm.drop_params = True @@ -16,9 +16,8 @@ logger = logging.getLogger(__name__) class LLMRole: - LONG_CONTEXT = "long_context" - FAST = "fast" - STRATEGIC = "strategic" + AGENT = "agent" # For agent/chat operations + DOCUMENT_SUMMARY = "document_summary" # For document summarization def get_global_llm_config(llm_config_id: int) -> dict | None: @@ -155,7 +154,7 @@ async def get_search_space_llm_instance( Args: session: Database session search_space_id: Search Space ID - role: LLM role ('long_context', 'fast', or 'strategic') + role: LLM role ('agent' or 'document_summary') Returns: ChatLiteLLM instance or None if not found @@ -173,12 +172,10 @@ async def get_search_space_llm_instance( # Get the appropriate LLM config ID based on role llm_config_id = None - if role == LLMRole.LONG_CONTEXT: - llm_config_id = search_space.long_context_llm_id - elif role == LLMRole.FAST: - llm_config_id = search_space.fast_llm_id - elif role == LLMRole.STRATEGIC: - llm_config_id = search_space.strategic_llm_id + if role == LLMRole.AGENT: + llm_config_id = search_space.agent_llm_id + elif role == LLMRole.DOCUMENT_SUMMARY: + llm_config_id = search_space.document_summary_llm_id else: logger.error(f"Invalid LLM role: {role}") return None @@ -250,11 +247,11 @@ async def get_search_space_llm_instance( return ChatLiteLLM(**litellm_kwargs) - # Get the LLM configuration from database (user-specific config) + # Get the LLM configuration from database (NewLLMConfig) result = await session.execute( - select(LLMConfig).where( - LLMConfig.id == llm_config_id, - LLMConfig.search_space_id == search_space_id, + select(NewLLMConfig).where( + NewLLMConfig.id == llm_config_id, + NewLLMConfig.search_space_id == search_space_id, ) ) llm_config = result.scalars().first() @@ -265,11 +262,11 @@ async def get_search_space_llm_instance( ) return None - # Build the model string for litellm / 构建 LiteLLM 的模型字符串 + # Build the model string for litellm if llm_config.custom_provider: model_string = f"{llm_config.custom_provider}/{llm_config.model_name}" else: - # Map provider enum to litellm format / 将提供商枚举映射为 LiteLLM 格式 + # Map provider enum to litellm format provider_map = { "OPENAI": "openai", "ANTHROPIC": "anthropic", @@ -283,7 +280,7 @@ async def get_search_space_llm_instance( "COMETAPI": "cometapi", "XAI": "xai", "BEDROCK": "bedrock", - "AWS_BEDROCK": "bedrock", # Legacy support (backward compatibility) + "AWS_BEDROCK": "bedrock", "VERTEX_AI": "vertex_ai", "TOGETHER_AI": "together_ai", "FIREWORKS_AI": "fireworks_ai", @@ -296,7 +293,6 @@ async def get_search_space_llm_instance( "AI21": "ai21", "CLOUDFLARE": "cloudflare", "DATABRICKS": "databricks", - # Chinese LLM providers "DEEPSEEK": "openai", "ALIBABA_QWEN": "openai", "MOONSHOT": "openai", @@ -330,28 +326,19 @@ async def get_search_space_llm_instance( return None -async def get_long_context_llm( +async def get_agent_llm( session: AsyncSession, search_space_id: int ) -> ChatLiteLLM | None: - """Get the search space's long context LLM instance.""" + """Get the search space's agent LLM instance for chat operations.""" + return await get_search_space_llm_instance(session, search_space_id, LLMRole.AGENT) + + +async def get_document_summary_llm( + session: AsyncSession, search_space_id: int +) -> ChatLiteLLM | None: + """Get the search space's document summary LLM instance.""" return await get_search_space_llm_instance( - session, search_space_id, LLMRole.LONG_CONTEXT - ) - - -async def get_fast_llm( - session: AsyncSession, search_space_id: int -) -> ChatLiteLLM | None: - """Get the search space's fast LLM instance.""" - return await get_search_space_llm_instance(session, search_space_id, LLMRole.FAST) - - -async def get_strategic_llm( - session: AsyncSession, search_space_id: int -) -> ChatLiteLLM | None: - """Get the search space's strategic LLM instance.""" - return await get_search_space_llm_instance( - session, search_space_id, LLMRole.STRATEGIC + session, search_space_id, LLMRole.DOCUMENT_SUMMARY ) @@ -366,22 +353,54 @@ async def get_user_llm_instance( return await get_search_space_llm_instance(session, search_space_id, role) +# Legacy aliases for backward compatibility +async def get_long_context_llm( + session: AsyncSession, search_space_id: int +) -> ChatLiteLLM | None: + """Deprecated: Use get_document_summary_llm instead.""" + return await get_document_summary_llm(session, search_space_id) + + +async def get_fast_llm( + session: AsyncSession, search_space_id: int +) -> ChatLiteLLM | None: + """Deprecated: Use get_agent_llm instead.""" + return await get_agent_llm(session, search_space_id) + + +async def get_strategic_llm( + session: AsyncSession, search_space_id: int +) -> ChatLiteLLM | None: + """Deprecated: Use get_document_summary_llm instead.""" + return await get_document_summary_llm(session, search_space_id) + + +# User-based legacy aliases (LLM preferences are now per-search-space, not per-user) async def get_user_long_context_llm( session: AsyncSession, user_id: str, search_space_id: int ) -> ChatLiteLLM | None: - """Deprecated: Use get_long_context_llm instead.""" - return await get_long_context_llm(session, search_space_id) + """ + Deprecated: Use get_document_summary_llm instead. + The user_id parameter is ignored as LLM preferences are now per-search-space. + """ + return await get_document_summary_llm(session, search_space_id) async def get_user_fast_llm( session: AsyncSession, user_id: str, search_space_id: int ) -> ChatLiteLLM | None: - """Deprecated: Use get_fast_llm instead.""" - return await get_fast_llm(session, search_space_id) + """ + Deprecated: Use get_agent_llm instead. + The user_id parameter is ignored as LLM preferences are now per-search-space. + """ + return await get_agent_llm(session, search_space_id) async def get_user_strategic_llm( session: AsyncSession, user_id: str, search_space_id: int ) -> ChatLiteLLM | None: - """Deprecated: Use get_strategic_llm instead.""" - return await get_strategic_llm(session, search_space_id) + """ + Deprecated: Use get_document_summary_llm instead. + The user_id parameter is ignored as LLM preferences are now per-search-space. + """ + return await get_document_summary_llm(session, search_space_id) diff --git a/surfsense_backend/app/services/query_service.py b/surfsense_backend/app/services/query_service.py index 84485c37d..863ff58a4 100644 --- a/surfsense_backend/app/services/query_service.py +++ b/surfsense_backend/app/services/query_service.py @@ -4,7 +4,7 @@ from typing import Any from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from sqlalchemy.ext.asyncio import AsyncSession -from app.services.llm_service import get_strategic_llm +from app.services.llm_service import get_document_summary_llm class QueryService: @@ -20,7 +20,7 @@ class QueryService: chat_history_str: str | None = None, ) -> str: """ - Reformulate the user query using the search space's strategic LLM to make it more + Reformulate the user query using the search space's document summary LLM to make it more effective for information retrieval and research purposes. Args: @@ -36,11 +36,11 @@ class QueryService: return user_query try: - # Get the search space's strategic LLM instance - llm = await get_strategic_llm(session, search_space_id) + # Get the search space's document summary LLM instance + llm = await get_document_summary_llm(session, search_space_id) if not llm: print( - f"Warning: No strategic LLM configured for search space {search_space_id}. Using original query." + f"Warning: No document summary LLM configured for search space {search_space_id}. Using original query." ) return user_query diff --git a/surfsense_backend/app/services/streaming_service.py b/surfsense_backend/app/services/streaming_service.py deleted file mode 100644 index 98c0d3ac5..000000000 --- a/surfsense_backend/app/services/streaming_service.py +++ /dev/null @@ -1,191 +0,0 @@ -import json -from typing import Any - - -class StreamingService: - def __init__(self): - self.terminal_idx = 1 - self.message_annotations = [ - {"type": "TERMINAL_INFO", "content": []}, - {"type": "SOURCES", "content": []}, - {"type": "ANSWER", "content": []}, - {"type": "FURTHER_QUESTIONS", "content": []}, - ] - - # DEPRECATED: This sends the full annotation array every time (inefficient) - def _format_annotations(self) -> str: - """ - Format the annotations as a string - - DEPRECATED: This method sends the full annotation state every time. - Use the delta formatters instead for optimal streaming. - - Returns: - str: The formatted annotations string - """ - return f"8:{json.dumps(self.message_annotations)}\n" - - def format_terminal_info_delta(self, text: str, message_type: str = "info") -> str: - """ - Format a single terminal info message as a delta annotation - - Args: - text: The terminal message text - message_type: The message type (info, error, success, etc.) - - Returns: - str: The formatted annotation delta string - """ - message = {"id": self.terminal_idx, "text": text, "type": message_type} - self.terminal_idx += 1 - - # Update internal state for reference - self.message_annotations[0]["content"].append(message) - - # Return only the delta annotation - annotation = {"type": "TERMINAL_INFO", "data": message} - return f"8:[{json.dumps(annotation)}]\n" - - def format_sources_delta(self, sources: list[dict[str, Any]]) -> str: - """ - Format sources as a delta annotation - - Args: - sources: List of source objects - - Returns: - str: The formatted annotation delta string - """ - # Update internal state - self.message_annotations[1]["content"] = sources - - # Return only the delta annotation - nodes = [] - - for group in sources: - for source in group.get("sources", []): - node = { - "id": str(source.get("id", "")), - "text": source.get("description", "").strip(), - "url": source.get("url", ""), - "metadata": { - "title": source.get("title", ""), - "source_type": group.get("type", ""), - "group_name": group.get("name", ""), - }, - } - nodes.append(node) - - annotation = {"type": "sources", "data": {"nodes": nodes}} - return f"8:[{json.dumps(annotation)}]\n" - - def format_answer_delta(self, answer_chunk: str) -> str: - """ - Format a single answer chunk as a delta annotation - - Args: - answer_chunk: The new answer chunk to add - - Returns: - str: The formatted annotation delta string - """ - # Update internal state by appending the chunk - if isinstance(self.message_annotations[2]["content"], list): - self.message_annotations[2]["content"].append(answer_chunk) - else: - self.message_annotations[2]["content"] = [answer_chunk] - - # Return only the delta annotation with the new chunk - annotation = {"type": "ANSWER", "content": [answer_chunk]} - return f"8:[{json.dumps(annotation)}]\n" - - def format_answer_annotation(self, answer_lines: list[str]) -> str: - """ - Format the complete answer as a replacement annotation - - Args: - answer_lines: Complete list of answer lines - - Returns: - str: The formatted annotation string - """ - # Update internal state - self.message_annotations[2]["content"] = answer_lines - - # Return the full answer annotation - annotation = {"type": "ANSWER", "content": answer_lines} - return f"8:[{json.dumps(annotation)}]\n" - - def format_further_questions_delta( - self, further_questions: list[dict[str, Any]] - ) -> str: - """ - Format further questions as a delta annotation - - Args: - further_questions: List of further question objects - - Returns: - str: The formatted annotation delta string - """ - # Update internal state - self.message_annotations[3]["content"] = further_questions - - # Return only the delta annotation - annotation = { - "type": "FURTHER_QUESTIONS", - "data": [ - question.get("question", "") - for question in further_questions - if question.get("question", "") != "" - ], - } - return f"8:[{json.dumps(annotation)}]\n" - - def format_text_chunk(self, text: str) -> str: - """ - Format a text chunk using the text stream part - - Args: - text: The text chunk to stream - - Returns: - str: The formatted text part string - """ - return f"0:{json.dumps(text)}\n" - - def format_error(self, error_message: str) -> str: - """ - Format an error using the error stream part - - Args: - error_message: The error message - - Returns: - str: The formatted error part string - """ - return f"3:{json.dumps(error_message)}\n" - - def format_completion( - self, prompt_tokens: int = 156, completion_tokens: int = 204 - ) -> str: - """ - Format a completion message - - Args: - prompt_tokens: Number of prompt tokens - completion_tokens: Number of completion tokens - - Returns: - str: The formatted completion string - """ - total_tokens = prompt_tokens + completion_tokens - completion_data = { - "finishReason": "stop", - "usage": { - "promptTokens": prompt_tokens, - "completionTokens": completion_tokens, - "totalTokens": total_tokens, - }, - } - return f"d:{json.dumps(completion_data)}\n" diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 0f4ff26b8..daf7a20c7 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -3,10 +3,15 @@ Streaming task for the new SurfSense deep agent chat. This module streams responses from the deep agent using the Vercel AI SDK Data Stream Protocol (SSE format). + +Supports loading LLM configurations from: +- YAML files (negative IDs for global configs) +- NewLLMConfig database table (positive IDs for user-created configs with prompt settings) """ import json from collections.abc import AsyncGenerator + from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -14,11 +19,14 @@ from sqlalchemy.future import select from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.llm_config import ( + AgentConfig, + create_chat_litellm_from_agent_config, create_chat_litellm_from_config, + load_agent_config, load_llm_config_from_yaml, ) from app.db import Document -from app.schemas.new_chat import ChatAttachment, ChatMessage +from app.schemas.new_chat import ChatAttachment from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService @@ -67,7 +75,6 @@ async def stream_new_chat( chat_id: int, session: AsyncSession, llm_config_id: int = -1, - messages: list[ChatMessage] | None = None, attachments: list[ChatAttachment] | None = None, mentioned_document_ids: list[int] | None = None, ) -> AsyncGenerator[str, None]: @@ -97,17 +104,40 @@ async def stream_new_chat( current_text_id: str | None = None try: - # Load LLM config - llm_config = load_llm_config_from_yaml(llm_config_id=llm_config_id) - if not llm_config: - yield streaming_service.format_error( - f"Failed to load LLM config with id {llm_config_id}" - ) - yield streaming_service.format_done() - return + # Load LLM config - supports both YAML (negative IDs) and database (positive IDs) + agent_config: AgentConfig | None = None + + if llm_config_id >= 0: + # Positive ID: Load from NewLLMConfig database table + agent_config = await load_agent_config( + session=session, + config_id=llm_config_id, + search_space_id=search_space_id, + ) + if not agent_config: + yield streaming_service.format_error( + f"Failed to load NewLLMConfig with id {llm_config_id}" + ) + yield streaming_service.format_done() + return + + # Create ChatLiteLLM from AgentConfig + llm = create_chat_litellm_from_agent_config(agent_config) + else: + # Negative ID: Load from YAML (global configs) + llm_config = load_llm_config_from_yaml(llm_config_id=llm_config_id) + if not llm_config: + yield streaming_service.format_error( + f"Failed to load LLM config with id {llm_config_id}" + ) + yield streaming_service.format_done() + return + + # Create ChatLiteLLM from YAML config dict + llm = create_chat_litellm_from_config(llm_config) + # Create AgentConfig from YAML for consistency (uses defaults for prompt settings) + agent_config = AgentConfig.from_yaml_config(llm_config) - # Create ChatLiteLLM instance - llm = create_chat_litellm_from_config(llm_config) if not llm: yield streaming_service.format_error("Failed to create LLM instance") yield streaming_service.format_done() @@ -119,13 +149,14 @@ async def stream_new_chat( # Get the PostgreSQL checkpointer for persistent conversation memory checkpointer = await get_checkpointer() - # Create the deep agent with checkpointer with podcast capability + # Create the deep agent with checkpointer and configurable prompts agent = create_surfsense_deep_agent( llm=llm, search_space_id=search_space_id, db_session=session, connector_service=connector_service, checkpointer=checkpointer, + agent_config=agent_config, # Pass prompt configuration ) # Build input with message history from frontend @@ -223,7 +254,9 @@ async def stream_new_chat( analyze_step_id = next_thinking_step_id() last_active_step_id = analyze_step_id last_active_step_title = "Understanding your request" - last_active_step_items = [f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}"] + last_active_step_items = [ + f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}" + ] yield streaming_service.format_thinking_step( step_id=analyze_step_id, title="Understanding your request", @@ -298,7 +331,9 @@ async def stream_new_chat( else str(tool_input) ) last_active_step_title = "Searching knowledge base" - last_active_step_items = [f"Query: {query[:100]}{'...' if len(query) > 100 else ''}"] + last_active_step_items = [ + f"Query: {query[:100]}{'...' if len(query) > 100 else ''}" + ] yield streaming_service.format_thinking_step( step_id=tool_step_id, title="Searching knowledge base", @@ -312,7 +347,9 @@ async def stream_new_chat( else str(tool_input) ) last_active_step_title = "Fetching link preview" - last_active_step_items = [f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"] + last_active_step_items = [ + f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" + ] yield streaming_service.format_thinking_step( step_id=tool_step_id, title="Fetching link preview", @@ -347,7 +384,9 @@ async def stream_new_chat( else str(tool_input) ) last_active_step_title = "Scraping webpage" - last_active_step_items = [f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"] + last_active_step_items = [ + f"URL: {url[:80]}{'...' if len(url) > 80 else ''}" + ] yield streaming_service.format_thinking_step( step_id=tool_step_id, title="Scraping webpage", @@ -484,7 +523,9 @@ async def stream_new_chat( tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown" # Get the original tool step ID to update it (not create a new one) - original_step_id = tool_step_ids.get(run_id, f"thinking-unknown-{run_id[:8]}") + original_step_id = tool_step_ids.get( + run_id, f"thinking-unknown-{run_id[:8]}" + ) # Mark the tool thinking step as completed using the SAME step ID # Also add to completed set so we don't try to complete it again @@ -495,7 +536,9 @@ async def stream_new_chat( if isinstance(tool_output, dict): result_len = tool_output.get("result_length", 0) if result_len > 0: - result_info = f"Found relevant information ({result_len} chars)" + result_info = ( + f"Found relevant information ({result_len} chars)" + ) # Include original query in completed items completed_items = [*last_active_step_items, result_info] yield streaming_service.format_thinking_step( @@ -584,7 +627,7 @@ async def stream_new_chat( if isinstance(tool_output, dict) else "Podcast" ) - + if podcast_status == "processing": completed_items = [ f"Title: {podcast_title}", @@ -609,7 +652,7 @@ async def stream_new_chat( ] else: completed_items = last_active_step_items - + yield streaming_service.format_thinking_step( step_id=original_step_id, title="Generating podcast", @@ -695,7 +738,9 @@ async def stream_new_chat( ) # Send terminal message if isinstance(tool_output, dict): - title = tool_output.get("title") or tool_output.get("alt", "Image") + title = tool_output.get("title") or tool_output.get( + "alt", "Image" + ) yield streaming_service.format_terminal_info( f"Image displayed: {title[:40]}{'...' if len(title) > 40 else ''}", "success", diff --git a/surfsense_backend/app/tasks/podcast_tasks.py b/surfsense_backend/app/tasks/podcast_tasks.py deleted file mode 100644 index b3b01eb93..000000000 --- a/surfsense_backend/app/tasks/podcast_tasks.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Legacy podcast task for old chat system. - -NOTE: The old Chat model has been removed. This module is kept for backwards -compatibility but the generate_chat_podcast function will raise an error -if called. Use generate_content_podcast_task in celery_tasks/podcast_tasks.py -for new-chat podcast generation instead. -""" - -from app.db import Podcast # noqa: F401 - imported for backwards compatibility - - -async def generate_chat_podcast(*args, **kwargs): - """ - Legacy function for generating podcasts from old chat system. - - This function is deprecated as the old Chat model has been removed. - Use generate_content_podcast_task for new-chat podcast generation. - """ - raise NotImplementedError( - "generate_chat_podcast is deprecated. The old Chat model has been removed. " - "Use generate_content_podcast_task for podcast generation from new-chat." - ) diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index 8f85774ac..e0478fce3 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -3,10 +3,8 @@ import { CTAHomepage } from "@/components/homepage/cta"; import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; import { FeaturesCards } from "@/components/homepage/features-card"; -import { Footer } from "@/components/homepage/footer"; import { HeroSection } from "@/components/homepage/hero-section"; import ExternalIntegrations from "@/components/homepage/integrations"; -import { Navbar } from "@/components/homepage/navbar"; export default function HomePage() { return ( 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 0e0f32d2b..bfe8599f6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -5,9 +5,14 @@ import { Loader2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; +import { + globalNewLLMConfigsAtom, + llmPreferencesAtom, +} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; @@ -33,18 +38,24 @@ export function DashboardClientLayout({ const { search_space_id } = useParams(); const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom); - const { data: preferences = {}, isFetching: loading, error } = useAtomValue(llmPreferencesAtom); + const { + data: preferences = {}, + isFetching: loading, + error, + refetch: refetchPreferences, + } = useAtomValue(llmPreferencesAtom); + const { data: globalConfigs = [], isFetching: globalConfigsLoading } = + useAtomValue(globalNewLLMConfigsAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); const isOnboardingComplete = useCallback(() => { - return !!( - preferences.long_context_llm_id && - preferences.fast_llm_id && - preferences.strategic_llm_id - ); + return !!(preferences.agent_llm_id && preferences.document_summary_llm_id); }, [preferences]); const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); + const [isAutoConfiguring, setIsAutoConfiguring] = useState(false); + const hasAttemptedAutoConfig = useRef(false); // Skip onboarding check if we're already on the onboarding page const isOnboardingPage = pathname?.includes("/onboard"); @@ -89,27 +100,82 @@ export function DashboardClientLayout({ return; } - // Wait for both preferences and access data to load - if (!loading && !accessLoading && !hasCheckedOnboarding) { + // Wait for all data to load + if ( + !loading && + !accessLoading && + !globalConfigsLoading && + !hasCheckedOnboarding && + !isAutoConfiguring + ) { const onboardingComplete = isOnboardingComplete(); - // Only redirect to onboarding if user is the owner and onboarding is not complete - // Invited members (non-owners) should skip onboarding and use existing config - if (!onboardingComplete && isOwner) { - router.push(`/dashboard/${searchSpaceId}/onboard`); + // If onboarding is complete, nothing to do + if (onboardingComplete) { + setHasCheckedOnboarding(true); + return; } + // Only handle onboarding for owners + if (!isOwner) { + setHasCheckedOnboarding(true); + return; + } + + // If global configs available, auto-configure without going to onboard page + if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) { + hasAttemptedAutoConfig.current = true; + setIsAutoConfiguring(true); + + const autoConfigureWithGlobal = async () => { + try { + const firstGlobalConfig = globalConfigs[0]; + await updatePreferences({ + search_space_id: Number(searchSpaceId), + data: { + agent_llm_id: firstGlobalConfig.id, + document_summary_llm_id: firstGlobalConfig.id, + }, + }); + + await refetchPreferences(); + + toast.success("AI configured automatically!", { + description: `Using ${firstGlobalConfig.name}. Customize in Settings.`, + }); + + setHasCheckedOnboarding(true); + } catch (error) { + console.error("Auto-configuration failed:", error); + // Fall back to onboard page + router.push(`/dashboard/${searchSpaceId}/onboard`); + } finally { + setIsAutoConfiguring(false); + } + }; + + autoConfigureWithGlobal(); + return; + } + + // No global configs - redirect to onboard page + router.push(`/dashboard/${searchSpaceId}/onboard`); setHasCheckedOnboarding(true); } }, [ loading, accessLoading, + globalConfigsLoading, isOnboardingComplete, isOnboardingPage, isOwner, + isAutoConfiguring, + globalConfigs, router, searchSpaceId, hasCheckedOnboarding, + updatePreferences, + refetchPreferences, ]); // Synchronize active search space and chat IDs with URL @@ -124,14 +190,25 @@ export function DashboardClientLayout({ setActiveSearchSpaceIdState(activeSeacrhSpaceId); }, [search_space_id, setActiveSearchSpaceIdState]); - // Show loading screen while checking onboarding status (only on first load) - if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) { + // Show loading screen while checking onboarding status or auto-configuring + if ( + (!hasCheckedOnboarding && + (loading || accessLoading || globalConfigsLoading) && + !isOnboardingPage) || + isAutoConfiguring + ) { return (
- {t("loading_config")} - {t("checking_llm_prefs")} + + {isAutoConfiguring ? "Setting up AI..." : t("loading_config")} + + + {isAutoConfiguring + ? "Auto-configuring with available settings" + : t("checking_llm_prefs")} + diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index fa11e9ecf..6da678d7c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -12,11 +12,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, messageDocumentsMapAtom, type MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom"; import { Thread } from "@/components/assistant-ui/thread"; +import { ChatHeader } from "@/components/new-chat/chat-header"; +import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; +import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; -import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; -import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { getBearerToken } from "@/lib/auth-utils"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { @@ -36,15 +37,15 @@ import { */ function extractThinkingSteps(content: unknown): ThinkingStep[] { if (!Array.isArray(content)) return []; - + const thinkingPart = content.find( - (part: unknown) => - typeof part === "object" && - part !== null && - "type" in part && + (part: unknown) => + typeof part === "object" && + part !== null && + "type" in part && (part as { type: string }).type === "thinking-steps" ) as { type: "thinking-steps"; steps: ThinkingStep[] } | undefined; - + return thinkingPart?.steps || []; } @@ -67,7 +68,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { /** * Convert backend message to assistant-ui ThreadMessageLike format - * Filters out 'thinking-steps' part as it's handled separately + * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps */ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { let content: ThreadMessageLike["content"]; @@ -77,16 +78,18 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { } else if (Array.isArray(msg.content)) { // Filter out custom metadata parts - they're handled separately const filteredContent = msg.content.filter( - (part: unknown) => { - if (typeof part !== "object" || part === null || !("type" in part)) return true; - const partType = (part as { type: string }).type; - // Filter out thinking-steps and mentioned-documents - return partType !== "thinking-steps" && partType !== "mentioned-documents"; - } + (part: unknown) => + !( + typeof part === "object" && + part !== null && + "type" in part && + (part as { type: string }).type === "thinking-steps" + ) ); - content = filteredContent.length > 0 - ? (filteredContent as ThreadMessageLike["content"]) - : [{ type: "text", text: "" }]; + content = + filteredContent.length > 0 + ? (filteredContent as ThreadMessageLike["content"]) + : [{ type: "text", text: "" }]; } else { content = [{ type: "text", text: String(msg.content) }]; } @@ -102,7 +105,12 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { /** * Tools that should render custom UI in the chat. */ -const TOOLS_WITH_UI = new Set(["generate_podcast", "link_preview", "display_image", "scrape_webpage"]); +const TOOLS_WITH_UI = new Set([ + "generate_podcast", + "link_preview", + "display_image", + "scrape_webpage", +]); /** * Type for thinking step data from the backend @@ -121,10 +129,11 @@ export default function NewChatPage() { const [threadId, setThreadId] = useState(null); const [messages, setMessages] = useState([]); const [isRunning, setIsRunning] = useState(false); - // Store thinking steps per message ID - const [messageThinkingSteps, setMessageThinkingSteps] = useState< - Map - >(new Map()); + // Store thinking steps per message ID - kept separate from content to avoid + // "unsupported part type" errors from assistant-ui + const [messageThinkingSteps, setMessageThinkingSteps] = useState>( + new Map() + ); const abortControllerRef = useRef(null); // Get mentioned document IDs from the composer @@ -168,7 +177,7 @@ export default function NewChatPage() { if (response.messages && response.messages.length > 0) { const loadedMessages = response.messages.map(convertToThreadMessage); setMessages(loadedMessages); - + // Extract and restore thinking steps from persisted messages const restoredThinkingSteps = new Map(); // Extract and restore mentioned documents from persisted messages @@ -309,10 +318,10 @@ export default function NewChatPage() { // Prepare assistant message const assistantMsgId = `msg-assistant-${Date.now()}`; const currentThinkingSteps = new Map(); - + // Ordered content parts to preserve inline tool call positions // Each part is either a text segment or a tool call - type ContentPart = + type ContentPart = | { type: "text"; text: string } | { type: "tool-call"; @@ -322,13 +331,13 @@ export default function NewChatPage() { result?: unknown; }; const contentParts: ContentPart[] = []; - + // Track the current text segment index (for appending text deltas) let currentTextPartIndex = -1; - + // Map to track tool call indices for updating results const toolCallIndices = new Map(); - + // Helper to get or create the current text part for appending text const appendText = (delta: string) => { if (currentTextPartIndex >= 0 && contentParts[currentTextPartIndex]?.type === "text") { @@ -340,7 +349,7 @@ export default function NewChatPage() { currentTextPartIndex = contentParts.length - 1; } }; - + // Helper to add a tool call (this "breaks" the current text segment) const addToolCall = (toolCallId: string, toolName: string, args: Record) => { if (TOOLS_WITH_UI.has(toolName)) { @@ -355,9 +364,12 @@ export default function NewChatPage() { currentTextPartIndex = -1; } }; - + // Helper to update a tool call's args or result - const updateToolCall = (toolCallId: string, update: { args?: Record; result?: unknown }) => { + const updateToolCall = ( + toolCallId: string, + update: { args?: Record; result?: unknown } + ) => { const index = toolCallIndices.get(toolCallId); if (index !== undefined && contentParts[index]?.type === "tool-call") { const tc = contentParts[index] as ContentPart & { type: "tool-call" }; @@ -366,7 +378,7 @@ export default function NewChatPage() { } }; - // Helper to build content for UI (without thinking-steps) + // Helper to build content for UI (without thinking-steps to avoid assistant-ui errors) const buildContentForUI = (): ThreadMessageLike["content"] => { // Filter to only include text parts with content and tool-calls with UI const filtered = contentParts.filter((part) => { @@ -379,10 +391,10 @@ export default function NewChatPage() { : [{ type: "text", text: "" }]; }; - // Helper to build content for persistence (includes thinking-steps) + // Helper to build content for persistence (includes thinking-steps for restoration) const buildContentForPersistence = (): unknown[] => { const parts: unknown[] = []; - + // Include thinking steps for persistence if (currentThinkingSteps.size > 0) { parts.push({ @@ -390,7 +402,7 @@ export default function NewChatPage() { steps: Array.from(currentThinkingSteps.values()), }); } - + // Add content parts (filtered) for (const part of contentParts) { if (part.type === "text" && part.text.length > 0) { @@ -399,7 +411,7 @@ export default function NewChatPage() { parts.push(part); } } - + return parts.length > 0 ? parts : [{ type: "text", text: "" }]; }; @@ -554,13 +566,12 @@ export default function NewChatPage() { const stepData = parsed.data as ThinkingStepData; if (stepData?.id) { currentThinkingSteps.set(stepData.id, stepData); - // Update message-specific thinking steps + // Update thinking steps state for rendering + // The ThinkingStepsScrollHandler in Thread component + // will handle auto-scrolling when this state changes setMessageThinkingSteps((prev) => { const newMap = new Map(prev); - newMap.set( - assistantMsgId, - Array.from(currentThinkingSteps.values()) - ); + newMap.set(assistantMsgId, Array.from(currentThinkingSteps.values())); return newMap; }); } @@ -686,8 +697,11 @@ export default function NewChatPage() { -
- +
+ +
+ +
); 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 1df54d8b2..25f189203 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx @@ -1,312 +1,268 @@ "use client"; import { useAtomValue } from "jotai"; -import { FileText, MessageSquare, UserPlus, Users } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { updateLLMPreferencesMutationAtom } from "@/atoms/llm-config/llm-config-mutation.atoms"; import { - globalLLMConfigsAtom, - llmConfigsAtom, + createNewLLMConfigMutationAtom, + updateLLMPreferencesMutationAtom, +} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; +import { + globalNewLLMConfigsAtom, llmPreferencesAtom, -} from "@/atoms/llm-config/llm-config-query.atoms"; -import { OnboardActionCard } from "@/components/onboard/onboard-action-card"; -import { OnboardAdvancedSettings } from "@/components/onboard/onboard-advanced-settings"; -import { OnboardHeader } from "@/components/onboard/onboard-header"; -import { OnboardLLMSetup } from "@/components/onboard/onboard-llm-setup"; -import { OnboardLoading } from "@/components/onboard/onboard-loading"; -import { OnboardStats } from "@/components/onboard/onboard-stats"; +} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; +import { Logo } from "@/components/Logo"; +import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; -const OnboardPage = () => { - const t = useTranslations("onboard"); +export default function OnboardPage() { const router = useRouter(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); + // Queries const { - data: llmConfigs = [], - isFetching: configsLoading, - refetch: refreshConfigs, - } = useAtomValue(llmConfigsAtom); - const { data: globalConfigs = [], isFetching: globalConfigsLoading } = - useAtomValue(globalLLMConfigsAtom); - const { - data: preferences = {}, - isFetching: preferencesLoading, - refetch: refreshPreferences, - } = useAtomValue(llmPreferencesAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + data: globalConfigs = [], + isFetching: globalConfigsLoading, + isSuccess: globalConfigsLoaded, + } = useAtomValue(globalNewLLMConfigsAtom); + const { data: preferences = {}, isFetching: preferencesLoading } = + useAtomValue(llmPreferencesAtom); - // Compute isOnboardingComplete - const isOnboardingComplete = useMemo(() => { - return !!( - preferences.long_context_llm_id && - preferences.fast_llm_id && - preferences.strategic_llm_id - ); - }, [preferences]); + // Mutations + const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue( + createNewLLMConfigMutationAtom + ); + const { mutateAsync: updatePreferences, isPending: isUpdatingPreferences } = useAtomValue( + updateLLMPreferencesMutationAtom + ); + // State const [isAutoConfiguring, setIsAutoConfiguring] = useState(false); - const [autoConfigComplete, setAutoConfigComplete] = useState(false); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const [showPromptSettings, setShowPromptSettings] = useState(false); - - const handleRefreshPreferences = useCallback(async () => { - await refreshPreferences(); - }, []); - - // Track if we've already attempted auto-configuration const hasAttemptedAutoConfig = useRef(false); - // Track if onboarding was complete on initial mount - const wasCompleteOnMount = useRef(null); - const hasCheckedInitialState = useRef(false); - - // Check if user is authenticated + // Check authentication useEffect(() => { const token = getBearerToken(); if (!token) { - // Save current path and redirect to login redirectToLogin(); - return; } }, []); - // Capture onboarding state on first load + // Check if onboarding is already complete + const isOnboardingComplete = preferences.agent_llm_id && preferences.document_summary_llm_id; + + // If onboarding is already complete, redirect immediately useEffect(() => { - if ( - !hasCheckedInitialState.current && - !preferencesLoading && - !configsLoading && - !globalConfigsLoading - ) { - wasCompleteOnMount.current = isOnboardingComplete; - hasCheckedInitialState.current = true; + if (!preferencesLoading && isOnboardingComplete) { + router.push(`/dashboard/${searchSpaceId}/new-chat`); } - }, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]); + }, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]); - // Redirect to dashboard if onboarding was already complete + // Auto-configure if global configs are available useEffect(() => { - if ( - wasCompleteOnMount.current === true && - !preferencesLoading && - !configsLoading && - !globalConfigsLoading - ) { - const timer = setTimeout(() => { - router.push(`/dashboard/${searchSpaceId}`); - }, 300); - return () => clearTimeout(timer); - } - }, [preferencesLoading, configsLoading, globalConfigsLoading, router, searchSpaceId]); + const autoConfigureWithGlobal = async () => { + if (hasAttemptedAutoConfig.current) return; + if (globalConfigsLoading || preferencesLoading) return; + if (!globalConfigsLoaded) return; + if (isOnboardingComplete) return; - // Auto-configure LLM roles if global configs are available - const autoConfigureLLMs = useCallback(async () => { - if (hasAttemptedAutoConfig.current) return; - if (globalConfigs.length === 0) return; - if (isOnboardingComplete) { - setAutoConfigComplete(true); - return; - } + // Only auto-configure if we have global configs + if (globalConfigs.length > 0) { + hasAttemptedAutoConfig.current = true; + setIsAutoConfiguring(true); - hasAttemptedAutoConfig.current = true; - setIsAutoConfiguring(true); + try { + const firstGlobalConfig = globalConfigs[0]; - try { - const allConfigs = [...globalConfigs, ...llmConfigs]; + await updatePreferences({ + search_space_id: searchSpaceId, + data: { + agent_llm_id: firstGlobalConfig.id, + document_summary_llm_id: firstGlobalConfig.id, + }, + }); - if (allConfigs.length === 0) { - setIsAutoConfiguring(false); - return; + toast.success("AI configured automatically!", { + description: `Using ${firstGlobalConfig.name}. You can customize this later in Settings.`, + }); + + // Redirect to new-chat + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } catch (error) { + console.error("Auto-configuration failed:", error); + toast.error("Auto-configuration failed. Please add a configuration manually."); + setIsAutoConfiguring(false); + } } + }; - // Use first available config for all roles - const defaultConfigId = allConfigs[0].id; + autoConfigureWithGlobal(); + }, [ + globalConfigs, + globalConfigsLoading, + globalConfigsLoaded, + preferencesLoading, + isOnboardingComplete, + updatePreferences, + searchSpaceId, + router, + ]); - const newPreferences = { - long_context_llm_id: defaultConfigId, - fast_llm_id: defaultConfigId, - strategic_llm_id: defaultConfigId, - }; + // Handle form submission + const handleSubmit = async (formData: LLMConfigFormData) => { + try { + // Create the config + const newConfig = await createConfig(formData); + // Auto-assign to all roles await updatePreferences({ search_space_id: searchSpaceId, - data: newPreferences, + data: { + agent_llm_id: newConfig.id, + document_summary_llm_id: newConfig.id, + }, }); - await refreshPreferences(); - setAutoConfigComplete(true); - toast.success("AI models configured automatically!", { - description: "You can customize these in advanced settings.", + + toast.success("Configuration created!", { + description: "Redirecting to chat...", }); + + // Redirect to new-chat + router.push(`/dashboard/${searchSpaceId}/new-chat`); } catch (error) { - console.error("Auto-configuration failed:", error); - } finally { - setIsAutoConfiguring(false); + console.error("Failed to create config:", error); + if (error instanceof Error) { + toast.error(error.message || "Failed to create configuration"); + } } - }, [globalConfigs, llmConfigs, isOnboardingComplete, updatePreferences, refreshPreferences]); + }; - // Trigger auto-configuration once data is loaded - useEffect(() => { - if (!configsLoading && !globalConfigsLoading && !preferencesLoading) { - autoConfigureLLMs(); - } - }, [configsLoading, globalConfigsLoading, preferencesLoading, autoConfigureLLMs]); - - const allConfigs = [...globalConfigs, ...llmConfigs]; - const isReady = autoConfigComplete || isOnboardingComplete; + const isSubmitting = isCreating || isUpdatingPreferences; // Loading state - if (configsLoading || preferencesLoading || globalConfigsLoading || isAutoConfiguring) { + if (globalConfigsLoading || preferencesLoading || isAutoConfiguring) { return ( - - ); - } - - // Show LLM setup if no configs available OR if roles are not assigned yet - // This forces users to complete role assignment before seeing the final screen - if (allConfigs.length === 0 || !isOnboardingComplete) { - return ( - refreshConfigs()} - onConfigDeleted={() => refreshConfigs()} - onPreferencesUpdated={handleRefreshPreferences} - /> - ); - } - - // Main onboarding view - return ( -
-
+
+
+
+
+ +
+
+
+

+ {isAutoConfiguring ? "Setting up your AI..." : "Loading..."} +

+

+ {isAutoConfiguring + ? "Auto-configuring with available settings" + : "Please wait while we check your configuration"} +

+
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ); + } + + // If global configs exist but auto-config failed, show simple message + if (globalConfigs.length > 0 && !isAutoConfiguring) { + return null; // Will redirect via useEffect + } + + // No global configs - show the config form + return ( +
+
+ {/* Header */} - +
+ + + - {/* Quick Stats */} - +
+

Configure Your AI

+

+ Add your LLM provider to get started with SurfSense +

+
+
- {/* Action Cards */} + {/* Config Form */} - router.push(`/dashboard/${searchSpaceId}/researcher`)} - colorScheme="violet" - delay={0.9} - /> - - router.push(`/dashboard/${searchSpaceId}/sources/add`)} - colorScheme="blue" - delay={0.8} - /> - - router.push(`/dashboard/${searchSpaceId}/team`)} - colorScheme="emerald" - delay={0.7} - /> + + + LLM Configuration + + + + + - {/* Advanced Settings */} - refreshConfigs()} - onConfigDeleted={() => refreshConfigs()} - onPreferencesUpdated={handleRefreshPreferences} - /> - - {/* Footer */} - -

- You can always adjust these settings later in{" "} - -

-
+ You can add more configurations and customize settings anytime in{" "} + +
); -}; - -export default OnboardPage; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index dd68e1a18..ad96402a4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -30,20 +30,20 @@ interface SettingsNavItem { const settingsNavItems: SettingsNavItem[] = [ { id: "models", - label: "Model Configs", - description: "Configure AI models and providers", + label: "Agent Configs", + description: "LLM models with prompts & citations", icon: Bot, }, { id: "roles", - label: "LLM Roles", - description: "Manage language model roles", + label: "Role Assignments", + description: "Assign configs to agent roles", icon: Brain, }, { id: "prompts", label: "System Instructions", - description: "Customize system prompts", + description: "SearchSpace-wide AI instructions", icon: MessageSquare, }, ]; @@ -236,9 +236,6 @@ function SettingsContent({

{activeItem?.label}

-

- {activeItem?.description} -

@@ -275,7 +272,7 @@ export default function SettingsPage() { const [isSidebarOpen, setIsSidebarOpen] = useState(false); const handleBackToApp = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/researcher`); + router.push(`/dashboard/${searchSpaceId}/new-chat`); }, [router, searchSpaceId]); return ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 49e1de2ab..ed67fa1f5 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -807,7 +807,6 @@ function RolesTab({ { // TODO: Implement edit role dialog/modal - console.log("Edit role not yet implemented", role); }} > diff --git a/surfsense_web/atoms/llm-config/llm-config-mutation.atoms.ts b/surfsense_web/atoms/llm-config/llm-config-mutation.atoms.ts deleted file mode 100644 index f28b1d708..000000000 --- a/surfsense_web/atoms/llm-config/llm-config-mutation.atoms.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { atomWithMutation } from "jotai-tanstack-query"; -import { toast } from "sonner"; -import type { - CreateLLMConfigRequest, - DeleteLLMConfigRequest, - GetLLMConfigsResponse, - UpdateLLMConfigRequest, - UpdateLLMConfigResponse, - UpdateLLMPreferencesRequest, -} from "@/contracts/types/llm-config.types"; -import { llmConfigApiService } from "@/lib/apis/llm-config-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { queryClient } from "@/lib/query-client/client"; -import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; - -export const createLLMConfigMutationAtom = atomWithMutation((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - - return { - mutationKey: cacheKeys.llmConfigs.all(searchSpaceId!), - enabled: !!searchSpaceId, - mutationFn: async (request: CreateLLMConfigRequest) => { - return llmConfigApiService.createLLMConfig(request); - }, - - onSuccess: () => { - toast.success("LLM configuration created successfully"); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.all(searchSpaceId!), - }); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.global(), - }); - }, - }; -}); - -export const updateLLMConfigMutationAtom = atomWithMutation((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - - return { - mutationKey: cacheKeys.llmConfigs.all(searchSpaceId!), - enabled: !!searchSpaceId, - mutationFn: async (request: UpdateLLMConfigRequest) => { - return llmConfigApiService.updateLLMConfig(request); - }, - - onSuccess: (_: UpdateLLMConfigResponse, request: UpdateLLMConfigRequest) => { - toast.success("LLM configuration updated successfully"); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.all(searchSpaceId!), - }); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.byId(String(request.id)), - }); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.global(), - }); - }, - }; -}); - -export const deleteLLMConfigMutationAtom = atomWithMutation((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - const authToken = localStorage.getItem("surfsense_bearer_token"); - - return { - mutationKey: cacheKeys.llmConfigs.all(searchSpaceId!), - enabled: !!searchSpaceId && !!authToken, - mutationFn: async (request: DeleteLLMConfigRequest) => { - return llmConfigApiService.deleteLLMConfig(request); - }, - - onSuccess: (_, request: DeleteLLMConfigRequest) => { - toast.success("LLM configuration deleted successfully"); - queryClient.setQueryData( - cacheKeys.llmConfigs.all(searchSpaceId!), - (oldData: GetLLMConfigsResponse | undefined) => { - if (!oldData) return oldData; - return oldData.filter((config) => config.id !== request.id); - } - ); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.byId(String(request.id)), - }); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.global(), - }); - }, - }; -}); - -export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - - return { - mutationKey: cacheKeys.llmConfigs.preferences(searchSpaceId!), - enabled: !!searchSpaceId, - mutationFn: async (request: UpdateLLMPreferencesRequest) => { - return llmConfigApiService.updateLLMPreferences(request); - }, - - onSuccess: () => { - toast.success("LLM preferences updated successfully"); - queryClient.invalidateQueries({ - queryKey: cacheKeys.llmConfigs.preferences(searchSpaceId!), - }); - }, - }; -}); diff --git a/surfsense_web/atoms/llm-config/llm-config-query.atoms.ts b/surfsense_web/atoms/llm-config/llm-config-query.atoms.ts deleted file mode 100644 index 22ae63d7f..000000000 --- a/surfsense_web/atoms/llm-config/llm-config-query.atoms.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { atomWithQuery } from "jotai-tanstack-query"; -import { llmConfigApiService } from "@/lib/apis/llm-config-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; - -export const llmConfigsAtom = atomWithQuery((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - - return { - queryKey: cacheKeys.llmConfigs.all(searchSpaceId!), - enabled: !!searchSpaceId, - staleTime: 5 * 60 * 1000, // 5 minutes - queryFn: async () => { - return llmConfigApiService.getLLMConfigs({ - queryParams: { - search_space_id: searchSpaceId!, - }, - }); - }, - }; -}); - -export const globalLLMConfigsAtom = atomWithQuery(() => { - return { - queryKey: cacheKeys.llmConfigs.global(), - staleTime: 10 * 60 * 1000, // 10 minutes - queryFn: async () => { - return llmConfigApiService.getGlobalLLMConfigs(); - }, - }; -}); - -export const llmPreferencesAtom = atomWithQuery((get) => { - const searchSpaceId = get(activeSearchSpaceIdAtom); - - return { - queryKey: cacheKeys.llmConfigs.preferences(String(searchSpaceId)), - enabled: !!searchSpaceId, - staleTime: 5 * 60 * 1000, // 5 minutes - queryFn: async () => { - return llmConfigApiService.getLLMPreferences({ - search_space_id: Number(searchSpaceId), - }); - }, - }; -}); diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts new file mode 100644 index 000000000..8f81b7475 --- /dev/null +++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts @@ -0,0 +1,116 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + CreateNewLLMConfigRequest, + DeleteNewLLMConfigRequest, + GetNewLLMConfigsResponse, + UpdateLLMPreferencesRequest, + UpdateNewLLMConfigRequest, + UpdateNewLLMConfigResponse, +} from "@/contracts/types/new-llm-config.types"; +import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; +import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; + +/** + * Mutation atom for creating a new NewLLMConfig + */ +export const createNewLLMConfigMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + mutationKey: ["new-llm-configs", "create"], + enabled: !!searchSpaceId, + mutationFn: async (request: CreateNewLLMConfigRequest) => { + return newLLMConfigApiService.createConfig(request); + }, + onSuccess: () => { + toast.success("Configuration created successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), + }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create configuration"); + }, + }; +}); + +/** + * Mutation atom for updating an existing NewLLMConfig + */ +export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + mutationKey: ["new-llm-configs", "update"], + enabled: !!searchSpaceId, + mutationFn: async (request: UpdateNewLLMConfigRequest) => { + return newLLMConfigApiService.updateConfig(request); + }, + onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => { + toast.success("Configuration updated successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.newLLMConfigs.byId(request.id), + }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update configuration"); + }, + }; +}); + +/** + * Mutation atom for deleting a NewLLMConfig + */ +export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + mutationKey: ["new-llm-configs", "delete"], + enabled: !!searchSpaceId, + mutationFn: async (request: DeleteNewLLMConfigRequest) => { + return newLLMConfigApiService.deleteConfig(request); + }, + onSuccess: (_, request: DeleteNewLLMConfigRequest) => { + toast.success("Configuration deleted successfully"); + queryClient.setQueryData( + cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), + (oldData: GetNewLLMConfigsResponse | undefined) => { + if (!oldData) return oldData; + return oldData.filter((config) => config.id !== request.id); + } + ); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete configuration"); + }, + }; +}); + +/** + * Mutation atom for updating LLM preferences (role assignments) + */ +export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + mutationKey: ["llm-preferences", "update"], + enabled: !!searchSpaceId, + mutationFn: async (request: UpdateLLMPreferencesRequest) => { + return newLLMConfigApiService.updateLLMPreferences(request); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)), + }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update LLM preferences"); + }, + }; +}); diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts new file mode 100644 index 000000000..9f5085d33 --- /dev/null +++ b/surfsense_web/atoms/new-llm-config/new-llm-config-query.atoms.ts @@ -0,0 +1,64 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; + +/** + * Query atom for fetching all NewLLMConfigs for the active search space + */ +export const newLLMConfigsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + return newLLMConfigApiService.getConfigs({ + search_space_id: Number(searchSpaceId), + }); + }, + }; +}); + +/** + * Query atom for fetching global NewLLMConfigs (from YAML, negative IDs) + */ +export const globalNewLLMConfigsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.newLLMConfigs.global(), + staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change + queryFn: async () => { + return newLLMConfigApiService.getGlobalConfigs(); + }, + }; +}); + +/** + * Query atom for fetching LLM preferences (role assignments) for the active search space + */ +export const llmPreferencesAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + return newLLMConfigApiService.getLLMPreferences(Number(searchSpaceId)); + }, + }; +}); + +/** + * Query atom for fetching default system instructions template + */ +export const defaultSystemInstructionsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.newLLMConfigs.defaultInstructions(), + staleTime: 60 * 60 * 1000, // 1 hour - this rarely changes + queryFn: async () => { + return newLLMConfigApiService.getDefaultSystemInstructions(); + }, + }; +}); diff --git a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts index 4aa024e93..588466d90 100644 --- a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts @@ -25,13 +25,3 @@ export const searchSpacesAtom = atomWithQuery((get) => { }, }; }); - -export const communityPromptsAtom = atomWithQuery(() => { - return { - queryKey: cacheKeys.searchSpaces.communityPrompts, - staleTime: 30 * 60 * 1000, - queryFn: async () => { - return searchSpacesApiService.getCommunityPrompts(); - }, - }; -}); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index ea45c3cae..98f5539a1 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -7,8 +7,11 @@ import { MessagePrimitive, ThreadPrimitive, useAssistantState, + useThreadViewport, } from "@assistant-ui/react"; +import { useAtomValue } from "jotai"; import { + AlertCircle, ArrowDownIcon, ArrowUpIcon, Brain, @@ -40,7 +43,14 @@ import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { + globalNewLLMConfigsAtom, + llmPreferencesAtom, + newLLMConfigsAtom, +} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ComposerAddAttachment, ComposerAttachments, @@ -57,11 +67,13 @@ import { ChainOfThoughtTrigger, } from "@/components/prompt-kit/chain-of-thought"; import { DocumentsDataTable, type DocumentsDataTableRef } from "@/components/new-chat/DocumentsDataTable"; +import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import type { Document } from "@/contracts/types/document.types"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { cn } from "@/lib/utils"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; /** * Props for the Thread component @@ -78,35 +90,38 @@ const ThinkingStepsContext = createContext>(new Map( */ function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) { const titleLower = title.toLowerCase(); - + if (status === "in_progress") { return ; } - + if (status === "completed") { return ; } - + if (titleLower.includes("search") || titleLower.includes("knowledge")) { return ; } - + if (titleLower.includes("analy") || titleLower.includes("understand")) { return ; } - + return ; } /** * Chain of thought display component with smart expand/collapse behavior */ -const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({ steps, isThreadRunning = true }) => { +const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({ + steps, + isThreadRunning = true, +}) => { // Track which steps the user has manually toggled (overrides auto behavior) const [manualOverrides, setManualOverrides] = useState>({}); // Track previous step statuses to detect changes const prevStatusesRef = useRef>({}); - + // Derive effective status: if thread stopped and step is in_progress, treat as completed const getEffectiveStatus = (step: ThinkingStep): "pending" | "in_progress" | "completed" => { if (step.status === "in_progress" && !isThreadRunning) { @@ -114,24 +129,24 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea } return step.status; }; - + // Check if any step is effectively in progress - const hasInProgressStep = steps.some(step => getEffectiveStatus(step) === "in_progress"); - + const hasInProgressStep = steps.some((step) => getEffectiveStatus(step) === "in_progress"); + // Find the last completed step index (using effective status) const lastCompletedIndex = steps - .map((s, i) => getEffectiveStatus(s) === "completed" ? i : -1) - .filter(i => i !== -1) + .map((s, i) => (getEffectiveStatus(s) === "completed" ? i : -1)) + .filter((i) => i !== -1) .pop(); - + // Clear manual overrides when a step's status changes useEffect(() => { const currentStatuses: Record = {}; - steps.forEach(step => { + steps.forEach((step) => { currentStatuses[step.id] = step.status; // If status changed, clear any manual override for this step if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) { - setManualOverrides(prev => { + setManualOverrides((prev) => { const next = { ...prev }; delete next[step.id]; return next; @@ -140,9 +155,9 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea }); prevStatusesRef.current = currentStatuses; }, [steps]); - + if (steps.length === 0) return null; - + const getStepOpenState = (step: ThinkingStep, index: number): boolean => { const effectiveStatus = getEffectiveStatus(step); // If user has manually toggled, respect that @@ -160,14 +175,14 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea // Default: collapsed return false; }; - + const handleToggle = (stepId: string, currentOpen: boolean) => { - setManualOverrides(prev => ({ + setManualOverrides((prev) => ({ ...prev, [stepId]: !currentOpen, })); }; - + return (
@@ -176,8 +191,8 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea const icon = getStepIcon(effectiveStatus, step.title); const isOpen = getStepOpenState(step, index); return ( - handleToggle(step.id, isOpen)} > @@ -194,9 +209,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea {step.items && step.items.length > 0 && ( {step.items.map((item, idx) => ( - - {item} - + {item} ))} )} @@ -208,6 +221,56 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea ); }; +/** + * Component that handles auto-scroll when thinking steps update. + * Uses useThreadViewport to scroll to bottom when thinking steps change, + * ensuring the user always sees the latest content during streaming. + */ +const ThinkingStepsScrollHandler: FC = () => { + const thinkingStepsMap = useContext(ThinkingStepsContext); + const viewport = useThreadViewport(); + const isRunning = useAssistantState(({ thread }) => thread.isRunning); + // Track the serialized state to detect any changes + const prevStateRef = useRef(""); + + useEffect(() => { + // Only act during streaming + if (!isRunning) { + prevStateRef.current = ""; + return; + } + + // Serialize the thinking steps state to detect any changes + // This catches new steps, status changes, and item additions + let stateString = ""; + thinkingStepsMap.forEach((steps, msgId) => { + steps.forEach((step) => { + stateString += `${msgId}:${step.id}:${step.status}:${step.items?.length || 0};`; + }); + }); + + // If state changed at all during streaming, scroll + if (stateString !== prevStateRef.current && stateString !== "") { + prevStateRef.current = stateString; + + // Multiple attempts to ensure scroll happens after DOM updates + const scrollAttempt = () => { + try { + viewport.scrollToBottom(); + } catch (e) { + // Ignore errors - viewport might not be ready + } + }; + + // Delayed attempts to handle async DOM updates + requestAnimationFrame(scrollAttempt); + setTimeout(scrollAttempt, 100); + } + }, [thinkingStepsMap, viewport, isRunning]); + + return null; // This component doesn't render anything +}; + export const Thread: FC = ({ messageThinkingSteps = new Map() }) => { return ( @@ -221,6 +284,9 @@ export const Thread: FC = ({ messageThinkingSteps = new Map() }) => turnAnchor="top" className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4" > + {/* Auto-scroll handler for thinking steps - must be inside Viewport */} + + thread.isEmpty}> @@ -263,49 +329,24 @@ const ThreadScrollToBottom: FC = () => { const getTimeBasedGreeting = (userEmail?: string): string => { const hour = new Date().getHours(); - + // Extract first name from email if available const firstName = userEmail - ? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() + - userEmail.split("@")[0].split(".")[0].slice(1) + ? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() + + userEmail.split("@")[0].split(".")[0].slice(1) : null; - + // Array of greeting variations for each time period - const morningGreetings = [ - "Good morning", - "Rise and shine", - "Morning", - "Hey there", - ]; - - const afternoonGreetings = [ - "Good afternoon", - "Afternoon", - "Hey there", - "Hi there", - ]; - - const eveningGreetings = [ - "Good evening", - "Evening", - "Hey there", - "Hi there", - ]; - - const nightGreetings = [ - "Good night", - "Evening", - "Hey there", - "Winding down", - ]; - - const lateNightGreetings = [ - "Still up", - "Night owl mode", - "The night is young", - "Hi there", - ]; - + const morningGreetings = ["Good morning", "Rise and shine", "Morning", "Hey there"]; + + const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"]; + + const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"]; + + const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"]; + + const lateNightGreetings = ["Still up", "Night owl mode", "The night is young", "Hi there"]; + // Select a random greeting based on time let greeting: string; if (hour < 5) { @@ -321,12 +362,12 @@ const getTimeBasedGreeting = (userEmail?: string): string => { // Night: 10 PM to midnight greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)]; } - + // Add personalization with first name if available if (firstName) { return `${greeting}, ${firstName}!`; } - + return `${greeting}!`; }; @@ -335,14 +376,14 @@ const ThreadWelcome: FC = () => { // Memoize greeting so it doesn't change on re-renders (only on user change) const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]); - + return (
{/* Greeting positioned above the composer - fixed position */}
-

- {greeting} -

+

+ {greeting} +

{/* Composer - top edge fixed, expands downward only */}
@@ -490,6 +531,23 @@ const Composer: FC = () => { setMentionedDocuments((prev) => prev.filter((doc) => doc.id !== docId)); }; + // Check if a model is configured - needed to disable input + const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); + const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); + const { data: preferences } = useAtomValue(llmPreferencesAtom); + + const hasModelConfigured = useMemo(() => { + if (!preferences) return false; + const agentLlmId = preferences.agent_llm_id; + if (agentLlmId === null || agentLlmId === undefined) return false; + + // Check if the configured model actually exists + if (agentLlmId < 0) { + return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; + } + return userConfigs?.some((c) => c.id === agentLlmId) ?? false; + }, [preferences, globalConfigs, userConfigs]); + return ( @@ -571,22 +629,26 @@ const Composer: FC = () => { const ConnectorIndicator: FC = () => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(false, searchSpaceId ? Number(searchSpaceId) : undefined); - const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); + const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors( + false, + searchSpaceId ? Number(searchSpaceId) : undefined + ); + const { data: documentTypeCounts, isLoading: documentTypesLoading } = + useAtomValue(documentTypeCountsAtom); const [isOpen, setIsOpen] = useState(false); const closeTimeoutRef = useRef(null); - + const isLoading = connectorsLoading || documentTypesLoading; - + // Get document types that have documents in the search space - const activeDocumentTypes = documentTypeCounts + const activeDocumentTypes = documentTypeCounts ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0) : []; - + const hasConnectors = connectors.length > 0; const hasSources = hasConnectors || activeDocumentTypes.length > 0; const totalSourceCount = connectors.length + activeDocumentTypes.length; - + const handleMouseEnter = useCallback(() => { // Clear any pending close timeout if (closeTimeoutRef.current) { @@ -595,16 +657,16 @@ const ConnectorIndicator: FC = () => { } setIsOpen(true); }, []); - + const handleMouseLeave = useCallback(() => { // Delay closing by 150ms for better UX closeTimeoutRef.current = setTimeout(() => { setIsOpen(false); }, 150); }, []); - + if (!searchSpaceId) return null; - + return ( @@ -618,7 +680,9 @@ const ConnectorIndicator: FC = () => { "data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0", "text-muted-foreground" )} - aria-label={hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector"} + aria-label={ + hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector" + } onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > @@ -640,9 +704,9 @@ const ConnectorIndicator: FC = () => { )} - { {hasSources ? (
-

- Connected Sources -

+

Connected Sources

{totalSourceCount} @@ -681,11 +743,11 @@ const ConnectorIndicator: FC = () => {
- - Manage connectors + + Add more sources
@@ -728,7 +790,24 @@ const ComposerAction: FC = () => { return text.length === 0; }); - const isSendDisabled = hasProcessingAttachments || isComposerEmpty; + // Check if a model is configured + const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); + const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); + const { data: preferences } = useAtomValue(llmPreferencesAtom); + + const hasModelConfigured = useMemo(() => { + if (!preferences) return false; + const agentLlmId = preferences.agent_llm_id; + if (agentLlmId === null || agentLlmId === undefined) return false; + + // Check if the configured model actually exists + if (agentLlmId < 0) { + return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; + } + return userConfigs?.some((c) => c.id === agentLlmId) ?? false; + }, [preferences, globalConfigs, userConfigs]); + + const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured; return (
@@ -745,15 +824,25 @@ const ComposerAction: FC = () => {
)} + {/* Show warning when no model is configured */} + {!hasModelConfigured && !hasProcessingAttachments && ( +
+ + Select a model +
+ )} + !thread.isRunning}> { ); }; -const AssistantMessageInner: FC = () => { +/** + * Custom component to render thinking steps from Context + */ +const ThinkingStepsPart: FC = () => { const thinkingStepsMap = useContext(ThinkingStepsContext); - + // Get the current message ID to look up thinking steps const messageId = useAssistantState(({ message }) => message?.id); const thinkingSteps = thinkingStepsMap.get(messageId) || []; - + // Check if thread is still running (for stopping the spinner when cancelled) const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); - + + if (thinkingSteps.length === 0) return null; + + return ( +
+ +
+ ); +}; + +const AssistantMessageInner: FC = () => { return ( <> - {/* Show thinking steps BEFORE the text response */} - {thinkingSteps.length > 0 && ( -
- -
- )} - + {/* Render thinking steps from message content - this ensures proper scroll tracking */} + +
-
-
-
-
- SurfSense -
-
- -
    - {pages.map((page) => ( -
  • - - {page.title} - -
  • - ))} -
- - -
-
-

- © SurfSense 2025 -

-
- - - - - - - - - - - - -
-
-
-
- ); -} - -const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => { - return ( -
- ); -}; diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx new file mode 100644 index 000000000..ef1533e23 --- /dev/null +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { + GlobalNewLLMConfig, + NewLLMConfigPublic, +} from "@/contracts/types/new-llm-config.types"; +import { ModelConfigSidebar } from "./model-config-sidebar"; +import { ModelSelector } from "./model-selector"; + +interface ChatHeaderProps { + searchSpaceId: number; +} + +export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [selectedConfig, setSelectedConfig] = useState< + NewLLMConfigPublic | GlobalNewLLMConfig | null + >(null); + const [isGlobal, setIsGlobal] = useState(false); + const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view"); + + const handleEditConfig = useCallback( + (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { + setSelectedConfig(config); + setIsGlobal(global); + setSidebarMode(global ? "view" : "edit"); + setSidebarOpen(true); + }, + [] + ); + + const handleAddNew = useCallback(() => { + setSelectedConfig(null); + setIsGlobal(false); + setSidebarMode("create"); + setSidebarOpen(true); + }, []); + + const handleSidebarClose = useCallback((open: boolean) => { + setSidebarOpen(open); + if (!open) { + // Reset state when closing + setSelectedConfig(null); + } + }, []); + + return ( + <> + {/* Header Bar */} +
+ +
+ + {/* Config Sidebar */} + + + ); +} diff --git a/surfsense_web/components/new-chat/model-config-sidebar.tsx b/surfsense_web/components/new-chat/model-config-sidebar.tsx new file mode 100644 index 000000000..f3d3c2dcd --- /dev/null +++ b/surfsense_web/components/new-chat/model-config-sidebar.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + createNewLLMConfigMutationAtom, + updateLLMPreferencesMutationAtom, + updateNewLLMConfigMutationAtom, +} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; +import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { + GlobalNewLLMConfig, + NewLLMConfigPublic, +} from "@/contracts/types/new-llm-config.types"; +import { cn } from "@/lib/utils"; + +interface ModelConfigSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: NewLLMConfigPublic | GlobalNewLLMConfig | null; + isGlobal: boolean; + searchSpaceId: number; + mode: "create" | "edit" | "view"; +} + +export function ModelConfigSidebar({ + open, + onOpenChange, + config, + isGlobal, + searchSpaceId, + mode, +}: ModelConfigSidebarProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + // Mutations - use mutateAsync from the atom value + const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); + const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + // Get title based on mode + const getTitle = () => { + if (mode === "create") return "Add New Configuration"; + if (isGlobal) return "View Global Configuration"; + return "Edit Configuration"; + }; + + // Handle form submit + const handleSubmit = useCallback( + async (data: LLMConfigFormData) => { + setIsSubmitting(true); + try { + if (mode === "create") { + // Create new config + const result = await createConfig({ + ...data, + search_space_id: searchSpaceId, + }); + + // Assign the new config to the agent role + if (result?.id) { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { + agent_llm_id: result.id, + }, + }); + } + + toast.success("Configuration created and assigned!"); + onOpenChange(false); + } else if (!isGlobal && config) { + // Update existing user config + await updateConfig({ + id: config.id, + data: { + name: data.name, + description: data.description, + provider: data.provider, + custom_provider: data.custom_provider, + model_name: data.model_name, + api_key: data.api_key, + api_base: data.api_base, + litellm_params: data.litellm_params, + system_instructions: data.system_instructions, + use_default_system_instructions: data.use_default_system_instructions, + citations_enabled: data.citations_enabled, + }, + }); + toast.success("Configuration updated!"); + onOpenChange(false); + } + } catch (error) { + console.error("Failed to save configuration:", error); + toast.error("Failed to save configuration"); + } finally { + setIsSubmitting(false); + } + }, + [ + mode, + isGlobal, + config, + searchSpaceId, + createConfig, + updateConfig, + updatePreferences, + onOpenChange, + ] + ); + + // Handle "Use this model" for global configs + const handleUseGlobalConfig = useCallback(async () => { + if (!config || !isGlobal) return; + setIsSubmitting(true); + try { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { + agent_llm_id: config.id, + }, + }); + toast.success(`Now using ${config.name}`); + onOpenChange(false); + } catch (error) { + console.error("Failed to set model:", error); + toast.error("Failed to set model"); + } finally { + setIsSubmitting(false); + } + }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); + + return ( + + {open && ( + <> + {/* Backdrop */} + onOpenChange(false)} + /> + + {/* Sidebar Panel */} + + {/* Header */} +
+
+
+ +
+
+

{getTitle()}

+
+ {isGlobal ? ( + + + Global + + ) : mode !== "create" ? ( + + + Custom + + ) : null} + {config && ( + {config.model_name} + )} +
+
+
+ +
+ + {/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */} +
+
+ {/* Global config notice */} + {isGlobal && mode !== "create" && ( + + + + Global configurations are read-only. To customize settings, create a new + configuration based on this template. + + + )} + + {/* Form */} + {mode === "create" ? ( + onOpenChange(false)} + isSubmitting={isSubmitting} + mode="create" + submitLabel="Create & Use" + /> + ) : isGlobal && config ? ( + // Read-only view for global configs +
+ {/* Config Details */} +
+
+
+ +

{config.name}

+
+ {config.description && ( +
+ +

{config.description}

+
+ )} +
+ +
+ +
+
+ +

{config.provider}

+
+
+ +

{config.model_name}

+
+
+ +
+ +
+
+ + + {config.citations_enabled ? "Enabled" : "Disabled"} + +
+
+ + {config.system_instructions && ( + <> +
+
+ +
+

+ {config.system_instructions} +

+
+
+ + )} +
+ + {/* Action Buttons */} +
+ + +
+
+ ) : config ? ( + // Edit form for user configs + onOpenChange(false)} + isSubmitting={isSubmitting} + mode="edit" + submitLabel="Save Changes" + /> + ) : null} +
+
+ + + )} + + ); +} diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx new file mode 100644 index 000000000..89390f957 --- /dev/null +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { + Bot, + Check, + ChevronDown, + Cloud, + Edit3, + Globe, + Loader2, + Plus, + Settings2, + Sparkles, + User, + Zap, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; +import { + globalNewLLMConfigsAtom, + llmPreferencesAtom, + newLLMConfigsAtom, +} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import type { + GlobalNewLLMConfig, + NewLLMConfigPublic, +} from "@/contracts/types/new-llm-config.types"; +import { cn } from "@/lib/utils"; + +// Provider icons mapping +const getProviderIcon = (provider: string) => { + const iconClass = "size-4"; + switch (provider?.toUpperCase()) { + case "OPENAI": + return ; + case "ANTHROPIC": + return ; + case "GOOGLE": + return ; + case "GROQ": + return ; + case "OLLAMA": + return ; + case "XAI": + return ; + default: + return ; + } +}; + +interface ModelSelectorProps { + onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; + onAddNew: () => void; + className?: string; +} + +export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [isSwitching, setIsSwitching] = useState(false); + + // Fetch configs + const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom); + const { data: globalConfigs, isLoading: globalConfigsLoading } = + useAtomValue(globalNewLLMConfigsAtom); + const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom); + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + + const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading; + + // Get current agent LLM config + const currentConfig = useMemo(() => { + if (!preferences) return null; + + const agentLlmId = preferences.agent_llm_id; + if (agentLlmId === null || agentLlmId === undefined) return null; + + // Check if it's a global config (negative ID) + if (agentLlmId < 0) { + return globalConfigs?.find((c) => c.id === agentLlmId) ?? null; + } + // Otherwise, check user configs + return userConfigs?.find((c) => c.id === agentLlmId) ?? null; + }, [preferences, globalConfigs, userConfigs]); + + // Filter configs based on search + const filteredGlobalConfigs = useMemo(() => { + if (!globalConfigs) return []; + if (!searchQuery) return globalConfigs; + const query = searchQuery.toLowerCase(); + return globalConfigs.filter( + (c) => + c.name.toLowerCase().includes(query) || + c.model_name.toLowerCase().includes(query) || + c.provider.toLowerCase().includes(query) + ); + }, [globalConfigs, searchQuery]); + + const filteredUserConfigs = useMemo(() => { + if (!userConfigs) return []; + if (!searchQuery) return userConfigs; + const query = searchQuery.toLowerCase(); + return userConfigs.filter( + (c) => + c.name.toLowerCase().includes(query) || + c.model_name.toLowerCase().includes(query) || + c.provider.toLowerCase().includes(query) + ); + }, [userConfigs, searchQuery]); + + const handleSelectConfig = useCallback( + async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => { + // If already selected, just close + if (currentConfig?.id === config.id) { + setOpen(false); + return; + } + + if (!searchSpaceId) { + toast.error("No search space selected"); + return; + } + + setIsSwitching(true); + try { + await updatePreferences({ + search_space_id: Number(searchSpaceId), + data: { + agent_llm_id: config.id, + }, + }); + toast.success(`Switched to ${config.name}`); + setOpen(false); + } catch (error) { + console.error("Failed to switch model:", error); + toast.error("Failed to switch model"); + } finally { + setIsSwitching(false); + } + }, + [currentConfig, searchSpaceId, updatePreferences] + ); + + const handleEditConfig = useCallback( + (e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => { + e.stopPropagation(); + onEdit(config, isGlobal); + setOpen(false); + }, + [onEdit] + ); + + return ( + + + + + + + + {/* Switching overlay */} + {isSwitching && ( +
+
+ + Switching model... +
+
+ )} + +
+ + +
+ + + +
+ +

No models found

+

Try a different search term

+
+
+ + {/* Global Configs Section */} + {filteredGlobalConfigs.length > 0 && ( + +
+ + Global Models +
+ {filteredGlobalConfigs.map((config) => { + const isSelected = currentConfig?.id === config.id; + return ( + handleSelectConfig(config)} + className={cn( + "mx-2 rounded-lg mb-1 cursor-pointer", + "aria-selected:bg-accent/50", + isSelected && "bg-accent/80" + )} + > +
+
+
{getProviderIcon(config.provider)}
+
+
+ {config.name} + {isSelected && } +
+
+ + {config.model_name} + + {config.citations_enabled && ( + + Citations + + )} +
+
+
+ +
+
+ ); + })} +
+ )} + + {filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && ( + + )} + + {/* User Configs Section */} + {filteredUserConfigs.length > 0 && ( + +
+ + Your Configurations +
+ {filteredUserConfigs.map((config) => { + const isSelected = currentConfig?.id === config.id; + return ( + handleSelectConfig(config)} + className={cn( + "mx-2 rounded-lg mb-1 cursor-pointer", + "aria-selected:bg-accent/50", + isSelected && "bg-accent/80" + )} + > +
+
+
{getProviderIcon(config.provider)}
+
+
+ {config.name} + {isSelected && } +
+
+ + {config.model_name} + + {config.citations_enabled && ( + + Citations + + )} +
+
+
+ +
+
+ ); + })} +
+ )} + + {/* Add New Config Button */} +
+ +
+
+
+
+
+ ); +} diff --git a/surfsense_web/components/onboard/index.ts b/surfsense_web/components/onboard/index.ts deleted file mode 100644 index 607ba4e7d..000000000 --- a/surfsense_web/components/onboard/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { OnboardActionCard } from "./onboard-action-card"; -export { OnboardAdvancedSettings } from "./onboard-advanced-settings"; -export { OnboardHeader } from "./onboard-header"; -export { OnboardLLMSetup } from "./onboard-llm-setup"; -export { OnboardLoading } from "./onboard-loading"; -export { OnboardStats } from "./onboard-stats"; -export { SetupLLMStep } from "./setup-llm-step"; -export { SetupPromptStep } from "./setup-prompt-step"; diff --git a/surfsense_web/components/onboard/onboard-action-card.tsx b/surfsense_web/components/onboard/onboard-action-card.tsx deleted file mode 100644 index c6bb41dbf..000000000 --- a/surfsense_web/components/onboard/onboard-action-card.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { ArrowRight, CheckCircle, type LucideIcon } from "lucide-react"; -import { motion } from "motion/react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; - -interface OnboardActionCardProps { - title: string; - description: string; - icon: LucideIcon; - features: string[]; - buttonText: string; - onClick: () => void; - colorScheme: "emerald" | "blue" | "violet"; - delay?: number; -} - -const colorSchemes = { - emerald: { - iconBg: "bg-emerald-500/10 dark:bg-emerald-500/20", - iconRing: "ring-emerald-500/20 dark:ring-emerald-500/30", - iconColor: "text-emerald-600 dark:text-emerald-400", - checkColor: "text-emerald-500", - buttonBg: "bg-emerald-600 hover:bg-emerald-500", - hoverBorder: "hover:border-emerald-500/50", - }, - blue: { - iconBg: "bg-blue-500/10 dark:bg-blue-500/20", - iconRing: "ring-blue-500/20 dark:ring-blue-500/30", - iconColor: "text-blue-600 dark:text-blue-400", - checkColor: "text-blue-500", - buttonBg: "bg-blue-600 hover:bg-blue-500", - hoverBorder: "hover:border-blue-500/50", - }, - violet: { - iconBg: "bg-violet-500/10 dark:bg-violet-500/20", - iconRing: "ring-violet-500/20 dark:ring-violet-500/30", - iconColor: "text-violet-600 dark:text-violet-400", - checkColor: "text-violet-500", - buttonBg: "bg-violet-600 hover:bg-violet-500", - hoverBorder: "hover:border-violet-500/50", - }, -}; - -export function OnboardActionCard({ - title, - description, - icon: Icon, - features, - buttonText, - onClick, - colorScheme, - delay = 0, -}: OnboardActionCardProps) { - const colors = colorSchemes[colorScheme]; - - return ( - - - - - - - {title} - {description} - - - -
- {features.map((feature, index) => ( -
- - {feature} -
- ))} -
- - -
-
-
- ); -} diff --git a/surfsense_web/components/onboard/onboard-advanced-settings.tsx b/surfsense_web/components/onboard/onboard-advanced-settings.tsx deleted file mode 100644 index b2b9c5080..000000000 --- a/surfsense_web/components/onboard/onboard-advanced-settings.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import { ChevronDown, MessageSquare, Settings2 } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { SetupLLMStep } from "@/components/onboard/setup-llm-step"; -import { SetupPromptStep } from "@/components/onboard/setup-prompt-step"; -import { Card, CardContent } from "@/components/ui/card"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { cn } from "@/lib/utils"; - -interface OnboardAdvancedSettingsProps { - searchSpaceId: number; - showLLMSettings: boolean; - setShowLLMSettings: (show: boolean) => void; - showPromptSettings: boolean; - setShowPromptSettings: (show: boolean) => void; - onConfigCreated: () => void; - onConfigDeleted: () => void; - onPreferencesUpdated: () => Promise; -} - -export function OnboardAdvancedSettings({ - searchSpaceId, - showLLMSettings, - setShowLLMSettings, - showPromptSettings, - setShowPromptSettings, - onConfigCreated, - onConfigDeleted, - onPreferencesUpdated, -}: OnboardAdvancedSettingsProps) { - return ( - - {/* LLM Configuration */} - - - - -
-
-
- -
-
-

LLM Configuration

-

- Customize AI models and role assignments -

-
-
- - - -
-
-
-
- - - - {showLLMSettings && ( - - - - - - - - )} - - -
- - {/* Prompt Configuration */} - - - - -
-
-
- -
-
-

AI Response Settings

-

- Configure citations and custom instructions (Optional) -

-
-
- - - -
-
-
-
- - - - {showPromptSettings && ( - - - - setShowPromptSettings(false)} - /> - - - - )} - - -
-
- ); -} diff --git a/surfsense_web/components/onboard/onboard-header.tsx b/surfsense_web/components/onboard/onboard-header.tsx deleted file mode 100644 index d84bb5adc..000000000 --- a/surfsense_web/components/onboard/onboard-header.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { CheckCircle } from "lucide-react"; -import { motion } from "motion/react"; -import { Logo } from "@/components/Logo"; -import { Badge } from "@/components/ui/badge"; - -interface OnboardHeaderProps { - title: string; - subtitle: string; - isReady?: boolean; -} - -export function OnboardHeader({ title, subtitle, isReady }: OnboardHeaderProps) { - return ( - - - - - - -

{title}

-

{subtitle}

-
- - {isReady && ( - - - - AI Configuration Complete - - - )} -
- ); -} diff --git a/surfsense_web/components/onboard/onboard-llm-setup.tsx b/surfsense_web/components/onboard/onboard-llm-setup.tsx deleted file mode 100644 index b0b2d3fac..000000000 --- a/surfsense_web/components/onboard/onboard-llm-setup.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { Bot } from "lucide-react"; -import { motion } from "motion/react"; -import { Logo } from "@/components/Logo"; -import { SetupLLMStep } from "@/components/onboard/setup-llm-step"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; - -interface OnboardLLMSetupProps { - searchSpaceId: number; - title: string; - configTitle: string; - configDescription: string; - onConfigCreated: () => void; - onConfigDeleted: () => void; - onPreferencesUpdated: () => Promise; -} - -export function OnboardLLMSetup({ - searchSpaceId, - title, - configTitle, - configDescription, - onConfigCreated, - onConfigDeleted, - onPreferencesUpdated, -}: OnboardLLMSetupProps) { - return ( -
- - {/* Header */} -
- - - - - {title} - - - Configure your AI model to get started - -
- - {/* LLM Setup Card */} - - - -
-
- -
- {configTitle} -
- {configDescription} -
- - - -
-
-
-
- ); -} diff --git a/surfsense_web/components/onboard/onboard-loading.tsx b/surfsense_web/components/onboard/onboard-loading.tsx deleted file mode 100644 index 4a85736d2..000000000 --- a/surfsense_web/components/onboard/onboard-loading.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { Wand2 } from "lucide-react"; -import { motion } from "motion/react"; - -interface OnboardLoadingProps { - title: string; - subtitle: string; -} - -export function OnboardLoading({ title, subtitle }: OnboardLoadingProps) { - return ( -
- -
- - - -
-

{title}

-

{subtitle}

-
- {[0, 1, 2].map((i) => ( - - ))} -
-
-
- ); -} diff --git a/surfsense_web/components/onboard/onboard-stats.tsx b/surfsense_web/components/onboard/onboard-stats.tsx deleted file mode 100644 index 0918c74e2..000000000 --- a/surfsense_web/components/onboard/onboard-stats.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { Bot, Brain, Sparkles } from "lucide-react"; -import { motion } from "motion/react"; -import { Badge } from "@/components/ui/badge"; - -interface OnboardStatsProps { - globalConfigsCount: number; - userConfigsCount: number; -} - -export function OnboardStats({ globalConfigsCount, userConfigsCount }: OnboardStatsProps) { - return ( - - {globalConfigsCount > 0 && ( - - - {globalConfigsCount} Global Model{globalConfigsCount > 1 ? "s" : ""} - - )} - {userConfigsCount > 0 && ( - - - {userConfigsCount} Custom Config{userConfigsCount > 1 ? "s" : ""} - - )} - - - All Roles Assigned - - - ); -} diff --git a/surfsense_web/components/onboard/setup-llm-step.tsx b/surfsense_web/components/onboard/setup-llm-step.tsx deleted file mode 100644 index 97555c2f9..000000000 --- a/surfsense_web/components/onboard/setup-llm-step.tsx +++ /dev/null @@ -1,813 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { - AlertCircle, - Bot, - Brain, - Check, - CheckCircle, - ChevronDown, - ChevronsUpDown, - ChevronUp, - Plus, - Trash2, - Zap, -} from "lucide-react"; -import { motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { - createLLMConfigMutationAtom, - deleteLLMConfigMutationAtom, - updateLLMPreferencesMutationAtom, -} from "@/atoms/llm-config/llm-config-mutation.atoms"; -import { - globalLLMConfigsAtom, - llmConfigsAtom, - llmPreferencesAtom, -} from "@/atoms/llm-config/llm-config-query.atoms"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { LANGUAGES } from "@/contracts/enums/languages"; -import { getModelsByProvider } from "@/contracts/enums/llm-models"; -import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers"; -import { type CreateLLMConfigRequest, LLMConfig } from "@/contracts/types/llm-config.types"; -import { cn } from "@/lib/utils"; -import InferenceParamsEditor from "../inference-params-editor"; - -interface SetupLLMStepProps { - searchSpaceId: number; - onConfigCreated?: () => void; - onConfigDeleted?: () => void; - onPreferencesUpdated?: () => Promise; -} - -const ROLE_DESCRIPTIONS = { - long_context: { - icon: Brain, - key: "long_context_llm_id" as const, - titleKey: "long_context_llm_title", - descKey: "long_context_llm_desc", - examplesKey: "long_context_llm_examples", - color: - "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800", - }, - fast: { - icon: Zap, - key: "fast_llm_id" as const, - titleKey: "fast_llm_title", - descKey: "fast_llm_desc", - examplesKey: "fast_llm_examples", - color: - "bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800", - }, - strategic: { - icon: Bot, - key: "strategic_llm_id" as const, - titleKey: "strategic_llm_title", - descKey: "strategic_llm_desc", - examplesKey: "strategic_llm_examples", - color: - "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800", - }, -}; - -export function SetupLLMStep({ - searchSpaceId, - onConfigCreated, - onConfigDeleted, - onPreferencesUpdated, -}: SetupLLMStepProps) { - const { mutate: createLLMConfig, isPending: isCreatingLlmConfig } = useAtomValue( - createLLMConfigMutationAtom - ); - const t = useTranslations("onboard"); - const { mutateAsync: deleteLLMConfig } = useAtomValue(deleteLLMConfigMutationAtom); - const { data: llmConfigs = [] } = useAtomValue(llmConfigsAtom); - const { data: globalConfigs = [] } = useAtomValue(globalLLMConfigsAtom); - const { data: preferences = {} } = useAtomValue(llmPreferencesAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - - const [isAddingNew, setIsAddingNew] = useState(false); - const [formData, setFormData] = useState({ - name: "", - provider: "" as CreateLLMConfigRequest["provider"], // Allow it as Default - custom_provider: "", - model_name: "", - api_key: "", - api_base: "", - language: "English", - litellm_params: {}, - search_space_id: searchSpaceId, - }); - const [modelComboboxOpen, setModelComboboxOpen] = useState(false); - const [showProviderForm, setShowProviderForm] = useState(false); - - // Role assignments state - const [assignments, setAssignments] = useState({ - long_context_llm_id: preferences.long_context_llm_id || "", - fast_llm_id: preferences.fast_llm_id || "", - strategic_llm_id: preferences.strategic_llm_id || "", - }); - - // Combine global and user-specific configs - const allConfigs = [...globalConfigs, ...llmConfigs]; - - useEffect(() => { - setAssignments({ - long_context_llm_id: preferences.long_context_llm_id || "", - fast_llm_id: preferences.fast_llm_id || "", - strategic_llm_id: preferences.strategic_llm_id || "", - }); - }, [preferences]); - - const handleInputChange = (field: keyof CreateLLMConfigRequest, value: string) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) { - toast.error("Please fill in all required fields"); - return; - } - - createLLMConfig(formData, { - onError: (error) => { - console.error("Error creating LLM config:", error); - if (error instanceof Error) { - toast.error(error?.message || "Failed to create LLM config"); - } - }, - onSuccess: () => { - toast.success("LLM config created successfully"); - setFormData({ - name: "", - provider: "" as CreateLLMConfigRequest["provider"], - custom_provider: "", - model_name: "", - api_key: "", - api_base: "", - language: "English", - litellm_params: {}, - search_space_id: searchSpaceId, - }); - onConfigCreated?.(); - }, - onSettled: () => { - setIsAddingNew(false); - }, - }); - }; - - const handleRoleAssignment = async (role: string, configId: string) => { - const newAssignments = { - ...assignments, - [role]: configId === "" ? "" : parseInt(configId), - }; - - setAssignments(newAssignments); - - // Auto-save if this assignment completes all roles - const hasAllAssignments = - newAssignments.long_context_llm_id && - newAssignments.fast_llm_id && - newAssignments.strategic_llm_id; - - if (hasAllAssignments) { - const numericAssignments = { - long_context_llm_id: - typeof newAssignments.long_context_llm_id === "string" - ? parseInt(newAssignments.long_context_llm_id) - : newAssignments.long_context_llm_id, - fast_llm_id: - typeof newAssignments.fast_llm_id === "string" - ? parseInt(newAssignments.fast_llm_id) - : newAssignments.fast_llm_id, - strategic_llm_id: - typeof newAssignments.strategic_llm_id === "string" - ? parseInt(newAssignments.strategic_llm_id) - : newAssignments.strategic_llm_id, - }; - - await updatePreferences({ - search_space_id: searchSpaceId, - data: numericAssignments, - }); - - if (onPreferencesUpdated) { - await onPreferencesUpdated(); - } - } - }; - - const selectedProvider = LLM_PROVIDERS.find((p) => p.value === formData.provider); - const availableModels = formData.provider ? getModelsByProvider(formData.provider) : []; - - const handleParamsChange = (newParams: Record) => { - setFormData((prev) => ({ ...prev, litellm_params: newParams })); - }; - - const handleProviderChange = (value: string) => { - handleInputChange("provider", value); - setFormData((prev) => ({ ...prev, model_name: "" })); - }; - - const isAssignmentComplete = - assignments.long_context_llm_id && assignments.fast_llm_id && assignments.strategic_llm_id; - - return ( -
- {/* Global Configs Notice - Prominent at top */} - {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 - role assignment section below. -

-

- Or expand "Add LLM Provider" to add your own custom configurations. -

-
-
-
- )} - - {/* Section 1: Add LLM Providers */} -
-
-
-

- - {t("add_llm_provider")} -

-

{t("configure_first_provider")}

-
- -
- - {showProviderForm && ( - - {/* Info Alert */} - - - {t("add_provider_instruction")} - - - {/* Existing Configurations */} - {llmConfigs.length > 0 && ( -
-

- {t("your_llm_configs")} -

-
- {llmConfigs.map((config) => ( - - - -
-
-
- -

{config.name}

- - {config.provider} - -
-

- {t("model")}: {config.model_name} - {config.language && ` • ${t("language")}: ${config.language}`} - {config.api_base && ` • ${t("base")}: ${config.api_base}`} -

-
- -
-
-
-
- ))} -
-
- )} - - {/* Add New Provider */} - {!isAddingNew ? ( - - - -

{t("add_provider_title")}

-

- {t("add_provider_subtitle")} -

- -
-
- ) : ( - - - {t("add_new_llm_provider")} - {t("configure_new_provider")} - - -
-
-
- - handleInputChange("name", e.target.value)} - required - /> -
- -
- - -
- -
- - -
-
- - {formData.provider === "CUSTOM" && ( -
- - handleInputChange("custom_provider", e.target.value)} - required - /> -
- )} - -
- - - - - - - - handleInputChange("model_name", value)} - /> - - -
- {formData.model_name - ? `Using custom model: "${formData.model_name}"` - : "Type your model name above"} -
-
- {availableModels.length > 0 && ( - - {availableModels - .filter( - (model) => - !formData.model_name || - model.value - .toLowerCase() - .includes(formData.model_name.toLowerCase()) || - model.label - .toLowerCase() - .includes(formData.model_name.toLowerCase()) - ) - .map((model) => ( - { - handleInputChange("model_name", currentValue); - setModelComboboxOpen(false); - }} - className="flex flex-col items-start py-3" - > -
- -
-
{model.label}
- {model.contextWindow && ( -
- Context: {model.contextWindow} -
- )} -
-
-
- ))} -
- )} -
-
-
-
-

- {availableModels.length > 0 - ? `Type freely or select from ${availableModels.length} model suggestions` - : selectedProvider?.example - ? `${t("examples")}: ${selectedProvider.example}` - : "Type your model name freely"} -

-
- -
- - handleInputChange("api_key", e.target.value)} - required - /> - {formData.provider === "OLLAMA" && ( -

- 💡 Ollama doesn't require authentication — enter any value (e.g., - "ollama") -

- )} -
- -
- - handleInputChange("api_base", e.target.value)} - /> - {/* Ollama-specific help */} - {formData.provider === "OLLAMA" && ( -
-

- 💡 Ollama API Base URL Examples: -

-
- - -
-
- )} -
- -
- -
- -
- - -
-
-
-
- )} -
- )} -
- - - - {/* Section 2: Assign Roles */} -
-
-

- - {t("assign_llm_roles")} -

-

{t("assign_specific_roles")}

-
- - {allConfigs.length === 0 ? ( - - - {t("add_provider_before_roles")} - - ) : ( -
- - - {t("assign_roles_instruction")} - - -
- {Object.entries(ROLE_DESCRIPTIONS).map(([roleKey, role]) => { - const IconComponent = role.icon; - const currentAssignment = assignments[role.key]; - const assignedConfig = allConfigs.find((config) => config.id === currentAssignment); - - return ( - - - -
-
-
- -
-
- {t(role.titleKey)} - - {t(role.descKey)} - -
-
- {currentAssignment && } -
-
- -
- - -
- - {assignedConfig && ( -
-
- - {t("assigned")}: - {"is_global" in assignedConfig && assignedConfig.is_global && ( - - 🌐 Global - - )} - - {assignedConfig.provider} - - {assignedConfig.name} -
-
- {t("model")}: {assignedConfig.model_name} -
-
- )} -
-
-
- ); - })} -
- - {/* Status Indicators */} -
-
- {t("progress")}: -
- {Object.keys(ROLE_DESCRIPTIONS).map((key) => { - const roleKey = ROLE_DESCRIPTIONS[key as keyof typeof ROLE_DESCRIPTIONS].key; - return ( -
- ); - })} -
- - {t("roles_assigned", { - assigned: Object.values(assignments).filter(Boolean).length, - total: Object.keys(ROLE_DESCRIPTIONS).length, - })} - -
- - {isAssignmentComplete && ( -
- - {t("all_roles_assigned_saved")} -
- )} -
-
- )} -
-
- ); -} diff --git a/surfsense_web/components/onboard/setup-prompt-step.tsx b/surfsense_web/components/onboard/setup-prompt-step.tsx deleted file mode 100644 index b53e49700..000000000 --- a/surfsense_web/components/onboard/setup-prompt-step.tsx +++ /dev/null @@ -1,340 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { authenticatedFetch } from "@/lib/auth-utils"; - -interface SetupPromptStepProps { - searchSpaceId: number; - onComplete?: () => void; -} - -export function SetupPromptStep({ searchSpaceId, onComplete }: SetupPromptStepProps) { - const { data: prompts = [], isPending: loadingPrompts } = useAtomValue(communityPromptsAtom); - const [enableCitations, setEnableCitations] = useState(true); - const [customInstructions, setCustomInstructions] = useState(""); - const [saving, setSaving] = useState(false); - const [hasChanges, setHasChanges] = useState(false); - const [selectedPromptKey, setSelectedPromptKey] = useState(null); - const [expandedPrompts, setExpandedPrompts] = useState>(new Set()); - const [selectedCategory, setSelectedCategory] = useState("all"); - - // Mark that we have changes when user modifies anything - useEffect(() => { - setHasChanges(true); - }, [enableCitations, customInstructions]); - - const handleSelectCommunityPrompt = (promptKey: string, promptValue: string) => { - setCustomInstructions(promptValue); - setSelectedPromptKey(promptKey); - toast.success("Community prompt applied"); - }; - - const toggleExpand = (promptKey: string) => { - const newExpanded = new Set(expandedPrompts); - if (newExpanded.has(promptKey)) { - newExpanded.delete(promptKey); - } else { - newExpanded.add(promptKey); - } - setExpandedPrompts(newExpanded); - }; - - // Get unique categories - const categories = Array.from(new Set(prompts.map((p) => p.category || "general"))); - const filteredPrompts = - selectedCategory === "all" - ? prompts - : prompts.filter((p) => (p.category || "general") === selectedCategory); - - const truncateText = (text: string, maxLength: number = 150) => { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + "..."; - }; - - const handleSave = async () => { - try { - setSaving(true); - - // Prepare the update payload with simplified schema - const payload: any = { - citations_enabled: enableCitations, - qna_custom_instructions: customInstructions.trim() || "", - }; - - // Only send update if there's something to update - if (Object.keys(payload).length > 0) { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.detail || `Failed to save prompt configuration (${response.status})` - ); - } - - toast.success("Prompt configuration saved successfully"); - } - - setHasChanges(false); - onComplete?.(); - } catch (error: any) { - console.error("Error saving prompt configuration:", error); - toast.error(error.message || "Failed to save prompt configuration"); - } finally { - setSaving(false); - } - }; - - const handleSkip = () => { - // Skip without saving - use defaults - onComplete?.(); - }; - - return ( -
- - - - These settings are optional. You can skip this step and configure them later in settings. - - - - {/* Citation Toggle */} -
-
-
- -

- When enabled, AI responses will include citations to source documents using - [citation:id] format. -

-
- -
- - {!enableCitations && ( - - - - Disabling citations means AI responses won't include source references. You can - re-enable this anytime in settings. - - - )} -
- - {/* SearchSpace System Instructions */} -
-
- -

- Add system instructions to guide how the AI should respond. Choose from community - prompts below or write your own. -

- - {/* Community Prompts Section */} - {!loadingPrompts && prompts.length > 0 && ( - - - - - Community Prompts Library - - - Browse {prompts.length} curated prompts. Click to preview or apply directly - - - - - - - All ({prompts.length}) - - {categories.map((category) => ( - - {category} ( - {prompts.filter((p) => (p.category || "general") === category).length}) - - ))} - - - -
- {filteredPrompts.map((prompt) => { - const isExpanded = expandedPrompts.has(prompt.key); - const isSelected = selectedPromptKey === prompt.key; - const displayText = isExpanded - ? prompt.value - : truncateText(prompt.value, 120); - - return ( -
-
-
- - {prompt.key.replace(/_/g, " ")} - - {prompt.category && ( - - {prompt.category} - - )} - {isSelected && ( - - ✓ Selected - - )} -
- {prompt.link && ( - - - - )} -
- -

- {displayText} -

- -
-
- - {prompt.author} -
- -
- {prompt.value.length > 120 && ( - - )} - -
-
-
- ); - })} -
-
-
-
-
- )} - -