diff --git a/backend/app/ai_providers/openai.py b/backend/app/ai_providers/openai.py index 740a239..b0b8c7f 100644 --- a/backend/app/ai_providers/openai.py +++ b/backend/app/ai_providers/openai.py @@ -18,6 +18,8 @@ async def initialize(self, config: Dict[str, Any]) -> bool: self.api_key = config.get("api_key", "") self.organization = config.get("organization", None) self.base_url = config.get("base_url", None) + self.server_name = config.get("server_name", "OpenAI API") + # Initialize the OpenAI client client_kwargs = {"api_key": self.api_key} diff --git a/backend/app/ai_providers/openrouter.py b/backend/app/ai_providers/openrouter.py new file mode 100644 index 0000000..4a490e7 --- /dev/null +++ b/backend/app/ai_providers/openrouter.py @@ -0,0 +1,402 @@ +""" +OpenRouter provider implementation. +""" +from typing import Dict, List, Any, AsyncGenerator +from openai import AsyncOpenAI +from .base import AIProvider + + +class OpenRouterProvider(AIProvider): + """OpenRouter provider implementation.""" + + @property + def provider_name(self) -> str: + return "openrouter" + + async def initialize(self, config: Dict[str, Any]) -> bool: + """Initialize the provider with configuration.""" + self.api_key = config.get("api_key", "") + self.base_url = "https://openrouter.ai/api/v1" + self.server_name = config.get("server_name", "OpenRouter API") + + # Initialize the OpenAI client with OpenRouter configuration + client_kwargs = { + "api_key": self.api_key, + "base_url": self.base_url + } + + self.client = AsyncOpenAI(**client_kwargs) + return True + + async def get_models(self) -> List[Dict[str, Any]]: + """Get available models from OpenRouter.""" + try: + models = await self.client.models.list() + return [ + { + "id": model.id, + "name": model.id, + "provider": "openrouter", + "metadata": { + "created": model.created, + "owned_by": model.owned_by, + "context_length": getattr(model, 'context_length', None), + "pricing": getattr(model, 'pricing', None) + } + } + for model in models.data + ] + except Exception as e: + # If models.list fails, return a list of common OpenRouter models + return [ + { + "id": "openai/gpt-4", + "name": "GPT-4", + "provider": "openrouter", + "metadata": { + "owned_by": "openai", + "context_length": 8192 + } + }, + { + "id": "openai/gpt-4o", + "name": "GPT-4o", + "provider": "openrouter", + "metadata": { + "owned_by": "openai", + "context_length": 128000 + } + }, + { + "id": "openai/gpt-3.5-turbo", + "name": "GPT-3.5 Turbo", + "provider": "openrouter", + "metadata": { + "owned_by": "openai", + "context_length": 16385 + } + }, + { + "id": "anthropic/claude-3.5-sonnet", + "name": "Claude 3.5 Sonnet", + "provider": "openrouter", + "metadata": { + "owned_by": "anthropic", + "context_length": 200000 + } + }, + { + "id": "anthropic/claude-3-haiku", + "name": "Claude 3 Haiku", + "provider": "openrouter", + "metadata": { + "owned_by": "anthropic", + "context_length": 200000 + } + }, + { + "id": "google/gemini-pro", + "name": "Gemini Pro", + "provider": "openrouter", + "metadata": { + "owned_by": "google", + "context_length": 32768 + } + }, + { + "id": "meta-llama/llama-3.1-8b-instruct", + "name": "Llama 3.1 8B Instruct", + "provider": "openrouter", + "metadata": { + "owned_by": "meta-llama", + "context_length": 8192 + } + } + ] + + async def generate_text(self, prompt: str, model: str, params: Dict[str, Any]) -> Dict[str, Any]: + """ + Generate text from a prompt (batch/full response mode). + + Args: + prompt: The input prompt text + model: The model to use for generation + params: Additional parameters for the generation + + Returns: + A dictionary containing the generated text and metadata + """ + # Create a copy of params to avoid modifying the original + payload_params = params.copy() + + # Ensure stream is not set for batch mode + if "stream" in payload_params: + del payload_params["stream"] + + # Extract parameters that should not be passed to the API + max_tokens = payload_params.pop("max_tokens", None) + temperature = payload_params.pop("temperature", None) + top_p = payload_params.pop("top_p", None) + + # Build the API parameters + api_params = { + "model": model, + **payload_params + } + + # Add optional parameters if provided + if max_tokens is not None: + api_params["max_tokens"] = max_tokens + if temperature is not None: + api_params["temperature"] = temperature + if top_p is not None: + api_params["top_p"] = top_p + + try: + # Call the OpenRouter API + response = await self.client.completions.create( + prompt=prompt, + **api_params + ) + + return { + "text": response.choices[0].text, + "provider": "openrouter", + "model": model, + "finish_reason": response.choices[0].finish_reason, + "metadata": { + "id": response.id, + "usage": { + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + "total_tokens": response.usage.total_tokens + } if response.usage else None + } + } + except Exception as e: + return { + "error": str(e), + "provider": "openrouter", + "model": model + } + + async def generate_stream(self, prompt: str, model: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]: + """ + Generate streaming text from a prompt. + + Args: + prompt: The input prompt text + model: The model to use for generation + params: Additional parameters for the generation + + Yields: + Dictionary containing streaming text chunks and metadata + """ + # Create a copy of params to avoid modifying the original + payload_params = params.copy() + + # Set stream to True for streaming mode + payload_params["stream"] = True + + # Extract parameters that should not be passed to the API + max_tokens = payload_params.pop("max_tokens", None) + temperature = payload_params.pop("temperature", None) + top_p = payload_params.pop("top_p", None) + + # Build the API parameters + api_params = { + "model": model, + **payload_params + } + + # Add optional parameters if provided + if max_tokens is not None: + api_params["max_tokens"] = max_tokens + if temperature is not None: + api_params["temperature"] = temperature + if top_p is not None: + api_params["top_p"] = top_p + + try: + # Call the OpenRouter API with streaming + stream = await self.client.completions.create( + prompt=prompt, + **api_params + ) + + async for chunk in stream: + if chunk.choices[0].delta.text: + yield { + "choices": [ + { + "text": chunk.choices[0].delta.text, + "finish_reason": chunk.choices[0].finish_reason + } + ], + "provider": "openrouter", + "model": model, + "metadata": { + "id": chunk.id + } + } + except Exception as e: + yield { + "error": str(e), + "provider": "openrouter", + "model": model + } + + async def chat_completion(self, messages: List[Dict[str, Any]], model: str, params: Dict[str, Any]) -> Dict[str, Any]: + """ + Generate a chat completion. + + Args: + messages: List of message objects with 'role' and 'content' + model: The model to use for generation + params: Additional parameters for the generation + + Returns: + A dictionary containing the chat completion and metadata + """ + # Create a copy of params to avoid modifying the original + payload_params = params.copy() + + # Ensure stream is not set for batch mode + if "stream" in payload_params: + del payload_params["stream"] + + # Extract parameters that should not be passed to the API + max_tokens = payload_params.pop("max_tokens", None) + temperature = payload_params.pop("temperature", None) + top_p = payload_params.pop("top_p", None) + + # Build the API parameters + api_params = { + "model": model, + "messages": messages, + **payload_params + } + + # Add optional parameters if provided + if max_tokens is not None: + api_params["max_tokens"] = max_tokens + if temperature is not None: + api_params["temperature"] = temperature + if top_p is not None: + api_params["top_p"] = top_p + + try: + # Call the OpenRouter API + response = await self.client.chat.completions.create(**api_params) + + return { + "content": response.choices[0].message.content, + "role": response.choices[0].message.role, + "provider": "openrouter", + "model": model, + "finish_reason": response.choices[0].finish_reason, + "metadata": { + "id": response.id, + "usage": { + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + "total_tokens": response.usage.total_tokens + } if response.usage else None + } + } + except Exception as e: + return { + "error": str(e), + "provider": "openrouter", + "model": model + } + + async def chat_completion_stream(self, messages: List[Dict[str, Any]], model: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]: + """ + Generate a streaming chat completion. + + Args: + messages: List of message objects with 'role' and 'content' + model: The model to use for generation + params: Additional parameters for the generation + + Yields: + Dictionary containing streaming chat completion chunks and metadata + """ + # Create a copy of params to avoid modifying the original + payload_params = params.copy() + + # Set stream to True for streaming mode + payload_params["stream"] = True + + # Extract parameters that should not be passed to the API + max_tokens = payload_params.pop("max_tokens", None) + temperature = payload_params.pop("temperature", None) + top_p = payload_params.pop("top_p", None) + + # Build the API parameters + api_params = { + "model": model, + "messages": messages, + **payload_params + } + + # Add optional parameters if provided + if max_tokens is not None: + api_params["max_tokens"] = max_tokens + if temperature is not None: + api_params["temperature"] = temperature + if top_p is not None: + api_params["top_p"] = top_p + + try: + # Call the OpenRouter API with streaming + stream = await self.client.chat.completions.create(**api_params) + + async for chunk in stream: + if chunk.choices[0].delta.content: + yield { + "choices": [ + { + "delta": { + "content": chunk.choices[0].delta.content, + "role": chunk.choices[0].delta.role or "assistant" + }, + "finish_reason": chunk.choices[0].finish_reason + } + ], + "provider": "openrouter", + "model": model, + "metadata": { + "id": chunk.id + } + } + except Exception as e: + yield { + "error": str(e), + "provider": "openrouter", + "model": model + } + + async def validate_connection(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Validate connection to OpenRouter.""" + try: + # Initialize with the provided config + await self.initialize(config) + + # Try to get models to validate the connection + models = await self.get_models() + + return { + "valid": True, + "provider": "openrouter", + "message": f"Successfully connected to OpenRouter. Found {len(models)} models.", + "models_count": len(models) + } + except Exception as e: + return { + "valid": False, + "provider": "openrouter", + "error": str(e), + "message": "Failed to connect to OpenRouter" + } \ No newline at end of file diff --git a/backend/app/ai_providers/registry.py b/backend/app/ai_providers/registry.py index 3270e80..f0e3518 100644 --- a/backend/app/ai_providers/registry.py +++ b/backend/app/ai_providers/registry.py @@ -5,6 +5,7 @@ from .base import AIProvider from .ollama import OllamaProvider from .openai import OpenAIProvider +from .openrouter import OpenRouterProvider class AIProviderRegistry: """Registry for AI providers.""" @@ -16,6 +17,7 @@ def __init__(self): # Register built-in providers self.register_provider("ollama", OllamaProvider) self.register_provider("openai", OpenAIProvider) + self.register_provider("openrouter", OpenRouterProvider) def register_provider(self, name: str, provider_class: Type[AIProvider]) -> None: """Register a new provider class.""" diff --git a/backend/app/api/v1/endpoints/ai_providers.py b/backend/app/api/v1/endpoints/ai_providers.py index 8b592a3..5d74de7 100644 --- a/backend/app/api/v1/endpoints/ai_providers.py +++ b/backend/app/api/v1/endpoints/ai_providers.py @@ -13,7 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from app.core.database import get_db +from app.core.security import get_current_user from app.models.settings import SettingDefinition, SettingScope, SettingInstance +from app.models.user import User from app.ai_providers.registry import provider_registry from app.ai_providers.ollama import OllamaProvider from app.schemas.ai_providers import ( @@ -94,7 +96,7 @@ async def get_provider_instance_from_request(request, db): setting = settings[0] logger.info(f"Using setting with ID: {setting['id'] if isinstance(setting, dict) else setting.id}") - # Extract servers from settings value + # Extract configuration from settings value # Parse the JSON string if value is a string setting_value = setting['value'] if isinstance(setting, dict) else setting.value @@ -119,8 +121,6 @@ async def get_provider_instance_from_request(request, db): detail=f"Error parsing settings value: expected dict, got {type(value_dict)}. " f"Please check the format of your settings." ) - - servers = value_dict.get("servers", []) except json.JSONDecodeError as e: logger.error(f"Error parsing JSON string: {e}") raise HTTPException( @@ -130,47 +130,88 @@ async def get_provider_instance_from_request(request, db): ) else: logger.info("Settings value is already a dictionary") - servers = setting_value.get("servers", []) - - logger.info(f"Found {len(servers)} servers in settings") - - # Find the specific server by ID - logger.info(f"Looking for server with ID: {request.server_id}") - server = next((s for s in servers if s.get("id") == request.server_id), None) - if not server and servers: - # If the requested server ID is not found but there are servers available, - # use the first server as a fallback - logger.warning(f"Server with ID {request.server_id} not found, using first available server as fallback") - server = servers[0] - logger.info(f"Using fallback server: {server.get('serverName')} ({server.get('id')})") - - if not server: - logger.error(f"No server found with ID: {request.server_id} and no fallback available") - raise HTTPException( - status_code=404, - detail=f"Server not found with ID: {request.server_id}. " - f"Please check your server configuration or use a different server ID." - ) + value_dict = setting_value - logger.info(f"Found server: {server.get('serverName')}") - - # Create provider configuration from server details - server_url = server.get("serverAddress") - if not server_url: - logger.error(f"Server URL is missing for server: {server.get('id')}") - raise HTTPException( - status_code=400, - detail=f"Server URL is missing for server: {server.get('id')}. " - f"Please update your server configuration with a valid URL." - ) + # Handle different provider configurations + if request.provider == "openai": + # OpenAI uses simple api_key structure + logger.info("Processing OpenAI provider configuration") + api_key = value_dict.get("api_key", "") + if not api_key: + logger.error("OpenAI API key is missing") + raise HTTPException( + status_code=400, + detail="OpenAI API key is required. Please configure your OpenAI API key in settings." + ) - config = { - "server_url": server_url, - "api_key": server.get("apiKey", ""), - "server_name": server.get("serverName", "Unknown Server") - } + # For OpenAI, we create a virtual server configuration + config = { + "api_key": api_key, + "server_url": "https://api.openai.com/v1", # Default OpenAI API URL + "server_name": "OpenAI API" + } + logger.info(f"Created OpenAI config with API key") + elif request.provider == "openrouter": + # OpenRouter uses simple api_key structure (similar to OpenAI) + logger.info("Processing OpenRouter provider configuration") + api_key = value_dict.get("api_key", "") + if not api_key: + logger.error("OpenRouter API key is missing") + raise HTTPException( + status_code=400, + detail="OpenRouter API key is required. Please configure your OpenRouter API key in settings." + ) + + # For OpenRouter, we create a virtual server configuration + config = { + "api_key": api_key, + "server_url": "https://openrouter.ai/api/v1", # OpenRouter API URL + "server_name": "OpenRouter API" + } + logger.info(f"Created OpenRouter config with API key") + else: + # Other providers (like Ollama) use servers array + logger.info("Processing server-based provider configuration") + servers = value_dict.get("servers", []) + logger.info(f"Found {len(servers)} servers in settings") + + # Find the specific server by ID + logger.info(f"Looking for server with ID: {request.server_id}") + server = next((s for s in servers if s.get("id") == request.server_id), None) + if not server and servers: + # If the requested server ID is not found but there are servers available, + # use the first server as a fallback + logger.warning(f"Server with ID {request.server_id} not found, using first available server as fallback") + server = servers[0] + logger.info(f"Using fallback server: {server.get('serverName')} ({server.get('id')})") + + if not server: + logger.error(f"No server found with ID: {request.server_id} and no fallback available") + raise HTTPException( + status_code=404, + detail=f"Server not found with ID: {request.server_id}. " + f"Please check your server configuration or use a different server ID." + ) + + logger.info(f"Found server: {server.get('serverName')}") + + # Create provider configuration from server details + server_url = server.get("serverAddress") + if not server_url: + logger.error(f"Server URL is missing for server: {server.get('id')}") + raise HTTPException( + status_code=400, + detail=f"Server URL is missing for server: {server.get('id')}. " + f"Please update your server configuration with a valid URL." + ) + + config = { + "server_url": server_url, + "api_key": server.get("apiKey", ""), + "server_name": server.get("serverName", "Unknown Server") + } - logger.info(f"Created config with server_url: {config['server_url']}") + logger.info(f"Created config with server_url: {config.get('server_url', 'N/A')}") # Get provider instance logger.info(f"Getting provider instance for: {request.provider}, {request.server_id}") @@ -228,13 +269,19 @@ async def get_models( settings_id: str = Query(..., description="Settings ID"), server_id: str = Query(..., description="Server ID"), user_id: Optional[str] = Query("current", description="User ID"), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user) ): """Get available models from a provider.""" try: + # Resolve user_id from authentication if "current" is specified + if user_id == "current": + if not current_user: + raise HTTPException(status_code=401, detail="Authentication required") + user_id = str(current_user.id) + # Normalize user_id by removing hyphens if present - if user_id != "current": - user_id = user_id.replace("-", "") + user_id = user_id.replace("-", "") print(f"Getting models for: provider={provider}, settings_id={settings_id}, server_id={server_id}, user_id={user_id}") @@ -254,56 +301,69 @@ async def get_models( # Use the first setting found setting = settings[0] - # Extract servers from settings value + # Extract configuration from settings value # Parse the JSON string if value is a string setting_value = setting['value'] if isinstance(setting, dict) else setting.value - if isinstance(setting_value, str): try: - # First parse value_dict = json.loads(setting_value) - print(f"Parsed JSON string into: {value_dict}") - - # Check if the result is still a string (double-encoded JSON) - if isinstance(value_dict, str): - print("Value is double-encoded JSON, parsing again") - value_dict = json.loads(value_dict) - print(f"Parsed double-encoded JSON into: {value_dict}") - - # Now value_dict should be a dictionary - if not isinstance(value_dict, dict): - print(f"Error: value_dict is not a dictionary: {type(value_dict)}") - raise HTTPException(status_code=500, detail=f"Error parsing settings value: expected dict, got {type(value_dict)}") - - servers = value_dict.get("servers", []) - except json.JSONDecodeError as e: - print(f"Error parsing JSON string: {e}") - raise HTTPException(status_code=500, detail=f"Error parsing settings value: {str(e)}") + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid settings value format") else: - servers = setting_value.get("servers", []) - - print(f"Found {len(servers)} servers in settings") - - # Find the specific server by ID - server = next((s for s in servers if s.get("id") == server_id), None) - if not server and servers: - # If the requested server ID is not found but there are servers available, - # use the first server as a fallback - print(f"Server with ID {server_id} not found, using first available server as fallback") - server = servers[0] - print(f"Using fallback server: {server.get('serverName')} ({server.get('id')})") - - if not server: - raise HTTPException(status_code=404, detail=f"Server not found with ID: {server_id}") + value_dict = setting_value - print(f"Found server: {server.get('serverName')}") + print(f"Parsed settings value: {value_dict}") - # Create provider configuration from server details - config = { - "server_url": server.get("serverAddress"), - "api_key": server.get("apiKey", ""), - "server_name": server.get("serverName") - } + # Handle different provider configurations + if provider == "openai": + # OpenAI uses simple api_key structure + api_key = value_dict.get("api_key", "") + if not api_key: + raise HTTPException(status_code=400, detail="OpenAI API key is required") + + config = { + "api_key": api_key, + "server_url": "https://api.openai.com/v1", # Default OpenAI API URL + "server_name": "OpenAI API" + } + print(f"Created OpenAI config with API key") + elif provider == "openrouter": + # OpenRouter uses simple api_key structure + api_key = value_dict.get("api_key", "") + if not api_key: + raise HTTPException(status_code=400, detail="OpenRouter API key is required") + + config = { + "api_key": api_key, + "server_url": "https://openrouter.ai/api/v1", # OpenRouter API URL + "server_name": "OpenRouter API" + } + print(f"Created OpenRouter config with API key") + else: + # Other providers (like Ollama) use servers array + servers = value_dict.get("servers", []) + print(f"Found {len(servers)} servers in settings") + + # Find the specific server by ID + server = next((s for s in servers if s.get("id") == server_id), None) + if not server and servers: + # If the requested server ID is not found but there are servers available, + # use the first server as a fallback + print(f"Server with ID {server_id} not found, using first available server as fallback") + server = servers[0] + print(f"Using fallback server: {server.get('serverName')} ({server.get('id')})") + + if not server: + raise HTTPException(status_code=404, detail=f"Server not found with ID: {server_id}") + + print(f"Found server: {server.get('serverName')}") + + # Create provider configuration from server details + config = { + "server_url": server.get("serverAddress"), + "api_key": server.get("apiKey", ""), + "server_name": server.get("serverName") + } print(f"Created config with server_url: {config['server_url']}") diff --git a/backend/app/api/v1/endpoints/settings.py b/backend/app/api/v1/endpoints/settings.py index 2c46b34..fdbb6b3 100644 --- a/backend/app/api/v1/endpoints/settings.py +++ b/backend/app/api/v1/endpoints/settings.py @@ -25,6 +25,44 @@ router = APIRouter(prefix="/settings") logger = logging.getLogger(__name__) +def mask_sensitive_data(definition_id: str, value: any) -> any: + """ + Mask sensitive data in settings values to prevent exposure to frontend. + Currently handles OpenAI and OpenRouter API keys. + """ + if not value: + return value + + # Handle OpenAI API keys + if definition_id == "openai_api_keys_settings": + if isinstance(value, dict) and "api_key" in value: + api_key = value["api_key"] + if api_key and len(api_key) >= 11: + # Mask the API key (first 7 + last 4 characters) + masked_key = api_key[:7] + "..." + api_key[-4:] + return { + **value, + "api_key": masked_key, + "_has_key": bool(api_key.strip()), + "_key_valid": bool(api_key.startswith('sk-') and len(api_key) >= 23) + } + + # Handle OpenRouter API keys + if definition_id == "openrouter_api_keys_settings": + if isinstance(value, dict) and "api_key" in value: + api_key = value["api_key"] + if api_key and len(api_key) >= 11: + # Mask the API key (first 7 + last 4 characters) + masked_key = api_key[:7] + "..." + api_key[-4:] + return { + **value, + "api_key": masked_key, + "_has_key": bool(api_key.strip()), + "_key_valid": bool(api_key.startswith('sk-or-') and len(api_key) >= 26) + } + + return value + async def get_definition_by_id(db, definition_id: str): """Helper function to get a setting definition by ID using direct SQL.""" query = text(""" @@ -1056,11 +1094,14 @@ async def get_setting_instances( logger.error(f"Failed to parse value as JSON for instance {row[0]}: {json_error}") decrypted_value = None + # Mask sensitive data before sending to frontend + masked_value = mask_sensitive_data(row[1], decrypted_value) + instance = { "id": row[0], "definition_id": row[1], "name": row[2], - "value": decrypted_value, + "value": masked_value, "scope": row[4], "user_id": row[5], "page_id": row[6], diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3fffcba..eba9a66 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -90,4 +90,8 @@ async def get_db(): await db.rollback() raise finally: - await db.close() + try: + await db.close() + except Exception as close_error: + # Log but don't raise close errors to avoid masking the real error + logger.warning(f"Error closing database session: {close_error}") diff --git a/backend/app/models/relationships.py b/backend/app/models/relationships.py index 4037d05..ef7ad30 100644 --- a/backend/app/models/relationships.py +++ b/backend/app/models/relationships.py @@ -17,6 +17,11 @@ from app.models.plugin_state import PluginState, PluginStateHistory, PluginStateConfig from app.models.component import Component from app.models.persona import Persona +from app.models.settings import SettingDefinition, SettingInstance +from app.models.message import Message +from app.models.role import Role +from app.models.tenant_models import Tenant, UserRole, TenantUser, RolePermission, Session, OAuthAccount + # Define User relationships User.pages = relationship("Page", back_populates="creator", lazy="selectin", foreign_keys="Page.creator_id") diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 61fd3f8..1d72d63 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,6 +1,6 @@ from sqlalchemy import Column, String, DateTime, Boolean, JSON, ForeignKey, Enum, func, select, ARRAY from sqlalchemy.orm import relationship -from app.core.database import Base +from app.models.base import Base from app.core.encrypted_column import create_encrypted_column from datetime import datetime from uuid import uuid4 diff --git a/backend/app/routers/plugins.py b/backend/app/routers/plugins.py index b7b0482..cae0ca6 100644 --- a/backend/app/routers/plugins.py +++ b/backend/app/routers/plugins.py @@ -1112,21 +1112,26 @@ async def serve_plugin_static( shared_plugin_dir = PLUGINS_DIR.parent / "backend" / "plugins" / "shared" / plugin.plugin_slug possible_paths.append(shared_plugin_dir / path) - # 3. User-specific directory with plugin_slug + # 3. Backend plugins directory (where webpack builds to) + if plugin.plugin_slug: + backend_plugin_dir = PLUGINS_DIR.parent / "backend" / "plugins" / plugin.plugin_slug + possible_paths.append(backend_plugin_dir / path) + + # 4. User-specific directory with plugin_slug if plugin.user_id and plugin.plugin_slug: user_plugin_dir = PLUGINS_DIR / plugin.user_id / plugin.plugin_slug possible_paths.append(user_plugin_dir / path) - # 4. User-specific directory with plugin ID + # 5. User-specific directory with plugin ID if plugin.user_id: user_plugin_dir = PLUGINS_DIR / plugin.user_id / plugin.id possible_paths.append(user_plugin_dir / path) - # 5. Legacy path directly under plugins directory with plugin_slug + # 6. Legacy path directly under plugins directory with plugin_slug if plugin.plugin_slug: possible_paths.append(PLUGINS_DIR / plugin.plugin_slug / path) - # 6. Legacy path directly under plugins directory with plugin ID + # 7. Legacy path directly under plugins directory with plugin ID possible_paths.append(PLUGINS_DIR / plugin.id / path) # Try each path @@ -1197,21 +1202,27 @@ async def serve_plugin_static_public( possible_paths.append(shared_plugin_dir / path) logger.debug(f"Added new architecture path without version: {shared_plugin_dir / path}") - # 3. User-specific directory with plugin_slug + # 3. Backend plugins directory (where webpack builds to) + if plugin.plugin_slug: + backend_plugin_dir = PLUGINS_DIR.parent / "backend" / "plugins" / plugin.plugin_slug + possible_paths.append(backend_plugin_dir / path) + logger.debug(f"Added backend plugins path: {backend_plugin_dir / path}") + + # 4. User-specific directory with plugin_slug if plugin.user_id and plugin.plugin_slug: user_plugin_dir = PLUGINS_DIR / plugin.user_id / plugin.plugin_slug possible_paths.append(user_plugin_dir / path) - # 4. User-specific directory with plugin ID + # 5. User-specific directory with plugin ID if plugin.user_id: user_plugin_dir = PLUGINS_DIR / plugin.user_id / plugin.id possible_paths.append(user_plugin_dir / path) - # 5. Legacy path directly under plugins directory with plugin_slug + # 6. Legacy path directly under plugins directory with plugin_slug if plugin.plugin_slug: possible_paths.append(PLUGINS_DIR / plugin.plugin_slug / path) - # 6. Legacy path directly under plugins directory with plugin ID + # 7. Legacy path directly under plugins directory with plugin ID possible_paths.append(PLUGINS_DIR / plugin.id / path) # Try each path diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index e9bd9d5..052ad6a 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -1,61 +1,64 @@ -import { z } from 'zod'; +import { z } from "zod"; // Add type declaration for Vite's import.meta.env declare global { - interface ImportMeta { - env: Record; - } + interface ImportMeta { + env: Record; + } } - // Environment configuration schema const envSchema = z.object({ - VITE_API_URL: z.string().optional(), - VITE_API_TIMEOUT: z.string().transform(Number).optional(), - MODE: z.enum(['development', 'production']).default('development'), - VITE_DEV_AUTO_LOGIN: z.string().transform(val => val === 'true').optional(), - VITE_DEV_EMAIL: z.string().optional(), - VITE_DEV_PASSWORD: z.string().optional(), + VITE_API_URL: z.string().optional(), + VITE_API_TIMEOUT: z.string().transform(Number).optional(), + MODE: z.enum(["development", "production"]).default("development"), + VITE_DEV_AUTO_LOGIN: z + .string() + .transform((val) => val === "true") + .optional(), + VITE_DEV_EMAIL: z.string().optional(), + VITE_DEV_PASSWORD: z.string().optional(), }); // Parse environment variables with fallback for import.meta.env -const env = envSchema.parse(typeof import.meta !== 'undefined' ? import.meta.env : {}); +const env = envSchema.parse( + typeof import.meta !== "undefined" ? import.meta.env : {} +); // Determine API URL based on environment and protocol const getApiBaseUrl = () => { - // If environment variable is provided, use it (highest priority) - if (env.VITE_API_URL) { - return env.VITE_API_URL; - } - - // In development, use the proxy - if (env.MODE === 'development') { - return 'http://10.0.2.149:8005'; // Direct connection to backend - } - - - // Default to localhost for local development/testing - return 'http://localhost:8005'; + // If environment variable is provided, use it (highest priority) + if (env.VITE_API_URL) { + return env.VITE_API_URL; + } + + // In development, use the proxy + if (env.MODE === "development") { + return "http://127.0.0.1:8005"; // Direct connection to backend + } + + // Default to localhost for local development/testing + return "http://localhost:8005"; }; // Application configuration export const config = { - api: { - baseURL: getApiBaseUrl(), - timeout: env.VITE_API_TIMEOUT || 10000, - }, - auth: { - tokenKey: 'accessToken', - development: { - autoLogin: env.VITE_DEV_AUTO_LOGIN || false, - email: env.VITE_DEV_EMAIL, - password: env.VITE_DEV_PASSWORD, - } - }, - env: { - isDevelopment: env.MODE === 'development', - isProduction: env.MODE === 'production', - }, + api: { + baseURL: getApiBaseUrl(), + timeout: env.VITE_API_TIMEOUT || 10000, + }, + auth: { + tokenKey: "accessToken", + development: { + autoLogin: env.VITE_DEV_AUTO_LOGIN || false, + email: env.VITE_DEV_EMAIL, + password: env.VITE_DEV_PASSWORD, + }, + }, + env: { + isDevelopment: env.MODE === "development", + isProduction: env.MODE === "production", + }, } as const; // Type exports diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7ed71b6..83b2adb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,501 +1,622 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { config } from '../config'; +import React, { useState, useEffect, useMemo } from "react"; +import { config } from "../config"; import { - Box, - Paper, - Typography, - Divider, - List, - ListItem, - ListItemText, - ListItemIcon, - Switch, - FormControlLabel, - TextField, - Grid, - MenuItem, - Select, - FormControl, - InputLabel, - Button, - IconButton, - Card, - CardContent, - CardHeader, - CardActions, - Tooltip, - Alert, - CircularProgress, -} from '@mui/material'; -import DarkModeIcon from '@mui/icons-material/DarkMode'; -import LanguageIcon from '@mui/icons-material/Language'; -import StorageIcon from '@mui/icons-material/Storage'; -import AddIcon from '@mui/icons-material/Add'; -import CloseIcon from '@mui/icons-material/Close'; -import SettingsIcon from '@mui/icons-material/Settings'; -import { useTheme } from '../contexts/ServiceContext'; -import { getAllModules, getModuleById } from '../plugins'; -import { DynamicModuleConfig } from '../types/index'; -import { PluginModuleRenderer } from '../components/PluginModuleRenderer'; -import { useApi } from '../contexts/ServiceContext'; + Box, + Paper, + Typography, + Divider, + List, + ListItem, + ListItemText, + ListItemIcon, + Switch, + FormControlLabel, + TextField, + Grid, + MenuItem, + Select, + FormControl, + InputLabel, + Button, + IconButton, + Card, + CardContent, + CardHeader, + CardActions, + Tooltip, + Alert, + CircularProgress, +} from "@mui/material"; +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import LanguageIcon from "@mui/icons-material/Language"; +import StorageIcon from "@mui/icons-material/Storage"; +import AddIcon from "@mui/icons-material/Add"; +import CloseIcon from "@mui/icons-material/Close"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { useTheme } from "../contexts/ServiceContext"; +import { getAllModules, getModuleById, enableLocalPlugins } from "../plugins"; +import { DynamicModuleConfig } from "../types/index"; +import { PluginModuleRenderer } from "../components/PluginModuleRenderer"; +import { useApi } from "../contexts/ServiceContext"; +import { remotePluginService } from "../services/remotePluginService"; // Interface for settings plugin with additional metadata interface SettingsPlugin { - pluginId: string; - moduleId: string; - moduleName: string; - displayName: string; - category: string; - priority: number; - settingName: string; - isActive: boolean; + pluginId: string; + moduleId: string; + moduleName: string; + displayName: string; + category: string; + priority: number; + settingName: string; + isActive: boolean; } // Interface for settings data from the database interface SettingsData { - id: string; - name: string; - value: any; - definition_id?: string; - scope?: string; - user_id?: string; - page_id?: string; - created_at?: string; - updated_at?: string; + id: string; + name: string; + value: any; + definition_id?: string; + scope?: string; + user_id?: string; + page_id?: string; + created_at?: string; + updated_at?: string; } const Settings = () => { - console.log('Settings component rendered'); - const themeService = useTheme(); - const apiService = useApi(); - const [apiEndpoint, setApiEndpoint] = useState(config.api.baseURL); - const [language, setLanguage] = useState('en'); - - // Settings plugins state - const [categories, setCategories] = useState([]); - const [selectedCategory, setSelectedCategory] = useState(''); - const [availablePlugins, setAvailablePlugins] = useState([]); - const [selectedPlugin, setSelectedPlugin] = useState(''); - const [activePlugins, setActivePlugins] = useState([]); - const [existingSettings, setExistingSettings] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // Handle theme change - const handleThemeChange = () => { - const newTheme = themeService.getCurrentTheme() === 'light' ? 'dark' : 'light'; - themeService.setTheme(newTheme); - }; - - // Fetch all settings plugins (modules with "settings" tag) - const fetchSettingsPlugins = () => { - try { - const allModules = getAllModules(); - - // Filter modules with "settings" tag (case-insensitive) - const settingsModules = allModules.filter(({ module }) => - module.tags?.some(tag => tag.toLowerCase() === 'settings') - ); - - // Extract categories and create settings plugins - const pluginsMap = new Map(); - const categoriesSet = new Set(); - - settingsModules.forEach(({ pluginId, module }) => { - // Find the settings name tag (any tag other than "settings") - const settingNameTag = module.tags?.find(tag => - tag.toLowerCase() !== 'settings' - ); - - if (settingNameTag) { - const category = module.category || 'General'; - categoriesSet.add(category); - - const settingsPlugin: SettingsPlugin = { - pluginId, - moduleId: module.id || module.name, - moduleName: module.name, - displayName: module.displayName || module.name, - category, - priority: module.priority || 0, - settingName: settingNameTag, - isActive: false, // Will be updated when we fetch existing settings - }; - - // Use settingName as key to ensure uniqueness - pluginsMap.set(settingNameTag.toLowerCase(), settingsPlugin); - } - }); - - // Convert to arrays - const allCategories = Array.from(categoriesSet).sort(); - const allPlugins = Array.from(pluginsMap.values()); - - // Set state - setCategories(allCategories); - setAvailablePlugins(allPlugins); - - // Set default category if available - if (allCategories.length > 0 && !selectedCategory) { - setSelectedCategory(allCategories[0]); - } - - return allPlugins; - } catch (error) { - console.error('Error fetching settings plugins:', error); - setError('Failed to load settings plugins'); - return []; - } - }; - - // Fetch existing settings from the database - const fetchExistingSettings = async () => { - setIsLoading(true); - setError(null); - - try { - if (!apiService) { - throw new Error('API service not available'); - } - - const response = await apiService.get('/api/v1/settings/instances', { - params: { - scope: 'user', - user_id: 'current' - } - }); - - let settingsData: SettingsData[] = []; - - if (Array.isArray(response)) { - settingsData = response; - } else if (response && typeof response === 'object' && response.data) { - settingsData = Array.isArray(response.data) ? response.data : [response.data]; - } - - console.log('Fetched settings:', settingsData); - setExistingSettings(settingsData); - - return settingsData; - } catch (error) { - console.error('Error fetching settings:', error); - setError('Failed to load existing settings'); - return []; - } finally { - setIsLoading(false); - } - }; - - // Update active plugins based on existing settings and available plugins - const updateActivePlugins = (plugins: SettingsPlugin[], settings: SettingsData[]) => { - // Create a map of setting name to plugin for quick lookup - const pluginsBySettingName = new Map(); - plugins.forEach(plugin => { - // Log each plugin to debug - console.log(`Available plugin: ${plugin.displayName}, category: ${plugin.category}, settingName: ${plugin.settingName}`); - pluginsBySettingName.set(plugin.settingName.toLowerCase(), plugin); - }); - - // Mark plugins as active if they have a corresponding setting - const active: SettingsPlugin[] = []; - - settings.forEach(setting => { - console.log(`Checking setting: ${setting.name}, definition_id: ${setting.definition_id}`); - - // Try to match by both setting name and definition_id - let plugin = pluginsBySettingName.get(setting.name.toLowerCase()); - - if (!plugin && setting.definition_id) { - // If not found by name, try to find by definition_id - plugin = plugins.find(p => - p.settingName.toLowerCase() === setting.definition_id!.toLowerCase() || - p.settingName.toLowerCase().includes(setting.definition_id!.toLowerCase()) - ); - - if (plugin) { - console.log(`Found plugin by definition_id: ${setting.definition_id} -> ${plugin.displayName}`); - } - } - - if (plugin) { - console.log(`Activating plugin: ${plugin.displayName} for setting: ${setting.name}`); - active.push({ - ...plugin, - isActive: true - }); - } else { - console.log(`No plugin found for setting: ${setting.name}`); - - // Special case for Ollama servers settings - if (setting.definition_id && setting.definition_id === 'ollama_servers_settings') { - // Find any plugin in the LLM Servers category with Ollama in the name or tags - const ollamaPlugin = plugins.find(p => - p.category === 'LLM Servers' && - (p.displayName.includes('Ollama') || - (p.settingName && p.settingName.toLowerCase().includes('ollama'))) - ); - - if (ollamaPlugin) { - console.log(`Found Ollama plugin by special case: ${ollamaPlugin.displayName}`); - active.push({ - ...ollamaPlugin, - isActive: true - }); - } - } - } - }); - - // Sort active plugins by priority (high to low) and then by name - const sortedActive = [...active].sort((a, b) => { - if (a.priority !== b.priority) { - return b.priority - a.priority; - } - return a.displayName.localeCompare(b.displayName); - }); - - console.log(`Total active plugins: ${sortedActive.length}`); - sortedActive.forEach(plugin => { - console.log(`Active plugin: ${plugin.displayName}, category: ${plugin.category}`); - }); - - setActivePlugins(sortedActive); - }; - - // Initialize data on component mount - useEffect(() => { - const initializeData = async () => { - const plugins = fetchSettingsPlugins(); - const settings = await fetchExistingSettings(); - updateActivePlugins(plugins, settings); - }; - - initializeData(); - }, []); - - // Effect to ensure plugins are properly displayed when category changes - useEffect(() => { - if (selectedCategory && activePlugins.length > 0) { - // Force re-computation of filtered plugins when category changes - const filtered = activePlugins.filter(plugin => plugin.category === selectedCategory); - console.log(`Category ${selectedCategory} has ${filtered.length} active plugins`); - - // Special case for LLM Servers category - ensure Ollama plugin is loaded - if (selectedCategory === 'LLM Servers' && filtered.length === 0) { - console.log('LLM Servers category selected but no active plugins found, checking for Ollama plugin'); - - // Check if we have Ollama settings in the database - const ollamaSettings = existingSettings.find(s => s.definition_id === 'ollama_servers_settings'); - - if (ollamaSettings) { - console.log('Found Ollama settings in database, looking for matching plugin'); - - // Find Ollama plugin in available plugins - const ollamaPlugin = availablePlugins.find(p => - p.category === 'LLM Servers' && - (p.displayName.includes('Ollama') || - (p.settingName && p.settingName.toLowerCase().includes('ollama'))) - ); - - if (ollamaPlugin && !activePlugins.some(p => p.moduleId === ollamaPlugin.moduleId)) { - console.log(`Found Ollama plugin (${ollamaPlugin.displayName}), activating it`); - - // Activate the Ollama plugin - setActivePlugins(prev => [ - ...prev, - { ...ollamaPlugin, isActive: true } - ]); - } - } - } - } - }, [selectedCategory, activePlugins, existingSettings, availablePlugins]); - - // Filter available plugins by selected category - const filteredAvailablePlugins = useMemo(() => { - if (!selectedCategory) return []; - - // Get plugins for the selected category that aren't already active - const activePluginIds = new Set(activePlugins.map(p => p.settingName.toLowerCase())); - - return availablePlugins.filter(plugin => - plugin.category === selectedCategory && - !activePluginIds.has(plugin.settingName.toLowerCase()) - ); - }, [availablePlugins, activePlugins, selectedCategory]); - - // Filter active plugins by selected category - const filteredActivePlugins = useMemo(() => { - if (!selectedCategory) return []; - - return activePlugins.filter(plugin => plugin.category === selectedCategory); - }, [activePlugins, selectedCategory]); - - // Add a plugin to the active list - const handleAddPlugin = () => { - if (!selectedPlugin) return; - - const pluginToAdd = availablePlugins.find(p => - p.moduleId === selectedPlugin && p.category === selectedCategory - ); - - if (pluginToAdd) { - const updatedActivePlugins = [ - ...activePlugins, - { ...pluginToAdd, isActive: true } - ]; - - // Sort by priority and name - const sortedPlugins = [...updatedActivePlugins].sort((a, b) => { - if (a.priority !== b.priority) { - return b.priority - a.priority; - } - return a.displayName.localeCompare(b.displayName); - }); - - setActivePlugins(sortedPlugins); - setSelectedPlugin(''); - } - }; - - // Remove a plugin from the active list - const handleRemovePlugin = (pluginToRemove: SettingsPlugin) => { - const updatedActivePlugins = activePlugins.filter( - plugin => !(plugin.pluginId === pluginToRemove.pluginId && - plugin.moduleId === pluginToRemove.moduleId) - ); - - setActivePlugins(updatedActivePlugins); - }; - - return ( - - - Settings - - - {error && ( - - {error} - - )} - - {/* Category and Plugin Selection */} - - - {/* Category Dropdown */} - - - Category - - - - - {/* Available Plugins Dropdown */} - - - Available Plugins - - - - - {/* Add Button */} - - - - - - - {/* Settings Plugins Grid */} - {isLoading ? ( - - - - ) : ( - - {filteredActivePlugins.length === 0 ? ( - - - No settings plugins available for this category. Select a plugin from the dropdown above to add it. - - - ) : ( - filteredActivePlugins.map((plugin) => ( - - - - handleRemovePlugin(plugin)} - size="small" - > - - - - } - avatar={} - /> - - - - - - )) - )} - - )} - - ); + console.log("Settings component rendered"); + const themeService = useTheme(); + const apiService = useApi(); + const [apiEndpoint, setApiEndpoint] = useState(config.api.baseURL); + const [language, setLanguage] = useState("en"); + + // Settings plugins state + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(""); + const [availablePlugins, setAvailablePlugins] = useState( + [] + ); + const [selectedPlugin, setSelectedPlugin] = useState(""); + const [activePlugins, setActivePlugins] = useState([]); + const [existingSettings, setExistingSettings] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Handle theme change + const handleThemeChange = () => { + const newTheme = + themeService.getCurrentTheme() === "light" ? "dark" : "light"; + themeService.setTheme(newTheme); + }; + + // Fetch all settings plugins (modules with "settings" tag) + const fetchSettingsPlugins = async () => { + try { + // Enable local plugins to ensure BrainDriveOpenAI is available + enableLocalPlugins(); + + // Load remote plugins to ensure BrainDriveOpenAI is available + const remoteManifest = + await remotePluginService.getRemotePluginManifest(); + await Promise.all( + remoteManifest.map((plugin) => + remotePluginService.loadRemotePlugin(plugin) + ) + ); + + // Get modules from local plugins + const localModules = getAllModules(); + + // Get modules from remote plugins with their plugin IDs + const remoteModules: { pluginId: string; module: DynamicModuleConfig }[] = + []; + const loadedPlugins = remotePluginService.getAllLoadedPlugins(); + + loadedPlugins.forEach((plugin) => { + plugin.loadedModules.forEach((module) => { + remoteModules.push({ + pluginId: plugin.id, + module: { + id: module.id, + name: module.name, + displayName: module.displayName, + description: module.description, + icon: module.icon, + category: module.category, + enabled: true, + priority: module.priority, + props: module.props, + configFields: module.configFields, + messages: module.messages, + requiredServices: module.requiredServices, + dependencies: module.dependencies, + layout: module.layout, + tags: module.tags, + type: module.type, + } as DynamicModuleConfig, + }); + }); + }); + + // Combine local and remote modules + const allModules = [...localModules, ...remoteModules]; + + console.log("Local modules found:", localModules.length); + console.log("Remote modules found:", remoteModules.length); + console.log("Total modules found:", allModules.length); + console.log("All modules:", allModules); + + // Filter modules with "settings" tag (case-insensitive) + const settingsModules = allModules.filter(({ module }) => + module.tags?.some((tag) => tag.toLowerCase() === "settings") + ); + + console.log("Settings modules found:", settingsModules.length); + console.log("Settings modules:", settingsModules); + + // Extract categories and create settings plugins + const pluginsMap = new Map(); + const categoriesSet = new Set(); + + settingsModules.forEach(({ pluginId, module }) => { + // Find the settings name tag (any tag other than "settings") + const settingNameTag = module.tags?.find( + (tag) => tag.toLowerCase() !== "settings" + ); + + if (settingNameTag) { + const category = module.category || "General"; + categoriesSet.add(category); + + const settingsPlugin: SettingsPlugin = { + pluginId, + moduleId: module.id || module.name, + moduleName: module.name, + displayName: module.displayName || module.name, + category, + priority: module.priority || 0, + settingName: settingNameTag, + isActive: false, // Will be updated when we fetch existing settings + }; + + // Use settingName as key to ensure uniqueness + pluginsMap.set(settingNameTag.toLowerCase(), settingsPlugin); + } + }); + + // Convert to arrays + const allCategories = Array.from(categoriesSet).sort(); + const allPlugins = Array.from(pluginsMap.values()); + + // Set state + setCategories(allCategories); + setAvailablePlugins(allPlugins); + + // Set default category if available + if (allCategories.length > 0 && !selectedCategory) { + setSelectedCategory(allCategories[0]); + } + + return allPlugins; + } catch (error) { + console.error("Error fetching settings plugins:", error); + setError("Failed to load settings plugins"); + return []; + } + }; + + // Fetch existing settings from the database + const fetchExistingSettings = async () => { + setIsLoading(true); + setError(null); + + try { + if (!apiService) { + throw new Error("API service not available"); + } + + const response = await apiService.get("/api/v1/settings/instances", { + params: { + scope: "user", + user_id: "current", + }, + }); + + let settingsData: SettingsData[] = []; + + if (Array.isArray(response)) { + settingsData = response; + } else if (response && typeof response === "object" && response.data) { + settingsData = Array.isArray(response.data) + ? response.data + : [response.data]; + } + + console.log("Fetched settings:", settingsData); + setExistingSettings(settingsData); + + return settingsData; + } catch (error) { + console.error("Error fetching settings:", error); + setError("Failed to load existing settings"); + return []; + } finally { + setIsLoading(false); + } + }; + + // Update active plugins based on existing settings and available plugins + const updateActivePlugins = ( + plugins: SettingsPlugin[], + settings: SettingsData[] + ) => { + // Create a map of setting name to plugin for quick lookup + const pluginsBySettingName = new Map(); + plugins.forEach((plugin) => { + // Log each plugin to debug + console.log( + `Available plugin: ${plugin.displayName}, category: ${plugin.category}, settingName: ${plugin.settingName}` + ); + pluginsBySettingName.set(plugin.settingName.toLowerCase(), plugin); + }); + + // Mark plugins as active if they have a corresponding setting + const active: SettingsPlugin[] = []; + + settings.forEach((setting) => { + console.log( + `Checking setting: ${setting.name}, definition_id: ${setting.definition_id}` + ); + + // Try to match by both setting name and definition_id + let plugin = pluginsBySettingName.get(setting.name.toLowerCase()); + + if (!plugin && setting.definition_id) { + // If not found by name, try to find by definition_id + plugin = plugins.find( + (p) => + p.settingName.toLowerCase() === + setting.definition_id!.toLowerCase() || + p.settingName + .toLowerCase() + .includes(setting.definition_id!.toLowerCase()) + ); + + if (plugin) { + console.log( + `Found plugin by definition_id: ${setting.definition_id} -> ${plugin.displayName}` + ); + } + } + + if (plugin) { + console.log( + `Activating plugin: ${plugin.displayName} for setting: ${setting.name}` + ); + active.push({ + ...plugin, + isActive: true, + }); + } else { + console.log(`No plugin found for setting: ${setting.name}`); + + // Special case for Ollama servers settings + if ( + setting.definition_id && + setting.definition_id === "ollama_servers_settings" + ) { + // Find any plugin in the LLM Servers category with Ollama in the name or tags + const ollamaPlugin = plugins.find( + (p) => + p.category === "LLM Servers" && + (p.displayName.includes("Ollama") || + (p.settingName && + p.settingName.toLowerCase().includes("ollama"))) + ); + + if (ollamaPlugin) { + console.log( + `Found Ollama plugin by special case: ${ollamaPlugin.displayName}` + ); + active.push({ + ...ollamaPlugin, + isActive: true, + }); + } + } + } + }); + + // Sort active plugins by priority (high to low) and then by name + const sortedActive = [...active].sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return a.displayName.localeCompare(b.displayName); + }); + + console.log(`Total active plugins: ${sortedActive.length}`); + sortedActive.forEach((plugin) => { + console.log( + `Active plugin: ${plugin.displayName}, category: ${plugin.category}` + ); + }); + + setActivePlugins(sortedActive); + }; + + // Initialize data on component mount + useEffect(() => { + const initializeData = async () => { + const plugins = await fetchSettingsPlugins(); + const settings = await fetchExistingSettings(); + updateActivePlugins(plugins, settings); + }; + + initializeData(); + }, []); + + // Effect to ensure plugins are properly displayed when category changes + useEffect(() => { + if (selectedCategory && activePlugins.length > 0) { + // Force re-computation of filtered plugins when category changes + const filtered = activePlugins.filter( + (plugin) => plugin.category === selectedCategory + ); + console.log( + `Category ${selectedCategory} has ${filtered.length} active plugins` + ); + + // Special case for LLM Servers category - ensure Ollama plugin is loaded + if (selectedCategory === "LLM Servers" && filtered.length === 0) { + console.log( + "LLM Servers category selected but no active plugins found, checking for Ollama plugin" + ); + + // Check if we have Ollama settings in the database + const ollamaSettings = existingSettings.find( + (s) => s.definition_id === "ollama_servers_settings" + ); + + if (ollamaSettings) { + console.log( + "Found Ollama settings in database, looking for matching plugin" + ); + + // Find Ollama plugin in available plugins + const ollamaPlugin = availablePlugins.find( + (p) => + p.category === "LLM Servers" && + (p.displayName.includes("Ollama") || + (p.settingName && + p.settingName.toLowerCase().includes("ollama"))) + ); + + if ( + ollamaPlugin && + !activePlugins.some((p) => p.moduleId === ollamaPlugin.moduleId) + ) { + console.log( + `Found Ollama plugin (${ollamaPlugin.displayName}), activating it` + ); + + // Activate the Ollama plugin + setActivePlugins((prev) => [ + ...prev, + { ...ollamaPlugin, isActive: true }, + ]); + } + } + } + } + }, [selectedCategory, activePlugins, existingSettings, availablePlugins]); + + // Filter available plugins by selected category + const filteredAvailablePlugins = useMemo(() => { + if (!selectedCategory) return []; + + // Get plugins for the selected category that aren't already active + const activePluginIds = new Set( + activePlugins.map((p) => p.settingName.toLowerCase()) + ); + + return availablePlugins.filter( + (plugin) => + plugin.category === selectedCategory && + !activePluginIds.has(plugin.settingName.toLowerCase()) + ); + }, [availablePlugins, activePlugins, selectedCategory]); + + // Filter active plugins by selected category + const filteredActivePlugins = useMemo(() => { + if (!selectedCategory) return []; + + return activePlugins.filter( + (plugin) => plugin.category === selectedCategory + ); + }, [activePlugins, selectedCategory]); + + // Add a plugin to the active list + const handleAddPlugin = () => { + if (!selectedPlugin) return; + + const pluginToAdd = availablePlugins.find( + (p) => p.moduleId === selectedPlugin && p.category === selectedCategory + ); + + if (pluginToAdd) { + const updatedActivePlugins = [ + ...activePlugins, + { ...pluginToAdd, isActive: true }, + ]; + + // Sort by priority and name + const sortedPlugins = [...updatedActivePlugins].sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return a.displayName.localeCompare(b.displayName); + }); + + setActivePlugins(sortedPlugins); + setSelectedPlugin(""); + } + }; + + // Remove a plugin from the active list + const handleRemovePlugin = (pluginToRemove: SettingsPlugin) => { + const updatedActivePlugins = activePlugins.filter( + (plugin) => + !( + plugin.pluginId === pluginToRemove.pluginId && + plugin.moduleId === pluginToRemove.moduleId + ) + ); + + setActivePlugins(updatedActivePlugins); + }; + + return ( + + + Settings + + + {error && ( + + {error} + + )} + + {/* Category and Plugin Selection */} + + + {/* Category Dropdown */} + + + Category + + + + + {/* Available Plugins Dropdown */} + + + + Available Plugins + + + + + + {/* Add Button */} + + + + + + + {/* Settings Plugins Grid */} + {isLoading ? ( + + + + ) : ( + + {filteredActivePlugins.length === 0 ? ( + + + No settings plugins available for this category. Select a plugin + from the dropdown above to add it. + + + ) : ( + filteredActivePlugins.map((plugin) => ( + + + + handleRemovePlugin(plugin)} + size="small" + > + + + + } + avatar={} + /> + + + + + + )) + )} + + )} + + ); }; export default Settings; diff --git a/plugins/BrainDriveBasicAIChat/src/ComponentModelSelection.tsx b/plugins/BrainDriveBasicAIChat/src/ComponentModelSelection.tsx index 1119cca..ad29641 100644 --- a/plugins/BrainDriveBasicAIChat/src/ComponentModelSelection.tsx +++ b/plugins/BrainDriveBasicAIChat/src/ComponentModelSelection.tsx @@ -1,532 +1,652 @@ -import React from 'react'; -import './ComponentModelSelection.css'; -import CustomDropdown from './CustomDropdown'; +import React from "react"; +import "./ComponentModelSelection.css"; +import CustomDropdown from "./CustomDropdown"; // Define model interfaces interface ModelInfo { - name: string; - provider: string; - providerId: string; - serverName: string; - serverId: string; + name: string; + provider: string; + providerId: string; + serverName: string; + serverId: string; } interface ServerInfo { - id: string; - serverName: string; - serverAddress: string; - apiKey?: string; + id: string; + serverName: string; + serverAddress: string; + apiKey?: string; } interface ProviderSettings { - id: string; - name: string; - servers: ServerInfo[]; + id: string; + name: string; + servers: ServerInfo[]; } // Define the component props interface ComponentModelSelectionProps { - moduleId?: string; - label?: string; - labelPosition?: 'top' | 'left' | 'right' | 'bottom'; - providerSettings?: string[]; - targetComponent?: string; - services?: { - api?: { - get: (url: string, options?: any) => Promise; - post: (url: string, data: any) => Promise; - }; - settings?: { - getSetting: (id: string) => Promise; - setSetting: (id: string, value: any) => Promise; - getSettingDefinitions: () => Promise; - }; - event?: { - sendMessage: (target: string, message: any, options?: any) => void; - subscribeToMessages: (target: string, callback: (message: any) => void) => void; - unsubscribeFromMessages: (target: string, callback: (message: any) => void) => void; - }; - theme?: { - getCurrentTheme: () => string; - addThemeChangeListener: (callback: (theme: string) => void) => void; - removeThemeChangeListener: (callback: (theme: string) => void) => void; - }; - }; + moduleId?: string; + label?: string; + labelPosition?: "top" | "left" | "right" | "bottom"; + providerSettings?: string[]; + targetComponent?: string; + services?: { + api?: { + get: (url: string, options?: any) => Promise; + post: (url: string, data: any) => Promise; + }; + settings?: { + getSetting: (id: string) => Promise; + setSetting: (id: string, value: any) => Promise; + getSettingDefinitions: () => Promise; + }; + event?: { + sendMessage: (target: string, message: any, options?: any) => void; + subscribeToMessages: ( + target: string, + callback: (message: any) => void + ) => void; + unsubscribeFromMessages: ( + target: string, + callback: (message: any) => void + ) => void; + }; + theme?: { + getCurrentTheme: () => string; + addThemeChangeListener: (callback: (theme: string) => void) => void; + removeThemeChangeListener: (callback: (theme: string) => void) => void; + }; + }; } // Define the component state interface ComponentModelSelectionState { - models: ModelInfo[]; - selectedModel: ModelInfo | null; - isLoading: boolean; - error: string | null; - currentTheme: 'light' | 'dark'; - providerSettingsData: ProviderSettings[]; - pendingModelSelection: { - name: string; - provider: string; - serverId: string; - } | null; + models: ModelInfo[]; + selectedModel: ModelInfo | null; + isLoading: boolean; + error: string | null; + currentTheme: "light" | "dark"; + providerSettingsData: ProviderSettings[]; + pendingModelSelection: { + name: string; + provider: string; + serverId: string; + } | null; } -class ComponentModelSelection extends React.Component { - private eventService: any; - private themeChangeListener: ((theme: string) => void) | null = null; - private conversationModelListener: ((content: any) => void) | null = null; - - constructor(props: ComponentModelSelectionProps) { - super(props); - - this.state = { - models: [], - selectedModel: null, - isLoading: true, - error: null, - currentTheme: 'light', - providerSettingsData: [], - pendingModelSelection: null - }; - - // Initialize event service - if (props.services?.event) { - const { createEventService } = require('./services/eventService'); - this.eventService = createEventService('pluginA', props.moduleId || 'model-selection-v2'); - this.eventService.setServiceBridge(props.services.event); - } - } - - componentDidMount() { - this.initializeThemeService(); - this.loadProviderSettings(); - this.initializeEventListeners(); - } - - componentWillUnmount() { - if (this.themeChangeListener && this.props.services?.theme) { - this.props.services.theme.removeThemeChangeListener(this.themeChangeListener); - } - - if (this.conversationModelListener && this.props.services?.event) { - this.props.services.event.unsubscribeFromMessages( - 'model-selection-v2', - this.conversationModelListener - ); - } - } - - /** - * Initialize event listeners for conversation model selection - */ - initializeEventListeners() { - if (this.props.services?.event) { - try { - // Set up conversation model selection listener - this.conversationModelListener = (message: any) => { - console.log('Received model selection from conversation:', message); - - // Extract model from the message content - const modelInfo = message.content?.model; - - if (modelInfo) { - // First try to find the exact model by ID - const modelId = `${modelInfo.provider}_${modelInfo.serverId}_${modelInfo.name}`; - let matchingModel = this.state.models.find(model => - `${model.provider}_${model.serverId}_${model.name}` === modelId - ); - - // If exact match not found, try to find by name only - if (!matchingModel) { - console.log('Exact model match not found, trying to find by name:', modelInfo.name); - matchingModel = this.state.models.find(model => model.name === modelInfo.name); - } - - // If model is found and different from current selection, update it - if (matchingModel && - (!this.state.selectedModel || - this.state.selectedModel.name !== matchingModel.name)) { - this.setState({ - selectedModel: matchingModel, - pendingModelSelection: null // Clear any pending selection - }); - console.log('Updated model selection from conversation event:', matchingModel.name); - } else if (!matchingModel) { - console.log('Model from conversation not found in available models:', modelId); - console.log('Available models:', this.state.models.map(m => `${m.provider}_${m.serverId}_${m.name}`)); - - // Store the model info for later selection when models are loaded - this.setState({ - pendingModelSelection: { - name: modelInfo.name, - provider: modelInfo.provider, - serverId: modelInfo.serverId - } - }); - console.log('Saved pending model selection for later:', modelInfo.name); - } - } - }; - - // Subscribe to model selection events from conversation history - this.props.services.event.subscribeToMessages( - 'model-selection-v2', - this.conversationModelListener - ); - - console.log('Subscribed to model selection events from conversation history'); - } catch (error) { - console.error('Error initializing event listeners:', error); - } - } - } - - /** - * Initialize the theme service to listen for theme changes - */ - initializeThemeService() { - if (this.props.services?.theme) { - try { - // Get the current theme - const currentTheme = this.props.services.theme.getCurrentTheme(); - this.setState({ currentTheme: currentTheme as 'light' | 'dark' }); - - // Set up theme change listener - this.themeChangeListener = (newTheme: string) => { - this.setState({ currentTheme: newTheme as 'light' | 'dark' }); - }; - - // Add the listener to the theme service - this.props.services.theme.addThemeChangeListener(this.themeChangeListener); - } catch (error) { - console.error('Error initializing theme service:', error); - } - } - } - - /** - * Load provider settings based on configuration - */ - loadProviderSettings = async () => { - this.setState({ isLoading: true, error: null }); - - if (!this.props.services?.api) { - this.setState({ - isLoading: false, - error: 'API service not available' - }); - return; - } - - try { - // Get provider settings from configuration or use default - const providerSettingIds = this.props.providerSettings || ['ollama_servers_settings']; - const providerSettingsData: ProviderSettings[] = []; - - // Load each provider setting - for (const settingId of providerSettingIds) { - try { - const response = await this.props.services.api.get('/api/v1/settings/instances', { - params: { - definition_id: settingId, - scope: 'user', - user_id: 'current' - } - }); - - // Process response to extract settings data - let settingsData = null; - - if (Array.isArray(response) && response.length > 0) { - settingsData = response[0]; - } else if (response && typeof response === 'object') { - const responseObj = response as Record; - - if (responseObj.data) { - if (Array.isArray(responseObj.data) && responseObj.data.length > 0) { - settingsData = responseObj.data[0]; - } else if (typeof responseObj.data === 'object') { - settingsData = responseObj.data; - } - } else { - settingsData = response; - } - } - - if (settingsData && settingsData.value) { - // Parse the value field - let parsedValue = typeof settingsData.value === 'string' - ? JSON.parse(settingsData.value) - : settingsData.value; - - // Add to provider settings data - providerSettingsData.push({ - id: settingId, - name: settingsData.name || settingId, - servers: Array.isArray(parsedValue.servers) ? parsedValue.servers : [] - }); - } - } catch (error) { - console.error(`Error loading provider setting ${settingId}:`, error); - } - } - - this.setState({ - providerSettingsData, - isLoading: false - }, () => { - // Load models after settings are loaded - this.loadModels(); - }); - } catch (error: any) { - console.error("Error loading provider settings:", error); - - this.setState({ - isLoading: false, - error: `Error loading provider settings: ${error.message || 'Unknown error'}` - }); - } - }; - - /** - * Load models from all configured providers - */ - loadModels = async () => { - this.setState({ isLoading: true, error: null }); - - if (!this.props.services?.api) { - this.setState({ - isLoading: false, - error: 'API service not available' - }); - return; - } - - try { - const models: ModelInfo[] = []; - const { providerSettingsData } = this.state; - - // Process each provider setting - for (const providerSetting of providerSettingsData) { - // Determine provider type from setting ID - const providerType = providerSetting.id.includes('ollama') ? 'ollama' : - providerSetting.id.includes('openai') ? 'openai' : - 'unknown'; - - // Process each server in the provider setting - for (const server of providerSetting.servers) { - try { - let serverModels: any[] = []; - - // Fetch models based on provider type - if (providerType === 'ollama') { - // Encode the server URL - const encodedUrl = encodeURIComponent(server.serverAddress); - - // Build query parameters - const params: Record = { - server_url: encodedUrl, - settings_id: providerSetting.id, - server_id: server.id - }; - - // Add API key if provided - if (server.apiKey) { - params.api_key = server.apiKey; - } - - // Fetch models from the backend - const response = await this.props.services.api.get('/api/v1/ollama/models', { params }); - serverModels = Array.isArray(response) ? response : []; - } else if (providerType === 'openai') { - // Fetch OpenAI models - const response = await this.props.services.api.get('/api/v1/ai/models', { - params: { - provider: 'openai', - settings_id: providerSetting.id, - server_id: server.id - } - }); - - serverModels = response?.models || []; - } - - // Map server models to ModelInfo format - for (const model of serverModels) { - models.push({ - name: model.name, - provider: providerType, - providerId: providerSetting.id, - serverName: server.serverName, - serverId: server.id - }); - } - } catch (error) { - console.error(`Error loading models for server ${server.serverName}:`, error); - } - } - } - - // Check if we have a pending model selection - const { pendingModelSelection } = this.state; - let modelToSelect = models.length > 0 ? models[0] : null; - - if (pendingModelSelection && models.length > 0) { - console.log('Checking for pending model selection:', pendingModelSelection.name); - - // First try to find an exact match - let matchingModel = models.find(model => - model.name === pendingModelSelection.name && - model.provider === pendingModelSelection.provider && - model.serverId === pendingModelSelection.serverId - ); - - // If not found, try to match by name only - if (!matchingModel) { - matchingModel = models.find(model => model.name === pendingModelSelection.name); - } - - if (matchingModel) { - console.log('Found pending model in available models:', matchingModel.name); - modelToSelect = matchingModel; - } else { - console.log('Pending model still not found in available models:', pendingModelSelection.name); - } - } - - // Update state with models - this.setState({ - models, - isLoading: false, - selectedModel: modelToSelect, - pendingModelSelection: modelToSelect ? null : pendingModelSelection // Clear pending selection if found - }); - - // Broadcast initial model selection if available - if (modelToSelect) { - this.broadcastModelSelection(modelToSelect); - } - } catch (error) { - console.error('Error loading models:', error); - this.setState({ - isLoading: false, - error: 'Error loading models' - }); - } - }; - - /** - * Handle model selection change - */ - handleModelChange = (modelId: string) => { - const selectedModel = this.state.models.find(model => - `${model.provider}_${model.serverId}_${model.name}` === modelId - ); - - if (selectedModel) { - this.setState({ selectedModel }, () => { - this.broadcastModelSelection(selectedModel); - }); - } - }; - - /** - * Broadcast model selection event - */ - broadcastModelSelection = (model: ModelInfo) => { - if (!this.eventService && !this.props.services?.event) { - console.error('Event service not available'); - return; - } - - // Create model selection message - const modelInfo = { - type: 'model.selection', - content: { - model: { - name: model.name, - provider: model.provider, - providerId: model.providerId, - serverName: model.serverName, - serverId: model.serverId - }, - timestamp: new Date().toISOString() - } - }; - - // Send to target component or broadcast to all - const target = this.props.targetComponent || 'ai-prompt-chat'; - - // Log the target and message for debugging - console.log(`Sending model selection to target: ${target}`, modelInfo); - - // Send via both methods to ensure delivery - // The receiving component will handle deduplication - if (this.props.services?.event) { - this.props.services.event.sendMessage(target, modelInfo.content); - console.log('Model selection sent via services.event'); - } - - if (this.eventService) { - this.eventService.sendMessage(target, modelInfo, { remote: true }); - console.log('Model selection sent via eventService'); - } - }; - - /** - * Render the component - */ - render() { - const { models, selectedModel, isLoading, error, currentTheme } = this.state; - const { label = 'Select Model', labelPosition = 'top' } = this.props; - - // Determine layout based on position - const isHorizontal = labelPosition === 'top' || labelPosition === 'bottom'; - const layoutClass = isHorizontal ? 'horizontal' : 'vertical'; - - // Adjust order based on position - const labelOrder = labelPosition === 'bottom' || labelPosition === 'right' ? 2 : 1; - const dropdownOrder = labelPosition === 'bottom' || labelPosition === 'right' ? 1 : 2; - - // Create a unique ID for each model - const getModelId = (model: ModelInfo) => `${model.provider}_${model.serverId}_${model.name}`; - - // Convert models to dropdown options - const dropdownOptions = models.map(model => ({ - id: getModelId(model), - primaryText: model.name, - secondaryText: model.serverName - })); - - return ( -
-
- - -
- {isLoading ? ( -
- ) : error ? ( -
{error}
- ) : ( - - )} -
-
-
- ); - } +class ComponentModelSelection extends React.Component< + ComponentModelSelectionProps, + ComponentModelSelectionState +> { + private eventService: any; + private themeChangeListener: ((theme: string) => void) | null = null; + private conversationModelListener: ((content: any) => void) | null = null; + + constructor(props: ComponentModelSelectionProps) { + super(props); + + this.state = { + models: [], + selectedModel: null, + isLoading: true, + error: null, + currentTheme: "light", + providerSettingsData: [], + pendingModelSelection: null, + }; + + // Initialize event service + if (props.services?.event) { + const { createEventService } = require("./services/eventService"); + this.eventService = createEventService( + "pluginA", + props.moduleId || "model-selection-v2" + ); + this.eventService.setServiceBridge(props.services.event); + } + } + + componentDidMount() { + this.initializeThemeService(); + this.loadProviderSettings(); + this.initializeEventListeners(); + } + + componentWillUnmount() { + if (this.themeChangeListener && this.props.services?.theme) { + this.props.services.theme.removeThemeChangeListener( + this.themeChangeListener + ); + } + + if (this.conversationModelListener && this.props.services?.event) { + this.props.services.event.unsubscribeFromMessages( + "model-selection-v2", + this.conversationModelListener + ); + } + } + + /** + * Initialize event listeners for conversation model selection + */ + initializeEventListeners() { + if (this.props.services?.event) { + try { + // Set up conversation model selection listener + this.conversationModelListener = (message: any) => { + console.log("Received model selection from conversation:", message); + + // Extract model from the message content + const modelInfo = message.content?.model; + + if (modelInfo) { + // First try to find the exact model by ID + const modelId = `${modelInfo.provider}_${modelInfo.serverId}_${modelInfo.name}`; + let matchingModel = this.state.models.find( + (model) => + `${model.provider}_${model.serverId}_${model.name}` === modelId + ); + + // If exact match not found, try to find by name only + if (!matchingModel) { + console.log( + "Exact model match not found, trying to find by name:", + modelInfo.name + ); + matchingModel = this.state.models.find( + (model) => model.name === modelInfo.name + ); + } + + // If model is found and different from current selection, update it + if ( + matchingModel && + (!this.state.selectedModel || + this.state.selectedModel.name !== matchingModel.name) + ) { + this.setState({ + selectedModel: matchingModel, + pendingModelSelection: null, // Clear any pending selection + }); + console.log( + "Updated model selection from conversation event:", + matchingModel.name + ); + } else if (!matchingModel) { + console.log( + "Model from conversation not found in available models:", + modelId + ); + console.log( + "Available models:", + this.state.models.map( + (m) => `${m.provider}_${m.serverId}_${m.name}` + ) + ); + + // Store the model info for later selection when models are loaded + this.setState({ + pendingModelSelection: { + name: modelInfo.name, + provider: modelInfo.provider, + serverId: modelInfo.serverId, + }, + }); + console.log( + "Saved pending model selection for later:", + modelInfo.name + ); + } + } + }; + + // Subscribe to model selection events from conversation history + this.props.services.event.subscribeToMessages( + "model-selection-v2", + this.conversationModelListener + ); + + console.log( + "Subscribed to model selection events from conversation history" + ); + } catch (error) { + console.error("Error initializing event listeners:", error); + } + } + } + + /** + * Initialize the theme service to listen for theme changes + */ + initializeThemeService() { + if (this.props.services?.theme) { + try { + // Get the current theme + const currentTheme = this.props.services.theme.getCurrentTheme(); + this.setState({ currentTheme: currentTheme as "light" | "dark" }); + + // Set up theme change listener + this.themeChangeListener = (newTheme: string) => { + this.setState({ currentTheme: newTheme as "light" | "dark" }); + }; + + // Add the listener to the theme service + this.props.services.theme.addThemeChangeListener( + this.themeChangeListener + ); + } catch (error) { + console.error("Error initializing theme service:", error); + } + } + } + + /** + * Load provider settings based on configuration + */ + loadProviderSettings = async () => { + this.setState({ isLoading: true, error: null }); + + if (!this.props.services?.api) { + this.setState({ + isLoading: false, + error: "API service not available", + }); + return; + } + + try { + // Include Ollama, OpenAI, and OpenRouter settings + const providerSettingIds = [ + "ollama_servers_settings", + "openai_api_keys_settings", + "openrouter_api_keys_settings", + ]; + const providerSettingsData: ProviderSettings[] = []; + + // Load each provider setting + for (const settingId of providerSettingIds) { + try { + const response = await this.props.services.api.get( + "/api/v1/settings/instances", + { + params: { + definition_id: settingId, + scope: "user", + user_id: "current", + }, + } + ); + + // Process response to extract settings data + let settingsData = null; + + if (Array.isArray(response) && response.length > 0) { + settingsData = response[0]; + } else if (response && typeof response === "object") { + const responseObj = response as Record; + + if (responseObj.data) { + if ( + Array.isArray(responseObj.data) && + responseObj.data.length > 0 + ) { + settingsData = responseObj.data[0]; + } else if (typeof responseObj.data === "object") { + settingsData = responseObj.data; + } + } else { + settingsData = response; + } + } + + if (settingsData && settingsData.value) { + // Parse the value field + let parsedValue = + typeof settingsData.value === "string" + ? JSON.parse(settingsData.value) + : settingsData.value; + + // Determine provider type from setting ID + const providerType = settingId.includes("ollama") + ? "ollama" + : settingId.includes("openai") + ? "openai" + : settingId.includes("openrouter") + ? "openrouter" + : "unknown"; + + if (providerType === "openai") { + // For OpenAI, create a virtual server structure if API key exists + if (parsedValue.api_key) { + providerSettingsData.push({ + id: settingId, + name: settingsData.name || settingId, + servers: [ + { + id: "openai_default_server", + serverName: "OpenAI API", + serverAddress: "https://api.openai.com", + apiKey: parsedValue.api_key, + }, + ], + }); + } + } else if (providerType === "openrouter") { + // For OpenRouter, create a virtual server structure if API key exists + if (parsedValue.api_key) { + providerSettingsData.push({ + id: settingId, + name: settingsData.name || settingId, + servers: [ + { + id: "openrouter_default_server", + serverName: "OpenRouter API", + serverAddress: "https://openrouter.ai/api/v1", + apiKey: parsedValue.api_key, + }, + ], + }); + } + } else if (providerType === "ollama") { + // For Ollama, use the servers structure + providerSettingsData.push({ + id: settingId, + name: settingsData.name || settingId, + servers: Array.isArray(parsedValue.servers) + ? parsedValue.servers + : [], + }); + } + } + } catch (error) { + console.error(`Error loading provider setting ${settingId}:`, error); + } + } + + this.setState( + { + providerSettingsData, + isLoading: false, + }, + () => { + // Load models after settings are loaded + this.loadModels(); + } + ); + } catch (error: any) { + console.error("Error loading provider settings:", error); + + this.setState({ + isLoading: false, + error: `Error loading provider settings: ${ + error.message || "Unknown error" + }`, + }); + } + }; + + /** + * Load models from all configured providers + */ + loadModels = async () => { + this.setState({ isLoading: true, error: null }); + + if (!this.props.services?.api) { + this.setState({ + isLoading: false, + error: "API service not available", + }); + return; + } + + try { + const models: ModelInfo[] = []; + const { providerSettingsData } = this.state; + + // Process each provider setting + for (const providerSetting of providerSettingsData) { + const providerType = providerSetting.id.includes("ollama") + ? "ollama" + : providerSetting.id.includes("openai") + ? "openai" + : providerSetting.id.includes("openrouter") + ? "openrouter" + : "unknown"; + + // Skip if no servers configured for this provider + if (!providerSetting.servers || providerSetting.servers.length === 0) { + continue; + } + + // Process each server in the provider setting + for (const server of providerSetting.servers) { + try { + let serverModels: any[] = []; + if (providerType === "ollama") { + // Ollama endpoint + const encodedUrl = encodeURIComponent(server.serverAddress); + const params: Record = { + server_url: encodedUrl, + settings_id: providerSetting.id, + server_id: server.id, + }; + if (server.apiKey) { + params.api_key = server.apiKey; + } + const response = await this.props.services.api.get( + "/api/v1/ollama/models", + { params } + ); + serverModels = Array.isArray(response) ? response : []; + } else if (providerType === "openai") { + // OpenAI endpoint + const response = await this.props.services.api.get( + "/api/v1/ai/providers/models", + { + params: { + provider: "openai", + settings_id: providerSetting.id, + server_id: server.id, + }, + } + ); + serverModels = response?.models || []; + } else if (providerType === "openrouter") { + // OpenRouter endpoint + const response = await this.props.services.api.get( + "/api/v1/ai/providers/models", + { + params: { + provider: "openrouter", + settings_id: providerSetting.id, + server_id: server.id, + }, + } + ); + serverModels = response?.models || []; + } + // Add models to dropdown + for (const model of serverModels) { + models.push({ + name: model.name, + provider: providerType, + providerId: providerSetting.id, + serverName: server.serverName, + serverId: server.id, + }); + } + } catch (error) { + console.error( + `Error loading models for server ${server.serverName}:`, + error + ); + } + } + } + // Model selection logic (unchanged) + const { pendingModelSelection } = this.state; + let modelToSelect = models.length > 0 ? models[0] : null; + if (pendingModelSelection && models.length > 0) { + let matchingModel = models.find( + (model) => + model.name === pendingModelSelection.name && + model.provider === pendingModelSelection.provider && + model.serverId === pendingModelSelection.serverId + ); + if (!matchingModel) { + matchingModel = models.find( + (model) => model.name === pendingModelSelection.name + ); + } + if (matchingModel) { + modelToSelect = matchingModel; + } + } + this.setState({ + models, + isLoading: false, + selectedModel: modelToSelect, + pendingModelSelection: modelToSelect ? null : pendingModelSelection, + }); + if (modelToSelect) { + this.broadcastModelSelection(modelToSelect); + } + } catch (error) { + console.error("Error loading models:", error); + this.setState({ + isLoading: false, + error: "Error loading models", + }); + } + }; + + /** + * Handle model selection change + */ + handleModelChange = (modelId: string) => { + const selectedModel = this.state.models.find( + (model) => `${model.provider}_${model.serverId}_${model.name}` === modelId + ); + + if (selectedModel) { + this.setState({ selectedModel }, () => { + this.broadcastModelSelection(selectedModel); + }); + } + }; + + /** + * Broadcast model selection event + */ + broadcastModelSelection = (model: ModelInfo) => { + if (!this.eventService && !this.props.services?.event) { + console.error("Event service not available"); + return; + } + + // Create model selection message + const modelInfo = { + type: "model.selection", + content: { + model: { + name: model.name, + provider: model.provider, + providerId: model.providerId, + serverName: model.serverName, + serverId: model.serverId, + }, + timestamp: new Date().toISOString(), + }, + }; + + // Send to target component or broadcast to all + const target = this.props.targetComponent || "ai-prompt-chat"; + + // Log the target and message for debugging + console.log(`Sending model selection to target: ${target}`, modelInfo); + + // Send via both methods to ensure delivery + // The receiving component will handle deduplication + if (this.props.services?.event) { + this.props.services.event.sendMessage(target, modelInfo.content); + console.log("Model selection sent via services.event"); + } + + if (this.eventService) { + this.eventService.sendMessage(target, modelInfo, { remote: true }); + console.log("Model selection sent via eventService"); + } + }; + + /** + * Render the component + */ + render() { + const { models, selectedModel, isLoading, error, currentTheme } = + this.state; + const { label = "Select Model", labelPosition = "top" } = this.props; + + // Determine layout based on position + const isHorizontal = labelPosition === "top" || labelPosition === "bottom"; + const layoutClass = isHorizontal ? "horizontal" : "vertical"; + + // Adjust order based on position + const labelOrder = + labelPosition === "bottom" || labelPosition === "right" ? 2 : 1; + const dropdownOrder = + labelPosition === "bottom" || labelPosition === "right" ? 1 : 2; + + // Create a unique ID for each model + const getModelId = (model: ModelInfo) => + `${model.provider}_${model.serverId}_${model.name}`; + + // Convert models to dropdown options + const dropdownOptions = models.map((model) => ({ + id: getModelId(model), + primaryText: model.name, + secondaryText: model.serverName, + })); + + return ( +
+
+ + +
+ {isLoading ? ( +
+ ) : error ? ( +
{error}
+ ) : ( + + )} +
+
+
+ ); + } } export default ComponentModelSelection;