diff --git a/ai-hub/app/api/dependencies.py b/ai-hub/app/api/dependencies.py index 0023ac0..783f8f2 100644 --- a/ai-hub/app/api/dependencies.py +++ b/ai-hub/app/api/dependencies.py @@ -37,6 +37,17 @@ return user +async def get_current_admin( + current_user: models.User = Depends(get_current_user) +) -> models.User: + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required" + ) + return current_user + + class ServiceContainer: """ A flexible container for managing and providing various application services. diff --git a/ai-hub/app/api/routes/admin.py b/ai-hub/app/api/routes/admin.py new file mode 100644 index 0000000..1ed915c --- /dev/null +++ b/ai-hub/app/api/routes/admin.py @@ -0,0 +1,120 @@ +from fastapi import APIRouter, Depends, HTTPException +from app.api import schemas +from app.api.dependencies import get_current_admin +from app.config import settings + +def create_admin_router() -> APIRouter: + router = APIRouter() + + @router.put("/config/oidc", summary="Update OIDC Configuration") + async def update_oidc_config( + update: schemas.OIDCConfigUpdate, + admin = Depends(get_current_admin) + ): + if update.enabled is not None: + settings.OIDC_ENABLED = update.enabled + if update.client_id is not None: + settings.OIDC_CLIENT_ID = update.client_id + if update.client_secret is not None: + settings.OIDC_CLIENT_SECRET = update.client_secret + if update.server_url is not None: + settings.OIDC_SERVER_URL = update.server_url + if update.redirect_uri is not None: + settings.OIDC_REDIRECT_URI = update.redirect_uri + if update.allow_oidc_login is not None: + if not update.allow_oidc_login and not settings.ALLOW_PASSWORD_LOGIN: + raise HTTPException(status_code=400, detail="Cannot disable OIDC login while password login is also disabled.") + settings.ALLOW_OIDC_LOGIN = update.allow_oidc_login + + settings.save_to_yaml() + return {"message": "OIDC configuration updated successfully"} + + @router.put("/config/app", summary="Update Application Configuration") + async def update_app_config( + update: schemas.AppConfigUpdate, + admin = Depends(get_current_admin) + ): + if update.allow_password_login is not None: + if not update.allow_password_login and not settings.ALLOW_OIDC_LOGIN: + raise HTTPException(status_code=400, detail="Cannot disable password login while OIDC login is also disabled.") + settings.ALLOW_PASSWORD_LOGIN = update.allow_password_login + + settings.save_to_yaml() + return {"message": "Application configuration updated successfully"} + + @router.post("/config/oidc/test", summary="Test OIDC Discovery") + async def test_oidc_connection( + update: schemas.OIDCConfigUpdate, + admin = Depends(get_current_admin) + ): + if not update.server_url: + raise HTTPException(status_code=400, detail="Server URL is required for testing.") + + import httpx + try: + discovery_url = update.server_url.rstrip("/") + "/.well-known/openid-configuration" + async with httpx.AsyncClient() as client: + response = await client.get(discovery_url, timeout=5.0) + if response.status_code == 200: + return {"success": True, "message": "OIDC Identity Provider discovered successfully!"} + else: + return {"success": False, "message": f"Discovery failed with status {response.status_code}"} + except Exception as e: + return {"success": False, "message": f"Failed to reach OIDC provider: {str(e)}"} + + @router.post("/config/swarm/test", summary="Test Swarm Connection") + async def test_swarm_connection( + update: schemas.SwarmConfigUpdate, + admin = Depends(get_current_admin) + ): + if not update.external_endpoint: + raise HTTPException(status_code=400, detail="External endpoint is required for testing.") + + import httpx + try: + # We try to reach the endpoint. Since it's gRPC, we might just do a TCP check + # or a basic GET if it's behind a proxy that handles health checks. + # For simplicity, we'll check if the protocol is valid and we can reach it. + async with httpx.AsyncClient() as client: + # Most swarm proxies will have a /health or just return 404/405 for GET on root + response = await client.get(update.external_endpoint, timeout=5.0) + return {"success": True, "message": f"Reached endpoint with status {response.status_code}"} + except Exception as e: + return {"success": False, "message": f"Failed to connect: {str(e)}"} + + @router.put("/config/swarm", summary="Update Swarm Configuration") + async def update_swarm_config( + update: schemas.SwarmConfigUpdate, + admin = Depends(get_current_admin) + ): + if update.external_endpoint is not None: + settings.GRPC_EXTERNAL_ENDPOINT = update.external_endpoint + # Derived TLS enabled from endpoint protocol + endpoint = update.external_endpoint.lower() + settings.GRPC_TLS_ENABLED = endpoint.startswith("https://") or ":443" in endpoint + + settings.save_to_yaml() + return {"message": "Swarm configuration updated successfully"} + + @router.get("/config", summary="Get Admin Configuration") + async def get_admin_config( + admin = Depends(get_current_admin) + ): + return { + "app": { + "allow_password_login": settings.ALLOW_PASSWORD_LOGIN + }, + "oidc": { + "enabled": settings.OIDC_ENABLED, + "client_id": settings.OIDC_CLIENT_ID, + "client_secret": settings.OIDC_CLIENT_SECRET, + "server_url": settings.OIDC_SERVER_URL, + "redirect_uri": settings.OIDC_REDIRECT_URI, + "allow_oidc_login": settings.ALLOW_OIDC_LOGIN + }, + "swarm": { + "external_endpoint": settings.GRPC_EXTERNAL_ENDPOINT + } + } + + return router diff --git a/ai-hub/app/api/routes/api.py b/ai-hub/app/api/routes/api.py index 529e771..3e27520 100644 --- a/ai-hub/app/api/routes/api.py +++ b/ai-hub/app/api/routes/api.py @@ -11,6 +11,7 @@ from .nodes import create_nodes_router from .skills import create_skills_router from .agent_update import create_agent_update_router +from .admin import create_admin_router def create_api_router(services: ServiceContainer) -> APIRouter: """ @@ -29,5 +30,6 @@ router.include_router(create_nodes_router(services)) router.include_router(create_skills_router(services)) router.include_router(create_agent_update_router()) + router.include_router(create_admin_router(), prefix="/admin") return router \ No newline at end of file diff --git a/ai-hub/app/api/routes/general.py b/ai-hub/app/api/routes/general.py index dd6902d..ece0ba0 100644 --- a/ai-hub/app/api/routes/general.py +++ b/ai-hub/app/api/routes/general.py @@ -1,5 +1,6 @@ from fastapi import APIRouter from app.api.dependencies import ServiceContainer +from app.api.schemas import SystemStatus def create_general_router(services: ServiceContainer) -> APIRouter: router = APIRouter(tags=["General"]) @@ -7,5 +8,16 @@ @router.get("/", summary="Check Service Status") def read_root(): return {"status": "AI Model Hub is running!"} + + @router.get("/status", response_model=SystemStatus, summary="Get Full System Status") + def get_status(): + settings = services.settings() + return SystemStatus( + status="running", + oidc_enabled=settings.OIDC_ENABLED, + tls_enabled=settings.GRPC_TLS_ENABLED, + external_endpoint=settings.GRPC_EXTERNAL_ENDPOINT, + version=settings.VERSION + ) return router \ No newline at end of file diff --git a/ai-hub/app/api/routes/nodes.py b/ai-hub/app/api/routes/nodes.py index 70e081b..479b7a0 100644 --- a/ai-hub/app/api/routes/nodes.py +++ b/ai-hub/app/api/routes/nodes.py @@ -251,26 +251,28 @@ if not user: raise HTTPException(status_code=404, detail="User not found.") - # Both admins and users only see nodes explicitly granted to their group in this user-facing list. - # This prevents the 'Personal Preferences' and 'Mesh Explorer' from showing ungranted nodes. - - # Nodes accessible via user's group (relational) - accesses = db.query(models.NodeGroupAccess).filter( - models.NodeGroupAccess.group_id == user.group_id - ).all() - node_ids = set([a.node_id for a in accesses]) - - # Nodes accessible via group policy whitelist - if user.group and user.group.policy: - policy_nodes = user.group.policy.get("nodes", []) - if isinstance(policy_nodes, list): - for nid in policy_nodes: - node_ids.add(nid) + # Admins see all active nodes for management/configuration purposes. + # Regular users only see nodes explicitly granted to their group. + if user.role == "admin": + nodes = db.query(models.AgentNode).filter(models.AgentNode.is_active == True).all() + else: + # Nodes accessible via user's group (relational) + accesses = db.query(models.NodeGroupAccess).filter( + models.NodeGroupAccess.group_id == user.group_id + ).all() + node_ids = set([a.node_id for a in accesses]) + + # Nodes accessible via group policy whitelist + if user.group and user.group.policy: + policy_nodes = user.group.policy.get("nodes", []) + if isinstance(policy_nodes, list): + for nid in policy_nodes: + node_ids.add(nid) - nodes = db.query(models.AgentNode).filter( - models.AgentNode.node_id.in_(list(node_ids)), - models.AgentNode.is_active == True - ).all() + nodes = db.query(models.AgentNode).filter( + models.AgentNode.node_id.in_(list(node_ids)), + models.AgentNode.is_active == True + ).all() registry = _registry() return [services.mesh_service.node_to_user_view(n, registry) for n in nodes] @@ -702,19 +704,22 @@ logger.warning(f"[📶] User {user_id} not found for global stream.") return - # Nodes accessible via user's group - accesses = db.query(models.NodeGroupAccess).filter( - models.NodeGroupAccess.group_id == user.group_id - ).all() - accessible_ids = [a.node_id for a in accesses] - - # Nodes in group policy - if user.group and user.group.policy: - policy_nodes = user.group.policy.get("nodes", []) - if isinstance(policy_nodes, list): - accessible_ids.extend(policy_nodes) - - accessible_ids = list(set(accessible_ids)) + if user.role == "admin": + accessible_ids = [n.node_id for n in db.query(models.AgentNode).filter(models.AgentNode.is_active == True).all()] + else: + # Nodes accessible via user's group + accesses = db.query(models.NodeGroupAccess).filter( + models.NodeGroupAccess.group_id == user.group_id + ).all() + accessible_ids = [a.node_id for a in accesses] + + # Nodes in group policy + if user.group and user.group.policy: + policy_nodes = user.group.policy.get("nodes", []) + if isinstance(policy_nodes, list): + accessible_ids.extend(policy_nodes) + + accessible_ids = list(set(accessible_ids)) try: await websocket.accept() diff --git a/ai-hub/app/api/routes/skills.py b/ai-hub/app/api/routes/skills.py index a6aa2a6..8020b3e 100644 --- a/ai-hub/app/api/routes/skills.py +++ b/ai-hub/app/api/routes/skills.py @@ -17,19 +17,23 @@ """List all skills accessible to the user.""" # Start queries system_query = db.query(models.Skill).filter(models.Skill.is_system == True) - user_query = db.query(models.Skill).filter( - models.Skill.owner_id == current_user.id, - models.Skill.is_system == False - ) + + if current_user.role == 'admin': + # Admins see ALL skills (system + user-owned for management) + user_query = db.query(models.Skill).filter(models.Skill.is_system == False) + else: + user_query = db.query(models.Skill).filter( + models.Skill.owner_id == current_user.id, + models.Skill.is_system == False + ) # Policy: Only show enabled skills to non-admins if current_user.role != 'admin': system_query = system_query.filter(models.Skill.is_enabled == True) user_query = user_query.filter(models.Skill.is_enabled == True) - # Target feature filtering (PostgreSQL JSONB contains or standard JSON) + # Target feature filtering if feature: - # Using standard list comparison as fallback or JSONB contains system_skills = [s for s in system_query.all() if feature in (s.features or [])] user_skills = [s for s in user_query.all() if feature in (s.features or [])] else: @@ -38,7 +42,7 @@ # Skills shared with the user's group via Group Policy group_skills = [] - if current_user.group and current_user.group.policy: + if current_user.role != 'admin' and current_user.group and current_user.group.policy: group_skill_names = current_user.group.policy.get("skills", []) if group_skill_names: g_query = db.query(models.Skill).filter( diff --git a/ai-hub/app/api/routes/user.py b/ai-hub/app/api/routes/user.py index d8c394c..87dd010 100644 --- a/ai-hub/app/api/routes/user.py +++ b/ai-hub/app/api/routes/user.py @@ -1,3 +1,4 @@ +from datetime import datetime from fastapi import APIRouter, HTTPException, Depends, Header, Query, Request, UploadFile, File from fastapi.responses import RedirectResponse as redirect from sqlalchemy.orm import Session @@ -34,7 +35,7 @@ Retrieves the user ID from the X-User-ID header. This simulates an authentication system and is used by the login_required decorator. """ - return x_user_id + return x_user_id or "anonymous" def create_users_router(services: ServiceContainer) -> APIRouter: @@ -63,8 +64,13 @@ """ result = await services.auth_service.handle_callback(code, db) user_id = result["user_id"] + linked = result.get("linked", False) + # Pass linked flag to frontend for notification frontend_redirect_url = f"{state}?user_id={user_id}" + if linked: + frontend_redirect_url += "&linked=true" + return redirect(url=frontend_redirect_url) @router.get("/me", response_model=schemas.UserStatus, summary="Get Current User Status") @@ -100,6 +106,9 @@ db: Session = Depends(get_db) ): """Day 1: Local Username/Password Login.""" + if not settings.ALLOW_PASSWORD_LOGIN: + raise HTTPException(status_code=403, detail="Password-based login is disabled. Please use OIDC/SSO.") + user = db.query(models.User).filter(models.User.email == request.email).first() if not user or not user.password_hash: raise HTTPException(status_code=401, detail="Invalid email or password") diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 99a9980..d0127ed 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -21,7 +21,7 @@ class UserStatus(BaseModel): """Schema for the response when checking a user's status.""" id: str = Field(..., description="The internal user ID.") - email: str = Field(..., description="The user's email address.") + email: Optional[str] = Field(None, description="The user's email address.") is_logged_in: bool = Field(True, description="Indicates if the user is currently authenticated.") is_anonymous: bool = Field(False, description="Indicates if the user is an anonymous user.") oidc_configured: bool = Field(False, description="Whether OIDC SSO is enabled on the server.") @@ -84,6 +84,28 @@ preferences: UserPreferences effective: dict = Field(default_factory=dict) +class SystemStatus(BaseModel): + """Schema for overall system status, including TLS and OIDC state.""" + status: str + oidc_enabled: bool + tls_enabled: bool + external_endpoint: Optional[str] = None + version: str + +class OIDCConfigUpdate(BaseModel): + enabled: Optional[bool] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None + server_url: Optional[str] = None + redirect_uri: Optional[str] = None + allow_oidc_login: Optional[bool] = None + +class SwarmConfigUpdate(BaseModel): + external_endpoint: Optional[str] = None + +class AppConfigUpdate(BaseModel): + allow_password_login: Optional[bool] = None + # --- Skill Schemas --- class SkillBase(BaseModel): name: str diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0e6f5fd..3ea1803 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -181,6 +181,7 @@ prompt_service = PromptService() # 9. Initialize the Service Container with all initialized services services = ServiceContainer() + services.with_service("settings", service=lambda: settings) services.with_document_service(vector_store=vector_store) node_registry_service = NodeRegistryService() diff --git a/ai-hub/app/config.py b/ai-hub/app/config.py index da2ce5d..04d4216 100644 --- a/ai-hub/app/config.py +++ b/ai-hub/app/config.py @@ -15,6 +15,7 @@ version: str = "1.0.0" log_level: str = "INFO" super_admins: list[str] = Field(default_factory=list) + allow_password_login: bool = True class OIDCSettings(BaseModel): enabled: bool = False @@ -22,6 +23,7 @@ client_secret: str = "" server_url: str = "" redirect_uri: str = "" + allow_oidc_login: bool = False class DatabaseSettings(BaseModel): mode: str = "sqlite" @@ -50,16 +52,23 @@ index_path: str = "data/faiss_index.bin" embedding_dimension: int = 768 +class SwarmSettings(BaseModel): + external_endpoint: Optional[str] = None + class AppConfig(BaseModel): """Top-level Pydantic model for application configuration.""" application: ApplicationSettings = Field(default_factory=ApplicationSettings) database: DatabaseSettings = Field(default_factory=DatabaseSettings) - llm_providers: LLMProvidersSettings = Field(default_factory=LLMProvidersSettings) + llm_providers: dict[str, dict] = Field(default_factory=dict) + active_llm_provider: Optional[str] = None vector_store: VectorStoreSettings = Field(default_factory=VectorStoreSettings) embedding_provider: EmbeddingProviderSettings = Field(default_factory=EmbeddingProviderSettings) - tts_provider: ProviderSettings = Field(default_factory=ProviderSettings) - stt_provider: ProviderSettings = Field(default_factory=ProviderSettings) + tts_providers: dict[str, dict] = Field(default_factory=dict) + active_tts_provider: Optional[str] = None + stt_providers: dict[str, dict] = Field(default_factory=dict) + active_stt_provider: Optional[str] = None oidc: OIDCSettings = Field(default_factory=OIDCSettings) + swarm: SwarmSettings = Field(default_factory=SwarmSettings) # --- 2. Create the Final Settings Object --- @@ -97,11 +106,14 @@ self.SUPER_ADMINS: list[str] = [x.strip() for x in super_admins_env.split(",")] if super_admins_env else \ get_from_yaml(["application", "super_admins"]) or \ config_from_pydantic.application.super_admins + self.ALLOW_PASSWORD_LOGIN: bool = str(os.getenv("ALLOW_PASSWORD_LOGIN") if os.getenv("ALLOW_PASSWORD_LOGIN") is not None else + get_from_yaml(["application", "allow_password_login"]) if get_from_yaml(["application", "allow_password_login"]) is not None else + config_from_pydantic.application.allow_password_login).lower() == "true" # --- OIDC Settings --- - self.OIDC_ENABLED: bool = os.getenv("OIDC_ENABLED", "false").lower() == "true" or \ - get_from_yaml(["oidc", "enabled"]) or \ - config_from_pydantic.oidc.enabled + self.OIDC_ENABLED: bool = str(os.getenv("OIDC_ENABLED") if os.getenv("OIDC_ENABLED") is not None else + get_from_yaml(["oidc", "enabled"]) if get_from_yaml(["oidc", "enabled"]) is not None else + config_from_pydantic.oidc.enabled).lower() == "true" self.OIDC_CLIENT_ID: str = os.getenv("OIDC_CLIENT_ID") or \ get_from_yaml(["oidc", "client_id"]) or \ config_from_pydantic.oidc.client_id @@ -114,6 +126,22 @@ self.OIDC_REDIRECT_URI: str = os.getenv("OIDC_REDIRECT_URI") or \ get_from_yaml(["oidc", "redirect_uri"]) or \ config_from_pydantic.oidc.redirect_uri + self.ALLOW_OIDC_LOGIN: bool = str(os.getenv("ALLOW_OIDC_LOGIN") if os.getenv("ALLOW_OIDC_LOGIN") is not None else + get_from_yaml(["oidc", "allow_oidc_login"]) if get_from_yaml(["oidc", "allow_oidc_login"]) is not None else + config_from_pydantic.oidc.allow_oidc_login).lower() == "true" + + # --- Swarm Settings --- + self.GRPC_EXTERNAL_ENDPOINT: Optional[str] = os.getenv("GRPC_EXTERNAL_ENDPOINT") or \ + get_from_yaml(["swarm", "external_endpoint"]) or \ + config_from_pydantic.swarm.external_endpoint + + # Infer TLS from endpoint + protocol = self.GRPC_EXTERNAL_ENDPOINT.split("://")[0] if self.GRPC_EXTERNAL_ENDPOINT and "://" in self.GRPC_EXTERNAL_ENDPOINT else "http" + self.GRPC_TLS_ENABLED: bool = (protocol == "https") + + # Legacy paths (no longer in UI, but kept for env var parity if needed) + self.GRPC_CERT_PATH: Optional[str] = os.getenv("GRPC_CERT_PATH") or get_from_yaml(["swarm", "cert_path"]) + self.GRPC_KEY_PATH: Optional[str] = os.getenv("GRPC_KEY_PATH") or get_from_yaml(["swarm", "key_path"]) self.SECRET_KEY: str = os.getenv("SECRET_KEY") or \ get_from_yaml(["application", "secret_key"]) or \ @@ -141,8 +169,7 @@ # We store everything in a flat map for the legacy settings getters, # but also provide a dynamic map. - # 1. Resolve LLM Providers - self.LLM_PROVIDERS = config_from_pydantic.llm_providers.providers or {} + self.LLM_PROVIDERS = config_from_pydantic.llm_providers or {} # Support legacy environment variables and merge them into the providers map for env_key, env_val in os.environ.items(): if env_key.endswith("_API_KEY") and not any(x in env_key for x in ["TTS", "STT", "EMBEDDING"]): @@ -186,31 +213,44 @@ self.GEMINI_API_KEY # 3. Resolve TTS (Agnostic) - self.TTS_PROVIDER: str = os.getenv("TTS_PROVIDER") or \ - get_from_yaml(["tts_provider", "provider"]) or \ - config_from_pydantic.tts_provider.active_provider or "google_gemini" + self.TTS_PROVIDERS = config_from_pydantic.tts_providers or {} + # Legacy single-provider from YAML/Env + legacy_tts_provider = os.getenv("TTS_PROVIDER") or get_from_yaml(["tts_provider", "provider"]) + if legacy_tts_provider and legacy_tts_provider not in self.TTS_PROVIDERS: + self.TTS_PROVIDERS[legacy_tts_provider] = { + "provider": legacy_tts_provider, + "model_name": os.getenv("TTS_MODEL_NAME") or get_from_yaml(["tts_provider", "model_name"]), + "voice_name": os.getenv("TTS_VOICE_NAME") or get_from_yaml(["tts_provider", "voice_name"]), + "api_key": os.getenv("TTS_API_KEY") or get_from_yaml(["tts_provider", "api_key"]), + } - # Legacy back-compat fields - self.TTS_VOICE_NAME: str = os.getenv("TTS_VOICE_NAME") or \ - get_from_yaml(["tts_provider", "voice_name"]) or \ - config_from_pydantic.tts_provider.voice_name or "Kore" - self.TTS_MODEL_NAME: str = os.getenv("TTS_MODEL_NAME") or \ - get_from_yaml(["tts_provider", "model_name"]) or \ - config_from_pydantic.tts_provider.model_name or "gemini-2.5-flash-preview-tts" - self.TTS_API_KEY: Optional[str] = os.getenv("TTS_API_KEY") or \ - get_from_yaml(["tts_provider", "api_key"]) or \ - self.GEMINI_API_KEY + self.TTS_PROVIDER: str = legacy_tts_provider or \ + config_from_pydantic.active_tts_provider or \ + (list(self.TTS_PROVIDERS.keys())[0] if self.TTS_PROVIDERS else "google_gemini") + + # Legacy back-compat fields for the active one + active_tts = self.TTS_PROVIDERS.get(self.TTS_PROVIDER, {}) + self.TTS_VOICE_NAME: str = active_tts.get("voice_name") or "Kore" + self.TTS_MODEL_NAME: str = active_tts.get("model_name") or "gemini-2.5-flash-preview-tts" + self.TTS_API_KEY: Optional[str] = active_tts.get("api_key") or self.GEMINI_API_KEY # 4. Resolve STT (Agnostic) - self.STT_PROVIDER: str = os.getenv("STT_PROVIDER") or \ - get_from_yaml(["stt_provider", "provider"]) or \ - config_from_pydantic.stt_provider.active_provider or "google_gemini" + self.STT_PROVIDERS = config_from_pydantic.stt_providers or {} + legacy_stt_provider = os.getenv("STT_PROVIDER") or get_from_yaml(["stt_provider", "provider"]) + if legacy_stt_provider and legacy_stt_provider not in self.STT_PROVIDERS: + self.STT_PROVIDERS[legacy_stt_provider] = { + "provider": legacy_stt_provider, + "model_name": os.getenv("STT_MODEL_NAME") or get_from_yaml(["stt_provider", "model_name"]), + "api_key": os.getenv("STT_API_KEY") or get_from_yaml(["stt_provider", "api_key"]), + } + + self.STT_PROVIDER: str = legacy_stt_provider or \ + config_from_pydantic.active_stt_provider or \ + (list(self.STT_PROVIDERS.keys())[0] if self.STT_PROVIDERS else "google_gemini") - self.STT_MODEL_NAME: str = os.getenv("STT_MODEL_NAME") or \ - get_from_yaml(["stt_provider", "model_name"]) or \ - config_from_pydantic.stt_provider.model_name or "gemini-2.5-flash" - self.STT_API_KEY: Optional[str] = os.getenv("STT_API_KEY") or \ - get_from_yaml(["stt_provider", "api_key"]) or \ + active_stt = self.STT_PROVIDERS.get(self.STT_PROVIDER, {}) + self.STT_MODEL_NAME: str = active_stt.get("model_name") or "gemini-2.5-flash" + self.STT_API_KEY: Optional[str] = active_stt.get("api_key") or \ (self.OPENAI_API_KEY if self.STT_PROVIDER == "openai" else self.GEMINI_API_KEY) def save_to_yaml(self): @@ -230,7 +270,8 @@ "project_name": self.PROJECT_NAME, "version": self.VERSION, "log_level": self.LOG_LEVEL, - "super_admins": self.SUPER_ADMINS + "super_admins": self.SUPER_ADMINS, + "allow_password_login": self.ALLOW_PASSWORD_LOGIN }, "database": { "mode": self.DB_MODE, @@ -245,21 +286,17 @@ "client_id": self.OIDC_CLIENT_ID, "client_secret": self.OIDC_CLIENT_SECRET, "server_url": self.OIDC_SERVER_URL, - "redirect_uri": self.OIDC_REDIRECT_URI + "redirect_uri": self.OIDC_REDIRECT_URI, + "allow_oidc_login": self.ALLOW_OIDC_LOGIN }, - "llm_providers": { - "providers": self.LLM_PROVIDERS - }, - "tts_provider": { - "provider": self.TTS_PROVIDER, - "model_name": self.TTS_MODEL_NAME, - "voice_name": self.TTS_VOICE_NAME, - "api_key": self.TTS_API_KEY - }, - "stt_provider": { - "provider": self.STT_PROVIDER, - "model_name": self.STT_MODEL_NAME, - "api_key": self.STT_API_KEY + "llm_providers": self.LLM_PROVIDERS, + "active_llm_provider": getattr(self, "ACTIVE_LLM_PROVIDER", list(self.LLM_PROVIDERS.keys())[0] if self.LLM_PROVIDERS else None), + "tts_providers": self.TTS_PROVIDERS, + "active_tts_provider": self.TTS_PROVIDER, + "stt_providers": self.STT_PROVIDERS, + "active_stt_provider": self.STT_PROVIDER, + "swarm": { + "external_endpoint": self.GRPC_EXTERNAL_ENDPOINT } } diff --git a/ai-hub/app/core/services/auth.py b/ai-hub/app/core/services/auth.py index 4657094..9139be7 100644 --- a/ai-hub/app/core/services/auth.py +++ b/ai-hub/app/core/services/auth.py @@ -69,10 +69,10 @@ if not all([oidc_id, email]): raise HTTPException(status_code=400, detail="Essential user data missing from ID token (sub and email required).") - user_id = self.services.user_service.save_user( + user_id, linked = self.services.user_service.save_user( db=db, oidc_id=oidc_id, email=email, username=username ) - return {"user_id": user_id} + return {"user_id": user_id, "linked": linked} diff --git a/ai-hub/app/core/services/preference.py b/ai-hub/app/core/services/preference.py index 80dc802..e4e22c4 100644 --- a/ai-hub/app/core/services/preference.py +++ b/ai-hub/app/core/services/preference.py @@ -16,11 +16,39 @@ if len(k) <= 8: return "****" return k[:4] + "*" * (len(k)-8) + k[-4:] - def merge_user_config(self, user, db) -> Dict[str, Any]: + def merge_user_config(self, user, db) -> schemas.ConfigResponse: prefs_dict = user.preferences or {} - llm_prefs = prefs_dict.get("llm", {}) - tts_prefs = prefs_dict.get("tts", {}) - stt_prefs = prefs_dict.get("stt", {}) + + def normalize_section(section_name, default_active): + section = prefs_dict.get(section_name, {}) + # If already new style, just return a copy + if isinstance(section, dict) and "providers" in section: + return copy.deepcopy(section) + + # Legacy transformation + providers = {} + active = section.get("active_provider") or section.get("provider") or default_active + + # Known providers to check for legacy transformation + legacy_keys = ["openai", "gemini", "deepseek", "gcloud_tts", "azure", "google", "elevenlabs"] + for p in legacy_keys: + if p in section: + providers[p] = section[p] + + # If still no providers found but it's not empty, it might be a flat dict of other providers + if not providers and section and isinstance(section, dict): + for k, v in section.items(): + if k not in ["active_provider", "provider", "providers"] and isinstance(v, dict): + providers[k] = v + + return { + "active_provider": str(active) if active else default_active, + "providers": providers + } + + llm_prefs = normalize_section("llm", "deepseek") + tts_prefs = normalize_section("tts", settings.TTS_PROVIDER) + stt_prefs = normalize_section("stt", settings.STT_PROVIDER) system_prefs = self.services.user_service.get_system_settings(db) system_statuses = system_prefs.get("statuses", {}) @@ -32,93 +60,79 @@ has_key = p_data and p_data.get("api_key") and p_data.get("api_key") not in ("None", "none", "") return is_success or bool(has_key) - # Build effective providers map - # ... simplifying the code from user.py - user_providers = llm_prefs.get("providers", {}) - if not user_providers: - system_llm = system_prefs.get("llm", {}).get("providers", {}) - user_providers = system_llm if system_llm else { - "deepseek": {"api_key": settings.DEEPSEEK_API_KEY, "model": settings.DEEPSEEK_MODEL_NAME}, - "gemini": {"api_key": settings.GEMINI_API_KEY, "model": settings.GEMINI_MODEL_NAME}, - } + # Build effective combined config for processing + def get_effective_providers(section_name, user_section_providers, sys_defaults): + # Start with system defaults if user has none + effective_providers = {} + if not user_section_providers: + effective_providers = copy.deepcopy(sys_defaults) + else: + effective_providers = copy.deepcopy(user_section_providers) + + # Filter by health and mask keys + res = {} + for p, p_data in effective_providers.items(): + if p_data and is_provider_healthy(section_name, p, p_data): + masked_data = copy.deepcopy(p_data) + masked_data["api_key"] = self.mask_key(p_data.get("api_key")) + res[p] = masked_data + return res - llm_providers_effective = { - p: {"api_key": self.mask_key(p_p.get("api_key")), "model": p_p.get("model")} - for p, p_p in user_providers.items() if p_p and is_provider_healthy("llm", p, p_p) - } + system_llm = system_prefs.get("llm", {}).get("providers", { + "deepseek": {"api_key": settings.DEEPSEEK_API_KEY, "model": settings.DEEPSEEK_MODEL_NAME}, + "gemini": {"api_key": settings.GEMINI_API_KEY, "model": settings.GEMINI_MODEL_NAME}, + }) + llm_providers_effective = get_effective_providers("llm", llm_prefs["providers"], system_llm) - user_tts_providers = tts_prefs.get("providers", {}) - if not user_tts_providers: - system_tts = system_prefs.get("tts", {}).get("providers", {}) - user_tts_providers = system_tts if system_tts else { - settings.TTS_PROVIDER: { - "api_key": settings.TTS_API_KEY, - "model": settings.TTS_MODEL_NAME, - "voice": settings.TTS_VOICE_NAME - } + system_tts = system_prefs.get("tts", {}).get("providers", { + settings.TTS_PROVIDER: { + "api_key": settings.TTS_API_KEY, + "model": settings.TTS_MODEL_NAME, + "voice": settings.TTS_VOICE_NAME } - - tts_providers_effective = { - p: { - "api_key": self.mask_key(p_p.get("api_key")), - "model": p_p.get("model"), - "voice": p_p.get("voice") - } - for p, p_p in user_tts_providers.items() if p_p and is_provider_healthy("tts", p, p_p) - } + }) + tts_providers_effective = get_effective_providers("tts", tts_prefs["providers"], system_tts) - user_stt_providers = stt_prefs.get("stt", {}).get("providers", {}) or stt_prefs.get("providers", {}) - if not user_stt_providers: - system_stt = system_prefs.get("stt", {}).get("providers", {}) - user_stt_providers = system_stt if system_stt else { - settings.STT_PROVIDER: {"api_key": settings.STT_API_KEY, "model": settings.STT_MODEL_NAME} - } - - stt_providers_effective = { - p: {"api_key": self.mask_key(p_p.get("api_key")), "model": p_p.get("model")} - for p, p_p in user_stt_providers.items() if p_p and is_provider_healthy("stt", p, p_p) - } + system_stt = system_prefs.get("stt", {}).get("providers", { + settings.STT_PROVIDER: {"api_key": settings.STT_API_KEY, "model": settings.STT_MODEL_NAME} + }) + stt_providers_effective = get_effective_providers("stt", stt_prefs["providers"], system_stt) effective = { "llm": { - "active_provider": llm_prefs.get("active_provider") or (next(iter(llm_providers_effective), None)) or "deepseek", + "active_provider": llm_prefs.get("active_provider") or (next(iter(llm_providers_effective), "deepseek")), "providers": llm_providers_effective }, "tts": { - "active_provider": tts_prefs.get("active_provider") or (next(iter(tts_providers_effective), None)) or settings.TTS_PROVIDER, + "active_provider": tts_prefs.get("active_provider") or (next(iter(tts_providers_effective), settings.TTS_PROVIDER)), "providers": tts_providers_effective }, "stt": { - "active_provider": stt_prefs.get("active_provider") or (next(iter(stt_providers_effective), None)) or settings.STT_PROVIDER, + "active_provider": stt_prefs.get("active_provider") or (next(iter(stt_providers_effective), settings.STT_PROVIDER)), "providers": stt_providers_effective } } group = user.group or self.services.user_service.get_or_create_default_group(db) - if group and user.role != "admin": + if group: policy = group.policy or {} - def apply_policy(section_key, policy_key, p_dict): + def apply_policy(section_key, policy_key): allowed = policy.get(policy_key, []) if not allowed: effective[section_key]["providers"] = {} - if p_dict and "providers" in p_dict: p_dict["providers"] = {} effective[section_key]["active_provider"] = "" - return p_dict + return providers = effective[section_key]["providers"] filtered_eff = {k: v for k, v in providers.items() if k in allowed} effective[section_key]["providers"] = filtered_eff - if p_dict and "providers" in p_dict: - p_dict["providers"] = {k: v for k, v in p_dict["providers"].items() if k in allowed} - if effective[section_key].get("active_provider") not in allowed: effective[section_key]["active_provider"] = next(iter(filtered_eff), None) or "" - return p_dict - llm_prefs = apply_policy("llm", "llm", llm_prefs) - tts_prefs = apply_policy("tts", "tts", tts_prefs) - stt_prefs = apply_policy("stt", "stt", stt_prefs) + apply_policy("llm", "llm") + apply_policy("tts", "tts") + apply_policy("stt", "stt") def mask_section_prefs(section_dict): if not section_dict: return {} @@ -139,47 +153,42 @@ effective=effective ) + def update_user_config(self, user, prefs: schemas.UserPreferences, db) -> schemas.UserPreferences: # When saving, if the api_key contains ****, we must retain the old one from the DB old_prefs = user.preferences or {} - + + def get_old_providers(section_name): + section = old_prefs.get(section_name, {}) + if isinstance(section, dict) and "providers" in section: + return section["providers"] + + # Legacy extraction + providers = {} + legacy_keys = ["openai", "gemini", "deepseek", "gcloud_tts", "azure", "google", "elevenlabs"] + for p in legacy_keys: + if p in section: + providers[p] = section[p] + + if not providers and section and isinstance(section, dict): + for k, v in section.items(): + if k not in ["active_provider", "provider", "providers"] and isinstance(v, dict): + providers[k] = v + return providers + def preserve_masked_keys(section_name, new_section): if not new_section or "providers" not in new_section: return - old_section = old_prefs.get(section_name, {}).get("providers", {}) + old_section_providers = get_old_providers(section_name) for p_name, p_data in new_section["providers"].items(): - if p_data.get("api_key") and "***" in p_data["api_key"]: - if p_name in old_section: - p_data["api_key"] = old_section[p_name].get("api_key") - - def resolve_clone_from(section_name, new_section): - if not new_section or "providers" not in new_section: - return - old_section = old_prefs.get(section_name, {}).get("providers", {}) - system_prefs = self.services.user_service.get_system_settings(db) - system_section = system_prefs.get(section_name, {}).get("providers", {}) - - for p_name, p_data in new_section["providers"].items(): - clone_source = p_data.pop("_clone_from", None) - if not clone_source: - continue - real_key = ( - old_section.get(clone_source, {}).get("api_key") - or system_section.get(clone_source, {}).get("api_key") - ) - if real_key and "***" not in str(real_key): - p_data["api_key"] = real_key - logger.info(f"Resolved _clone_from: {p_name} inherited api_key from {clone_source} [{section_name}]") - else: - logger.warning(f"Could not resolve _clone_from for {p_name}: source '{clone_source}' key not found or masked.") + if p_data.get("api_key") and "***" in str(p_data["api_key"]): + if p_name in old_section_providers: + p_data["api_key"] = old_section_providers[p_name].get("api_key") if prefs.llm: preserve_masked_keys("llm", prefs.llm) if prefs.tts: preserve_masked_keys("tts", prefs.tts) if prefs.stt: preserve_masked_keys("stt", prefs.stt) - if prefs.llm: resolve_clone_from("llm", prefs.llm) - if prefs.tts: resolve_clone_from("tts", prefs.tts) - if prefs.stt: resolve_clone_from("stt", prefs.stt) current_prefs = dict(user.preferences or {}) current_prefs.update({ diff --git a/ai-hub/app/core/services/user.py b/ai-hub/app/core/services/user.py index e20e51d..beef381 100644 --- a/ai-hub/app/core/services/user.py +++ b/ai-hub/app/core/services/user.py @@ -80,53 +80,60 @@ db.rollback() print(f"Failed to bootstrap local admin: {e}") - def save_user(self, db: Session, oidc_id: str, email: str, username: str) -> str: + def save_user(self, db: Session, oidc_id: str, email: str, username: str) -> tuple[str, bool]: """ - Saves or updates a user record based on their OIDC ID. - If a user with this OIDC ID exists, it returns their existing ID. - Otherwise, it creates a new user record. - The first user to register will be granted the 'admin' role. + Saves or updates a user record based on their OIDC ID or Email. + Returns (user_id, linked_flag) """ try: - # Check if a user with this OIDC ID already exists - existing_user = db.query(models.User).filter(models.User.oidc_id == oidc_id).first() + # 1. Check if a user with this OIDC ID already exists + user_by_oidc = db.query(models.User).filter(models.User.oidc_id == oidc_id).first() - if existing_user: - # Update the user's information and login activity - existing_user.email = email - existing_user.username = username - existing_user.last_login_at = datetime.utcnow() + if user_by_oidc: + user_by_oidc.email = email + user_by_oidc.username = username + user_by_oidc.last_login_at = datetime.utcnow() # Check if user should be promoted to admin based on config from app.config import settings - if email in settings.SUPER_ADMINS and existing_user.role != "admin": - existing_user.role = "admin" + if email in settings.SUPER_ADMINS and user_by_oidc.role != "admin": + user_by_oidc.role = "admin" db.commit() - return existing_user.id - else: - # Ensure default group exists - default_group = self.get_or_create_default_group(db) - - # Determine role based on SUPER_ADMINS or fallback to user - from app.config import settings - role = "admin" if email in settings.SUPER_ADMINS else "user" + return user_by_oidc.id, False - # Create a new user record - new_user = models.User( - id=str(uuid.uuid4()), # Generate a unique ID for the user - oidc_id=oidc_id, - email=email, - username=username, - role=role, - group_id=default_group.id, - created_at=datetime.utcnow(), - last_login_at=datetime.utcnow() - ) - db.add(new_user) + # 2. Check if a user with this email already exists (Local -> OIDC Linking) + user_by_email = db.query(models.User).filter(models.User.email == email).first() + if user_by_email: + # Link the OIDC ID to the existing local account + user_by_email.oidc_id = oidc_id + user_by_email.username = username # Prefer OIDC display name + user_by_email.last_login_at = datetime.utcnow() + db.commit() - db.refresh(new_user) - return new_user.id + print(f"[Day 2] Linked OIDC identity {oidc_id} to existing account {email}") + return user_by_email.id, True + + # 3. Create a new user record + default_group = self.get_or_create_default_group(db) + from app.config import settings + role = "admin" if email in settings.SUPER_ADMINS else "user" + + new_user = models.User( + id=str(uuid.uuid4()), + oidc_id=oidc_id, + email=email, + username=username, + role=role, + group_id=default_group.id, + created_at=datetime.utcnow(), + last_login_at=datetime.utcnow() + ) + db.add(new_user) + db.commit() + db.refresh(new_user) + return new_user.id, False + except SQLAlchemyError as e: db.rollback() raise diff --git a/ai-hub/app/protos/browser_pb2_grpc.py b/ai-hub/app/protos/browser_pb2_grpc.py index c69dabb..276488e 100644 --- a/ai-hub/app/protos/browser_pb2_grpc.py +++ b/ai-hub/app/protos/browser_pb2_grpc.py @@ -2,7 +2,7 @@ """Client and server classes corresponding to protobuf-defined services.""" import grpc -from protos import browser_pb2 as protos_dot_browser__pb2 +from app.protos import browser_pb2 as protos_dot_browser__pb2 class BrowserServiceStub(object): diff --git a/docker-compose.yml b/docker-compose.yml index 838391d..1bfaf84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: # Unified Frontend and Nginx Gateway @@ -37,6 +36,7 @@ volumes: - ai_hub_data:/app/data:rw - ./config.yaml:/app/config.yaml:rw + - ./ai-hub/app:/app/app:rw - ./agent-node:/app/agent-node-source:ro - ./skills:/app/skills:ro - browser_shm:/dev/shm:rw @@ -53,7 +53,7 @@ container_name: cortex_browser_service restart: always ports: - - "50052:50052" + - "50053:50052" environment: - SHM_PATH=/dev/shm/cortex_browser volumes: diff --git a/docs/auth_tls_todo.md b/docs/auth_tls_todo.md index fc10662..17581f2 100644 --- a/docs/auth_tls_todo.md +++ b/docs/auth_tls_todo.md @@ -14,17 +14,17 @@ - [x] **Configuration**: Update `Settings` (`app/config.py`) to make OIDC settings optional and add an `oidc_enabled: bool` flag. - [x] **Backend Initialization**: If `CORTEX_ADMIN_PASSWORD` is present in the environment for the `SUPER_ADMINS` initialization, hash it and assign it to the admin account. - [x] **API Routes**: Create local login endpoints (`POST /api/v1/users/login/local` to issue JWTs) and (`PUT /api/v1/users/password` for password resets). -- [ ] **Frontend**: Redesign the Auth/Login page to display a Username/Password default form. +- [x] **Frontend**: Redesign the Auth/Login page to display a Username/Password default form. ## Phase 3: Day 1 Swarm Control (Insecure/Local Status) Support running the mesh over internal loopbacks but strictly warn the end-user. -- [ ] **Backend Configuration**: Add `GRPC_TLS_ENABLED`, `GRPC_EXTERNAL_ENDPOINT` to `config.py`. -- [ ] **Backend API**: Expose a `/api/v1/status` or equivalent endpoint providing the current TLS/Hostname state to the frontend. -- [ ] **Frontend UI**: Add persistent "Insecure Mode" and "Missing External Hostname" warning banners to the Swarm Dashboard frontend when running in Day 1 mode. +- [x] **Backend Configuration**: Add `GRPC_TLS_ENABLED`, `GRPC_EXTERNAL_ENDPOINT` to `config.py`. +- [x] **Backend API**: Expose a `/api/v1/status` or equivalent endpoint providing the current TLS/Hostname state to the frontend. +- [x] **Frontend UI**: Add persistent "Insecure Mode" and "Missing External Hostname" warning banners to the Swarm Dashboard frontend when running in Day 1 mode. ## Phase 4: Day 2 Single Sign-On (OIDC Linking) Allow transition to Enterprise SSO without breaking or duplicate accounting. -- [ ] **Backend Service**: Update `app/core/services/auth.py` (`handle_callback`) to search for existing local users via `email` and safely link the incoming OIDC `sub` payload. +- [x] **Backend Service**: Update `app/core/services/auth.py` (`handle_callback`) to search for existing local users via `email` and safely link the incoming OIDC `sub` payload. - [ ] **Admin API**: Create `PUT /api/v1/admin/config/oidc` for UI-based toggling and configuration of SSO parameters without restarting. - [ ] **Frontend Login**: Dynamically query `/api/v1/auth/config`. If enabled, render the "Log in with SSO" button instead of or alongside local Auth. - [ ] **Frontend Settings**: Create an Admin Settings UI panel for OIDC Configuration. diff --git a/docs/refactor_tracking.md b/docs/refactor_tracking.md new file mode 100644 index 0000000..c224a39 --- /dev/null +++ b/docs/refactor_tracking.md @@ -0,0 +1,18 @@ +# Refactor Tracking: Settings & Persistence + +## 1. Open Issues & Future Improvements +- [x] **UI Modernization (Modals)**: Replaced all native browser pop-outs (`alert()`, `confirm()`, `prompt()`) with custom UI Modals across Nodes, Skills, and Settings features for a persistent premium experience. + +## 2. Completed Items (Recent) +- [x] **Nodes feature Modals Refactor**: Replaced native browser popups with custom Error and Success modals. +- [x] **Skills feature Modals Refactor**: Replaced native browser popups with custom Error and Confirmation modals. +- [x] **Settings feature Modals Refactor**: Transitioned group/provider deletion confirmation to custom UI modals. +- [x] **Chrome Dark Mode Fixes**: Applied comprehensive dark mode visibility fixes to `SwarmControlPage`, `VoiceChatPage`, `ProfilePage`, and all settings cards. +- [x] **Login Flow Improvement**: Implemented automatic redirect to the home page upon successful local and OIDC login. +- [x] **User Preference Relocation**: Moved individual user settings (voice chat experience, AI defaults, silences sensitivity) to the Profile page. +- [x] **Export/Import Relocation**: Moved system-wide Export/Import features to a prominent "System Maintenance & Portability" card in the Settings page. +- [x] **Swarm Control Structural Fix**: Resolved JSX nesting errors and balanced tags in `SwarmControlPage.js`. +- [x] **UI Modernization (Modal Triage)**: Replaced several native alerts in core pages with a custom `ErrorModal`. +- [x] **SettingsPageContent.js Refactoring**: Modularized the settings page into domain-specific cards. +- [x] **apiService.js Refactoring**: Split monolithic API service into domain-driven modules. +- [x] **Multi-Provider Refactor**: Successfully transitioned STT and TTS to a multi-provider structure. diff --git a/frontend/src/features/auth/pages/LoginPage.js b/frontend/src/features/auth/pages/LoginPage.js index 1e53c14..f8ff809 100644 --- a/frontend/src/features/auth/pages/LoginPage.js +++ b/frontend/src/features/auth/pages/LoginPage.js @@ -1,35 +1,53 @@ import React, { useState, useEffect } from 'react'; -import { login, getUserStatus, logout } from '../../../services/apiService'; +import { login, loginLocal, getUserStatus, logout, getAuthConfig } from '../../../services/apiService'; const LoginPage = () => { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [oidcEnabled, setOidcEnabled] = useState(false); + + // Local login state + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); useEffect(() => { - // We now look for a 'user_id' in the URL, which is provided by the backend - // after a successful OIDC login and callback. + // 1. Check if OIDC is enabled + const checkConfig = async () => { + try { + const config = await getAuthConfig(); + setOidcEnabled(config.oidc_configured); + } catch (err) { + console.error("Failed to fetch auth config", err); + } + }; + checkConfig(); + + // 2. Handle OIDC callback or persistent session const params = new URLSearchParams(window.location.search); const userIdFromUrl = params.get('user_id'); - - // First, check localStorage for a saved user ID for persistent login const storedUserId = localStorage.getItem('userId'); const userId = userIdFromUrl || storedUserId; + const isLinked = params.get("linked") === "true"; if (userId) { setIsLoading(true); - // Fetch the full user details using the user ID from the URL. - // This is a more secure and robust way to handle the final callback. const fetchUserDetails = async () => { try { const userStatus = await getUserStatus(userId); setUser(userStatus); - // Store the user ID for future requests (e.g., in localStorage) localStorage.setItem('userId', userStatus.id); - // Clean up the URL by removing the query parameter window.history.replaceState({}, document.title, window.location.pathname); + if (isLinked) { + setSuccessMessage("Social identity successfully linked to your existing account!"); + } + setTimeout(() => { + window.location.href = "/swarm-control"; + }, 1500); } catch (err) { setError('Failed to get user status. Please try again.'); + localStorage.removeItem('userId'); // Clear invalid ID console.error(err); } finally { setIsLoading(false); @@ -39,12 +57,29 @@ } }, []); - const handleLogin = () => { - // Redirect to the backend's /users/login endpoint - // The backend handles the OIDC redirect from there. + const handleOidcLogin = () => { login(); }; + const handleLocalLogin = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + try { + const result = await loginLocal(email, password); + setUser({ id: result.user_id, email: result.email }); + localStorage.setItem('userId', result.user_id); + // Redirect to home page after successful local login + setTimeout(() => { + window.location.href = "/"; + }, 1000); + } catch (err) { + setError(err.message || 'Login failed. Please check your credentials.'); + } finally { + setIsLoading(false); + } + }; + const handleLogout = async () => { setIsLoading(true); try { @@ -63,60 +98,125 @@ const renderContent = () => { if (isLoading) { return ( -
- +
+ - Processing login... -
- ); - } - - if (error) { - return ( -
-

Error:

-

{error}

+ Authenticating...
); } if (user) { return ( -
-

Login Successful!

-

Welcome, {user.email}.

-

User ID: {user.id}

+
+
+ + + +
+

Welcome Back!

+

{user.email}

); } return ( - <> -

Login

-

- Click the button below to log in using OpenID Connect (OIDC). -

- - +
+
+

Sign In

+

Access your Cortex Hub dashboard

+
+ + {error && ( +
+ {error} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:text-white transition-all" + placeholder="admin@example.com" + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:text-white transition-all" + placeholder="••••••••" + autoComplete="current-password" + /> +
+ +
+ + {oidcEnabled && ( +
+
+
+ Or +
+
+ +
+ )} +
); }; return ( -
-
+
+
+
+
+ C +
+
{renderContent()}
diff --git a/frontend/src/features/chat/components/ChatWindow.css b/frontend/src/features/chat/components/ChatWindow.css index 7046008..fd514d6 100644 --- a/frontend/src/features/chat/components/ChatWindow.css +++ b/frontend/src/features/chat/components/ChatWindow.css @@ -7,6 +7,47 @@ --chat-bg: #f1f5f9; } +@media (prefers-color-scheme: dark) { + :root { + --assistant-bubble-bg: #1e293b; + --reasoning-bg: rgba(15, 23, 42, 0.3); + --border-subtle: rgba(255, 255, 255, 0.05); + --chat-bg: #111827; + } + + .assistant-message { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + color: #f3f4f6 !important; + } + + /* Force light text color on all nested elements in dark mode */ + .assistant-message *, + .assistant-message p, + .assistant-message li, + .assistant-message span, + .assistant-message div:not(.thought-panel), + .assistant-message .markdown-preview * { + color: #f3f4f6 !important; + } + + /* Explicitly for headers and bold text to be white */ + .assistant-message .markdown-preview h1, + .assistant-message .markdown-preview h2, + .assistant-message .markdown-preview h3, + .assistant-message .markdown-preview strong { + color: #ffffff !important; + } + + .thought-panel blockquote { + color: #818cf8 !important; + background: rgba(129, 140, 248, 0.05) !important; + } + + .thought-panel blockquote strong { + color: #a5b4fc; + } +} + .dark { --assistant-bubble-bg: #1e293b; --reasoning-bg: rgba(15, 23, 42, 0.3); @@ -29,6 +70,25 @@ .dark .assistant-message { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + color: #f3f4f6 !important; /* gray-100 */ +} + +/* Force light text color on all nested elements in dark mode */ +.dark .assistant-message *, +.dark .assistant-message p, +.dark .assistant-message li, +.dark .assistant-message span, +.dark .assistant-message div:not(.thought-panel), +.dark .assistant-message .markdown-preview * { + color: #f3f4f6 !important; +} + +/* Explicitly for headers and bold text to be white */ +.dark .assistant-message .markdown-preview h1, +.dark .assistant-message .markdown-preview h2, +.dark .assistant-message .markdown-preview h3, +.dark .assistant-message .markdown-preview strong { + color: #ffffff !important; } .user-message-container { @@ -39,6 +99,14 @@ overflow-wrap: anywhere; word-break: break-word; white-space: pre-wrap; + color: #ffffff !important; +} + +.user-message-container *, +.user-message-container p, +.user-message-container span, +.user-message-container .markdown-preview * { + color: #ffffff !important; } @keyframes slideInUp { diff --git a/frontend/src/features/chat/components/ChatWindow.js b/frontend/src/features/chat/components/ChatWindow.js index ff572ca..df9d404 100644 --- a/frontend/src/features/chat/components/ChatWindow.js +++ b/frontend/src/features/chat/components/ChatWindow.js @@ -102,7 +102,7 @@ }; return ( -
+
{/* Status indicator moved to top/bottom for better visibility */} {(message.reasoning || (message.status === "Thinking")) && (
@@ -132,7 +132,7 @@ )} -
+
{message.text}
@@ -141,7 +141,7 @@
boolean const [expandedFiles, setExpandedFiles] = useState({}); // node_id -> boolean const [editingNodeId, setEditingNodeId] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); const [editForm, setEditForm] = useState({ display_name: '', description: '', @@ -108,7 +110,7 @@ setShowCreateModal(false); fetchData(); } catch (err) { - alert(err.message); + setErrorMessage(err.message); } }; @@ -117,7 +119,7 @@ await adminUpdateNode(node.node_id, { is_active: !node.is_active }); fetchData(); } catch (err) { - alert(err.message); + setErrorMessage(err.message); } }; @@ -128,7 +130,7 @@ setNodeToDelete(null); fetchData(); } catch (err) { - alert(err.message); + setErrorMessage(err.message); } }; @@ -147,7 +149,7 @@ setEditingNodeId(null); fetchData(); } catch (err) { - alert(err.message); + setErrorMessage(err.message); } }; @@ -724,7 +726,7 @@ onClick={() => { const cmd = `curl -sSL '${window.location.origin}/api/v1/nodes/provision/${node.node_id}?token=${node.invite_token}' | python3`; navigator.clipboard.writeText(cmd); - alert("Provisioning command copied!"); + setSuccessMessage("Provisioning command copied!"); }} className="absolute top-2 right-2 p-1.5 bg-gray-800 hover:bg-indigo-600 text-gray-400 hover:text-white rounded-lg transition-all opacity-0 group-hover/cmd:opacity-100" title="Copy to clipboard" @@ -887,6 +889,52 @@
)} + + {/* ERROR MODAL */} + {errorMessage && ( +
+
+
+
+ + + +
+

Operation Failed

+

{errorMessage}

+ +
+
+
+ )} + + {/* SUCCESS MODAL */} + {successMessage && ( +
+
+
+
+ + + +
+

Success

+

{successMessage}

+ +
+
+
+ )}
); }; diff --git a/frontend/src/features/profile/pages/ProfilePage.js b/frontend/src/features/profile/pages/ProfilePage.js index 69212d2..00b708d 100644 --- a/frontend/src/features/profile/pages/ProfilePage.js +++ b/frontend/src/features/profile/pages/ProfilePage.js @@ -13,6 +13,7 @@ const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState({ type: '', text: '' }); + const [providerStatuses, setProviderStatuses] = useState({}); const [editData, setEditData] = useState({ full_name: '', username: '', @@ -34,6 +35,7 @@ ]); setProfile(prof); setConfig(conf.preferences); + setProviderStatuses(conf.preferences?.statuses || {}); setAccessibleNodes(nodes); setNodePrefs(nPrefs); setAvailable({ @@ -104,6 +106,22 @@ handleNodePrefChange({ default_node_ids: next }); }; + const handleGeneralPreferenceUpdate = async (updates) => { + try { + const newConfig = { + ...config, + ...updates + }; + await updateUserConfig(newConfig); + setConfig(newConfig); + setMessage({ type: 'success', text: 'Preferences updated.' }); + setTimeout(() => setMessage({ type: '', text: '' }), 3000); + } catch (err) { + setMessage({ type: 'error', text: 'Failed to update preferences.' }); + } + }; + + if (loading) { return (
@@ -116,7 +134,7 @@ const labelClass = "block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 ml-1"; return ( -
+
@@ -240,25 +258,90 @@ />
+ {/* Voice Experience Section */} +
+

+ + + + Voice Chat Experience +

+ +
+
+ + setConfig({...config, voice_voice: e.target.value})} + onBlur={() => handleGeneralPreferenceUpdate({ voice_voice: config.voice_voice })} + className={inputClass} + placeholder="e.g. onyx, alloy, shimmer" + /> +

The specific voice profile used by your TTS engine.

+
+ +
+
+
+ +

Automatically reactivates microphone after AI finishes speaking.

+
+ +
+
+ +
+ + setConfig({...config, voice_sensitivity: parseFloat(e.target.value)})} + onMouseUp={() => handleGeneralPreferenceUpdate({ voice_sensitivity: config.voice_sensitivity })} + className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-indigo-600" + /> +
+ Aggressive + Balanced + Relaxed +
+
+
+
+ {/* Node Defaults Section */} - {accessibleNodes.length > 0 && ( -
-

- - Default Node Attachment -

-
- +
+

+ + + + Default Node Attachment +

+ + {accessibleNodes.length > 0 ? ( +
+

Auto-attach these nodes to new sessions:

{accessibleNodes.map(node => { const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id); return (
- -
- -
- - {nodePrefs.data_source?.source === 'node_local' && ( - handleNodePrefChange({ data_source: { ...nodePrefs.data_source, path: e.target.value } })} - placeholder="/home/user/workspace" - className="flex-1 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl px-3 py-2 text-xs font-mono text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> - )} -
-

- Determines where the agent should look for files on the node when starting a chat. -

+ ) : ( +
+

No agent nodes are currently assigned to your group.

+ )} + + {/* Workspace Directory (Always visible within the section) */} +
+ +
+ + {nodePrefs.data_source?.source === 'node_local' && ( + handleNodePrefChange({ data_source: { ...nodePrefs.data_source, path: e.target.value } })} + placeholder="/home/user/workspace" + className="flex-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 text-sm font-mono text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm" + /> + )} +
+

+ Determines where the agent should look for files on the node when starting a chat. +

- )} +
diff --git a/frontend/src/features/settings/components/SettingsPageContent.js b/frontend/src/features/settings/components/SettingsPageContent.js index 5116b4d..1a8f990 100644 --- a/frontend/src/features/settings/components/SettingsPageContent.js +++ b/frontend/src/features/settings/components/SettingsPageContent.js @@ -1,627 +1,125 @@ import React from 'react'; -import VoicesModal from './components/VoicesModal'; +import AIConfigurationCard from './cards/AIConfigurationCard'; +import IdentityGovernanceCard from './cards/IdentityGovernanceCard'; +import NetworkIdentityCard from './cards/NetworkIdentityCard'; export default function SettingsPageContent({ context }) { const { - config, - effective, - loading, - saving, message, - activeConfigTab, - setActiveConfigTab, - activeAdminTab, - setActiveAdminTab, - userSearch, - setUserSearch, - expandedProvider, - setExpandedProvider, - selectedNewProvider, - setSelectedNewProvider, - verifying, - setVerifying, - fetchedModels, - setFetchedModels, - providerLists, - providerStatuses, - voiceList, - showVoicesModal, - setShowVoicesModal, - voicesLoading, - allUsers, - usersLoading, - loadUsers, - allGroups, - groupsLoading, - editingGroup, - setEditingGroup, - addingSection, - setAddingSection, - addForm, - setAddForm, - allNodes, - nodesLoading, - allSkills, - skillsLoading, - accessibleNodes, - nodePrefs, - fileInputRef, - handleViewVoices, - handleRoleToggle, - handleGroupChange, - handleNodePrefChange, - toggleDefaultNode, - handleSaveGroup, - handleDeleteGroup, - handleSave, - handleImport, + confirmAction, + setConfirmAction, + executeConfirmAction, handleExport, - inputClass, - labelClass, - sectionClass, - filteredUsers, - sortedGroups, - renderProviderSection + handleImport, + fileInputRef, + userProfile } = context; return ( - <> -
+
-
-

Configuration

-
- - - -
-
-

- Customize your AI models, backend API tokens, and providers. These settings override system defaults. -

- - {message.text && ( -
- {message.text} -
- )} - -
- {/* Card 1: AI Provider Configuration */} -
-
-

- - AI Resource Configuration -

-

Manage your providers, models, and API keys

+
+
+

+ Settings & Governance +

+

+ Manage user roles, AI resources, and system-wide security policies. +

- {/* Config Tabs */} -
- {['llm', 'tts', 'stt'].map((tab) => ( - - ))} -
- -
- {/* LLM Settings */} - {activeConfigTab === 'llm' && ( -
- {renderProviderSection('llm', providerLists.llm, false)} -
- )} - - {/* TTS Settings */} - {activeConfigTab === 'tts' && ( -
- {renderProviderSection('tts', providerLists.tts, true)} -
- )} - - {/* STT Settings */} - {activeConfigTab === 'stt' && ( -
- {renderProviderSection('stt', providerLists.stt, false)} -
- )} - -
- -
-
-
- - {/* Card 2: Team & Access Management */} -
-
-

- - Identity & Access Governance -

-

Define groups, policies, and manage members

-
- - {/* Admin Tabs */} -
- {['groups', 'users', 'personal'].map((tab) => ( - - ))} -
- -
- {/* Groups Management */} - {activeAdminTab === 'groups' && ( -
- {!editingGroup ? ( -
-
-

- Registered Groups -

- -
- -
- {sortedGroups.map((g) => ( -
-
-

- {g.id === 'ungrouped' ? 'Standard / Guest Policy' : g.name} - {g.id === 'ungrouped' && Global Fallback} -

-

- {g.id === 'ungrouped' ? 'Baseline access for all unassigned members.' : (g.description || 'No description')} -

-
- {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( -
- {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} -
- {g.policy?.[section]?.length > 0 ? ( - g.policy?.[section].slice(0, 3).map(p => ( -
- {p[0].toUpperCase()} -
- )) - ) : ( - None - )} - {g.policy?.[section]?.length > 3 && ( -
- +{g.policy?.[section].length - 3} -
- )} -
-
- ))} -
-
-
- - {g.id !== 'ungrouped' && ( - - )} -
-
- ))} -
-
- ) : ( -
- {/* (Group editing form - unchanged logic, just cleaner container) */} -
- -

- {editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`} -

- {editingGroup.id === 'ungrouped' && ( - - - System Group - - )} -
- -
-
-
- - editingGroup.id !== 'ungrouped' && setEditingGroup({ ...editingGroup, name: e.target.value })} - readOnly={editingGroup.id === 'ungrouped'} - placeholder="Engineering, Designers, etc." - className={`${inputClass} ${editingGroup.id === 'ungrouped' - ? 'opacity-60 cursor-not-allowed bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400' - : (editingGroup.name.trim() && - allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) - ? '!border-red-400 dark:!border-red-600 !ring-red-300' - : '') - }`} - /> - {editingGroup.id === 'ungrouped' ? ( -

- - System group name is locked. Only the access policy can be changed. -

- ) : editingGroup.name.trim() && - allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) && ( -

- - A group with this name already exists -

- )} -
-
- - setEditingGroup({ ...editingGroup, description: e.target.value })} - placeholder="Short description of this group..." - className={inputClass} - /> -
-
- -
- - -
- {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( -
-
- {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} -
- - -
-
-
- {(section === 'nodes' ? allNodes.map(n => ({ id: n.node_id, label: n.display_name })) : - (section === 'skills' ? allSkills.filter(s => !s.is_system).map(s => ({ id: s.name, label: s.name })) : - (effective[section]?.providers ? Object.keys(effective[section].providers) : []).map(pId => { - const baseType = pId.split('_')[0]; - const baseDef = providerLists[section].find(ld => ld.id === baseType || ld.id === pId); - return { id: pId, label: baseDef ? (pId.includes('_') ? `${baseDef.label} (${pId.split('_').slice(1).join('_')})` : baseDef.label) : pId }; - }))).map(item => { - const isChecked = (editingGroup.policy?.[section] || []).includes(item.id); - return ( - - ); - })} -
- {section === 'nodes' && allNodes.length === 0 && ( -

No agent nodes registered yet.

- )} -
- ))} -
- -
- - -
-
-
-
- )} -
- )} - - {/* Users Management */} - {activeAdminTab === 'users' && ( -
-
-
-

- Active Roster - {filteredUsers.length} -

-
-
- setUserSearch(e.target.value)} - placeholder="Search by name, email..." - className="w-full text-xs p-2.5 pl-9 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none transition-all" - /> - -
- -
-
-
- - - - - - - - - - - {filteredUsers.map((u) => ( - - - - - - - ))} - -
MemberPolicy GroupActivity AuditingActions
-
-
- {(u.username || u.email || '?')[0].toUpperCase()} -
-
-

{u.username || u.email}

-

{u.role}

-
-
-
- - -
-
- Join: - {new Date(u.created_at).toLocaleDateString()} -
-
- Last: - - {u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} - -
-
-
- -
- {allUsers.length === 0 && !usersLoading && ( -
No other users found.
- )} -
-
-
- )} - - {/* Personal Settings */} - {activeAdminTab === 'personal' && ( -
-
-
-
- -
-
-

My Preferences

-

Customize your individual experience

-
-
- -
- {accessibleNodes.length > 0 ? ( -
- -

Auto-attach these nodes to new sessions:

-
- {accessibleNodes.map(node => { - const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id); - return ( - - ); - })} -
-
- ) : ( -
-

No agent nodes are currently assigned to your group.

-
- )} - -
- -
- - {nodePrefs.data_source?.source === 'node_local' && ( - handleNodePrefChange({ data_source: { ...nodePrefs.data_source, path: e.target.value } })} - placeholder="/home/user/workspace" - className="flex-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 text-xs font-mono text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm" - /> - )} -
-

- Determines where the agent should look for files on the node when starting a chat. -

-
-
-
-
- )} -
- {showVoicesModal && ( -
setShowVoicesModal(false)}> -
e.stopPropagation()}> -
-
-

Available Cloud Voices

-

Found {voiceList.length} voices to choose from.

-

Highlighted voices (Chirp, Journey, Studio) use advanced AI for highest quality.

-
- -
-
- {voicesLoading ? ( -
-
-
- ) : voiceList.length > 0 ? ( -
    - {voiceList.map((v, i) => { - let highlight = v.toLowerCase().includes('chirp') || v.toLowerCase().includes('journey') || v.toLowerCase().includes('studio'); - return ( -
  • - {v} -
  • - ); - })} -
- ) : ( -

No voices found. Make sure your API key is configured and valid.

- )} -
-
- Double-click a name to select it, then paste it into the field. - -
-
+
)}
+ + {message.text && ( +
+
+ {message.type === 'error' ? ( + + ) : ( + + )} + {message.text} +
+
+ )} + +
+ {/* --- CARD 1: AI RESOURCE MANAGEMENT --- */} + + + {/* --- CARD 2: IDENTITY & ACCESS GOVERNANCE --- */} + + + {/* --- CARD 4: NETWORK ACCESS & EXTERNAL IDENTITY (ADMIN ONLY) --- */} +
-
+ + {/* GLOBAL CONFIRMATION MODAL */} + {confirmAction && ( +
+
+
+
+ ⚠️ +
+

Final Confirmation

+

+ {confirmAction.label} +

+
+ + +
+
+
+
+ )}
- +
); } diff --git a/frontend/src/features/settings/components/cards/AIConfigurationCard.js b/frontend/src/features/settings/components/cards/AIConfigurationCard.js new file mode 100644 index 0000000..348fd1d --- /dev/null +++ b/frontend/src/features/settings/components/cards/AIConfigurationCard.js @@ -0,0 +1,220 @@ +import React from 'react'; +import ProviderPanel from '../shared/ProviderPanel'; + +const AIConfigurationCard = ({ context }) => { + const { + config, + providerLists, + providerStatuses, + collapsedSections, + setCollapsedSections, + activeConfigTab, + setActiveConfigTab, + addingSection, + setAddingSection, + addForm, + setAddForm, + handleConfigChange, + handleAddInstance, + handleSaveConfig, + saving, + labelClass, + inputClass, + sectionClass, + fetchedModels, + expandedProvider, + setExpandedProvider, + verifying, + handleVerifyProvider, + handleDeleteProvider + } = context; + + const renderConfigSection = (sectionKey, title, description) => { + const sectionConfig = config[sectionKey] || {}; + const providers = sectionConfig.providers || {}; + const availableTypes = providerLists[sectionKey] || []; + + return ( +
+
+
+

{title} Resources

+

{description}

+
+
+ +
+ {Object.entries(providers).map(([id, prefs]) => ( + + ))} + + {addingSection === sectionKey ? ( +
+
+
+ + +
+
+ + setAddForm({ ...addForm, suffix: e.target.value })} + className={inputClass} + autoComplete="off" + /> +
+ {addForm.type && ( +
+
+ + setAddForm({ ...addForm, model: e.target.value })} + placeholder="Search or enter model..." + className={inputClass} + autoComplete="off" + /> + + {(fetchedModels[`${sectionKey}_${addForm.type}`] || []).map(m => ( + +
+
+ )} +
+
+ + +
+
+ ) : ( + + )} +
+ + +
+ ); + }; + + return ( +
+
setCollapsedSections(prev => ({ ...prev, ai: !prev.ai }))} + className="p-6 border-b border-gray-100 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors flex items-center justify-between group" + > +
+ +
+

+ AI Resource Management +

+

Global AI providers, model endpoints, and synthesis engines

+
+
+
+ +
+
+ {!collapsedSections.ai && ( +
+
+ {[ + { id: 'llm', label: 'Large Models' }, + { id: 'tts', label: 'Speech Synthesis' }, + { id: 'stt', label: 'Transcription' } + ].map((tab) => ( + + ))} +
+
+ {activeConfigTab === 'llm' && ( +
+ {renderConfigSection('llm', 'Large Language Model', 'Manage global AI providers, specialized models, and API endpoints.')} +
+ )} + {activeConfigTab === 'tts' && ( +
+ {renderConfigSection('tts', 'Text-to-Speech', 'Configure voice synthesis engines and region-specific endpoints.')} +
+ )} + {activeConfigTab === 'stt' && ( +
+ {renderConfigSection('stt', 'Speech-to-Text', 'Set up transcription services and language model defaults.')} +
+ )} +
+
+ +
+
+ )} +
+ ); +}; + +export default AIConfigurationCard; diff --git a/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js b/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js new file mode 100644 index 0000000..18269c6 --- /dev/null +++ b/frontend/src/features/settings/components/cards/IdentityGovernanceCard.js @@ -0,0 +1,391 @@ +import React from 'react'; + +const IdentityGovernanceCard = ({ context }) => { + const { + collapsedSections, + setCollapsedSections, + activeAdminTab, + setActiveAdminTab, + editingGroup, + setEditingGroup, + sortedGroups, + loadGroups, + handleDeleteGroup, + handleSaveGroup, + filteredUsers, + userSearch, + setUserSearch, + loadUsers, + usersLoading, + handleGroupChange, + handleRoleToggle, + saving, + labelClass, + inputClass, + sectionClass, + allGroups, + allNodes, + allSkills, + allUsers, + config, + providerLists, + userProfile + } = context; + + if (userProfile?.role !== 'admin') return null; + + return ( +
+
setCollapsedSections(prev => ({ ...prev, identity: !prev.identity }))} + className="p-6 border-b border-gray-100 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors flex items-center justify-between group" + > +
+ +
+

+ Identity & Access Governance +

+

Manage user groups, resource whitelists, and individual account policies

+
+
+
+ +
+
+ {!collapsedSections.identity && ( +
+
+ {[ + { id: 'groups', label: 'Security Groups', adminOnly: true }, + { id: 'users', label: 'User Roster', adminOnly: true } + ].filter(t => !t.adminOnly || userProfile?.role === 'admin').map((tab) => ( + + ))} +
+ +
+ {/* Groups Management */} + {activeAdminTab === 'groups' && ( +
+ {!editingGroup ? ( +
+
+
+

Governed Policy Groups

+

Define resource whitelists for teams and users

+
+ +
+ +
+ {sortedGroups.map(g => ( +
+
+
+ {g.name[0].toUpperCase()} +
+
+
+

{g.id === 'ungrouped' ? 'Ungrouped (Default)' : g.name}

+ {g.id === 'ungrouped' && System} +
+

{g.description || 'No description provided.'}

+
+ {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( +
+ {(g.policy?.[section] || []).length > 0 ? ( + (g.policy?.[section] || []).slice(0, 3).map(p => ( +
+ {p[0].toUpperCase()} +
+ )) + ) : ( + None + )} + {g.policy?.[section]?.length > 3 && ( +
+ +{g.policy?.[section].length - 3} +
+ )} +
+ ))} +
+
+
+
+ + {g.id !== 'ungrouped' && ( + + )} +
+
+ ))} +
+
+ ) : ( +
+ {/* (Group editing form - unchanged logic, just cleaner container) */} +
+ +

+ {editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`} +

+ {editingGroup.id === 'ungrouped' && ( + + + System Group + + )} +
+ +
+
+
+ + editingGroup.id !== 'ungrouped' && setEditingGroup({ ...editingGroup, name: e.target.value })} + readOnly={editingGroup.id === 'ungrouped'} + placeholder="Engineering, Designers, etc." + className={`${inputClass} ${editingGroup.id === 'ungrouped' + ? 'opacity-60 cursor-not-allowed bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400' + : (editingGroup.name.trim() && + allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) + ? '!border-red-400 dark:!border-red-600 !ring-red-300' + : '') + }`} + /> + {editingGroup.id === 'ungrouped' ? ( +

+ + System group name is locked. Only the access policy can be changed. +

+ ) : editingGroup.name.trim() && + allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) && ( +

+ + A group with this name already exists +

+ )} +
+
+ + setEditingGroup({ ...editingGroup, description: e.target.value })} + placeholder="Short description of this group..." + className={inputClass} + /> +
+
+ +
+ + +
+ {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( +
+
+ {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} +
+ + +
+
+
+ {(section === 'nodes' ? allNodes.map(n => ({ id: n.node_id, label: n.display_name })) : + (section === 'skills' ? allSkills.filter(s => !s.is_system).map(s => ({ id: s.name, label: s.name })) : + (config[section]?.providers ? Object.keys(config[section].providers) : []).map(pId => { + const baseType = pId.split('_')[0]; + const baseDef = providerLists[section].find(ld => ld.id === baseType || ld.id === pId); + return { id: pId, label: baseDef ? (pId.includes('_') ? `${baseDef.label} (${pId.split('_').slice(1).join('_')})` : baseDef.label) : pId }; + }))).map(item => { + const isChecked = (editingGroup.policy?.[section] || []).includes(item.id); + return ( + + ); + })} +
+ {section === 'nodes' && allNodes.length === 0 && ( +

No agent nodes registered yet.

+ )} +
+ ))} +
+ +
+ + +
+
+
+
+ )} +
+ )} + + {/* Users Management */} + {activeAdminTab === 'users' && ( +
+
+
+

+ Active Roster + {filteredUsers.length} +

+
+
+ setUserSearch(e.target.value)} + placeholder="Search by name, email..." + className="w-full text-xs p-2.5 pl-9 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none transition-all" + /> + +
+ +
+
+
+ + + + + + + + + + + {filteredUsers.map((u) => ( + + + + + + + ))} + +
MemberPolicy GroupActivity AuditingActions
+
+
+ {(u.username || u.email || '?')[0].toUpperCase()} +
+
+

{u.username || u.email}

+

{u.role}

+
+
+
+ + +
+
+ Join: + {new Date(u.created_at).toLocaleDateString()} +
+
+ Last: + + {u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} + +
+
+
+ +
+ {allUsers.length === 0 && !usersLoading && ( +
No other users found.
+ )} +
+
+
+ )} +
+
+ )} +
+ ); +}; + +export default IdentityGovernanceCard; diff --git a/frontend/src/features/settings/components/cards/NetworkIdentityCard.js b/frontend/src/features/settings/components/cards/NetworkIdentityCard.js new file mode 100644 index 0000000..31908cf --- /dev/null +++ b/frontend/src/features/settings/components/cards/NetworkIdentityCard.js @@ -0,0 +1,227 @@ +import React from 'react'; + +const NetworkIdentityCard = ({ context }) => { + const { + collapsedSections, + setCollapsedSections, + adminConfig, + setAdminConfig, + handleSaveAdminConfig, + fileInputRef, + handleTestConnection, + testingConnection, + userProfile, + labelClass, + inputClass + } = context; + + if (userProfile?.role !== 'admin') return null; + + return ( +
+
setCollapsedSections(prev => ({ ...prev, infrastructure: !prev.infrastructure }))} + className="p-6 border-b border-gray-100 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/50 cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-700/50 transition-colors flex items-center justify-between group" + > +
+ +
+

+ Network Access & External Identity +

+

Manage authentication protocols, SSO, and swarm deployment endpoints

+
+
+
+ +
+
+ + {!collapsedSections.infrastructure && ( +
+
+ {/* Access Security Model */} +
+
+
+

+ + Access Security Model +

+

+ Mutual Exclusivity: Enabling OIDC automatically disables local password login to ensure enterprise SSO compliance. +

+
+
+ + +
+
+
+ + {/* OIDC Details */} +
+
+
+

+ OIDC Configuration Details +

+

Enterprise Social Login & Identity Synchronization

+
+
+ +
+
+ + {/* Local Connection Feedback */} + {testingConnection === 'oidc' && ( +
+ Attempting to discover OIDC provider configuration... +
+ )} + +
+
+ + setAdminConfig({...adminConfig, oidc: {...adminConfig.oidc, client_id: e.target.value}})} + className={inputClass} + placeholder="e.g. 123456789.apps.googleusercontent.com" + autoComplete="off" + /> +
+
+ + setAdminConfig({...adminConfig, oidc: {...adminConfig.oidc, client_secret: e.target.value}})} + className={inputClass} + placeholder="••••••••••••••••" + autoComplete="new-password" + /> +
+
+ + setAdminConfig({...adminConfig, oidc: {...adminConfig.oidc, server_url: e.target.value}})} + className={inputClass} + placeholder="https://accounts.google.com" + autoComplete="off" + /> +
+
+ + setAdminConfig({...adminConfig, oidc: {...adminConfig.oidc, redirect_uri: e.target.value}})} + className={inputClass} + placeholder="https://ai.example.com/login/callback" + autoComplete="off" + /> +
+
+ +
+
+
+ + {/* Swarm Details */} +
+
+
+

+ Swarm Access Configuration +

+

Infrastructure gRPC Connectivity & Discovery

+
+
+ +
+
+ + {testingConnection === 'swarm' && ( +
+ Pinging Swarm external endpoint... +
+ )} + +
+
+ +

Protocol (http/https) implicitly determines TLS mode.

+ setAdminConfig({...adminConfig, swarm: {...adminConfig.swarm, external_endpoint: e.target.value}})} + className={inputClass} + placeholder="https://swarm.example.com:443" + autoComplete="off" + /> +
+
+ +
+
+
+
+
+ )} +
+ ); +}; + +export default NetworkIdentityCard; diff --git a/frontend/src/features/settings/components/shared/ProviderPanel.js b/frontend/src/features/settings/components/shared/ProviderPanel.js new file mode 100644 index 0000000..f8be052 --- /dev/null +++ b/frontend/src/features/settings/components/shared/ProviderPanel.js @@ -0,0 +1,112 @@ +import React from 'react'; + +const ProviderPanel = ({ + id, + prefs, + sectionKey, + status, + providers, + expandedProvider, + setExpandedProvider, + verifying, + handleVerifyProvider, + handleDeleteProvider, + handleConfigChange, + labelClass, + inputClass, + fetchedModels +}) => { + const isExpanded = expandedProvider === `${sectionKey}_${id}`; + const providerType = prefs.provider_type || id.split('_')[0]; + const isVerifying = verifying === `${sectionKey}_${id}`; + + return ( +
+
setExpandedProvider(isExpanded ? null : `${sectionKey}_${id}`)} + > +
+
+ {id.substring(0, 2).toUpperCase()} +
+
+

+ {id} + {status === 'success' && } + {status === 'error' && } +

+

{providerType}

+
+
+
+ + + +
+
+ + {isExpanded && ( +
+
+
+ +
+ handleConfigChange(sectionKey, 'providers', { ...providers, [id]: { ...prefs, api_key: e.target.value } }, id)} + placeholder="••••••••••••••••" + className={inputClass} + autoComplete="new-password" + /> +
+
+
+ + { + const val = (sectionKey === 'tts' && providerType === 'gcloud_tts' ? prefs.voice : prefs.model); + if (!val) return ''; + return typeof val === 'object' ? (val.model_name || val.id || val.voice_id || '') : val; + })() || ''} + onChange={e => handleConfigChange(sectionKey, 'providers', { ...providers, [id]: { ...prefs, [sectionKey === 'tts' && providerType === 'gcloud_tts' ? 'voice' : 'model']: e.target.value } }, id)} + placeholder="e.g. gpt-4, whisper-1" + className={inputClass} + autoComplete="off" + /> + + {(fetchedModels[`${sectionKey}_${providerType}`] || []).map(m => ( + +
+
+ + +
+
+
+ )} +
+ ); +}; + +export default ProviderPanel; diff --git a/frontend/src/features/settings/pages/SettingsPage.js b/frontend/src/features/settings/pages/SettingsPage.js index dac691d..b479706 100644 --- a/frontend/src/features/settings/pages/SettingsPage.js +++ b/frontend/src/features/settings/pages/SettingsPage.js @@ -1,60 +1,87 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { - getUserConfig, updateUserConfig, exportUserConfig, importUserConfig, - verifyProvider, getProviderModels, getAllProviders, getVoices, - getAdminUsers, updateUserRole, getAdminGroups, createAdminGroup, - updateAdminGroup, deleteAdminGroup, updateUserGroup, getAdminNodes, - getSkills, getUserNodePreferences, updateUserNodePreferences, - getUserAccessibleNodes + getUserConfig, + getAdminUsers, + getAdminGroups, + getAdminNodes, + updateUserRole, + updateUserGroup, + createAdminGroup, + updateAdminGroup, + deleteAdminGroup, + getSkills, + getUserProfile, + getAdminConfig, + updateAdminOIDCConfig, + updateAdminSwarmConfig, + updateAdminAppConfig, + testAdminOIDCConfig, + testAdminSwarmConfig, + getAllProviders, + updateUserConfig, + getProviderModels, + verifyProvider, + exportUserConfig, + importUserConfig } from '../../../services/apiService'; import SettingsPageContent from '../components/SettingsPageContent'; const SettingsPage = () => { const [config, setConfig] = useState({ llm: {}, tts: {}, stt: {} }); - const [effective, setEffective] = useState({ llm: {}, tts: {}, stt: {} }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState({ type: '', text: '' }); - const [activeConfigTab, setActiveConfigTab] = useState('llm'); const [activeAdminTab, setActiveAdminTab] = useState('groups'); const [userSearch, setUserSearch] = useState(''); - const [expandedProvider, setExpandedProvider] = useState(null); - const [selectedNewProvider, setSelectedNewProvider] = useState(''); - const [verifying, setVerifying] = useState(null); - const [fetchedModels, setFetchedModels] = useState({}); const [providerLists, setProviderLists] = useState({ llm: [], tts: [], stt: [] }); - const [providerStatuses, setProviderStatuses] = useState({}); - const [voiceList, setVoiceList] = useState([]); - const [showVoicesModal, setShowVoicesModal] = useState(false); - const [voicesLoading, setVoicesLoading] = useState(false); const [allUsers, setAllUsers] = useState([]); const [usersLoading, setUsersLoading] = useState(false); const [allGroups, setAllGroups] = useState([]); const [groupsLoading, setGroupsLoading] = useState(false); const [editingGroup, setEditingGroup] = useState(null); - const [addingSection, setAddingSection] = useState(null); - const [addForm, setAddForm] = useState({ type: '', suffix: '', model: '', cloneFrom: '' }); const [allNodes, setAllNodes] = useState([]); const [nodesLoading, setNodesLoading] = useState(false); const [allSkills, setAllSkills] = useState([]); const [skillsLoading, setSkillsLoading] = useState(false); - const [accessibleNodes, setAccessibleNodes] = useState([]); - const [nodePrefs, setNodePrefs] = useState({ default_node_ids: [], data_source: { source: 'empty', path: '' } }); - const fileInputRef = useRef(null); + const [adminConfig, setAdminConfig] = useState({ oidc: {}, swarm: {} }); + const [adminConfigLoading, setAdminConfigLoading] = useState(false); + const [userProfile, setUserProfile] = useState(null); + const [activeConfigTab, setActiveConfigTab] = useState('llm'); + const [expandedProvider, setExpandedProvider] = useState(null); + const [addingSection, setAddingSection] = useState(null); + const [addForm, setAddForm] = useState({ type: '', suffix: '', model: '' }); + const [collapsedSections, setCollapsedSections] = useState({ ai: true, identity: true, infrastructure: true }); + const [verifying, setVerifying] = useState(null); + const [testingConnection, setTestingConnection] = useState(null); // 'oidc' or 'swarm' + const [fetchedModels, setFetchedModels] = useState({}); + const [providerStatuses, setProviderStatuses] = useState({}); + const [confirmAction, setConfirmAction] = useState(null); // { type, id, sectionKey, label } + const fileInputRef = React.useRef(null); - const handleViewVoices = async (providerId, apiKey = null) => { - setShowVoicesModal(true); - setVoicesLoading(true); - setVoiceList([]); // Clear previous list while loading - try { - const voices = await getVoices(providerId, apiKey); - setVoiceList(voices); - } catch (e) { - console.error(e); - } finally { - setVoicesLoading(false); + useEffect(() => { + if (expandedProvider) { + const parts = expandedProvider.split('_'); + const sectionKey = parts[0]; + const providerId = parts.slice(1).join('_'); + const fetchKey = `${sectionKey}_${providerId}`; + if (!fetchedModels[fetchKey]) { + getProviderModels(providerId, sectionKey).then(models => { + setFetchedModels(prev => ({ ...prev, [fetchKey]: models })); + }).catch(e => console.warn("Failed fetching models for", providerId)); + } } - }; + }, [expandedProvider, fetchedModels]); + + useEffect(() => { + if (addingSection && addForm.type) { + const fetchKey = `${addingSection}_${addForm.type}`; + if (!fetchedModels[fetchKey]) { + getProviderModels(addForm.type, addingSection).then(models => { + setFetchedModels(prev => ({ ...prev, [fetchKey]: models })); + }).catch(() => { }); + } + } + }, [addingSection, addForm.type, fetchedModels]); useEffect(() => { const fetchProviders = async () => { @@ -82,19 +109,31 @@ loadGroups(); loadNodes(); loadSkills(); - loadPersonalNodePrefs(); + loadUserProfile(); }, []); - const loadPersonalNodePrefs = async () => { + + const loadUserProfile = async () => { try { - const [nodes, prefs] = await Promise.all([ - getUserAccessibleNodes(), - getUserNodePreferences() - ]); - setAccessibleNodes(nodes); - setNodePrefs(prefs); + const profile = await getUserProfile(); + setUserProfile(profile); + if (profile.role === 'admin') { + loadAdminConfig(); + } } catch (e) { - console.error("Failed to load personal node prefs", e); + console.error("Failed to load user profile", e); + } + }; + + const loadAdminConfig = async () => { + try { + setAdminConfigLoading(true); + const data = await getAdminConfig(); + setAdminConfig(data); + } catch (e) { + console.error("Failed to load admin config", e); + } finally { + setAdminConfigLoading(false); } }; @@ -169,26 +208,6 @@ } }; - const handleNodePrefChange = async (updates) => { - try { - const newNodePrefs = { ...nodePrefs, ...updates }; - await updateUserNodePreferences(newNodePrefs); - setNodePrefs(newNodePrefs); - setMessage({ type: 'success', text: 'Personal node preferences updated.' }); - setTimeout(() => setMessage({ type: '', text: '' }), 3000); - } catch (err) { - setMessage({ type: 'error', text: 'Failed to update node preferences.' }); - } - }; - - const toggleDefaultNode = (nodeId) => { - const current = nodePrefs.default_node_ids || []; - const next = current.includes(nodeId) - ? current.filter(id => id !== nodeId) - : [...current, nodeId]; - handleNodePrefChange({ default_node_ids: next }); - }; - const handleSaveGroup = async (e) => { e.preventDefault(); try { @@ -211,8 +230,15 @@ } }; - const handleDeleteGroup = async (groupId) => { - if (!window.confirm("Are you sure? Users in this group will be moved to 'Ungrouped'.")) return; + const handleDeleteGroup = (groupId) => { + setConfirmAction({ + type: 'delete-group', + id: groupId, + label: "Are you sure? Users in this group will be moved to 'Ungrouped'." + }); + }; + + const confirmDeleteGroup = async (groupId) => { try { await deleteAdminGroup(groupId); setMessage({ type: 'success', text: 'Group deleted' }); @@ -224,73 +250,75 @@ } }; - const loadConfig = async () => { + const handleSaveAdminConfig = async (type, data) => { try { - setLoading(true); - const data = await getUserConfig(); - - // Pre-seed config with effective providers if the user's config is empty - const seedEffective = (prefSec, effSec) => { - if (prefSec && prefSec.providers && Object.keys(prefSec.providers).length > 0) return prefSec; - return { - ...prefSec, - providers: { ...(effSec?.providers || {}) }, - active_provider: prefSec?.active_provider || effSec?.active_provider - }; - }; - - setConfig({ - llm: seedEffective(data.preferences?.llm, data.effective?.llm), - tts: seedEffective(data.preferences?.tts, data.effective?.tts), - stt: seedEffective(data.preferences?.stt, data.effective?.stt) - }); - setProviderStatuses(data.preferences?.statuses || {}); - setEffective(data.effective || { llm: {}, tts: {}, stt: {} }); - - setMessage({ type: '', text: '' }); - } catch (err) { - console.error("Error loading config:", err); - setMessage({ type: 'error', text: 'Failed to load configuration.' }); + setSaving(true); + if (type === 'oidc') { + // Mutual Exclusivity: If OIDC is being enabled, disable password login + // Note: 'enabled' is the master switch that controls the login button. + if (data.enabled === true && adminConfig.app?.allow_password_login) { + await updateAdminAppConfig({ allow_password_login: false }); + } + + // Consolidate 'allow_oidc_login' with 'enabled' to prevent redundancy + const updatedData = { ...data }; + if (data.enabled !== undefined) { + updatedData.allow_oidc_login = data.enabled; + } + + await updateAdminOIDCConfig(updatedData); + setMessage({ type: 'success', text: 'OIDC configuration updated successfully' }); + } else if (type === 'swarm') { + await updateAdminSwarmConfig(data); + setMessage({ type: 'success', text: 'Swarm configuration updated successfully' }); + } else if (type === 'app') { + // Mutual Exclusivity: If password login is being enabled, disable OIDC + if (data.allow_password_login === true && adminConfig.oidc?.enabled) { + await updateAdminOIDCConfig({ enabled: false, allow_oidc_login: false }); + } + await updateAdminAppConfig(data); + setMessage({ type: 'success', text: 'Application configuration updated successfully' }); + } + loadAdminConfig(); + setTimeout(() => setMessage({ type: '', text: '' }), 5000); + } catch (e) { + setMessage({ type: 'error', text: e.message || 'Failed to update admin config' }); } finally { - setLoading(false); + setSaving(false); } }; - useEffect(() => { - if (expandedProvider) { - const parts = expandedProvider.split('_'); - const sectionKey = parts[0]; - const providerId = parts.slice(1).join('_'); - - const fetchKey = `${sectionKey}_${providerId}`; - if (!fetchedModels[fetchKey]) { - getProviderModels(providerId, sectionKey).then(models => { - setFetchedModels(prev => ({ ...prev, [fetchKey]: models })); - }).catch(e => console.warn("Failed fetching models for", providerId, "section", sectionKey)); + const handleTestConnection = async (type) => { + try { + setTestingConnection(type); + setMessage({ type: '', text: `Testing ${type.toUpperCase()} connection...` }); + let response; + if (type === 'oidc') { + response = await testAdminOIDCConfig(adminConfig.oidc); + } else if (type === 'swarm') { + response = await testAdminSwarmConfig(adminConfig.swarm); } - } - }, [expandedProvider, fetchedModels]); - - // Pre-fetch model list for the selected type in the add-new-instance form - useEffect(() => { - if (addingSection && addForm.type) { - const fetchKey = `${addingSection}_${addForm.type}`; - if (!fetchedModels[fetchKey]) { - getProviderModels(addForm.type, addingSection).then(models => { - setFetchedModels(prev => ({ ...prev, [fetchKey]: models })); - }).catch(() => { }); + + if (response && response.success) { + setMessage({ type: 'success', text: response.message }); + } else { + setMessage({ type: 'error', text: (response && response.message) || 'Connection test failed.' }); } + } catch (e) { + setMessage({ type: 'error', text: e.message || `Error occurred while testing ${type} connection` }); + } finally { + setTestingConnection(null); + setTimeout(() => setMessage({ type: '', text: '' }), 10000); } - }, [addingSection, addForm.type, fetchedModels]); + }; - const handleSave = async (e) => { - e.preventDefault(); + + const handleSaveConfig = async (e) => { + if (e) e.preventDefault(); try { setSaving(true); setMessage({ type: '', text: 'Saving and verifying configuration...' }); - // Before saving, let's identify any "active" providers that have been modified - // (i.e. they are grey/have no status) and run a quick verification for them. const updatedStatuses = { ...providerStatuses }; const sections = ['llm', 'tts', 'stt']; @@ -317,15 +345,18 @@ setProviderStatuses(updatedStatuses); const payload = { ...config, statuses: updatedStatuses }; - await updateUserConfig(payload); - - // reload after save to get latest effective config - await loadConfig(); + const data = await updateUserConfig(payload); + + setConfig({ + llm: data.llm || {}, + tts: data.tts || {}, + stt: data.stt || {} + }); setMessage({ type: 'success', text: 'Settings saved and verified successfully!' }); setTimeout(() => setMessage({ type: '', text: '' }), 3000); } catch (err) { console.error("Error saving config:", err); - setMessage({ type: 'error', text: 'Failed to save configuration: ' + (err.message || "Unknown error") }); + setMessage({ type: 'error', text: 'Failed to save configuration.' }); } finally { setSaving(false); } @@ -349,35 +380,11 @@ } }; - const handleGrantToAll = async (section, providerId) => { - if (!window.confirm(`Are you sure? This will whitelist ${providerId} for ALL existing groups.`)) return; - try { - setSaving(true); - setMessage({ type: '', text: `Syncing group policies for ${providerId}...` }); - for (const group of allGroups) { - const currentPolicy = group.policy || { llm: [], tts: [], stt: [] }; - const sectionList = currentPolicy[section] || []; - if (!sectionList.includes(providerId)) { - const newPolicy = { ...currentPolicy, [section]: [...sectionList, providerId] }; - await updateAdminGroup(group.id, { ...group, policy: newPolicy }); - } - } - await loadGroups(); - setMessage({ type: 'success', text: `Global access granted for ${providerId}!` }); - setTimeout(() => setMessage({ type: '', text: '' }), 3000); - } catch (e) { - console.error(e); - setMessage({ type: 'error', text: 'Failed to sync group access.' }); - } finally { - setSaving(false); - } - }; - const handleImport = async (e) => { const file = e.target.files[0]; if (!file) return; try { - setLoading(true); + setSaving(true); const formData = new FormData(); formData.append('file', file); await importUserConfig(formData); @@ -388,12 +395,12 @@ console.error("Import Error: ", error); setMessage({ type: 'error', text: 'Failed to import YAML: ' + error.message }); } finally { - setLoading(false); + setSaving(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; - const handleChange = (section, field, value, providerId = null) => { + const handleConfigChange = (section, field, value, providerId = null) => { if (field === 'providers' && providerId) { setProviderStatuses(prev => { const updated = { ...prev }; @@ -410,6 +417,112 @@ })); }; + const handleVerifyProvider = async (sectionKey, providerId, providerPrefs) => { + try { + setVerifying(`${sectionKey}_${providerId}`); + setMessage({ type: '', text: '' }); + const payload = { + provider_name: providerId, + provider_type: providerPrefs.provider_type, + api_key: providerPrefs.api_key, + model: providerPrefs.model, + voice: providerPrefs.voice + }; + const res = await verifyProvider(sectionKey, payload); + if (res.success) { + const newStatuses = { ...providerStatuses, [`${sectionKey}_${providerId}`]: 'success' }; + setProviderStatuses(newStatuses); + await updateUserConfig({ ...config, statuses: newStatuses }); + setMessage({ type: 'success', text: `Verified ${providerId} successfully!` }); + } else { + const newStatuses = { ...providerStatuses, [`${sectionKey}_${providerId}`]: 'error' }; + setProviderStatuses(newStatuses); + await updateUserConfig({ ...config, statuses: newStatuses }); + setMessage({ type: 'error', text: `Verification failed for ${providerId}: ${res.message}` }); + } + } catch (err) { + setMessage({ type: 'error', text: `Error verifying ${providerId}.` }); + } finally { + setVerifying(null); + setTimeout(() => setMessage({ type: '', text: '' }), 5000); + } + }; + + const handleDeleteProviderAction = (sectionKey, providerId) => { + setConfirmAction({ + type: 'delete-provider', + id: providerId, + sectionKey, + label: `Permanently delete the "${providerId}" resource instance?` + }); + }; + + const confirmDeleteProvider = (sectionKey, providerId) => { + const newProviders = { ...((config[sectionKey] && config[sectionKey].providers) || {}) }; + delete newProviders[providerId]; + handleConfigChange(sectionKey, 'providers', newProviders, providerId); + if (expandedProvider === `${sectionKey}_${providerId}`) setExpandedProvider(null); + }; + + const executeConfirmAction = () => { + if (!confirmAction) return; + if (confirmAction.type === 'delete-group') { + confirmDeleteGroup(confirmAction.id); + } else if (confirmAction.type === 'delete-provider') { + confirmDeleteProvider(confirmAction.sectionKey, confirmAction.id); + } + setConfirmAction(null); + }; + + const handleAddInstance = (sectionKey) => { + if (!addForm.type) return; + const newId = addForm.suffix ? `${addForm.type}_${addForm.suffix.toLowerCase().replace(/\s+/g, '_')}` : addForm.type; + + if (config[sectionKey]?.providers?.[newId]) { + setMessage({ type: 'error', text: `Instance "${newId}" already exists.` }); + return; + } + + const initData = { provider_type: addForm.type }; + + if (addForm.model.trim()) { + if (sectionKey === 'tts' && addForm.type === 'gcloud_tts') { + initData.voice = addForm.model.trim(); + } else { + initData.model = addForm.model.trim(); + } + } + + const newProviders = { ...(config[sectionKey]?.providers || {}) }; + newProviders[newId] = initData; + handleConfigChange(sectionKey, 'providers', newProviders, newId); + setAddingSection(null); + setAddForm({ type: '', suffix: '', model: '' }); + setExpandedProvider(`${sectionKey}_${newId}`); + }; + + + const loadConfig = async () => { + try { + setLoading(true); + const data = await getUserConfig(); + setConfig({ + llm: data.preferences?.llm || {}, + tts: data.preferences?.tts || {}, + stt: data.preferences?.stt || {} + }); + if (data.preferences?.statuses) { + setProviderStatuses(data.preferences.statuses); + } + setMessage({ type: '', text: '' }); + } catch (err) { + console.error("Error loading config:", err); + setMessage({ type: 'error', text: 'Failed to load configuration.' }); + } finally { + setLoading(false); + } + }; + if (loading) { return (
@@ -418,514 +531,6 @@ ); } - const inputClass = "w-full border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors duration-200 shadow-sm"; - const labelClass = "block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2"; - const sectionClass = "animate-fade-in"; - - const renderProviderSection = (sectionKey, providerDefs, allowVoice = false) => { - const activeProviderIds = new Set([ - ...Object.keys(config[sectionKey]?.providers || {}) - ]); - const activeProviders = Array.from(activeProviderIds).map(id => { - const baseP = providerDefs.find(p => p.id === id); - if (baseP) return baseP; - // Handle suffixed IDs (e.g. gemini_2) - const parts = id.split('_'); - let baseId = parts[0]; - // Special case for google_gemini - if (id.startsWith('google_gemini_')) baseId = 'google_gemini'; - - const baseDef = providerDefs.find(p => p.id === baseId); - const suffix = id.replace(baseId + '_', ''); - return { - id: id, - label: baseDef ? `${baseDef.label} (${suffix})` : id - }; - }).sort((a, b) => a.label.localeCompare(b.label)); - - - const handleAddInstance = () => { - if (!addForm.type) return; - const newId = addForm.suffix ? `${addForm.type}_${addForm.suffix.toLowerCase().replace(/\s+/g, '_')}` : addForm.type; - - if (activeProviderIds.has(newId)) { - setMessage({ type: 'error', text: `Instance "${newId}" already exists.` }); - return; - } - - // Build initial provider data - const initData = { provider_type: addForm.type }; - - // Store a _clone_from marker — the backend will resolve the real API key - // from the source provider. We never have the plaintext key on the frontend. - if (addForm.cloneFrom) { - initData._clone_from = addForm.cloneFrom; - } - - // Pre-set model (or voice for Google Cloud TTS) if specified - if (addForm.model.trim()) { - if (sectionKey === 'tts' && addForm.type === 'gcloud_tts') { - initData.voice = addForm.model.trim(); - } else { - initData.model = addForm.model.trim(); - } - } - - const newProviders = { ...(config[sectionKey]?.providers || {}) }; - newProviders[newId] = initData; - handleChange(sectionKey, 'providers', newProviders, newId); - setAddingSection(null); - setAddForm({ type: '', suffix: '', model: '', cloneFrom: '' }); - setExpandedProvider(`${sectionKey}_${newId}`); - }; - - // Existing instances of the same type that have an API key — for cloning - const cloneableSources = Array.from(activeProviderIds).filter(id => { - const baseType = id.startsWith('google_gemini') ? 'google_gemini' : id.split('_')[0]; - return baseType === addForm.type && id !== addForm.type + (addForm.suffix ? '_' + addForm.suffix.toLowerCase().replace(/\s+/g, '_') : ''); - }); - - const handleDeleteProvider = (providerId) => { - const newProviders = { ...((config[sectionKey] && config[sectionKey].providers) || {}) }; - delete newProviders[providerId]; - handleChange(sectionKey, 'providers', newProviders, providerId); - if (expandedProvider === `${sectionKey}_${providerId}`) setExpandedProvider(null); - }; - - - - const handleVerifyProvider = async (providerId, providerPrefs) => { - try { - setVerifying(`${sectionKey}_${providerId}`); - setMessage({ type: '', text: '' }); - const payload = { - provider_name: providerId, - provider_type: providerPrefs.provider_type, - api_key: providerPrefs.api_key, - model: providerPrefs.model, - voice: providerPrefs.voice - }; - const res = await verifyProvider(sectionKey, payload); - if (res.success) { - const newStatuses = { ...providerStatuses, [`${sectionKey}_${providerId}`]: 'success' }; - setProviderStatuses(newStatuses); - await updateUserConfig({ ...config, statuses: newStatuses }); - setMessage({ type: 'success', text: `Verified ${providerId} successfully!` }); - } else { - const newStatuses = { ...providerStatuses, [`${sectionKey}_${providerId}`]: 'error' }; - setProviderStatuses(newStatuses); - await updateUserConfig({ ...config, statuses: newStatuses }); - setMessage({ type: 'error', text: `Verification failed for ${providerId}: ${res.message}` }); - } - } catch (err) { - setMessage({ type: 'error', text: `Error verifying ${providerId}.` }); - } finally { - setVerifying(null); - setTimeout(() => setMessage({ type: '', text: '' }), 5000); - } - }; - - return ( -
- {/* Header & Add Form */} -
-
-
- -
-
-

Resource Instances

-

Configure specific account credentials

-
-
- - {addingSection !== sectionKey ? ( - - ) : ( -
-

New Provider Instance

- - {/* Row 1: Type + Label suffix */} -
-
- - -
-
- - setAddForm({ ...addForm, suffix: e.target.value })} - className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none" - /> - {addForm.type && addForm.suffix && ( -

- ID: {addForm.type}_{addForm.suffix.toLowerCase().replace(/\s+/g, '_')} -

- )} -
-
- - {/* Row 2: Model + Clone-from */} -
-
- - {addForm.type && fetchedModels[`${sectionKey}_${addForm.type}`]?.length > 0 ? ( - - ) : ( - setAddForm({ ...addForm, model: e.target.value })} - className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none" - /> - )} -
- {cloneableSources.length > 0 && ( -
- - - {addForm.cloneFrom && ( -

✓ API key will be copied from "{addForm.cloneFrom}" on save

- )} -
- )} -
- - {/* Action buttons */} -
- - -
-
- )} -
- -
- Status Legend: -
-
- Verified -
-
-
- Failed -
-
-
- Not Tested -
-
- -
- {activeProviders.length === 0 && ( -

No providers enabled. Add one above.

- )} - {activeProviders.map((provider) => { - const isExpanded = expandedProvider === `${sectionKey}_${provider.id}`; - const providerPrefs = config[sectionKey]?.providers?.[provider.id] || {}; - const providerEff = effective[sectionKey]?.providers?.[provider.id] || {}; - - let displayMeta = providerPrefs.model || providerEff.model; - if (sectionKey === 'tts' && provider.id.startsWith('gcloud_tts')) { - displayMeta = providerPrefs.voice || providerEff.voice; - } - - return ( -
-
setExpandedProvider(isExpanded ? null : `${sectionKey}_${provider.id}`)} - className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50" - > -

-
- {provider.label} -

-
-
- {displayMeta ? ( - - {displayMeta} - - ) : null} -
- - - - - - -
-
- {isExpanded && ( -
-
-
-
- -
- <> - { - const newProviders = { ...(config[sectionKey]?.providers || {}) }; - const p = { ...providerPrefs, api_key: e.target.value }; - delete p._clone_from; - newProviders[provider.id] = p; - handleChange(sectionKey, 'providers', newProviders, provider.id); - }} - onFocus={(e) => { - if (e.target.value.includes('***')) { - const newProviders = { ...(config[sectionKey]?.providers || {}) }; - const p = { ...providerPrefs, api_key: '' }; - delete p._clone_from; - newProviders[provider.id] = p; - handleChange(sectionKey, 'providers', newProviders, provider.id); - } - }} - placeholder="sk-..." - className={inputClass} - /> -

Specify your API key for {provider.label}.

- -
-
- {!(sectionKey === 'tts' && provider.id === 'gcloud_tts') && ( -
- - {fetchedModels[`${sectionKey}_${provider.id}`] && fetchedModels[`${sectionKey}_${provider.id}`].length > 0 ? ( - - ) : ( - { - const newProviders = { ...(config[sectionKey]?.providers || {}) }; - newProviders[provider.id] = { ...providerPrefs, model: e.target.value }; - handleChange(sectionKey, 'providers', newProviders, provider.id); - }} - placeholder={provider.id === 'general' ? "E.g. vertex_ai/gemini-1.5-flash" : (sectionKey === 'llm' ? "E.g. gpt-4, claude-3-opus" : "E.g. whisper-1, gemini-1.5-flash")} - className={inputClass} - /> - )} -

- Specify exactly which model to pass to the provider API. Active default: {providerEff.model || 'None'} -

-
- )} - - {allowVoice && ( -
-
- - -
- { - const newProviders = { ...(config[sectionKey]?.providers || {}) }; - newProviders[provider.id] = { ...providerPrefs, voice: e.target.value }; - handleChange(sectionKey, 'providers', newProviders, provider.id); - }} - placeholder="E.g., Kore, en-US-Journey-F" - className={inputClass} - /> -

- Active default: {providerEff.voice || 'None'} -

-
- )} - - {/* Custom Parameters Section */} -
- - -
- {Object.entries(providerPrefs).map(([key, value]) => { - // Filter out standard fields to only show "custom" ones here - if (['api_key', 'model', 'voice'].includes(key)) return null; - return ( -
-
- {key}: - {value} -
- -
- ); - })} -
- -
-
- -
-
- -
- -
-
- -
-
-

Access Control

-

Manage which groups can use this provider.

-
- -
-
- )} -
- ); - })} -
-
- ); - }; - const filteredUsers = allUsers.filter(u => (u.username || '').toLowerCase().includes(userSearch.toLowerCase()) || (u.email || '').toLowerCase().includes(userSearch.toLowerCase()) || @@ -938,591 +543,42 @@ return a.name.localeCompare(b.name); }); - return ( -
-
-
-

Configuration

-
- - - -
-
-

- Customize your AI models, backend API tokens, and providers. These settings override system defaults. -

- - {message.text && ( -
- {message.text} -
- )} - -
- {/* Card 1: AI Provider Configuration */} -
-
-

- - AI Resource Configuration -

-

Manage your providers, models, and API keys

-
- - {/* Config Tabs */} -
- {['llm', 'tts', 'stt'].map((tab) => ( - - ))} -
- -
- {/* LLM Settings */} - {activeConfigTab === 'llm' && ( -
- {renderProviderSection('llm', providerLists.llm, false)} -
- )} - - {/* TTS Settings */} - {activeConfigTab === 'tts' && ( -
- {renderProviderSection('tts', providerLists.tts, true)} -
- )} - - {/* STT Settings */} - {activeConfigTab === 'stt' && ( -
- {renderProviderSection('stt', providerLists.stt, false)} -
- )} - -
- -
-
-
- - {/* Card 2: Team & Access Management */} -
-
-

- - Identity & Access Governance -

-

Define groups, policies, and manage members

-
- - {/* Admin Tabs */} -
- {['groups', 'users', 'personal'].map((tab) => ( - - ))} -
- -
- {/* Groups Management */} - {activeAdminTab === 'groups' && ( -
- {!editingGroup ? ( -
-
-

- Registered Groups -

- -
- -
- {sortedGroups.map((g) => ( -
-
-

- {g.id === 'ungrouped' ? 'Standard / Guest Policy' : g.name} - {g.id === 'ungrouped' && Global Fallback} -

-

- {g.id === 'ungrouped' ? 'Baseline access for all unassigned members.' : (g.description || 'No description')} -

-
- {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( -
- {section} -
- {g.policy?.[section]?.length > 0 ? ( - g.policy?.[section].slice(0, 3).map(p => ( -
- {p[0].toUpperCase()} -
- )) - ) : ( - None - )} - {g.policy?.[section]?.length > 3 && ( -
- +{g.policy?.[section].length - 3} -
- )} -
-
- ))} -
-
-
- - {g.id !== 'ungrouped' && ( - - )} -
-
- ))} -
-
- ) : ( -
- {/* (Group editing form - unchanged logic, just cleaner container) */} -
- -

- {editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`} -

- {editingGroup.id === 'ungrouped' && ( - - - System Group - - )} -
- -
-
-
- - editingGroup.id !== 'ungrouped' && setEditingGroup({ ...editingGroup, name: e.target.value })} - readOnly={editingGroup.id === 'ungrouped'} - placeholder="Engineering, Designers, etc." - className={`${inputClass} ${editingGroup.id === 'ungrouped' - ? 'opacity-60 cursor-not-allowed bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400' - : (editingGroup.name.trim() && - allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) - ? '!border-red-400 dark:!border-red-600 !ring-red-300' - : '') - }`} - /> - {editingGroup.id === 'ungrouped' ? ( -

- - System group name is locked. Only the access policy can be changed. -

- ) : editingGroup.name.trim() && - allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) && ( -

- - A group with this name already exists -

- )} -
-
- - setEditingGroup({ ...editingGroup, description: e.target.value })} - placeholder="Short description of this group..." - className={inputClass} - /> -
-
- -
- - -
- {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( -
-
- {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} -
- - -
-
-
- {(section === 'nodes' ? allNodes.map(n => ({ id: n.node_id, label: n.display_name })) : - (section === 'skills' ? allSkills.filter(s => !s.is_system).map(s => ({ id: s.name, label: s.name })) : - (effective[section]?.providers ? Object.keys(effective[section].providers) : []).map(pId => { - const baseType = pId.split('_')[0]; - const baseDef = providerLists[section].find(ld => ld.id === baseType || ld.id === pId); - return { id: pId, label: baseDef ? (pId.includes('_') ? `${baseDef.label} (${pId.split('_').slice(1).join('_')})` : baseDef.label) : pId }; - }))).map(item => { - const isChecked = (editingGroup.policy?.[section] || []).includes(item.id); - return ( - - ); - })} -
- {section === 'nodes' && allNodes.length === 0 && ( -

No agent nodes registered yet.

- )} -
- ))} -
-
- -
- - -
-
-
- )} -
- )} - - {/* Users Management */} - {activeAdminTab === 'users' && ( -
-
-
-

- Active Roster - {filteredUsers.length} -

-
-
- setUserSearch(e.target.value)} - placeholder="Search by name, email..." - className="w-full text-xs p-2.5 pl-9 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none transition-all" - /> - -
- -
-
-
- - - - - - - - - - - {filteredUsers.map((u) => ( - - - - - - - ))} - -
MemberPolicy GroupActivity AuditingActions
-
-
- {(u.username || u.email || '?')[0].toUpperCase()} -
-
-

{u.username || u.email}

-

{u.role}

-
-
-
- - -
-
- Join: - {new Date(u.created_at).toLocaleDateString()} -
-
- Last: - - {u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} - -
-
-
- -
- {allUsers.length === 0 && !usersLoading && ( -
No other users found.
- )} -
-
-
- )} - - {/* Personal Settings */} - {activeAdminTab === 'personal' && ( -
-
-
-
- -
-
-

My Preferences

-

Customize your individual experience

-
-
- -
- {accessibleNodes.length > 0 ? ( -
- -

Auto-attach these nodes to new sessions:

-
- {accessibleNodes.map(node => { - const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id); - return ( - - ); - })} -
-
- ) : ( -
-

No agent nodes are currently assigned to your group.

-
- )} - -
- -
- - {nodePrefs.data_source?.source === 'node_local' && ( - handleNodePrefChange({ data_source: { ...nodePrefs.data_source, path: e.target.value } })} - placeholder="/home/user/workspace" - className="flex-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 text-xs font-mono text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm" - /> - )} -
-

- Determines where the agent should look for files on the node when starting a chat. -

-
-
-
-
- )} -
-
-
-
- {showVoicesModal && ( -
setShowVoicesModal(false)}> -
e.stopPropagation()}> -
-
-

Available Cloud Voices

-

Found {voiceList.length} voices to choose from.

-

Highlighted voices (Chirp, Journey, Studio) use advanced AI for highest quality.

-
- -
-
- {voicesLoading ? ( -
-
-
- ) : voiceList.length > 0 ? ( -
    - {voiceList.map((v, i) => { - let highlight = v.toLowerCase().includes('chirp') || v.toLowerCase().includes('journey') || v.toLowerCase().includes('studio'); - return ( -
  • - {v} -
  • - ) - })} -
- ) : ( -

No voices found. Make sure your API key is configured and valid.

- )} -
-
- Double-click a name to select it, then paste it into the field. - -
-
-
- )} -
- ); - const context = { config, - effective, loading, saving, message, - activeConfigTab, - setActiveConfigTab, activeAdminTab, setActiveAdminTab, - userSearch, - setUserSearch, + activeConfigTab, + setActiveConfigTab, expandedProvider, setExpandedProvider, - selectedNewProvider, - setSelectedNewProvider, + addingSection, + setAddingSection, + addForm, + setAddForm, + collapsedSections, + setCollapsedSections, verifying, - setVerifying, + testingConnection, fetchedModels, - setFetchedModels, - providerLists, providerStatuses, - voiceList, - showVoicesModal, - setShowVoicesModal, - voicesLoading, + fileInputRef, + handleExport, + handleImport, + handleConfigChange, + handleVerifyProvider, + handleTestConnection, + handleDeleteProvider: handleDeleteProviderAction, + handleAddInstance, + confirmAction, + setConfirmAction, + executeConfirmAction, + + userSearch, + setUserSearch, + providerLists, allUsers, usersLoading, loadUsers, @@ -1530,33 +586,27 @@ groupsLoading, editingGroup, setEditingGroup, - addingSection, - setAddingSection, - addForm, - setAddForm, allNodes, nodesLoading, allSkills, skillsLoading, - accessibleNodes, - nodePrefs, - fileInputRef, - handleViewVoices, + adminConfig, + setAdminConfig, + adminConfigLoading, + userProfile, + handleSaveAdminConfig, + handleSaveConfig, + setConfig, + setProviderLists, handleRoleToggle, handleGroupChange, - handleNodePrefChange, - toggleDefaultNode, handleSaveGroup, handleDeleteGroup, - handleSave, - handleImport, - handleExport, - inputClass, - labelClass, - sectionClass, filteredUsers, sortedGroups, - renderProviderSection + inputClass: "w-full border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors duration-200 shadow-sm", + labelClass: "block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2", + sectionClass: "animate-fade-in" }; return ; diff --git a/frontend/src/features/skills/components/SkillsPageContent.js b/frontend/src/features/skills/components/SkillsPageContent.js index 3a5f99f..98a07de 100644 --- a/frontend/src/features/skills/components/SkillsPageContent.js +++ b/frontend/src/features/skills/components/SkillsPageContent.js @@ -30,7 +30,12 @@ formData, setFormData, showAdvanced, - setShowAdvanced + setShowAdvanced, + errorModalMessage, + setErrorModalMessage, + confirmDeleteId, + setConfirmDeleteId, + confirmDelete } = context; const SidebarItem = ({ id, label, icon, count, active }) => ( @@ -430,6 +435,58 @@
)} + + {/* ERROR MODAL */} + {errorModalMessage && ( +
+
+
+
+ +
+

Protocol Violation

+

{errorModalMessage}

+ +
+
+
+ )} + + {/* CONFIRM DELETE MODAL */} + {confirmDeleteId && ( +
+
+
+
+ 🔥 +
+

Decomission Skill?

+

+ Are you certain you wish to purge this capability? This action is irreversible. +

+
+ + +
+
+
+
+ )}
); } diff --git a/frontend/src/features/skills/pages/SkillsPage.js b/frontend/src/features/skills/pages/SkillsPage.js index 5227abb..0a61314 100644 --- a/frontend/src/features/skills/pages/SkillsPage.js +++ b/frontend/src/features/skills/pages/SkillsPage.js @@ -11,6 +11,8 @@ const [showSystemSkills, setShowSystemSkills] = useState(false); const [viewingDoc, setViewingDoc] = useState(null); const [showRawDoc, setShowRawDoc] = useState(false); + const [errorModalMessage, setErrorModalMessage] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [editingSkill, setEditingSkill] = useState(null); @@ -133,7 +135,7 @@ try { configObj = JSON.parse(formData.config); } catch (e) { - alert("Invalid JSON in config"); + setErrorModalMessage("Invalid JSON in config. Please verify your syntax."); return; } @@ -150,17 +152,23 @@ closeModal(); fetchSkills(); } catch (err) { - alert("Error saving skill"); + setErrorModalMessage("Error saving skill. Please check your permissions and try again."); } }; - const handleDelete = async (id) => { - if (!window.confirm("Are you sure you want to delete this skill?")) return; + const handleDelete = (id) => { + setConfirmDeleteId(id); + }; + + const confirmDelete = async () => { + if (!confirmDeleteId) return; try { - await deleteSkill(id); + await deleteSkill(confirmDeleteId); + setConfirmDeleteId(null); fetchSkills(); } catch (err) { - alert("Error deleting skill"); + setErrorModalMessage("Error deleting skill. It might be in use or you may lack permissions."); + setConfirmDeleteId(null); } }; @@ -194,7 +202,12 @@ formData, setFormData, showAdvanced, - setShowAdvanced + setShowAdvanced, + errorModalMessage, + setErrorModalMessage, + confirmDeleteId, + setConfirmDeleteId, + confirmDelete }; return ; diff --git a/frontend/src/features/swarm/pages/SwarmControlPage.js b/frontend/src/features/swarm/pages/SwarmControlPage.js index d317ae7..3923c83 100644 --- a/frontend/src/features/swarm/pages/SwarmControlPage.js +++ b/frontend/src/features/swarm/pages/SwarmControlPage.js @@ -5,7 +5,7 @@ import { updateSession, getSessionNodeStatus, attachNodesToSession, detachNodeFromSession, getUserAccessibleNodes, getUserNodePreferences, nodeFsList, - clearSessionHistory + clearSessionHistory, getSystemStatus } from "../../../services/apiService"; import { SwarmControlConsoleOverlay, @@ -62,6 +62,7 @@ handleSendChat, handleCancelChat, setShowErrorModal, + setErrorMessage, handleSwitchSession, sessionId, userConfigData, @@ -83,7 +84,8 @@ // Reload the page to refresh chat history from the server window.location.reload(); } catch (e) { - alert(`Failed to clear history: ${e.message}`); + setErrorMessage(`Failed to clear history: ${e.message}`); + setShowErrorModal(true); } finally { setIsClearingHistory(false); setShowClearChatModal(false); @@ -126,6 +128,20 @@ return localStorage.getItem("swarm_auto_collapse") === "true"; }); + // Day 1 Swarm Control (Phase 3) + const [systemStatus, setSystemStatus] = useState(null); + useEffect(() => { + const fetchStatus = async () => { + try { + const status = await getSystemStatus(); + setSystemStatus(status); + } catch (e) { + console.warn("Failed to fetch system status", e); + } + }; + fetchStatus(); + }, []); + const toggleAutoCollapse = () => { const newState = !autoCollapse; setAutoCollapse(newState); @@ -203,7 +219,8 @@ await fetchNodeInfo(); } catch (err) { - alert(`Sync Error: ${err.message}`); + setErrorMessage(`Sync Error: ${err.message}`); + setShowErrorModal(true); } finally { setIsInitiatingSync(false); } @@ -366,25 +383,45 @@ {/* Main content area */}
+ {/* Day 1 Security Banners */} + {systemStatus && !systemStatus.tls_enabled && ( +
+ + + + INSECURE MODE: Mesh communication is currently running over unencrypted channels. Strictly for local/internal use only. +
+ )} + {systemStatus && !systemStatus.external_endpoint && ( +
+ + + + HOSTNAME CONFIGURATION: No external hostname detected. Remote nodes may fail to call back to this hub. +
+ )} +
{/* Main Layout Area */}
{/* Chat Area & Console (Left Panel) */}
-

+
-
- Swarm Control - - Mesh: {accessibleNodes.filter(n => n.last_status === 'online' || n.last_status === 'idle').length} Online / {accessibleNodes.length} Total - -
+
+

+ Swarm Control +

+ + Mesh: {accessibleNodes.filter(n => n.last_status === 'online' || n.last_status === 'idle').length} Online / {accessibleNodes.length} Total + +
{/* Nodes Indicator Bar (M3/M6) */}
@@ -443,7 +480,7 @@
-
+
Token Usage
@@ -515,7 +552,7 @@ style={{ width: `${Math.min(tokenUsage?.percentage || 0, 100)}%` }} >
- 80 ? 'text-red-500' : 'text-gray-400'}`}> + 80 ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}> {tokenUsage?.percentage || 0}%
@@ -541,7 +578,7 @@ + NEW
-

+
diff --git a/frontend/src/features/voice/components/VoiceControls.js b/frontend/src/features/voice/components/VoiceControls.js index 984b079..c3e5736 100644 --- a/frontend/src/features/voice/components/VoiceControls.js +++ b/frontend/src/features/voice/components/VoiceControls.js @@ -23,7 +23,7 @@ {/* Status indicator */}
-
+
{status || (isBusy ? "Thinking..." : "Ready")}
diff --git a/frontend/src/features/voice/pages/VoiceChatPage.js b/frontend/src/features/voice/pages/VoiceChatPage.js index 4ae3ee9..201de72 100644 --- a/frontend/src/features/voice/pages/VoiceChatPage.js +++ b/frontend/src/features/voice/pages/VoiceChatPage.js @@ -90,8 +90,8 @@
- Voice Chat Assistant - Real-time Conversational AI + Voice Chat Assistant + Real-time Conversational AI
{!isConfigured && (
@@ -115,7 +115,7 @@
-
+
Token Usage
@@ -125,7 +125,7 @@ style={{ width: `${Math.min(tokenUsage?.percentage || 0, 100)}%` }} >
- 80 ? 'text-red-500' : 'text-gray-400'}`}> + 80 ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}> {tokenUsage?.percentage || 0}%
@@ -253,7 +253,7 @@
diff --git a/frontend/src/index.css b/frontend/src/index.css index 90d92f8..dbdd91e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,19 +3,23 @@ @tailwind utilities; .markdown-preview h1 { - @apply text-xl font-bold mb-3 mt-4 text-gray-900 dark:text-white; + @apply text-xl font-bold mb-3 mt-4; + color: inherit; } .markdown-preview h2 { - @apply text-lg font-bold mb-2 mt-3 text-gray-900 dark:text-white; + @apply text-lg font-bold mb-2 mt-3; + color: inherit; } .markdown-preview h3 { - @apply text-base font-bold mb-1 mt-2 text-gray-800 dark:text-gray-100; + @apply text-base font-bold mb-1 mt-2; + color: inherit; } .markdown-preview p { @apply mb-2 leading-normal; + color: inherit; } .markdown-preview ul { @@ -28,6 +32,7 @@ .markdown-preview li { @apply mb-0.5 leading-normal; + color: inherit; } .markdown-preview code { @@ -43,5 +48,42 @@ } .markdown-preview strong { - @apply font-black text-gray-900 dark:text-white; + @apply font-black; + color: inherit; +} + +@media (prefers-color-scheme: dark) { + .markdown-preview, + .markdown-preview p, + .markdown-preview li, + .markdown-preview span, + .markdown-preview h1, + .markdown-preview h2, + .markdown-preview h3, + .markdown-preview strong { + color: #f3f4f6 !important; + } + + .markdown-preview h1, + .markdown-preview h2, + .markdown-preview strong { + color: #ffffff !important; + } +} + +.dark .markdown-preview, +.dark .markdown-preview p, +.dark .markdown-preview li, +.dark .markdown-preview span, +.dark .markdown-preview h1, +.dark .markdown-preview h2, +.dark .markdown-preview h3, +.dark .markdown-preview strong { + color: #f3f4f6 !important; +} + +.dark .markdown-preview h1, +.dark .markdown-preview h2, +.dark .markdown-preview strong { + color: #ffffff !important; } \ No newline at end of file diff --git a/frontend/src/services/api/adminService.js b/frontend/src/services/api/adminService.js new file mode 100644 index 0000000..287c7ae --- /dev/null +++ b/frontend/src/services/api/adminService.js @@ -0,0 +1,203 @@ +import { fetchWithAuth, getUserId } from './apiClient'; + +/** + * [ADMIN ONLY] Fetches all registered users. + */ +export const getAdminUsers = async () => { + return await fetchWithAuth('/users/admin/users'); +}; + +/** + * [ADMIN ONLY] Updates a user's role. + */ +export const updateUserRole = async (targetUserId, role) => { + return await fetchWithAuth(`/users/admin/users/${targetUserId}/role`, { + method: "PUT", + body: { role } + }); +}; + +/** + * [ADMIN ONLY] Assigns a user to a group. + */ +export const updateUserGroup = async (targetUserId, groupId) => { + return await fetchWithAuth(`/users/admin/users/${targetUserId}/group`, { + method: "PUT", + body: { group_id: groupId } + }); +}; + +/** + * [ADMIN ONLY] Fetches all groups. + */ +export const getAdminGroups = async () => { + return await fetchWithAuth('/users/admin/groups'); +}; + +/** + * [ADMIN ONLY] Creates a new group. + */ +export const createAdminGroup = async (groupData) => { + return await fetchWithAuth('/users/admin/groups', { + method: "POST", + body: groupData + }); +}; + +/** + * [ADMIN ONLY] Updates a group. + */ +export const updateAdminGroup = async (groupId, groupData) => { + return await fetchWithAuth(`/users/admin/groups/${groupId}`, { + method: "PUT", + body: groupData + }); +}; + +/** + * [ADMIN ONLY] Deletes a group. + */ +export const deleteAdminGroup = async (groupId) => { + return await fetchWithAuth(`/users/admin/groups/${groupId}`, { + method: "DELETE" + }); +}; + +/** + * [ADMIN ONLY] Fetches global server configuration (OIDC, Swarm). + */ +export const getAdminConfig = async () => { + return await fetchWithAuth('/admin/config'); +}; + +/** + * [ADMIN ONLY] Updates OIDC configuration. + */ +export const updateAdminOIDCConfig = async (config) => { + return await fetchWithAuth('/admin/config/oidc', { + method: "PUT", + body: config + }); +}; + +/** + * [ADMIN ONLY] Updates Swarm configuration. + */ +export const updateAdminSwarmConfig = async (config) => { + return await fetchWithAuth('/admin/config/swarm', { + method: "PUT", + body: config + }); +}; + +/** + * [ADMIN ONLY] Updates basic app configuration. + */ +export const updateAdminAppConfig = async (config) => { + return await fetchWithAuth('/admin/config/app', { + method: "PUT", + body: config + }); +}; + +/** + * [ADMIN ONLY] Tests OIDC connectivity. + */ +export const testAdminOIDCConfig = async (config) => { + return await fetchWithAuth('/admin/config/oidc/test', { + method: "POST", + body: config + }); +}; + +/** + * [ADMIN ONLY] Tests Swarm endpoint connectivity. + */ +export const testAdminSwarmConfig = async (config) => { + return await fetchWithAuth('/admin/config/swarm/test', { + method: "POST", + body: config + }); +}; + +/** + * [ADMIN] Fetch all registered nodes. + */ +export const getAdminNodes = async () => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/admin?admin_id=${userId}`); +}; + +/** + * [ADMIN] Register a new Agent Node. + */ +export const adminCreateNode = async (nodeData) => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/admin?admin_id=${userId}`, { + method: "POST", + body: nodeData + }); +}; + +/** + * [ADMIN] Update node metadata or skill toggles. + */ +export const adminUpdateNode = async (nodeId, updateData) => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/admin/${nodeId}?admin_id=${userId}`, { + method: "PATCH", + body: updateData + }); +}; + +/** + * [ADMIN] Deregister an Agent Node. + */ +export const adminDeleteNode = async (nodeId) => { + const adminId = getUserId(); + return await fetchWithAuth(`/nodes/admin/${nodeId}?admin_id=${adminId}`, { + method: "DELETE" + }); +}; + +/** + * [ADMIN] Grant a group access to a node. + */ +export const adminGrantNodeAccess = async (nodeId, grantData) => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/admin/${nodeId}/access?admin_id=${userId}`, { + method: "POST", + body: grantData + }); +}; + +/** + * [ADMIN] Revoke a group's access to a node. + */ +export const adminRevokeNodeAccess = async (nodeId, groupId) => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/admin/${nodeId}/access/${groupId}?admin_id=${userId}`, { + method: "DELETE" + }); +}; + +/** + * [ADMIN] Download the pre-configured Agent Node bundle (ZIP). + */ +export const adminDownloadNodeBundle = async (nodeId) => { + const userId = getUserId(); + try { + const url = `/nodes/admin/${nodeId}/download?admin_id=${userId}`; + const response = await fetchWithAuth(url, { method: "GET", raw: true }); + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.setAttribute('download', `cortex-node-${nodeId}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (e) { + throw new Error(`Failed to download node bundle: ${e.message}`); + } +}; diff --git a/frontend/src/services/api/aiService.js b/frontend/src/services/api/aiService.js new file mode 100644 index 0000000..3450f87 --- /dev/null +++ b/frontend/src/services/api/aiService.js @@ -0,0 +1,201 @@ +import { fetchWithAuth, API_BASE_URL, getUserId } from './apiClient'; +import { convertPcmToFloat32 } from "../audioUtils"; + +/** + * Sends an audio blob to the STT endpoint for transcription. + */ +export const transcribeAudio = async (audioBlob, providerName = null) => { + const formData = new FormData(); + formData.append("audio_file", audioBlob, "audio.wav"); + + const url = providerName + ? `/stt/transcribe?provider_name=${encodeURIComponent(providerName)}` + : '/stt/transcribe'; + + const result = await fetchWithAuth(url, { + method: "POST", + body: formData + }); + return result.transcript; +}; + +/** + * Sends a text prompt to the LLM endpoint and gets a streaming text response (SSE). + */ +export const chatWithAI = async (sessionId, prompt, providerName = "gemini", onMessage = null) => { + const userId = getUserId(); + const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-User-ID": userId }, + body: JSON.stringify({ prompt: prompt, provider_name: providerName }), + }); + + if (!response.ok) { + let detail = "LLM API failed"; + try { + const errBody = await response.json(); + detail = errBody.detail || JSON.stringify(errBody); + } catch { } + throw new Error(detail); + } + + // Handle Streaming Response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let accumulatedBuffer = ""; + + // Track final result for backward compatibility + let fullAnswer = ""; + let lastMessageId = null; + let finalProvider = providerName; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + accumulatedBuffer += decoder.decode(value, { stream: true }); + + const parts = accumulatedBuffer.split("\n\n"); + accumulatedBuffer = parts.pop(); + + for (const part of parts) { + if (part.startsWith("data: ")) { + try { + const jsonStr = part.slice(6).trim(); + if (jsonStr) { + const data = JSON.parse(jsonStr); + + // Accumulate content and info + if (data.type === "content" && data.content) { + fullAnswer += data.content; + } else if (data.type === "finish") { + lastMessageId = data.message_id; + finalProvider = data.provider; + } + + // Pass to streaming callback if provided + if (onMessage) onMessage(data); + } + } catch (e) { + console.warn("Failed to parse SSE line:", part, e); + } + } + } + } + + // Return the full result as the standard API used to + return { + answer: fullAnswer, + message_id: lastMessageId, + provider_used: finalProvider + }; +}; + +/** + * Streams speech from the TTS endpoint and processes each chunk. + */ +export const streamSpeech = async (text, onData, onDone, providerName = null) => { + const userId = getUserId(); + try { + let url = `${API_BASE_URL}/speech?stream=true&as_wav=false`; + if (providerName) { + url += `&provider_name=${encodeURIComponent(providerName)}`; + } + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-User-ID": userId }, + body: JSON.stringify({ text }), + }).catch(err => { + console.error("Fetch transport error:", err); + throw new Error(`Network transport failed: ${err.message}`); + }); + + if (!response.ok) { + let detail = `HTTP error! Status: ${response.status}`; + try { + const errBody = await response.json(); + detail = errBody.detail || detail; + } catch { } + throw new Error(detail); + } + + const totalChunks = parseInt(response.headers.get("X-TTS-Chunk-Count") || "0"); + const reader = response.body.getReader(); + let leftover = new Uint8Array(0); + let chunkIndex = 0; + + try { + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + + let combined = new Uint8Array(leftover.length + chunk.length); + combined.set(leftover); + combined.set(chunk, leftover.length); + + let length = combined.length; + if (length % 2 !== 0) length -= 1; + + const toConvert = combined.slice(0, length); + leftover = combined.slice(length); + const float32Raw = convertPcmToFloat32(toConvert); + + onData(float32Raw, totalChunks, ++chunkIndex); + } + } catch (readError) { + console.error("Error reading response body stream:", readError); + throw new Error(`Stream interrupted: ${readError.message}`); + } + } catch (error) { + console.error("Failed to stream speech:", error); + throw error; + } finally { + if (onDone) { + await Promise.resolve(onDone()); + } + } +}; + +/** + * Verify a provider configuration + */ +export const verifyProvider = async (section, payload) => { + return await fetchWithAuth(`/users/me/config/verify_${section}`, { + method: "POST", + body: payload + }); +}; + +/** + * Fetches available models for a provider. + */ +export const getProviderModels = async (providerName, section = "llm") => { + const params = new URLSearchParams({ provider_name: providerName, section: section }); + return await fetchWithAuth(`/users/me/config/models?${params.toString()}`); +}; + +/** + * Fetches all underlying providers. + */ +export const getAllProviders = async (section = "llm") => { + const params = new URLSearchParams({ section: section }); + return await fetchWithAuth(`/users/me/config/providers?${params.toString()}`); +}; + +/** + * Fetches available TTS voice names. + */ +export const getVoices = async (provider = null, apiKey = null) => { + try { + const urlParams = new URLSearchParams(); + if (provider) urlParams.append('provider', provider); + if (apiKey && apiKey !== 'null') urlParams.append('api_key', apiKey); + + const url = `/speech/voices?${urlParams.toString()}`; + return await fetchWithAuth(url, { method: 'GET' }); + } catch (e) { + console.error("Failed to fetch voices", e); + return []; + } +}; diff --git a/frontend/src/services/api/apiClient.js b/frontend/src/services/api/apiClient.js new file mode 100644 index 0000000..eeea608 --- /dev/null +++ b/frontend/src/services/api/apiClient.js @@ -0,0 +1,52 @@ +export const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "http://localhost:8001"; + +/** + * A central utility function to get the user ID. + * If not found, it redirects to the login page. + * @returns {string} The user ID. + */ +export const getUserId = () => { + const userId = localStorage.getItem('userId'); + if (!userId) { + console.error("User not authenticated. Redirecting to login."); + // Redirect to the login page + window.location.href = '/'; + } + return userId; +}; + +/** + * Base fetch utility with common headers and error handling. + */ +export const fetchWithAuth = async (endpoint, options = {}) => { + const userId = options.anonymous ? 'anonymous' : getUserId(); + + const headers = { + "X-User-ID": userId, + ...options.headers, + }; + + if (options.json !== false && options.body && !(options.body instanceof FormData)) { + headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(options.body); + } + + const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + let detail = `API error (${response.status})`; + try { + const errBody = await response.json(); + detail = errBody.detail || detail; + } catch { } + throw new Error(detail); + } + + if (options.raw) return response; + return await response.json(); +}; diff --git a/frontend/src/services/api/nodeService.js b/frontend/src/services/api/nodeService.js new file mode 100644 index 0000000..355f6b1 --- /dev/null +++ b/frontend/src/services/api/nodeService.js @@ -0,0 +1,163 @@ +import { fetchWithAuth, getUserId } from './apiClient'; + +/** + * [USER] List nodes accessible to the current user's group. + */ +export const getUserAccessibleNodes = async () => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/?user_id=${userId}`); +}; + +/** + * [USER] Fetch node preferences. + */ +export const getUserNodePreferences = async () => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/preferences?user_id=${userId}`); +}; + +/** + * [USER] Update node preferences. + */ +export const updateUserNodePreferences = async (prefs) => { + const userId = getUserId(); + return await fetchWithAuth(`/nodes/preferences?user_id=${userId}`, { + method: "PATCH", + body: prefs + }); +}; + +/** + * [USER] Fetch sync status for all nodes attached to a session. + */ +export const getSessionNodeStatus = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}/nodes`); +}; + +/** + * [USER] Attach more nodes to an active session. + */ +export const attachNodesToSession = async (sessionId, nodeIds, config = null) => { + return await fetchWithAuth(`/sessions/${sessionId}/nodes`, { + method: "POST", + body: { node_ids: nodeIds, config } + }); +}; + +/** + * [USER] Detach a node from a session. + */ +export const detachNodeFromSession = async (sessionId, nodeId) => { + return await fetchWithAuth(`/sessions/${sessionId}/nodes/${nodeId}`, { + method: "DELETE" + }); +}; + +/** + * [AI Observability] Fetch terminal history for a node. + */ +export const getNodeTerminalHistory = async (nodeId) => { + return await fetchWithAuth(`/nodes/${nodeId}/terminal`); +}; + +/** + * Dispatch a task to a node. + */ +export const dispatchNodeTask = async (nodeId, payload) => { + return await fetchWithAuth(`/nodes/${nodeId}/dispatch`, { + method: 'POST', + body: payload + }); +}; + +/** + * Cancel a task on a node. + */ +export const cancelNodeTask = async (nodeId, taskId = "") => { + return await fetchWithAuth(`/nodes/${nodeId}/cancel?task_id=${taskId}`, { + method: 'POST' + }); +}; + +/** + * WebSocket helper for live node streams. + */ +export const getNodeStreamUrl = (nodeId = null) => { + const { API_BASE_URL } = require('./apiClient'); + // Convert http://... to ws://... + const wsBase = API_BASE_URL.replace(/^http/, 'ws'); + if (nodeId) { + const userId = getUserId(); + return `${wsBase}/nodes/${nodeId}/stream?user_id=${userId}`; + } + const userId = localStorage.getItem('userId'); + return `${wsBase}/nodes/stream/all?user_id=${userId}`; +}; + +/** + * [FS] List directory contents on an agent node. + */ +export const nodeFsList = async (nodeId, path = ".", sessionId = null) => { + const params = new URLSearchParams({ path }); + if (sessionId) params.append("session_id", sessionId); + return await fetchWithAuth(`/nodes/${nodeId}/fs/ls?${params.toString()}`); +}; + +/** + * [FS] Read file content from an agent node. + */ +export const nodeFsCat = async (nodeId, path, sessionId = null) => { + const params = new URLSearchParams({ path }); + if (sessionId) params.append("session_id", sessionId); + return await fetchWithAuth(`/nodes/${nodeId}/fs/cat?${params.toString()}`); +}; + +/** + * [FS] Create or update a file or directory on an agent node. + */ +export const nodeFsTouch = async (nodeId, path, content = "", isDir = false, sessionId = null) => { + return await fetchWithAuth(`/nodes/${nodeId}/fs/touch`, { + method: "POST", + body: { path, content, is_dir: isDir, session_id: sessionId } + }); +}; + +/** + * [FS] Upload a file to an agent node via multipart form. + */ +export const nodeFsUpload = async (nodeId, path, file, sessionId = null) => { + const formData = new FormData(); + formData.append("file", file); + + const params = new URLSearchParams({ path }); + if (sessionId) params.append("session_id", sessionId); + + return await fetchWithAuth(`/nodes/${nodeId}/fs/upload?${params.toString()}`, { + method: "POST", + body: formData + }); +}; + +/** + * [FS] Downloads a file as a blob. + */ +export const nodeFsDownloadBlob = async (nodeId, path, sessionId = null) => { + const params = new URLSearchParams({ path }); + if (sessionId) params.append("session_id", sessionId); + + const response = await fetchWithAuth(`/nodes/${nodeId}/fs/download?${params.toString()}`, { + method: "GET", + raw: true + }); + return await response.blob(); +}; + +/** + * [FS] Delete a file or directory from an agent node. + */ +export const nodeFsRm = async (nodeId, path, sessionId = null) => { + return await fetchWithAuth(`/nodes/${nodeId}/fs/rm`, { + method: "POST", + body: { path, session_id: sessionId } + }); +}; diff --git a/frontend/src/services/api/ragService.js b/frontend/src/services/api/ragService.js new file mode 100644 index 0000000..44ea79f --- /dev/null +++ b/frontend/src/services/api/ragService.js @@ -0,0 +1,14 @@ +import { fetchWithAuth } from './apiClient'; + +/** + * Upload a document to the RAG system. + */ +export const uploadDocument = async (file) => { + const formData = new FormData(); + formData.append("file", file); + + return await fetchWithAuth('/rag/documents', { + method: "POST", + body: formData + }); +}; diff --git a/frontend/src/services/api/sessionService.js b/frontend/src/services/api/sessionService.js new file mode 100644 index 0000000..dd3d869 --- /dev/null +++ b/frontend/src/services/api/sessionService.js @@ -0,0 +1,123 @@ +import { fetchWithAuth, getUserId } from './apiClient'; + +/** + * Creates a new chat session. + */ +export const createSession = async (featureName = "default", providerName = "deepseek", extraParams = {}) => { + const userId = getUserId(); + return await fetchWithAuth('/sessions/', { + method: "POST", + body: { + user_id: userId, + feature_name: featureName, + provider_name: providerName, + ...extraParams + } + }); +}; + +/** + * Fetches all sessions for a specific feature for the current user. + */ +export const getUserSessions = async (featureName = "default") => { + const userId = getUserId(); + const params = new URLSearchParams({ user_id: userId, feature_name: featureName, _t: Date.now() }); + return await fetchWithAuth(`/sessions/?${params.toString()}`); +}; + +/** + * Updates a session's metadata. + */ +export const updateSession = async (sessionId, payload) => { + return await fetchWithAuth(`/sessions/${sessionId}`, { + method: "PATCH", + body: payload + }); +}; + +/** + * Gets a single chat session by ID. + */ +export const getSession = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}`); +}; + +/** + * Deletes a single chat session by ID. + */ +export const deleteSession = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}`, { + method: "DELETE" + }); +}; + +/** + * Clears all chat messages for a session while preserving the session. + */ +export const clearSessionHistory = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}/clear-history`, { + method: "POST" + }); +}; + +/** + * Deletes all chat sessions for a given feature. + */ +export const deleteAllSessions = async (featureName = "default") => { + const userId = getUserId(); + const params = new URLSearchParams({ user_id: userId, feature_name: featureName, _t: Date.now() }); + return await fetchWithAuth(`/sessions/?${params.toString()}`, { + method: "DELETE" + }); +}; + +/** + * Fetches the token usage status for a session. + */ +export const getSessionTokenStatus = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}/tokens`); +}; + +/** + * Fetches the chat history for a session. + */ +export const getSessionMessages = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}/messages`); +}; + +/** + * Requests cancellation of any running tasks for a specific session. + */ +export const cancelSession = async (sessionId) => { + return await fetchWithAuth(`/sessions/${sessionId}/cancel`, { + method: "POST" + }); +}; + +/** + * Uploads an audio blob for a specific message. + */ +export const uploadMessageAudio = async (messageId, audioBlob) => { + const formData = new FormData(); + formData.append("file", audioBlob, "audio.wav"); + + return await fetchWithAuth(`/sessions/messages/${messageId}/audio`, { + method: "POST", + body: formData + }); +}; + +/** + * Fetches the audio for a specific message as a blob. + */ +export const fetchMessageAudio = async (messageId) => { + try { + const response = await fetchWithAuth(`/sessions/messages/${messageId}/audio`, { + method: "GET", + raw: true + }); + return await response.blob(); + } catch (e) { + return null; // Silent if no audio exists + } +}; diff --git a/frontend/src/services/api/skillService.js b/frontend/src/services/api/skillService.js new file mode 100644 index 0000000..473f0b7 --- /dev/null +++ b/frontend/src/services/api/skillService.js @@ -0,0 +1,37 @@ +import { fetchWithAuth } from './apiClient'; + +/** + * Fetches all skills. + */ +export const getSkills = async () => { + return await fetchWithAuth('/skills/'); +}; + +/** + * Creates a new skill. + */ +export const createSkill = async (skillData) => { + return await fetchWithAuth('/skills/', { + method: "POST", + body: skillData + }); +}; + +/** + * Updates a skill. + */ +export const updateSkill = async (skillId, skillData) => { + return await fetchWithAuth(`/skills/${skillId}`, { + method: "PUT", + body: skillData + }); +}; + +/** + * Deletes a skill. + */ +export const deleteSkill = async (skillId) => { + return await fetchWithAuth(`/skills/${skillId}`, { + method: "DELETE" + }); +}; diff --git a/frontend/src/services/api/systemService.js b/frontend/src/services/api/systemService.js new file mode 100644 index 0000000..cbc493c --- /dev/null +++ b/frontend/src/services/api/systemService.js @@ -0,0 +1,13 @@ +import { fetchWithAuth } from './apiClient'; + +/** + * Fetches the system status. + */ +export const getSystemStatus = async () => { + try { + return await fetchWithAuth('/status', { anonymous: true }); + } catch (error) { + console.error('getSystemStatus failed:', error); + throw error; + } +}; diff --git a/frontend/src/services/api/userService.js b/frontend/src/services/api/userService.js new file mode 100644 index 0000000..5032949 --- /dev/null +++ b/frontend/src/services/api/userService.js @@ -0,0 +1,108 @@ +import { fetchWithAuth, API_BASE_URL } from './apiClient'; + +const USERS_LOGIN_ENDPOINT = `${API_BASE_URL}/users/login`; +const USERS_LOGOUT_ENDPOINT = `${API_BASE_URL}/users/logout`; +const USERS_ME_ENDPOINT = `${API_BASE_URL}/users/me`; + +/** + * Initiates the OIDC login flow. + */ +export const login = () => { + const frontendCallbackUri = window.location.origin; + const loginUrl = `${USERS_LOGIN_ENDPOINT}?frontend_callback_uri=${encodeURIComponent(frontendCallbackUri)}`; + window.location.href = loginUrl; +}; + +/** + * Performs local email/password login. + */ +export const loginLocal = async (email, password) => { + const response = await fetch(`${USERS_LOGIN_ENDPOINT}/local`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({ detail: "Login failed" })); + throw new Error(err.detail || `Login failed. Status: ${response.status}`); + } + return await response.json(); +}; + +/** + * Fetches authentication configuration. + */ +export const getAuthConfig = async () => { + const response = await fetch(`${API_BASE_URL}/users/me`, { + method: "GET", + headers: { "X-User-ID": "anonymous" }, + }); + if (!response.ok) return { oidc_configured: false }; + const data = await response.json(); + return { oidc_configured: data.oidc_configured }; +}; + +/** + * Fetches the current user's status. + */ +export const getUserStatus = async (userId) => { + return await fetchWithAuth('/users/me', { method: 'GET', headers: { 'X-User-ID': userId } }); +}; + +/** + * Logs the current user out. + */ +export const logout = async () => { + return await fetchWithAuth('/users/logout', { method: 'POST' }); +}; + +/** + * Fetches the user profile info. + */ +export const getUserProfile = async () => { + return await fetchWithAuth('/users/me/profile', { method: 'GET' }); +}; + +/** + * Updates the user profile info. + */ +export const updateUserProfile = async (profileData) => { + return await fetchWithAuth('/users/me/profile', { + method: 'PUT', + body: profileData + }); +}; + +/** + * Fetches the current user's preferences. + */ +export const getUserConfig = async () => { + return await fetchWithAuth('/users/me/config'); +}; + +/** + * Updates the current user's preferences. + */ +export const updateUserConfig = async (config) => { + return await fetchWithAuth('/users/me/config', { + method: 'PUT', + body: config + }); +}; + +/** + * Download the effective user configurations as YAML. + */ +export const exportUserConfig = async () => { + return await fetchWithAuth('/users/me/config/export', { method: 'GET', raw: true }); +}; + +/** + * Import user configuration via multipart form. + */ +export const importUserConfig = async (formData) => { + return await fetchWithAuth('/users/me/config/import', { + method: 'POST', + body: formData + }); +}; diff --git a/frontend/src/services/apiService.js b/frontend/src/services/apiService.js index d10b3ec..347b89a 100644 --- a/frontend/src/services/apiService.js +++ b/frontend/src/services/apiService.js @@ -1,1216 +1,18 @@ -// This file handles all communication with your API endpoints. -// It is designed to be stateless and does not use any React hooks. - -import { convertPcmToFloat32 } from "./audioUtils"; - -// Read base API URL from environment variables, defaulting to localhost -const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "http://localhost:8001"; -export { API_BASE_URL }; -const CHAT_ENDPOINT = `${API_BASE_URL}/chat`; -const USER_CONFIG_ENDPOINT = `${API_BASE_URL}/users/me/config`; -const STT_ENDPOINT = `${API_BASE_URL}/stt/transcribe`; -const SESSIONS_CREATE_ENDPOINT = `${API_BASE_URL}/sessions/`; -const SESSIONS_CHAT_ENDPOINT = (id) => `${API_BASE_URL}/sessions/${id}/chat`; -const SESSIONS_TOKEN_ENDPOINT = (id) => `${API_BASE_URL}/sessions/${id}/tokens`; -const SESSIONS_MESSAGES_ENDPOINT = (id) => `${API_BASE_URL}/sessions/${id}/messages`; -const SESSIONS_BASE_ENDPOINT = (id) => `${API_BASE_URL}/sessions/${id}`; -const SESSIONS_MESSAGE_AUDIO_ENDPOINT = (id) => `${API_BASE_URL}/sessions/messages/${id}/audio`; -const TTS_ENDPOINT = `${API_BASE_URL}/speech`; -const USERS_LOGIN_ENDPOINT = `${API_BASE_URL}/users/login`; -const USERS_LOGOUT_ENDPOINT = `${API_BASE_URL}/users/logout`; -const USERS_ME_ENDPOINT = `${API_BASE_URL}/users/me`; - /** - * A central utility function to get the user ID. - * If not found, it redirects to the login page. - * @returns {string} The user ID. + * apiService.js - Entry point for all API services. + * This file re-exports all modular services for backward compatibility + * while encouraging a more organized structure in 'services/api/'. */ -const getUserId = () => { - const userId = localStorage.getItem('userId'); - if (!userId) { - console.error("User not authenticated. Redirecting to login."); - // Redirect to the login page - window.location.href = '/'; - } - return userId; -}; -/** - * Initiates the OIDC login flow by redirecting the user to the login endpoint. - * This function now sends the frontend's URI to the backend so the backend - * knows where to redirect the user after a successful login with the OIDC provider. - */ -export const login = () => { - // Pass the current frontend origin to the backend's login endpoint. - // The backend will use this as the `state` parameter for the OIDC provider. - const frontendCallbackUri = window.location.origin; - const loginUrl = `${USERS_LOGIN_ENDPOINT}?frontend_callback_uri=${encodeURIComponent(frontendCallbackUri)}`; - window.location.href = loginUrl; -}; +// Central Client & Constants +export * from './api/apiClient'; -/** - * Fetches the current user's status from the backend. - * @param {string} userId - The unique ID of the current user. - * @returns {Promise} The user status object from the API response. - */ -export const getUserStatus = async (userId) => { - // The backend uses the 'X-User-ID' header to identify the user. - const response = await fetch(USERS_ME_ENDPOINT, { - method: "GET", - headers: { - "X-User-ID": userId, - }, - }); - if (!response.ok) { - throw new Error(`Failed to get user status. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Logs the current user out. - * @returns {Promise} The logout message from the API response. - */ -export const logout = async () => { - const response = await fetch(USERS_LOGOUT_ENDPOINT, { - method: "POST", - }); - if (!response.ok) { - throw new Error(`Failed to log out. Status: ${response.status}`); - } - return await response.json(); -}; - - -// --- Updated Existing Functions --- - -const SESSIONS_GET_ALL_ENDPOINT = `${API_BASE_URL}/sessions/`; -const SESSIONS_DELETE_ENDPOINT = (id) => `${API_BASE_URL}/sessions/${id}`; - -/** - * Creates a new chat session. - * @param {string} featureName - The namespace for isolated feature tracking. - * @param {string} providerName - The initial LLM provider for the session. - * @returns {Promise} The session object from the API response. - */ -export const createSession = async (featureName = "default", providerName = "deepseek", extraParams = {}) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_CREATE_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json", "X-User-ID": userId }, - body: JSON.stringify({ - user_id: userId, - feature_name: featureName, - provider_name: providerName, - ...extraParams - }), - }); - if (!response.ok) { - throw new Error(`Failed to create session. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Fetches all sessions for a specific feature for the current user. - * @param {string} featureName - The namespace for isolated feature tracking. - * @returns {Promise} The sessions list from the API response. - */ -export const getUserSessions = async (featureName = "default") => { - const userId = getUserId(); - const params = new URLSearchParams({ user_id: userId, feature_name: featureName, _t: Date.now() }); - const response = await fetch(`${SESSIONS_GET_ALL_ENDPOINT}?${params.toString()}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch sessions. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Updates a session's metadata. - * @param {string} sessionId - * @param {object} payload - { title, provider_name } - */ -export const updateSession = async (sessionId, payload) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_BASE_ENDPOINT(sessionId), { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(`Failed to update session. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Clears all chat messages for a session while preserving the session - * (node attachments, workspace ID, sync config all remain intact). - * @param {number} sessionId - */ -export const clearSessionHistory = async (sessionId) => { - const userId = getUserId(); - const response = await fetch(`${SESSIONS_BASE_ENDPOINT(sessionId)}/clear-history`, { - method: "POST", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to clear history. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Uploads an audio blob for a specific message. - */ -export const uploadMessageAudio = async (messageId, audioBlob) => { - const userId = getUserId(); - const formData = new FormData(); - formData.append("file", audioBlob, "audio.wav"); - - const response = await fetch(SESSIONS_MESSAGE_AUDIO_ENDPOINT(messageId), { - method: "POST", - headers: { "X-User-ID": userId }, - body: formData, - }); - if (!response.ok) { - throw new Error(`Failed to upload audio. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Fetches the audio for a specific message as a blob. - */ -export const fetchMessageAudio = async (messageId) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_MESSAGE_AUDIO_ENDPOINT(messageId), { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - return null; // Silent if no audio exists - } - return await response.blob(); -}; - -/** - * Deletes a single chat session by ID. - * @param {string} sessionId - The session ID to delete. - */ -export const deleteSession = async (sessionId) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_DELETE_ENDPOINT(sessionId), { - method: "DELETE", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to delete session. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Gets a single chat session by ID. - * @param {string} sessionId - The session ID to fetch. - */ -export const getSession = async (sessionId) => { - const userId = getUserId(); - // We can reuse SESSIONS_DELETE_ENDPOINT generator since it creates the URL with the ID - const response = await fetch(SESSIONS_DELETE_ENDPOINT(sessionId), { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch session. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Deletes all chat sessions for a given feature. - * @param {string} featureName - The feature namespace to clear. - */ -export const deleteAllSessions = async (featureName = "default") => { - const userId = getUserId(); - const params = new URLSearchParams({ user_id: userId, feature_name: featureName, _t: Date.now() }); - const response = await fetch(`${SESSIONS_GET_ALL_ENDPOINT}?${params.toString()}`, { - method: "DELETE", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to delete all sessions. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Fetches the token usage status for a session. - * @param {string} sessionId - The session ID to fetch tokens for. - * @returns {Promise} The token usage object. - */ -export const getSessionTokenStatus = async (sessionId) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_TOKEN_ENDPOINT(sessionId), { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch token status. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Fetches the chat history for a session. - * @param {string} sessionId - The session ID to fetch messages for. - * @returns {Promise} The message history object. - */ -export const getSessionMessages = async (sessionId) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_MESSAGES_ENDPOINT(sessionId), { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch session messages. Status: ${response.status}`); - } - return await response.json(); -}; -/** - * Requests cancellation of any running tasks for a specific session. - * @param {string} sessionId - */ -export const cancelSession = async (sessionId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}/cancel`, { - method: "POST", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to cancel session. Status: ${response.status}`); - } - return await response.json(); -}; - -// --- Unchanged Functions --- - -/** - * Sends an audio blob to the STT endpoint for transcription. - * @param {Blob} audioBlob - The recorded audio data. - * @returns {Promise} The transcribed text. - */ -export const transcribeAudio = async (audioBlob, providerName = null) => { - const userId = getUserId(); - const formData = new FormData(); - formData.append("audio_file", audioBlob, "audio.wav"); - - let url = STT_ENDPOINT; - if (providerName) { - url += `?provider_name=${encodeURIComponent(providerName)}`; - } - - const response = await fetch(url, { - method: "POST", - body: formData, - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - let detail = `STT API failed (${response.status})`; - try { - const errBody = await response.json(); - detail = errBody.detail || detail; - } catch { } - throw new Error(detail); - } - const result = await response.json(); - return result.transcript; -}; - -/** - * Sends a text prompt to the LLM endpoint and gets a streaming text response (SSE). - * @param {string} sessionId - The current chat session ID. - * @param {string} prompt - The user's text prompt. - * @param {string} providerName - AI model provider. - * @param {function} onMessage - Callback for each event chunk {type, content}. - */ -export const chatWithAI = async (sessionId, prompt, providerName = "gemini", onMessage = null) => { - const userId = getUserId(); - const response = await fetch(SESSIONS_CHAT_ENDPOINT(sessionId), { - method: "POST", - headers: { "Content-Type": "application/json", "X-User-ID": userId }, - body: JSON.stringify({ prompt: prompt, provider_name: providerName }), - }); - - if (!response.ok) { - let detail = "LLM API failed"; - try { - const errBody = await response.json(); - detail = errBody.detail || JSON.stringify(errBody); - } catch { } - throw new Error(detail); - } - - // Handle Streaming Response - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let accumulatedBuffer = ""; - - // Track final result for backward compatibility - let fullAnswer = ""; - let lastMessageId = null; - let finalProvider = providerName; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - accumulatedBuffer += decoder.decode(value, { stream: true }); - - const parts = accumulatedBuffer.split("\n\n"); - accumulatedBuffer = parts.pop(); - - for (const part of parts) { - if (part.startsWith("data: ")) { - try { - const jsonStr = part.slice(6).trim(); - if (jsonStr) { - const data = JSON.parse(jsonStr); - - // Accumulate content and info - if (data.type === "content" && data.content) { - fullAnswer += data.content; - } else if (data.type === "finish") { - lastMessageId = data.message_id; - finalProvider = data.provider; - } - - // Pass to streaming callback if provided - if (onMessage) onMessage(data); - } - } catch (e) { - console.warn("Failed to parse SSE line:", part, e); - } - } - } - } - - // Return the full result as the standard API used to - return { - answer: fullAnswer, - message_id: lastMessageId, - provider_used: finalProvider - }; -}; - - - -/** - * Streams speech from the TTS endpoint and processes each chunk. - * It uses a callback to pass the processed audio data back to the caller. - * @param {string} text - The text to be synthesized. - * @param {function(Float32Array, number, number): void} onData - Callback (data, totalChunks, currentChunkIndex). - * @param {function(): void} onDone - Callback to execute when the stream is finished. - * @returns {Promise} - */ -export const streamSpeech = async (text, onData, onDone, providerName = null) => { - const userId = getUserId(); - try { - let url = `${TTS_ENDPOINT}?stream=true&as_wav=false`; - if (providerName) { - url += `&provider_name=${encodeURIComponent(providerName)}`; - } - - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", "X-User-ID": userId }, - body: JSON.stringify({ text }), - }).catch(err => { - console.error("Fetch transport error:", err); - throw new Error(`Network transport failed: ${err.message}`); - }); - - if (!response.ok) { - let detail = `HTTP error! Status: ${response.status}`; - try { - const errBody = await response.json(); - detail = errBody.detail || detail; - } catch { } - throw new Error(detail); - } - - const totalChunks = parseInt(response.headers.get("X-TTS-Chunk-Count") || "0"); - const reader = response.body.getReader(); - let leftover = new Uint8Array(0); - let chunkIndex = 0; - - try { - while (true) { - const { done, value: chunk } = await reader.read(); - if (done) break; - - let combined = new Uint8Array(leftover.length + chunk.length); - combined.set(leftover); - combined.set(chunk, leftover.length); - - let length = combined.length; - if (length % 2 !== 0) length -= 1; - - const toConvert = combined.slice(0, length); - leftover = combined.slice(length); - const float32Raw = convertPcmToFloat32(toConvert); - - onData(float32Raw, totalChunks, ++chunkIndex); - } - } catch (readError) { - console.error("Error reading response body stream:", readError); - throw new Error(`Stream interrupted: ${readError.message}`); - } - } catch (error) { - console.error("Failed to stream speech:", error); - throw error; - } finally { - if (onDone) { - // await onDone to ensure persistent storage is finished before resolving - await Promise.resolve(onDone()); - } - } -}; - -/** - * Fetches the current user's preferences. - * @returns {Promise} The user preferences object. - */ -export const getUserConfig = async () => { - const userId = getUserId(); - const response = await fetch(USER_CONFIG_ENDPOINT, { - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch user config. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Updates the current user's preferences. - * @param {Object} config The new configuration object. - * @returns {Promise} The updated user preferences object. - */ -export const updateUserConfig = async (config) => { - const userId = getUserId(); - const response = await fetch(USER_CONFIG_ENDPOINT, { - method: "PUT", - headers: { - "X-User-ID": userId, - "Content-Type": "application/json", - }, - body: JSON.stringify(config), - }); - if (!response.ok) { - throw new Error(`Failed to update user config. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * Download the effective user configurations as YAML. - * @returns {Promise} The raw API response to extract the file. - */ -export const exportUserConfig = async () => { - const userId = getUserId(); - const exportUrl = `${USER_CONFIG_ENDPOINT}/export`; - const response = await fetch(exportUrl, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - throw new Error(`Failed to export config. Status: ${response.status}`); - } - return response; -}; - -/** - * Download the effective user configurations as YAML. - * @returns {Promise} The raw API response to extract the file. - */ -export const uploadDocument = async (file) => { - const userId = getUserId(); - const formData = new FormData(); - formData.append("file", file); - - const response = await fetch(`${API_BASE_URL}/rag/documents`, { - method: "POST", - headers: { - "X-User-ID": userId, - }, - body: formData, - }); - - if (!response.ok) { - throw new Error(`Failed to upload document. Status: ${response.status}`); - } - - return await response.json(); -}; - -/** - * Verify a provider configuration - * @param {string} section - 'llm', 'tts', or 'stt' - * @param {Object} payload - { provider_name, api_key, model, voice } - */ -export const verifyProvider = async (section, payload) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/me/config/verify_${section}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(`Failed to verify provider. Status: ${response.status}`); - } - return await response.json(); -}; - -export const getProviderModels = async (providerName, section = "llm") => { - const userId = getUserId(); - const params = new URLSearchParams({ provider_name: providerName, section: section }); - const response = await fetch(`${API_BASE_URL}/users/me/config/models?${params.toString()}`, { - method: "GET", - headers: { - "X-User-ID": userId, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch models for ${providerName}`); - } - return await response.json(); -}; - -export const getAllProviders = async (section = "llm") => { - const userId = getUserId(); - const params = new URLSearchParams({ section: section }); - const response = await fetch(`${API_BASE_URL}/users/me/config/providers?${params.toString()}`, { - method: "GET", - headers: { - "X-User-ID": userId, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch underlying providers.`); - } - return await response.json(); -}; - -/** - * Fetches the user profile info. - */ -export const getUserProfile = async () => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/me/profile`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch user profile"); - return await response.json(); -}; - -/** - * Updates the user profile info. - */ -export const updateUserProfile = async (profileData) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/me/profile`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(profileData), - }); - if (!response.ok) throw new Error("Failed to update user profile"); - return await response.json(); -}; - -/** - * Fetches available TTS voice names. - */ -export const getVoices = async (provider = null, apiKey = null) => { - try { - const userId = getUserId(); - const urlParams = new URLSearchParams(); - if (provider) urlParams.append('provider', provider); - if (apiKey && apiKey !== 'null') urlParams.append('api_key', apiKey); - - const url = `${API_BASE_URL}/speech/voices?${urlParams.toString()}`; - - const response = await fetch(url, { - method: 'GET', - headers: { 'X-User-ID': userId } - }); - if (!response.ok) return []; - return await response.json(); - } catch (e) { - console.error("Failed to fetch voices", e); - return []; - } -}; - -export const importUserConfig = async (formData) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/me/config/import`, { - method: "POST", - headers: { - "X-User-ID": userId, - }, - body: formData, - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || `Failed to import configuration. Status: ${response.status}`); - } - return await response.json(); -}; - -/** - * [ADMIN ONLY] Fetches all registered users. - */ -export const getAdminUsers = async () => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/users`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch admin user list"); - return await response.json(); -}; - -/** - * [ADMIN ONLY] Updates a user's role. - */ -export const updateUserRole = async (targetUserId, role) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/users/${targetUserId}/role`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify({ role }), - }); - if (!response.ok) throw new Error("Failed to update user role"); - return await response.json(); -}; - -/** - * [ADMIN ONLY] Assigns a user to a group. - */ -export const updateUserGroup = async (targetUserId, groupId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/users/${targetUserId}/group`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify({ group_id: groupId }), - }); - if (!response.ok) throw new Error("Failed to update user group"); - return await response.json(); -}; - -/** - * [ADMIN ONLY] Fetches all groups. - */ -export const getAdminGroups = async () => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/groups`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch group list"); - return await response.json(); -}; - -/** - * [ADMIN ONLY] Creates a new group. - */ -export const createAdminGroup = async (groupData) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/groups`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(groupData), - }); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error(errData.detail || "Failed to create group"); - } - return await response.json(); -}; - -/** - * [ADMIN ONLY] Updates a group. - */ -export const updateAdminGroup = async (groupId, groupData) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/groups/${groupId}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(groupData), - }); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error(errData.detail || "Failed to update group"); - } - return await response.json(); -}; - -/** - * [ADMIN ONLY] Deletes a group. - */ -export const deleteAdminGroup = async (groupId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/users/admin/groups/${groupId}`, { - method: "DELETE", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to delete group"); - return await response.json(); -}; - -// --- Agent Node APIs --- - -const NODES_BASE_ENDPOINT = `${API_BASE_URL}/nodes`; - -/** - * [ADMIN] Fetch all registered nodes with full detail (tokens, config). - */ -export const getAdminNodes = async () => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/admin?admin_id=${userId}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch admin node list"); - return await response.json(); -}; - -/** - * [ADMIN] Register a new Agent Node. - */ -export const adminCreateNode = async (nodeData) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/admin?admin_id=${userId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(nodeData), - }); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error(errData.detail || "Failed to create node registration"); - } - return await response.json(); -}; - -/** - * [ADMIN] Update node metadata or skill toggles. - */ -export const adminUpdateNode = async (nodeId, updateData) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/admin/${nodeId}?admin_id=${userId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(updateData), - }); - if (!response.ok) throw new Error("Failed to update node configuration"); - return await response.json(); -}; - -/** - * [ADMIN] Deregister an Agent Node. - */ -export const adminDeleteNode = async (nodeId) => { - const adminId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/admin/${nodeId}?admin_id=${adminId}`, { - method: "DELETE", - headers: { "X-User-ID": adminId }, - }); - if (!response.ok) throw new Error("Failed to delete node"); - return await response.json(); -}; - -/** - * [ADMIN] Grant a group access to a node. - */ -export const adminGrantNodeAccess = async (nodeId, grantData) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/admin/${nodeId}/access?admin_id=${userId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(grantData), - }); - if (!response.ok) throw new Error("Failed to grant node access"); - return await response.json(); -}; - -/** - * [ADMIN] Revoke a group's access to a node. - */ -export const adminRevokeNodeAccess = async (nodeId, groupId) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/admin/${nodeId}/access/${groupId}?admin_id=${userId}`, { - method: "DELETE", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to revoke node access"); - return await response.json(); -}; - -/** - * [ADMIN] Download the pre-configured Agent Node bundle (ZIP). - */ -export const adminDownloadNodeBundle = async (nodeId) => { - const userId = getUserId(); - const url = `${NODES_BASE_ENDPOINT}/admin/${nodeId}/download?admin_id=${userId}`; - const response = await fetch(url, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to download node bundle"); - - // Handle binary download - const blob = await response.blob(); - const downloadUrl = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = downloadUrl; - link.setAttribute('download', `cortex-node-${nodeId}.zip`); - document.body.appendChild(link); - link.click(); - link.remove(); -}; - -/** - * [USER] List nodes accessible to the current user's group. - */ -export const getUserAccessibleNodes = async () => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/?user_id=${userId}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch accessible nodes"); - return await response.json(); -}; - -/** - * [USER] Update node preferences (default nodes, sync data source). - */ -export const updateUserNodePreferences = async (prefs) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/preferences?user_id=${userId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify(prefs), - }); - if (!response.ok) throw new Error("Failed to save node preferences"); - return await response.json(); -}; - -/** - * [FS] List directory contents on an agent node. - */ -export const nodeFsList = async (nodeId, path = ".", sessionId = null) => { - const userId = getUserId(); - const params = new URLSearchParams({ path }); - if (sessionId) params.append("session_id", sessionId); - const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/ls?${params.toString()}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) { - let detail = "Failed to list directory"; - try { - const errBody = await response.json(); - detail = errBody.detail || detail; - } catch { } - throw new Error(detail); - } - return await response.json(); -}; - -/** - * [FS] Read file content from an agent node. - */ -export const nodeFsCat = async (nodeId, path, sessionId = null) => { - const userId = getUserId(); - const params = new URLSearchParams({ path }); - if (sessionId) params.append("session_id", sessionId); - const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/cat?${params.toString()}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to read file"); - return await response.json(); -}; - -/** - * [FS] Create or update a file or directory on an agent node. - */ -export const nodeFsTouch = async (nodeId, path, content = "", isDir = false, sessionId = null) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/touch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify({ path, content, is_dir: isDir, session_id: sessionId }), - }); - if (!response.ok) throw new Error("Failed to create file/directory"); - return await response.json(); -}; - -/** - * [FS] Upload a file to an agent node via multipart form. - */ -export const nodeFsUpload = async (nodeId, path, file, sessionId = null) => { - const userId = getUserId(); - const formData = new FormData(); - formData.append("file", file); - - const params = new URLSearchParams({ path }); - if (sessionId) params.append("session_id", sessionId); - - const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/upload?${params.toString()}`, { - method: "POST", - headers: { - "X-User-ID": userId, - }, - body: formData, - }); - if (!response.ok) throw new Error("Failed to upload file"); - return await response.json(); -}; - -/** - * [FS] Downloads a file as a blob (preserves headers). - */ -export const nodeFsDownloadBlob = async (nodeId, path, sessionId = null) => { - const userId = getUserId(); - const params = new URLSearchParams({ path }); - if (sessionId) params.append("session_id", sessionId); - - const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/download?${params.toString()}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to download file"); - return await response.blob(); -}; - -/** - * [FS] Delete a file or directory from an agent node. - */ -export const nodeFsRm = async (nodeId, path, sessionId = null) => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/${nodeId}/fs/rm`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify({ path, session_id: sessionId }), - }); - if (!response.ok) throw new Error("Failed to delete file/directory"); - return await response.json(); -}; - -/** - * [USER] Fetch node preferences. - */ -export const getUserNodePreferences = async () => { - const userId = getUserId(); - const response = await fetch(`${NODES_BASE_ENDPOINT}/preferences?user_id=${userId}`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch node preferences"); - return await response.json(); -}; - -/** - * [USER] Fetch sync status for all nodes attached to a session. - */ -export const getSessionNodeStatus = async (sessionId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}/nodes`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch session node status"); - return await response.json(); -}; - -/** - * [USER] Attach more nodes to an active session. - */ -export const attachNodesToSession = async (sessionId, nodeIds, config = null) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}/nodes`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": userId, - }, - body: JSON.stringify({ node_ids: nodeIds, config }), - }); - if (!response.ok) throw new Error("Failed to attach nodes to session"); - return await response.json(); -}; - -/** - * [USER] Detach a node from a session. - */ -export const detachNodeFromSession = async (sessionId, nodeId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/sessions/${sessionId}/nodes/${nodeId}`, { - method: "DELETE", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to detach node from session"); - return await response.json(); -}; - -/** - * WebSocket helper for live node streams. - */ -export const getNodeStreamUrl = (nodeId = null) => { - // Convert http://... to ws://... - const wsBase = API_BASE_URL.replace(/^http/, 'ws'); - if (nodeId) { - const userId = getUserId(); - return `${wsBase}/nodes/${nodeId}/stream?user_id=${userId}`; - } - const userId = localStorage.getItem('userId'); - return `${wsBase}/nodes/stream/all?user_id=${userId}`; -}; - -/** - * [AI Observability] Fetch terminal history for a node. - */ -export const getNodeTerminalHistory = async (nodeId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/nodes/${nodeId}/terminal`, { - method: "GET", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch node terminal history"); - return await response.json(); -}; - -export const dispatchNodeTask = async (nodeId, payload) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/nodes/${nodeId}/dispatch`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-ID': userId, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || `Failed to dispatch task: ${response.statusText}`); - } - - return await response.json(); -}; - -export const cancelNodeTask = async (nodeId, taskId = "") => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/nodes/${nodeId}/cancel?task_id=${taskId}`, { - method: 'POST', - headers: { - 'X-User-ID': userId, - } - }); - - if (!response.ok) { - throw new Error(`Failed to cancel task: ${response.statusText}`); - } - - return await response.json(); -}; - -// --- Skills API --- - -export const getSkills = async () => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/skills/`, { - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to fetch skills"); - return await response.json(); -}; - -export const createSkill = async (skillData) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/skills/`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-User-ID": userId }, - body: JSON.stringify(skillData), - }); - if (!response.ok) throw new Error("Failed to create skill"); - return await response.json(); -}; - -export const updateSkill = async (skillId, skillData) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/skills/${skillId}`, { - method: "PUT", - headers: { "Content-Type": "application/json", "X-User-ID": userId }, - body: JSON.stringify(skillData), - }); - if (!response.ok) throw new Error("Failed to update skill"); - return await response.json(); -}; - -export const deleteSkill = async (skillId) => { - const userId = getUserId(); - const response = await fetch(`${API_BASE_URL}/skills/${skillId}`, { - method: "DELETE", - headers: { "X-User-ID": userId }, - }); - if (!response.ok) throw new Error("Failed to delete skill"); - return await response.json(); -}; +// Domain Services +export * from './api/userService'; +export * from './api/sessionService'; +export * from './api/aiService'; +export * from './api/adminService'; +export * from './api/nodeService'; +export * from './api/skillService'; +export * from './api/ragService'; +export * from './api/systemService'; diff --git a/frontend/src/shared/components/SessionSidebar.css b/frontend/src/shared/components/SessionSidebar.css index c7bc4a7..55d80aa 100644 --- a/frontend/src/shared/components/SessionSidebar.css +++ b/frontend/src/shared/components/SessionSidebar.css @@ -26,16 +26,14 @@ } /* Dark-mode variant — triggered by Tailwind's .dark class on */ -@media (prefers-color-scheme: dark) { - .session-sidebar { - background-color: rgb(31 41 55 / 0.98); - /* gray-800 */ - border-right-color: rgb(55 65 81); - /* gray-700 */ - color: #f3f4f6; - /* gray-100 */ - box-shadow: 4px 0 20px rgb(0 0 0 / 0.4); - } +.dark .session-sidebar { + background-color: rgb(31 41 55 / 0.98); + /* gray-800 */ + border-right-color: rgb(55 65 81); + /* gray-700 */ + color: #f3f4f6; + /* gray-100 */ + box-shadow: 4px 0 20px rgb(0 0 0 / 0.4); } .session-sidebar.open { @@ -71,10 +69,8 @@ padding: 4px 2px; } -@media (prefers-color-scheme: dark) { - .sidebar-toggle { - border-color: rgb(55 65 81); - } +.dark .sidebar-toggle { + border-color: rgb(55 65 81); } .sidebar-toggle-arrow { @@ -115,10 +111,8 @@ border-bottom: 1px solid rgb(209 213 219); } -@media (prefers-color-scheme: dark) { - .sidebar-header { - border-bottom-color: rgb(55 65 81); - } +.dark .sidebar-header { + border-bottom-color: rgb(55 65 81); } .sidebar-header h3 { @@ -184,12 +178,10 @@ position: relative; } -@media (prefers-color-scheme: dark) { - .sidebar-item { - background-color: rgb(55 65 81 / 0.6); - /* gray-700 */ - border-color: rgb(75 85 99); - } +.dark .sidebar-item { + background-color: rgb(55 65 81 / 0.6); + /* gray-700 */ + border-color: rgb(75 85 99); } .sidebar-item:hover { @@ -200,11 +192,9 @@ box-shadow: 0 1px 4px rgb(99 102 241 / 0.12); } -@media (prefers-color-scheme: dark) { - .sidebar-item:hover { - background-color: rgb(49 46 129 / 0.4); - border-color: #6366f1; - } +.dark .sidebar-item:hover { + background-color: rgb(49 46 129 / 0.4); + border-color: #6366f1; } /* Active (current) session */ @@ -216,13 +206,11 @@ border-left: 3px solid #6366f1; } -@media (prefers-color-scheme: dark) { - .sidebar-item.active { - background-color: rgb(49 46 129 / 0.5); - border-color: #818cf8; - /* indigo-400 */ - border-left-color: #818cf8; - } +.dark .sidebar-item.active { + background-color: rgb(49 46 129 / 0.5); + border-color: #818cf8; + /* indigo-400 */ + border-left-color: #818cf8; } /* ── Card body ───────────────────────────────────────────────────────── */ @@ -272,12 +260,10 @@ border-radius: 3px; } -@media (prefers-color-scheme: dark) { - .sidebar-item-provider { - background: rgb(49 46 129 / 0.5); - border-color: #6366f1; - color: #a5b4fc; - } +.dark .sidebar-item-provider { + background: rgb(49 46 129 / 0.5); + border-color: #6366f1; + color: #a5b4fc; } /* ── Delete (×) button on each card ─────────────────────────────────── */ @@ -318,12 +304,10 @@ background: #9ca3af; } -@media (prefers-color-scheme: dark) { - .sidebar-list::-webkit-scrollbar-thumb { - background: #4b5563; - } +.dark .sidebar-list::-webkit-scrollbar-thumb { + background: #4b5563; +} - .sidebar-list::-webkit-scrollbar-thumb:hover { - background: #6b7280; - } +.dark .sidebar-list::-webkit-scrollbar-thumb:hover { + background: #6b7280; } \ No newline at end of file diff --git a/frontend/src/shared/components/SessionSidebar.js b/frontend/src/shared/components/SessionSidebar.js index 831e9ba..b3078ff 100644 --- a/frontend/src/shared/components/SessionSidebar.js +++ b/frontend/src/shared/components/SessionSidebar.js @@ -52,7 +52,14 @@ localStorage.removeItem(`sessionId_${featureName}`); if (onNewSession) onNewSession(); } - } catch { alert('Failed to delete session.'); } + } catch { + setConfirmModal({ + isOpen: true, + title: 'Protocol Error', + message: 'Failed to purge session data from the nexus.', + onConfirm: () => {} + }); + } } }); }; @@ -68,7 +75,14 @@ await deleteAllSessions(featureName); fetchSessions(); if (onNewSession) onNewSession(); - } catch { alert('Failed to delete all sessions.'); } + } catch { + setConfirmModal({ + isOpen: true, + title: 'Protocol Error', + message: 'Failed to clear session history. System integrity may be compromised.', + onConfirm: () => {} + }); + } } }); }; @@ -178,15 +192,17 @@ > Cancel - + {confirmModal.onConfirm && ( + + )} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 80695b1..da267f9 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,4 +1,5 @@ module.exports = { + darkMode: 'class', content: [ "./src/**/*.{js,jsx,ts,tsx}", // React files "./public/index.html" // (optional, if using CRA)