From b1aa7d75e5104d578177d0d9715a1c46912bf141 Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Mon, 4 May 2026 11:00:26 -0600 Subject: [PATCH 01/18] feat(hybrid): add Hybrid provider option to ProviderDropdown (UI scaffold) Adds a 4th "Hybrid" option to the provider dropdown with a purple "Mixed" badge and dark-mode fix, plus a Settings2 configure button on the selected hybrid row. Passes new `defaultBackend` and `backendLocked` (BrainForge) fields from `/api/config` personas through AppConfigContext for the upcoming mapping UI. Backend needs: - `default_backend` + `brainforge` flags on persona config - accept "hybrid" on POST /switch-provider - GET/POST /hybrid-config for { orchestrator, personas{} } map - per-persona inference routing; BrainForge hard-pinned --- .../src/components/ProviderDropdown.js | 36 +++++++++++++++++-- .../src/contexts/AppConfigContext.js | 2 ++ phd-advisor-frontend/src/styles/ChatPage.css | 34 ++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/phd-advisor-frontend/src/components/ProviderDropdown.js b/phd-advisor-frontend/src/components/ProviderDropdown.js index bbadf8f8..d2a6fcf3 100644 --- a/phd-advisor-frontend/src/components/ProviderDropdown.js +++ b/phd-advisor-frontend/src/components/ProviderDropdown.js @@ -1,9 +1,9 @@ // src/components/ProviderDropdown.js import React, { useState, useRef, useEffect } from 'react'; -import { ChevronDown, Cpu, Cloud, Server, Loader2 } from 'lucide-react'; +import { ChevronDown, Cpu, Cloud, Server, Loader2, Layers, Settings2 } from 'lucide-react'; import { useTheme } from '../contexts/ThemeContext'; -const ProviderDropdown = ({ currentProvider, onProviderChange, isLoading = false }) => { +const ProviderDropdown = ({ currentProvider, onProviderChange, isLoading = false, onConfigureHybrid }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const { isDark } = useTheme(); @@ -29,6 +29,13 @@ const ProviderDropdown = ({ currentProvider, onProviderChange, isLoading = false description: 'vLLM inference endpoint', icon: Server, badge: 'API' + }, + { + id: 'hybrid', + name: 'Hybrid', + description: 'Per-advisor backend selection', + icon: Layers, + badge: 'Mixed' } ]; @@ -49,12 +56,25 @@ const ProviderDropdown = ({ currentProvider, onProviderChange, isLoading = false }, []); const handleProviderSelect = (providerId) => { - if (providerId !== currentProvider && !isLoading) { + if (isLoading) return; + if (providerId === 'hybrid') { + onProviderChange(providerId); + if (onConfigureHybrid) onConfigureHybrid(); + setIsOpen(false); + return; + } + if (providerId !== currentProvider) { onProviderChange(providerId); setIsOpen(false); } }; + const handleConfigureClick = (event) => { + event.stopPropagation(); + if (onConfigureHybrid) onConfigureHybrid(); + setIsOpen(false); + }; + const toggleDropdown = () => { if (!isLoading) { setIsOpen(!isOpen); @@ -111,6 +131,16 @@ const ProviderDropdown = ({ currentProvider, onProviderChange, isLoading = false {isSelected && (
)} + {provider.id === 'hybrid' && isSelected && onConfigureHybrid && ( + + )} ); })} diff --git a/phd-advisor-frontend/src/contexts/AppConfigContext.js b/phd-advisor-frontend/src/contexts/AppConfigContext.js index d205e089..97c4ca6e 100644 --- a/phd-advisor-frontend/src/contexts/AppConfigContext.js +++ b/phd-advisor-frontend/src/contexts/AppConfigContext.js @@ -61,6 +61,8 @@ const buildAdvisors = (personaItems, overrides = {}) => { darkBgColor: p.dark_bg_color || '#374151', icon: resolveIcon(isIcon ? image.replace('icon://', '') : null), avatarUrl, + defaultBackend: p.default_backend || null, + backendLocked: Boolean(p.brainforge || p.backend_locked), }; } return advisors; diff --git a/phd-advisor-frontend/src/styles/ChatPage.css b/phd-advisor-frontend/src/styles/ChatPage.css index a74bdd2f..4509e691 100644 --- a/phd-advisor-frontend/src/styles/ChatPage.css +++ b/phd-advisor-frontend/src/styles/ChatPage.css @@ -911,6 +911,12 @@ .provider-badge.gemini { color: #4285f4; } .provider-badge.ollama { color: #10b981; } .provider-badge.vllm { color: #ef4444; } +.provider-badge.hybrid { color: #a855f7; } + +[data-theme="dark"] .provider-badge.hybrid, +[data-theme="dark"] .provider-option-badge.hybrid { + color: #ffffff; +} .clarification-message-container { display: flex; @@ -1147,6 +1153,34 @@ color: #ef4444; } +.provider-option-badge.hybrid { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; +} + +.provider-option-configure { + margin-left: 8px; + background: transparent; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + padding: 4px 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + color: var(--text-secondary, #6b7280); +} + +.provider-option-configure:hover { + background: var(--accent-primary); + color: #fff; + border-color: var(--accent-primary); +} + +[data-theme="dark"] .provider-option-configure { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.25); +} + .provider-option-description { font-size: 11px; color: var(--text-tertiary); From 19b85fb033d9b08c670f36bb885363b815b087cc Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 5 May 2026 12:45:50 -0700 Subject: [PATCH 02/18] added per-user llm backend selection with uniform (all same provider) and hybird (different llm per persona/orchestrator) modes. --- .../app/api/routes/chat.py | 88 +++++++++-- .../app/api/routes/provider.py | 143 ++++++------------ .../app/core/bootstrap.py | 31 +++- .../app/core/improved_orchestrator.py | 64 +++++--- .../app/models/persona.py | 10 +- multi_llm_chatbot_backend/app/models/user.py | 34 ++++- 6 files changed, 234 insertions(+), 136 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index aaad4e0c..abc55d50 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -13,7 +13,7 @@ from app.api.utils import get_or_create_session_for_request_async from app.core.auth import get_current_active_user from app.config import get_settings -from app.core.bootstrap import chat_orchestrator +from app.core.bootstrap import chat_orchestrator, get_llm_client from app.core.database import get_database from app.core.persona_filter import get_available_persona_ids from app.core.session_manager import get_session_manager @@ -24,6 +24,41 @@ router = APIRouter() session_manager = get_session_manager() + +def resolve_llm_clients(user: User) -> Dict[str, Any]: + """Resolve LLM clients from a user's stored configuration. + + Returns ``{"orchestrator": LLMClient | None, "personas": {id: LLMClient} | None}``. + + - No saved config: both values are ``None``; callers fall back to + orchestrator/persona defaults. + - Uniform mode: the same cached client is returned for the orchestrator + and every persona. + - Hybrid mode: the orchestrator and each persona may receive different + clients based on the user's per-persona mapping. + """ + config = user.llm_config + if config is None: + return {"orchestrator": None, "personas": None} + + if config.mode == "uniform": + client = get_llm_client(config.default_backend) + persona_clients = { + pid: client for pid in chat_orchestrator.personas + } + return {"orchestrator": client, "personas": persona_clients} + + # Hybrid mode + orchestrator_backend = config.orchestrator_backend or config.default_backend + orchestrator_client = get_llm_client(orchestrator_backend) + + persona_clients = {} + for pid in chat_orchestrator.personas: + backend = (config.persona_backends or {}).get(pid, config.default_backend) + persona_clients[pid] = get_llm_client(backend) + + return {"orchestrator": orchestrator_client, "personas": persona_clients} + # Enhanced data models class UserInput(BaseModel): user_input: str @@ -81,6 +116,11 @@ async def chat_stream( async def _event_generator(): try: + # Resolve per-user LLM clients from their stored config + llm_clients = resolve_llm_clients(current_user) + orchestrator_llm = llm_clients["orchestrator"] + persona_llms = llm_clients["personas"] + # Load or create the in-memory session if message.chat_session_id: sid = f"chat_{message.chat_session_id}" @@ -106,8 +146,10 @@ async def _event_generator(): type="progress", data={"phase": "received"}, ).to_ndjson() - if await chat_orchestrator.needs_clarification_improved(session, message.user_input): - clar = await chat_orchestrator.generate_contextual_clarification(message.user_input) + if chat_orchestrator.needs_clarification(session, message.user_input): + clar = await chat_orchestrator.generate_contextual_clarification( + message.user_input, llm_client=orchestrator_llm, + ) yield ChatStreamLine( type="clarification", data={ @@ -123,7 +165,9 @@ async def _event_generator(): # If an enabled tool can handle this query, return its response # directly and skip persona generation. - tool_result = await chat_orchestrator.get_tool_response(message.user_input) + tool_result = await chat_orchestrator.get_tool_response( + message.user_input, llm_client=orchestrator_llm, + ) if tool_result.used_tool: # Append user message to in-memory session and persist to MongoDB session.append_message("orchestrator", tool_result.text) @@ -164,6 +208,7 @@ async def _event_generator(): top_personas = await chat_orchestrator.get_top_personas( session_id=sid, allowed_ids=available, + llm_client=orchestrator_llm, ) # Guard against race condition where all selected advisors @@ -210,9 +255,11 @@ async def _run(pid: str) -> None: "document_chunks_used": 0, }) return + persona_llm = (persona_llms or {}).get(pid) result = await chat_orchestrator.generate_single_persona_response( session, persona, message.response_length or "medium", + llm_client=persona_llm, ) session.append_message(pid, result["response"]) await done_queue.put(result) @@ -390,7 +437,10 @@ async def create_new_chat( raise HTTPException(status_code=500, detail="Failed to create new chat") @router.post("/chat/{persona_id}") -async def chat_with_specific_advisor(persona_id: str, input: UserInput, request: Request): +async def chat_with_specific_advisor( + persona_id: str, input: UserInput, request: Request, + current_user: User = Depends(get_current_active_user), +): """Chat with a specific advisor - UPDATED""" try: if persona_id not in chat_orchestrator.personas: @@ -408,11 +458,15 @@ async def chat_with_specific_advisor(persona_id: str, input: UserInput, request: isExpandRequest=True, ), ) + + llm_clients = resolve_llm_clients(current_user) + persona_llm = (llm_clients["personas"] or {}).get(persona_id) result = await chat_orchestrator.chat_with_persona( user_input=input.user_input, persona_id=persona_id, - session_id=session_id + session_id=session_id, + llm_client=persona_llm, ) # Handle response structure @@ -479,7 +533,10 @@ async def chat_with_specific_advisor(persona_id: str, input: UserInput, request: } @router.post("/reply-to-advisor") -async def reply_to_advisor(reply: ReplyToAdvisor, request: Request): +async def reply_to_advisor( + reply: ReplyToAdvisor, request: Request, + current_user: User = Depends(get_current_active_user), +): """Reply to a specific advisor with proper context - UPDATED""" try: if reply.advisor_id not in chat_orchestrator.personas: @@ -520,10 +577,14 @@ async def reply_to_advisor(reply: ReplyToAdvisor, request: Request): if original_message: contextual_input = f"[Replying to your previous message: '{original_message[:100]}...'] {reply.user_input}" + llm_clients = resolve_llm_clients(current_user) + advisor_llm = (llm_clients["personas"] or {}).get(reply.advisor_id) + result = await chat_orchestrator.chat_with_persona( user_input=contextual_input, persona_id=reply.advisor_id, - session_id=session_id + session_id=session_id, + llm_client=advisor_llm, ) # Handle response structure @@ -600,15 +661,22 @@ async def reply_to_advisor(reply: ReplyToAdvisor, request: Request): } @router.post("/ask/") -async def ask_question(query: PersonaQuery, request: Request): +async def ask_question( + query: PersonaQuery, request: Request, + current_user: User = Depends(get_current_active_user), +): """Ask question - UPDATED""" try: session_id = await get_or_create_session_for_request_async(request) + llm_clients = resolve_llm_clients(current_user) + persona_llm = (llm_clients["personas"] or {}).get(query.persona) + result = await chat_orchestrator.chat_with_persona( user_input=query.question, persona_id=query.persona, - session_id=session_id + session_id=session_id, + llm_client=persona_llm, ) if result["type"] == "single_persona_response": diff --git a/multi_llm_chatbot_backend/app/api/routes/provider.py b/multi_llm_chatbot_backend/app/api/routes/provider.py index b7760e74..e71f8a82 100644 --- a/multi_llm_chatbot_backend/app/api/routes/provider.py +++ b/multi_llm_chatbot_backend/app/api/routes/provider.py @@ -1,108 +1,65 @@ -from fastapi import APIRouter, Body, HTTPException -from app.config import get_settings -from app.llm.improved_gemini_client import ImprovedGeminiClient -from app.llm.improved_ollama_client import ImprovedOllamaClient -from app.llm.improved_vllm_client import ImprovedVllmClient -from app.models.default_personas import get_default_personas -from app.core.bootstrap import chat_orchestrator, llm, current_provider, available_providers -from app.core.brainforge_sync import BRAINFORGE_PERSONA_PREFIX -from pydantic import BaseModel -import os +from fastapi import APIRouter, Depends, HTTPException, status +from app.core.auth import get_current_active_user +from app.core.bootstrap import chat_orchestrator, get_llm_client +from app.core.database import get_database +from app.models.user import User, UserLLMConfig, LLM_BACKENDS import logging logger = logging.getLogger(__name__) router = APIRouter() -def create_llm_client(provider: str = None): - global current_provider - if provider is None: - provider = current_provider - - if provider == "gemini": - try: - return ImprovedGeminiClient(model_name=os.getenv("GEMINI_MODEL")) - except ValueError as e: - logger.warning(f"Gemini API key not found, falling back to Ollama: {e}") - return ImprovedOllamaClient(model_name="llama3.2:1b") - elif provider == "ollama": - return ImprovedOllamaClient(model_name="llama3.2:1b") - elif provider == "vllm": - settings = get_settings() - if not settings.llm.vllm.api_url: - raise ValueError("No vLLM endpoint configured. Set llm.vllm.api_url in your config.") - return ImprovedVllmClient( - api_url=settings.llm.vllm.api_url, - api_key=settings.llm.vllm.api_key, - ) - else: - raise ValueError(f"Unknown provider: {provider}") - -# Initialize LLM and personas -llm = create_llm_client(current_provider) -DEFAULT_PERSONAS = get_default_personas(llm) -for persona in DEFAULT_PERSONAS: - chat_orchestrator.register_persona(persona) - -class ProviderSwitch(BaseModel): - provider: str @router.get("/current-provider") -async def get_current_provider(): +async def get_current_provider( + current_user: User = Depends(get_current_active_user), +): + """Return the authenticated user's LLM configuration.""" + config = current_user.llm_config or UserLLMConfig() return { - "current_provider": current_provider, - "available_providers": available_providers, - "model_info": { - "name": llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash", - "provider": current_provider - } + "llm_config": config.model_dump(), + "available_backends": list(LLM_BACKENDS), } -@router.post("/switch-provider") -async def switch_provider(provider_data: ProviderSwitch): - global current_provider, llm - - if provider_data.provider not in available_providers: - raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}") - - try: - current_provider = provider_data.provider - new_llm = create_llm_client(current_provider) - llm = new_llm - chat_orchestrator.llm_client = new_llm - - new_personas = get_default_personas(new_llm) - # Clear only non-BrainForge personas; BF advisors have their own LLM clients - non_bf_ids = [pid for pid in chat_orchestrator.personas if not pid.startswith(f"{BRAINFORGE_PERSONA_PREFIX}_")] - for pid in non_bf_ids: - chat_orchestrator.unregister_persona(pid) - for persona in new_personas: - chat_orchestrator.register_persona(persona) - - return { - "message": f"Successfully switched to {current_provider}", - "current_provider": current_provider, - "model_info": { - "name": new_llm.model_name if hasattr(new_llm, 'model_name') else "gemini-2.0-flash", - "provider": current_provider - } - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to switch to {provider_data.provider}: {str(e)}") - -@router.post("/switch-model") -async def switch_model(model_name: str = Body(...)): - if "gemini" in model_name.lower(): - return await switch_provider(ProviderSwitch(provider="gemini")) - else: - return await switch_provider(ProviderSwitch(provider="ollama")) +@router.post("/switch-provider") +async def switch_provider( + llm_config: UserLLMConfig, + current_user: User = Depends(get_current_active_user), +): + """Persist the user's LLM configuration to their profile.""" + if llm_config.mode == "hybrid" and llm_config.persona_backends: + registered = set(chat_orchestrator.personas.keys()) + unknown = set(llm_config.persona_backends.keys()) - registered + if unknown: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown persona IDs: {sorted(unknown)}. " + f"Valid IDs: {sorted(registered)}", + ) + + backends_to_check = {llm_config.default_backend} + if llm_config.orchestrator_backend: + backends_to_check.add(llm_config.orchestrator_backend) + if llm_config.persona_backends: + backends_to_check.update(llm_config.persona_backends.values()) + + for backend in backends_to_check: + try: + get_llm_client(backend) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Backend {backend!r} is not configured: {exc}", + ) + + db = get_database() + await db.users.update_one( + {"_id": current_user.id}, + {"$set": {"llm_config": llm_config.model_dump()}}, + ) -@router.get("/current-model") -async def get_current_model(): - model_name = llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash" return { - "model": model_name, - "provider": current_provider + "message": "LLM configuration updated", + "llm_config": llm_config.model_dump(), } diff --git a/multi_llm_chatbot_backend/app/core/bootstrap.py b/multi_llm_chatbot_backend/app/core/bootstrap.py index c08d873e..f46e4d0b 100644 --- a/multi_llm_chatbot_backend/app/core/bootstrap.py +++ b/multi_llm_chatbot_backend/app/core/bootstrap.py @@ -5,18 +5,26 @@ from app.llm.improved_vllm_client import ImprovedVllmClient from app.core.improved_orchestrator import ImprovedChatOrchestrator from app.models.default_personas import get_default_personas +from app.models.user import LLM_BACKENDS +from app.llm.llm_client import LLMClient settings = get_settings() -current_provider = "gemini" -available_providers = ["ollama", "gemini", "vllm"] +DEFAULT_BACKEND = "gemini" -def create_llm_client(provider=None): - if provider is None: - provider = current_provider - if provider == "gemini": + +_client_cache = {} + + +def create_llm_client(backend: str = DEFAULT_BACKEND): + """Create an LLM client for the given backend name.""" + if backend not in LLM_BACKENDS: + raise ValueError( + f"Unknown backend {backend!r}. Must be one of {LLM_BACKENDS}" + ) + if backend == "gemini": return ImprovedGeminiClient(model_name=settings.llm.gemini.model) - elif provider == "vllm": + elif backend == "vllm": if not settings.llm.vllm.api_url: raise ValueError("No vLLM endpoint configured. Set llm.vllm.api_url in your config.") return ImprovedVllmClient( @@ -29,7 +37,16 @@ def create_llm_client(provider=None): base_url=settings.llm.ollama.base_url, ) + +def get_llm_client(backend: str) -> LLMClient: + """Return a cached LLM client for *backend*, creating it on first access.""" + if backend not in _client_cache: + _client_cache[backend] = create_llm_client(backend) + return _client_cache[backend] + + llm = create_llm_client() +_client_cache[DEFAULT_BACKEND] = llm chat_orchestrator = ImprovedChatOrchestrator(llm_client=llm) DEFAULT_PERSONAS = get_default_personas(llm) diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index 12f63a78..dd391ff1 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -45,7 +45,8 @@ def list_personas(self) -> List[str]: """List all available persona IDs""" return list(self.personas.keys()) - async def get_tool_response(self, user_message: str) -> ToolCallResult: + async def get_tool_response(self, user_message: str, + llm_client: LLMClient = None) -> ToolCallResult: """Check whether a tool can handle *user_message*. If tools are disabled in config, no LLM client is available, or the @@ -53,7 +54,8 @@ async def get_tool_response(self, user_message: str) -> ToolCallResult: ``ToolCallResult(used_tool=False)``. Otherwise executes the tool and returns the grounded response with ``used_tool=True``. """ - if self.llm_client is None: + effective_llm = llm_client or self.llm_client + if effective_llm is None: return ToolCallResult(text="", used_tool=False) settings = get_settings() @@ -80,7 +82,7 @@ async def get_tool_response(self, user_message: str) -> ToolCallResult: "to present structured data like course listings or professor ratings." ) - return await self.llm_client.generate_with_tools( + return await effective_llm.generate_with_tools( system_prompt=system_prompt, user_message=user_message, tool_definitions=tool_definitions, @@ -325,7 +327,9 @@ async def needs_clarification_improved(self, session: ConversationContext, user_ logger.warning("Falling back to rule-based clarification check") return self.needs_clarification(session, user_input) - async def generate_contextual_clarification(self, user_input: str) -> Dict[str, Any]: + + async def generate_contextual_clarification(self, user_input: str, + llm_client: LLMClient = None) -> Dict[str, Any]: """ Use the LLM to produce a clarification question and clickable suggestions that are tailored to what the user actually typed. @@ -355,10 +359,8 @@ async def generate_contextual_clarification(self, user_input: str) -> Dict[str, ) try: - # Use the orchestrator's own LLM rather than a persona's — BrainForge - # persona LLMs may not support the prompt format used here. - llm = self.llm_client - raw = await llm.generate( + effective_llm = llm_client or self.llm_client + raw = await effective_llm.generate( system_prompt=system_prompt, context=[{"role": "user", "content": user_prompt}], temperature=0.4, @@ -391,9 +393,14 @@ async def generate_contextual_clarification(self, user_input: str) -> Dict[str, "suggestions": fallback_suggestions, } - async def generate_persona_responses(self, session: ConversationContext, response_length: str = "medium"): + async def generate_persona_responses(self, session: ConversationContext, + response_length: str = "medium", + llm_clients: Dict[str, LLMClient] = None): """ - Generate responses from all personas with enhanced RAG integration + Generate responses from all personas with enhanced RAG integration. + + *llm_clients* maps persona IDs to the LLM client each should use. + Personas not present in the dict fall back to their default client. """ responses = [] @@ -401,7 +408,10 @@ async def generate_persona_responses(self, session: ConversationContext, respons logger.info(f"Generating response for {persona_id} with enhanced RAG") # Generate persona response with enhanced RAG - response_data = await self.generate_single_persona_response(session, persona, response_length) + persona_llm = (llm_clients or {}).get(persona_id) + response_data = await self.generate_single_persona_response( + session, persona, response_length, llm_client=persona_llm, + ) # Add persona response to session context session.append_message(persona_id, response_data["response"]) @@ -410,9 +420,14 @@ async def generate_persona_responses(self, session: ConversationContext, respons return responses - async def generate_single_persona_response(self, session, persona, response_length: str = "medium"): + async def generate_single_persona_response(self, session, persona, + response_length: str = "medium", + llm_client: LLMClient = None): """ - Enhanced version - Generate response from a single persona with enhanced RAG integration + Enhanced version - Generate response from a single persona with enhanced RAG integration. + + *llm_client* is forwarded to ``persona.respond()``; when ``None`` the + persona uses its default (system-default) client. """ try: # Get the user's latest message for document retrieval @@ -441,7 +456,7 @@ async def generate_single_persona_response(self, session, persona, response_leng ) # Generate response with enhanced context - response = await persona.respond(enhanced_context, response_length) + response = await persona.respond(enhanced_context, response_length, llm=llm_client) # Validate and improve response quality if not self._is_valid_response(response, persona.id): @@ -813,9 +828,13 @@ def _get_persona_context_keywords(self, persona_id: str) -> str: """ return self._get_enhanced_persona_context_keywords(persona_id) - async def chat_with_persona(self, user_input: str, persona_id: str, session_id: str, response_length: str = "medium") -> Dict[str, Any]: + async def chat_with_persona(self, user_input: str, persona_id: str, + session_id: str, response_length: str = "medium", + llm_client: LLMClient = None) -> Dict[str, Any]: """ - Chat with a specific persona directly - FIXED for consistent document access + Chat with a specific persona directly - FIXED for consistent document access. + + *llm_client* is forwarded to the persona's response generation. """ try: persona = self.get_persona(persona_id) @@ -838,7 +857,9 @@ async def chat_with_persona(self, user_input: str, persona_id: str, session_id: logger.info(f"Generating response for {persona_id} with session {session_id}") # Generate response from single persona using consistent session ID - response_data = await self.generate_single_persona_response(session, persona, response_length) + response_data = await self.generate_single_persona_response( + session, persona, response_length, llm_client=llm_client, + ) # Add response to session session.append_message(persona_id, response_data["response"]) @@ -884,7 +905,8 @@ async def chat_with_persona(self, user_input: str, persona_id: str, session_id: async def get_top_personas(self, session_id: str, k: int = 3, - allowed_ids: Optional[List[str]] = None) -> List[str]: + allowed_ids: Optional[List[str]] = None, + llm_client: LLMClient = None) -> List[str]: """ Use the LLM to rank personas based on current session context. Falls back to default persona order if LLM fails or returns invalid data. @@ -902,9 +924,7 @@ async def get_top_personas(self, session_id: str, k: int = 3, logger.warning("No personas available after filtering.") return [] - # Use the orchestrator's own LLM rather than a persona's — BrainForge - # persona LLMs may not support the prompt format used here. - llm = self.llm_client + effective_llm = llm_client or self.llm_client # Use recent conversation context (last 5 messages) recent_context = "\n".join( @@ -935,7 +955,7 @@ async def get_top_personas(self, session_id: str, k: int = 3, {persona_descriptions} """.strip() - llm_response = await llm.generate( + llm_response = await effective_llm.generate( system_prompt=f"You are an assistant that selects the best advisors for a user of {app_title}.", context=[{"role": "user", "content": prompt}], temperature=0.4, diff --git a/multi_llm_chatbot_backend/app/models/persona.py b/multi_llm_chatbot_backend/app/models/persona.py index ed34caf9..2a19034a 100644 --- a/multi_llm_chatbot_backend/app/models/persona.py +++ b/multi_llm_chatbot_backend/app/models/persona.py @@ -245,10 +245,14 @@ def __init__(self, id: str, name: str, system_prompt: str, llm: LLMClient, tempe self.llm = llm self.temperature = temperature - async def respond(self, context: List[Dict], response_length: str = "medium") -> str: + async def respond(self, context: List[Dict], response_length: str = "medium", + llm: LLMClient = None) -> str: """Generate a compact, well-formed Markdown response suitable for the UI. - Returns the compact Markdown string (backward compatible with previous callers). + + *llm* overrides the default client for this call (used for per-user + backend selection). Falls back to ``self.llm`` when not provided. """ + effective_llm = llm or self.llm max_tokens = MAX_TOKENS_MAP.get(response_length, 500) structure_hint = STRUCTURE_HINTS.get(response_length, STRUCTURE_HINTS["medium"]) temp_scaled = round(self.temperature / 10, 2) @@ -259,7 +263,7 @@ async def respond(self, context: List[Dict], response_length: str = "medium") -> f"{structure_hint}" ) - raw_text = await self.llm.generate( + raw_text = await effective_llm.generate( system_prompt=full_prompt, context=context, temperature=temp_scaled, diff --git a/multi_llm_chatbot_backend/app/models/user.py b/multi_llm_chatbot_backend/app/models/user.py index 24b08957..4592c7b4 100644 --- a/multi_llm_chatbot_backend/app/models/user.py +++ b/multi_llm_chatbot_backend/app/models/user.py @@ -1,8 +1,39 @@ from pydantic import BaseModel, EmailStr, Field, ConfigDict, model_validator -from typing import Literal, Optional, List, Any +from typing import Dict, Literal, Optional, List, Any, get_args from datetime import datetime from bson import ObjectId +BackendName = Literal["gemini", "ollama", "vllm"] +LLM_BACKENDS = get_args(BackendName) + + +class UserLLMConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + """Per-user LLM provider configuration. + + Uniform mode: all advisors and the orchestrator use ``default_backend``. + Hybrid mode: each advisor can use a different backend; ``default_backend`` + is the fallback for any persona not explicitly mapped. + """ + mode: Literal["uniform", "hybrid"] = "uniform" + default_backend: BackendName = "gemini" + orchestrator_backend: Optional[BackendName] = None + persona_backends: Optional[Dict[str, BackendName]] = None + + @model_validator(mode="after") + def _validate_hybrid_fields(self): + if self.mode == "hybrid": + if not self.orchestrator_backend and not self.persona_backends: + raise ValueError( + "hybrid mode requires at least one of " + "orchestrator_backend or persona_backends" + ) + else: + self.orchestrator_backend = None + self.persona_backends = None + return self + class PyObjectId(ObjectId): @classmethod def __get_validators__(cls): @@ -48,6 +79,7 @@ class User(BaseModel): academicStage: Optional[str] = None researchArea: Optional[str] = None disabled_advisors: Optional[List[str]] = None + llm_config: Optional[UserLLMConfig] = None created_at: datetime = Field(default_factory=datetime.utcnow) last_login: Optional[datetime] = None is_active: bool = True From 3cc75f7d1e195875499dc4bf661f2aa38cadd208 Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Tue, 5 May 2026 14:30:44 -0600 Subject: [PATCH 03/18] Enhance ChatPage with hybrid LLM configuration support - Added HybridConfigModal for configuring hybrid LLM settings. - Refactored state management to support both uniform and hybrid modes. -Each adviosr can now have its own model --- .../src/components/HybridConfigModal.js | 157 ++++++++++++++++++ phd-advisor-frontend/src/pages/ChatPage.js | 113 +++++++++---- 2 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 phd-advisor-frontend/src/components/HybridConfigModal.js diff --git a/phd-advisor-frontend/src/components/HybridConfigModal.js b/phd-advisor-frontend/src/components/HybridConfigModal.js new file mode 100644 index 00000000..90b78a58 --- /dev/null +++ b/phd-advisor-frontend/src/components/HybridConfigModal.js @@ -0,0 +1,157 @@ +import React, { useState, useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { X, Layers } from 'lucide-react'; + +const overlay = { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', + display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, +}; + +const modal = { + background: 'var(--bg-primary)', borderRadius: 16, padding: 24, width: 560, + maxWidth: '95vw', maxHeight: '85vh', overflowY: 'auto', + boxShadow: 'var(--shadow-xl)', color: 'var(--text-primary)', +}; + +const row = { + display: 'grid', gridTemplateColumns: '1fr 180px', alignItems: 'center', + gap: 12, padding: '10px 0', borderBottom: '1px solid var(--border-primary)', +}; + +const select = { + padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', + background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13.5, + width: '100%', +}; + +const HybridConfigModal = ({ + advisors, + availableBackends, + initialConfig, + isSaving, + onSubmit, + onClose, +}) => { + const personaIds = useMemo(() => Object.keys(advisors || {}), [advisors]); + + const [defaultBackend, setDefaultBackend] = useState( + initialConfig?.default_backend || availableBackends[0] || 'gemini' + ); + const [orchestratorBackend, setOrchestratorBackend] = useState( + initialConfig?.orchestrator_backend || initialConfig?.default_backend || availableBackends[0] || 'gemini' + ); + const [personaBackends, setPersonaBackends] = useState(() => { + const seed = initialConfig?.persona_backends || {}; + const out = {}; + for (const id of personaIds) { + out[id] = seed[id] || initialConfig?.default_backend || availableBackends[0] || 'gemini'; + } + return out; + }); + + const setPersona = (id, value) => { + setPersonaBackends(prev => ({ ...prev, [id]: value })); + }; + + const handleSave = () => { + onSubmit({ + default_backend: defaultBackend, + orchestrator_backend: orchestratorBackend, + persona_backends: personaBackends, + }); + }; + + return ReactDOM.createPortal( +
e.target === e.currentTarget && onClose()}> +
+
+
+ +

Hybrid LLM Configuration

+
+ +
+ +

+ Pick a backend for the orchestrator and each advisor. The default backend is used as a fallback. +

+ +
+
+
Default backend
+
Used when no specific override is set.
+
+ +
+ +
+
+
Orchestrator
+
Routes user input across advisors.
+
+ +
+ + {personaIds.map((id) => { + const advisor = advisors[id]; + const locked = advisor?.backendLocked; + const value = locked && advisor?.defaultBackend ? advisor.defaultBackend : personaBackends[id]; + return ( +
+
+
{advisor?.name || id}
+
+ {advisor?.role || id}{locked ? ' · backend locked' : ''} +
+
+ +
+ ); + })} + +
+ + +
+
+
, + document.body + ); +}; + +export default HybridConfigModal; diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 053811f8..956822d2 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -8,6 +8,7 @@ import ThinkingIndicator from '../components/ThinkingIndicator'; import SuggestionsPanel from '../components/SuggestionsPanel'; import ThemeToggle from '../components/ThemeToggle'; import ProviderDropdown from '../components/ProviderDropdown'; +import HybridConfigModal from '../components/HybridConfigModal'; import ExportButton from '../components/ExportButton'; import Sidebar from '../components/Sidebar'; import { useAppConfig } from '../contexts/AppConfigContext'; @@ -25,8 +26,17 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig const [thinkingAdvisors, setThinkingAdvisors] = useState([]); const [collectedInfo, setCollectedInfo] = useState({}); const [replyingTo, setReplyingTo] = useState(null); - const [currentProvider, setCurrentProvider] = useState('gemini'); + const [llmConfig, setLlmConfig] = useState({ + mode: 'uniform', + default_backend: 'gemini', + orchestrator_backend: null, + persona_backends: null, + }); + const [availableBackends, setAvailableBackends] = useState(['gemini', 'ollama', 'vllm']); const [isProviderSwitching, setIsProviderSwitching] = useState(false); + const [isHybridModalOpen, setIsHybridModalOpen] = useState(false); + + const currentProvider = llmConfig.mode === 'hybrid' ? 'hybrid' : llmConfig.default_backend; const [uploadedDocuments, setUploadedDocuments] = useState([]); const messagesEndRef = useRef(null); const { isDark } = useTheme(); @@ -53,16 +63,18 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig }, [messages, thinkingAdvisors]); useEffect(() => { - fetchCurrentProvider(); - }, []); + if (authToken) fetchCurrentProvider(); + }, [authToken]); const fetchCurrentProvider = async () => { try { - const response = await fetch(`${process.env.REACT_APP_API_URL}/current-provider`); + const response = await fetch(`${process.env.REACT_APP_API_URL}/current-provider`, { + headers: { 'Authorization': `Bearer ${authToken}` }, + }); if (response.ok) { const data = await response.json(); - setCurrentProvider(data.current_provider); - console.log('Loaded provider:', data.current_provider, 'Available:', data.available_providers); + if (data.llm_config) setLlmConfig(data.llm_config); + if (Array.isArray(data.available_backends)) setAvailableBackends(data.available_backends); } } catch (error) { console.error('Error fetching current provider:', error); @@ -71,57 +83,80 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig - const handleProviderSwitch = async (newProvider) => { - if (newProvider === currentProvider || isProviderSwitching) return; - + const submitProviderConfig = async (payload, label) => { setIsProviderSwitching(true); try { const response = await fetch(`${process.env.REACT_APP_API_URL}/switch-provider`, { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, }, - body: JSON.stringify({ - provider: newProvider - }), + body: JSON.stringify(payload), }); if (response.ok) { const data = await response.json(); - setCurrentProvider(newProvider); - - const switchMessage = { + if (data.llm_config) { + setLlmConfig(data.llm_config); + } else { + setLlmConfig(payload); + } + + setMessages(prev => [...prev, { id: generateMessageId(), type: 'system', - content: `✨ Switched to ${newProvider.charAt(0).toUpperCase() + newProvider.slice(1)} provider. Your advisors are now ready with the new AI model.`, + content: `✨ Switched to ${label}. Your advisors are now ready with the new configuration.`, timestamp: new Date() - }; - setMessages(prev => [...prev, switchMessage]); - } else { - const error = await response.json(); - console.error('Failed to switch provider:', error); - const errorMessage = { - id: generateMessageId(), - type: 'error', - content: `Failed to switch to ${newProvider}: ${error.detail || 'Unknown error'}`, - timestamp: new Date() - }; - setMessages(prev => [...prev, errorMessage]); + }]); + return true; } + + const error = await response.json().catch(() => ({})); + console.error('Failed to switch provider:', error); + setMessages(prev => [...prev, { + id: generateMessageId(), + type: 'error', + content: `Failed to switch to ${label}: ${error.detail || 'Unknown error'}`, + timestamp: new Date() + }]); + return false; } catch (error) { console.error('Error switching provider:', error); - const errorMessage = { + setMessages(prev => [...prev, { id: generateMessageId(), type: 'error', - content: `Error switching to ${newProvider}. Please try again.`, + content: `Error switching to ${label}. Please try again.`, timestamp: new Date() - }; - setMessages(prev => [...prev, errorMessage]); + }]); + return false; } finally { setIsProviderSwitching(false); } }; + const handleProviderSwitch = async (newProvider) => { + if (isProviderSwitching) return; + if (newProvider === 'hybrid') { + setIsHybridModalOpen(true); + return; + } + if (llmConfig.mode === 'uniform' && newProvider === llmConfig.default_backend) return; + + await submitProviderConfig( + { mode: 'uniform', default_backend: newProvider }, + newProvider.charAt(0).toUpperCase() + newProvider.slice(1) + ); + }; + + const handleHybridSubmit = async (hybridConfig) => { + const ok = await submitProviderConfig( + { mode: 'hybrid', ...hybridConfig }, + 'Hybrid configuration' + ); + if (ok) setIsHybridModalOpen(false); + }; + const generateMessageId = () => { return Date.now().toString() + Math.random().toString(36).substr(2, 9); }; @@ -517,6 +552,7 @@ const handleNewChat = async (sessionId = null) => { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: inputMessage, @@ -594,6 +630,7 @@ const handleNewChat = async (sessionId = null) => { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, }, body: JSON.stringify({ user_input: expandPrompt, @@ -786,6 +823,7 @@ const handleNewChat = async (sessionId = null) => { currentProvider={currentProvider} onProviderChange={handleProviderSwitch} isLoading={isProviderSwitching} + onConfigureHybrid={() => setIsHybridModalOpen(true)} /> {/* Theme Toggle */} @@ -960,6 +998,17 @@ const handleNewChat = async (sessionId = null) => { + + {isHybridModalOpen && ( + setIsHybridModalOpen(false)} + /> + )} ); From ee2e56298aea175bb5caec987f45ed6c59f86073 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 5 May 2026 17:47:39 -0700 Subject: [PATCH 04/18] added filter for avaliable backends with health status check set to ping every 300 seconds. --- .../app/api/routes/provider.py | 6 ++-- multi_llm_chatbot_backend/app/config.py | 1 + .../app/core/bootstrap.py | 36 +++++++++++++++++++ .../app/llm/improved_ollama_client.py | 10 +++++- .../app/llm/improved_vllm_client.py | 10 ++++++ .../app/llm/llm_client.py | 9 +++++ multi_llm_chatbot_backend/app/main.py | 5 +++ 7 files changed, 73 insertions(+), 4 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/provider.py b/multi_llm_chatbot_backend/app/api/routes/provider.py index e71f8a82..4aa9e9e2 100644 --- a/multi_llm_chatbot_backend/app/api/routes/provider.py +++ b/multi_llm_chatbot_backend/app/api/routes/provider.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException, status from app.core.auth import get_current_active_user -from app.core.bootstrap import chat_orchestrator, get_llm_client +from app.core.bootstrap import chat_orchestrator, get_llm_client, AVAILABLE_BACKENDS from app.core.database import get_database -from app.models.user import User, UserLLMConfig, LLM_BACKENDS +from app.models.user import User, UserLLMConfig import logging logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ async def get_current_provider( config = current_user.llm_config or UserLLMConfig() return { "llm_config": config.model_dump(), - "available_backends": list(LLM_BACKENDS), + "available_backends": AVAILABLE_BACKENDS, } diff --git a/multi_llm_chatbot_backend/app/config.py b/multi_llm_chatbot_backend/app/config.py index 753a1048..045d3320 100644 --- a/multi_llm_chatbot_backend/app/config.py +++ b/multi_llm_chatbot_backend/app/config.py @@ -294,6 +294,7 @@ class LLMConfig(BaseModel): ollama: OllamaConfig = OllamaConfig() vllm: VllmConfig = VllmConfig() brainforge: BrainForgeConfig = BrainForgeConfig() + health_check_interval: int = 300 class RAGConfig(BaseModel): diff --git a/multi_llm_chatbot_backend/app/core/bootstrap.py b/multi_llm_chatbot_backend/app/core/bootstrap.py index f46e4d0b..1a615a0e 100644 --- a/multi_llm_chatbot_backend/app/core/bootstrap.py +++ b/multi_llm_chatbot_backend/app/core/bootstrap.py @@ -1,4 +1,6 @@ # app/core/bootstrap.py +import asyncio + from app.config import get_settings from app.llm.improved_gemini_client import ImprovedGeminiClient from app.llm.improved_ollama_client import ImprovedOllamaClient @@ -45,8 +47,42 @@ def get_llm_client(backend: str) -> LLMClient: return _client_cache[backend] +def get_available_backends() -> list: + """Return backends that are properly configured (sync, used at startup).""" + available = [] + for backend in LLM_BACKENDS: + try: + get_llm_client(backend) + available.append(backend) + except Exception: + pass + return available + + +async def refresh_available_backends(): + """Re-check which backends are configured and reachable.""" + available = [] + for backend in LLM_BACKENDS: + try: + client = get_llm_client(backend) + if await client.health_check(): + available.append(backend) + except Exception: + pass + AVAILABLE_BACKENDS[:] = available + + +async def _backend_health_loop(): + """Background task that periodically refreshes AVAILABLE_BACKENDS.""" + interval = settings.llm.health_check_interval + while True: + await refresh_available_backends() + await asyncio.sleep(interval) + + llm = create_llm_client() _client_cache[DEFAULT_BACKEND] = llm +AVAILABLE_BACKENDS = get_available_backends() chat_orchestrator = ImprovedChatOrchestrator(llm_client=llm) DEFAULT_PERSONAS = get_default_personas(llm) diff --git a/multi_llm_chatbot_backend/app/llm/improved_ollama_client.py b/multi_llm_chatbot_backend/app/llm/improved_ollama_client.py index 63074523..ccf0d322 100644 --- a/multi_llm_chatbot_backend/app/llm/improved_ollama_client.py +++ b/multi_llm_chatbot_backend/app/llm/improved_ollama_client.py @@ -110,4 +110,12 @@ def _is_poor_quality(self, response: str) -> bool: len(response.split()) > 150, # Too verbose response.count("?") > 3, # Too many questions ] - return any(poor_indicators) \ No newline at end of file + return any(poor_indicators) + + async def health_check(self) -> bool: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{self.base_url}/api/tags") + return resp.is_success + except Exception: + return False \ No newline at end of file diff --git a/multi_llm_chatbot_backend/app/llm/improved_vllm_client.py b/multi_llm_chatbot_backend/app/llm/improved_vllm_client.py index de9a927b..5b568358 100644 --- a/multi_llm_chatbot_backend/app/llm/improved_vllm_client.py +++ b/multi_llm_chatbot_backend/app/llm/improved_vllm_client.py @@ -187,4 +187,14 @@ async def generate_with_tools( used_tool=False, ) + async def health_check(self) -> bool: + import httpx + try: + headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{self.api_url}/v1/models", headers=headers) + return resp.is_success + except Exception: + return False + diff --git a/multi_llm_chatbot_backend/app/llm/llm_client.py b/multi_llm_chatbot_backend/app/llm/llm_client.py index 995500da..74ee8f86 100644 --- a/multi_llm_chatbot_backend/app/llm/llm_client.py +++ b/multi_llm_chatbot_backend/app/llm/llm_client.py @@ -67,6 +67,15 @@ async def generate_with_tools( ) return ToolCallResult(text=text, used_tool=False) + async def health_check(self) -> bool: + """Check if the backend service is reachable. + + Subclasses for self-hosted services should override this with a + lightweight network probe. The default returns True (suitable for + cloud APIs where the config check is sufficient). + """ + return True + def _clean_response(self, response: str) -> str: """Clean up response text, preserving Markdown formatting.""" response = response.replace("\r\n", "\n").replace("\r", "\n") diff --git a/multi_llm_chatbot_backend/app/main.py b/multi_llm_chatbot_backend/app/main.py index 677df8f2..b63e72c0 100644 --- a/multi_llm_chatbot_backend/app/main.py +++ b/multi_llm_chatbot_backend/app/main.py @@ -11,6 +11,8 @@ from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager +import asyncio + # Load configuration FIRST so every module can use it from app.config import load_settings from app.version import __version__ @@ -18,6 +20,7 @@ # Import the new database functions from app.core.database import connect_to_mongo, close_mongo_connection +from app.core.bootstrap import _backend_health_loop # Import all route modules from app.api.routes import router as main_router @@ -41,9 +44,11 @@ async def lifespan(app: FastAPI): from app.core.brainforge_sync import async_sync_brainforge_personas, periodic_sync_loop await async_sync_brainforge_personas(chat_orchestrator) sync_task = asyncio.create_task(periodic_sync_loop(chat_orchestrator)) + health_task = asyncio.create_task(_backend_health_loop()) yield # Shutdown sync_task.cancel() + health_task.cancel() await close_mongo_connection() app = FastAPI( From 221950c4e96e9c17980ed0b84f9b65087c8c06e5 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 5 May 2026 18:01:29 -0700 Subject: [PATCH 05/18] added unit tests for the available backends filter and health check. --- .../app/tests/unit/test_available_backends.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py new file mode 100644 index 00000000..dfdebd4b --- /dev/null +++ b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py @@ -0,0 +1,105 @@ +import asyncio +import os +import sys +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +# Set env vars before any app modules are imported so that config +# validators don't raise during the bootstrap import chain. +os.environ.setdefault("GEMINI_API_KEY", "fake-test-key") +os.environ.setdefault("CONFIG_PATH", "") + +# Stub heavy modules that bootstrap imports which would fail or be slow +# in a unit-test environment (no real LLM connections, no NLTK, etc.) +for _name in ("app.core.rag_manager",): + sys.modules.setdefault(_name, MagicMock()) + +# Patch the Gemini client constructor so creating a client doesn't fail +with patch("app.llm.improved_gemini_client.ImprovedGeminiClient.__init__", lambda self, **kw: None): + from app.core.bootstrap import ( # noqa: E402 + get_available_backends, + refresh_available_backends, + AVAILABLE_BACKENDS, + LLM_BACKENDS, + ) + + +class TestGetAvailableBackends(unittest.TestCase): + """Sync config-only check used at startup.""" + + @patch("app.core.bootstrap.get_llm_client") + def test_all_configured(self, mock_get_client): + mock_get_client.return_value = MagicMock() + result = get_available_backends() + self.assertEqual(result, ["gemini", "ollama", "vllm"]) + + @patch("app.core.bootstrap.get_llm_client") + def test_vllm_not_configured(self, mock_get_client): + def side_effect(backend): + if backend == "vllm": + raise ValueError("No vLLM endpoint configured.") + return MagicMock() + mock_get_client.side_effect = side_effect + result = get_available_backends() + self.assertEqual(result, ["gemini", "ollama"]) + + @patch("app.core.bootstrap.get_llm_client") + def test_none_configured(self, mock_get_client): + mock_get_client.side_effect = ValueError("not configured") + result = get_available_backends() + self.assertEqual(result, []) + + +class TestRefreshAvailableBackends(unittest.TestCase): + """Async health-check refresh.""" + + @patch("app.core.bootstrap.get_llm_client") + def test_all_healthy(self, mock_get_client): + mock_client = MagicMock() + mock_client.health_check = AsyncMock(return_value=True) + mock_get_client.return_value = mock_client + + asyncio.run(refresh_available_backends()) + self.assertEqual(list(AVAILABLE_BACKENDS), ["gemini", "ollama", "vllm"]) + + @patch("app.core.bootstrap.get_llm_client") + def test_vllm_unhealthy(self, mock_get_client): + def side_effect(backend): + client = MagicMock() + client.health_check = AsyncMock(return_value=(backend != "vllm")) + return client + mock_get_client.side_effect = side_effect + + asyncio.run(refresh_available_backends()) + self.assertEqual(list(AVAILABLE_BACKENDS), ["gemini", "ollama"]) + + @patch("app.core.bootstrap.get_llm_client") + def test_health_check_exception(self, mock_get_client): + def side_effect(backend): + client = MagicMock() + if backend == "ollama": + client.health_check = AsyncMock(side_effect=Exception("timeout")) + else: + client.health_check = AsyncMock(return_value=True) + return client + mock_get_client.side_effect = side_effect + + asyncio.run(refresh_available_backends()) + self.assertEqual(list(AVAILABLE_BACKENDS), ["gemini", "vllm"]) + + @patch("app.core.bootstrap.get_llm_client") + def test_unconfigured_backend_excluded(self, mock_get_client): + def side_effect(backend): + if backend == "vllm": + raise ValueError("No vLLM endpoint configured.") + client = MagicMock() + client.health_check = AsyncMock(return_value=True) + return client + mock_get_client.side_effect = side_effect + + asyncio.run(refresh_available_backends()) + self.assertEqual(list(AVAILABLE_BACKENDS), ["gemini", "ollama"]) + + +if __name__ == "__main__": + unittest.main() From b5a1c6e9be75a5441ed349083ffe11d624dfeb4b Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Wed, 6 May 2026 10:25:52 -0600 Subject: [PATCH 06/18] Pending changes to build backend the menus will be moved to the welcome screens and settigns pages once merges are completed. --- .../src/components/AdvisorConfigPanel.js | 160 ++++++++++++ .../src/components/HybridConfigModal.js | 108 ++------ .../src/components/SettingsModal.js | 230 ++++++++---------- .../src/components/Sidebar.js | 8 +- .../src/components/WelcomeModelPicker.js | 162 ++++++++++++ phd-advisor-frontend/src/pages/ChatPage.js | 43 ++-- 6 files changed, 467 insertions(+), 244 deletions(-) create mode 100644 phd-advisor-frontend/src/components/AdvisorConfigPanel.js create mode 100644 phd-advisor-frontend/src/components/WelcomeModelPicker.js diff --git a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js new file mode 100644 index 00000000..296dd637 --- /dev/null +++ b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js @@ -0,0 +1,160 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +// Reusable per-advisor backend configuration panel. +// +// Used in two places: +// - Welcome-state "Advanced" expander on ChatPage +// - "Advisor Config" tab inside SettingsModal (lives on feat/UI-for-User-Account-updates; +// drop this component in once branches merge) +// +// Controlled component. Parent owns the config object and decides when to persist. +// +// Shape of `value`: +// { default_backend, orchestrator_backend, persona_backends: { [personaId]: backend } } + +const rowStyle = { + display: 'grid', gridTemplateColumns: '1fr 180px', alignItems: 'center', + gap: 12, padding: '10px 0', borderBottom: '1px solid var(--border-primary)', +}; + +const selectStyle = { + padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', + background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13.5, + width: '100%', +}; + +const buildInitial = (initialConfig, personaIds, availableBackends) => { + const fallback = initialConfig?.default_backend || availableBackends[0] || 'gemini'; + const seed = initialConfig?.persona_backends || {}; + const personas = {}; + for (const id of personaIds) { + personas[id] = seed[id] || fallback; + } + return { + default_backend: fallback, + orchestrator_backend: initialConfig?.orchestrator_backend || fallback, + persona_backends: personas, + }; +}; + +const AdvisorConfigPanel = ({ + advisors, + availableBackends, + value, + initialConfig, + onChange, + hideDefault = false, + hideOrchestrator = false, + description, +}) => { + const personaIds = useMemo(() => Object.keys(advisors || {}), [advisors]); + const isControlled = value !== undefined; + + const [internal, setInternal] = useState(() => + buildInitial(initialConfig, personaIds, availableBackends) + ); + + useEffect(() => { + if (isControlled) return; + setInternal(prev => { + const next = { ...prev.persona_backends }; + let changed = false; + const fallback = prev.default_backend || availableBackends[0] || 'gemini'; + for (const id of personaIds) { + if (next[id] === undefined) { + next[id] = fallback; + changed = true; + } + } + return changed ? { ...prev, persona_backends: next } : prev; + }); + }, [personaIds, availableBackends, isControlled]); + + const config = isControlled ? value : internal; + + const update = (next) => { + if (!isControlled) setInternal(next); + if (onChange) onChange(next); + }; + + const setDefault = (val) => update({ ...config, default_backend: val }); + const setOrchestrator = (val) => update({ ...config, orchestrator_backend: val }); + const setPersona = (id, val) => update({ + ...config, + persona_backends: { ...config.persona_backends, [id]: val }, + }); + + return ( +
+ {description && ( +

+ {description} +

+ )} + + {!hideDefault && ( +
+
+
Default backend
+
+ Used when no specific override is set. +
+
+ +
+ )} + + {!hideOrchestrator && ( +
+
+
Orchestrator
+
+ Routes user input across advisors. +
+
+ +
+ )} + + {personaIds.map((id) => { + const advisor = advisors[id]; + const locked = advisor?.backendLocked; + const personaValue = locked && advisor?.defaultBackend + ? advisor.defaultBackend + : (config.persona_backends?.[id] || config.default_backend); + return ( +
+
+
{advisor?.name || id}
+
+ {advisor?.role || id}{locked ? ' · backend locked' : ''} +
+
+ +
+ ); + })} +
+ ); +}; + +export default AdvisorConfigPanel; diff --git a/phd-advisor-frontend/src/components/HybridConfigModal.js b/phd-advisor-frontend/src/components/HybridConfigModal.js index 90b78a58..31fcef49 100644 --- a/phd-advisor-frontend/src/components/HybridConfigModal.js +++ b/phd-advisor-frontend/src/components/HybridConfigModal.js @@ -1,6 +1,7 @@ -import React, { useState, useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import ReactDOM from 'react-dom'; import { X, Layers } from 'lucide-react'; +import AdvisorConfigPanel from './AdvisorConfigPanel'; const overlay = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', @@ -13,15 +14,18 @@ const modal = { boxShadow: 'var(--shadow-xl)', color: 'var(--text-primary)', }; -const row = { - display: 'grid', gridTemplateColumns: '1fr 180px', alignItems: 'center', - gap: 12, padding: '10px 0', borderBottom: '1px solid var(--border-primary)', -}; - -const select = { - padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', - background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13.5, - width: '100%', +const seedConfig = (initialConfig, personaIds, availableBackends) => { + const fallback = initialConfig?.default_backend || availableBackends[0] || 'gemini'; + const seedPersonas = initialConfig?.persona_backends || {}; + const personas = {}; + for (const id of personaIds) { + personas[id] = seedPersonas[id] || fallback; + } + return { + default_backend: fallback, + orchestrator_backend: initialConfig?.orchestrator_backend || fallback, + persona_backends: personas, + }; }; const HybridConfigModal = ({ @@ -33,33 +37,7 @@ const HybridConfigModal = ({ onClose, }) => { const personaIds = useMemo(() => Object.keys(advisors || {}), [advisors]); - - const [defaultBackend, setDefaultBackend] = useState( - initialConfig?.default_backend || availableBackends[0] || 'gemini' - ); - const [orchestratorBackend, setOrchestratorBackend] = useState( - initialConfig?.orchestrator_backend || initialConfig?.default_backend || availableBackends[0] || 'gemini' - ); - const [personaBackends, setPersonaBackends] = useState(() => { - const seed = initialConfig?.persona_backends || {}; - const out = {}; - for (const id of personaIds) { - out[id] = seed[id] || initialConfig?.default_backend || availableBackends[0] || 'gemini'; - } - return out; - }); - - const setPersona = (id, value) => { - setPersonaBackends(prev => ({ ...prev, [id]: value })); - }; - - const handleSave = () => { - onSubmit({ - default_backend: defaultBackend, - orchestrator_backend: orchestratorBackend, - persona_backends: personaBackends, - }); - }; + const [config, setConfig] = useState(() => seedConfig(initialConfig, personaIds, availableBackends)); return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}> @@ -77,53 +55,13 @@ const HybridConfigModal = ({
-

- Pick a backend for the orchestrator and each advisor. The default backend is used as a fallback. -

- -
-
-
Default backend
-
Used when no specific override is set.
-
- -
- -
-
-
Orchestrator
-
Routes user input across advisors.
-
- -
- - {personaIds.map((id) => { - const advisor = advisors[id]; - const locked = advisor?.backendLocked; - const value = locked && advisor?.defaultBackend ? advisor.defaultBackend : personaBackends[id]; - return ( -
-
-
{advisor?.name || id}
-
- {advisor?.role || id}{locked ? ' · backend locked' : ''} -
-
- -
- ); - })} +
+<<<<<<< HEAD @@ -304,18 +234,47 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) =>
- {message &&
{message.text}
} - - {activeTab === 'profile' && ( -
-
- - + {activeTab === 'advisors' && ( + <> + +
+ +
+<<<<<<< HEAD
@@ -447,6 +406,9 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => {isSubmitting ? 'Deleting…' : 'Permanently Delete Account'} +======= + +>>>>>>> 8b7dfb4 (Pending changes to build backend the menus will be moved to the welcome screens and settigns pages once merges are completed.) )}
diff --git a/phd-advisor-frontend/src/components/Sidebar.js b/phd-advisor-frontend/src/components/Sidebar.js index b3e82fa6..0c8a2c4c 100644 --- a/phd-advisor-frontend/src/components/Sidebar.js +++ b/phd-advisor-frontend/src/components/Sidebar.js @@ -32,7 +32,8 @@ const Sidebar = ({ onMobileToggle, onNavigateToCanvas, refreshTrigger, - onCurrentSessionDeleted + onCurrentSessionDeleted, + onOpenSettings, }) => { const { config } = useAppConfig(); const canvasLabel = config?.app?.title ? `${config.app.title} Canvas` : 'Canvas'; @@ -245,7 +246,10 @@ const Sidebar = ({
+ ); + })} +
+ + + + {advancedOpen && ( +
+ +
+ +
+
+ )} +
+ ); +}; + +export default WelcomeModelPicker; diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 956822d2..0a13b6e0 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -7,8 +7,8 @@ import MessageBubble from '../components/MessageBubble'; import ThinkingIndicator from '../components/ThinkingIndicator'; import SuggestionsPanel from '../components/SuggestionsPanel'; import ThemeToggle from '../components/ThemeToggle'; -import ProviderDropdown from '../components/ProviderDropdown'; -import HybridConfigModal from '../components/HybridConfigModal'; +import WelcomeModelPicker from '../components/WelcomeModelPicker'; +import SettingsModal from '../components/SettingsModal'; import ExportButton from '../components/ExportButton'; import Sidebar from '../components/Sidebar'; import { useAppConfig } from '../contexts/AppConfigContext'; @@ -34,9 +34,7 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig }); const [availableBackends, setAvailableBackends] = useState(['gemini', 'ollama', 'vllm']); const [isProviderSwitching, setIsProviderSwitching] = useState(false); - const [isHybridModalOpen, setIsHybridModalOpen] = useState(false); - - const currentProvider = llmConfig.mode === 'hybrid' ? 'hybrid' : llmConfig.default_backend; + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [uploadedDocuments, setUploadedDocuments] = useState([]); const messagesEndRef = useRef(null); const { isDark } = useTheme(); @@ -137,10 +135,6 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig const handleProviderSwitch = async (newProvider) => { if (isProviderSwitching) return; - if (newProvider === 'hybrid') { - setIsHybridModalOpen(true); - return; - } if (llmConfig.mode === 'uniform' && newProvider === llmConfig.default_backend) return; await submitProviderConfig( @@ -154,7 +148,8 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig { mode: 'hybrid', ...hybridConfig }, 'Hybrid configuration' ); - if (ok) setIsHybridModalOpen(false); + if (ok) setIsSettingsOpen(false); + return ok; }; const generateMessageId = () => { @@ -768,6 +763,7 @@ const handleNewChat = async (sessionId = null) => { onMobileToggle={setIsMobileMenuOpen} onNavigateToCanvas={onNavigateToCanvas} refreshTrigger={sidebarRefreshTrigger} + onOpenSettings={() => setIsSettingsOpen(true)} />
@@ -818,14 +814,6 @@ const handleNewChat = async (sessionId = null) => { authToken={authToken} /> - {/* Provider Dropdown */} - setIsHybridModalOpen(true)} - /> - {/* Theme Toggle */} @@ -845,6 +833,14 @@ const handleNewChat = async (sessionId = null) => {
{!hasMessages ? (
+
@@ -999,14 +995,15 @@ const handleNewChat = async (sessionId = null) => {
- {isHybridModalOpen && ( - setIsHybridModalOpen(false)} + onSubmitConfig={handleHybridSubmit} + onClose={() => setIsSettingsOpen(false)} /> )}
From 79c091134aedeb2bb148dde6076b56ed7cfe0b29 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 6 May 2026 10:26:19 -0700 Subject: [PATCH 07/18] fixed bootstrap.py import causing test_available_backends failure. --- .../app/tests/unit/test_available_backends.py | 1 + 1 file changed, 1 insertion(+) diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py index dfdebd4b..bc14ea61 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py @@ -13,6 +13,7 @@ # in a unit-test environment (no real LLM connections, no NLTK, etc.) for _name in ("app.core.rag_manager",): sys.modules.setdefault(_name, MagicMock()) +sys.modules.pop("app.core.bootstrap", None) # Patch the Gemini client constructor so creating a client doesn't fail with patch("app.llm.improved_gemini_client.ImprovedGeminiClient.__init__", lambda self, **kw: None): From d3d8612f69bb2bc38543a4c7a0c0a2f23a2bb149 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Wed, 6 May 2026 12:05:52 -0700 Subject: [PATCH 08/18] added conftest.py to simplify mock module imports and unit tests for LLM provider config. --- .../app/tests/unit/conftest.py | 3 + .../app/tests/unit/test_available_backends.py | 27 +- .../tests/unit/test_llm_provider_config.py | 364 ++++++++++++++++++ 3 files changed, 373 insertions(+), 21 deletions(-) create mode 100644 multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py diff --git a/multi_llm_chatbot_backend/app/tests/unit/conftest.py b/multi_llm_chatbot_backend/app/tests/unit/conftest.py index 1f539bd3..8192a261 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/conftest.py +++ b/multi_llm_chatbot_backend/app/tests/unit/conftest.py @@ -17,11 +17,14 @@ coordinate cleanup with peer test modules. """ +import os import sys from unittest.mock import MagicMock from fastapi import APIRouter +os.environ.setdefault("GEMINI_API_KEY", "fake-test-key") +os.environ.setdefault("CONFIG_PATH", "") for _name in ("app.core.bootstrap", "app.core.rag_manager"): sys.modules.setdefault(_name, MagicMock()) diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py index bc14ea61..fc4c2d45 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py @@ -1,28 +1,13 @@ import asyncio -import os -import sys import unittest from unittest.mock import AsyncMock, MagicMock, patch -# Set env vars before any app modules are imported so that config -# validators don't raise during the bootstrap import chain. -os.environ.setdefault("GEMINI_API_KEY", "fake-test-key") -os.environ.setdefault("CONFIG_PATH", "") - -# Stub heavy modules that bootstrap imports which would fail or be slow -# in a unit-test environment (no real LLM connections, no NLTK, etc.) -for _name in ("app.core.rag_manager",): - sys.modules.setdefault(_name, MagicMock()) -sys.modules.pop("app.core.bootstrap", None) - -# Patch the Gemini client constructor so creating a client doesn't fail -with patch("app.llm.improved_gemini_client.ImprovedGeminiClient.__init__", lambda self, **kw: None): - from app.core.bootstrap import ( # noqa: E402 - get_available_backends, - refresh_available_backends, - AVAILABLE_BACKENDS, - LLM_BACKENDS, - ) +from app.core.bootstrap import ( + get_available_backends, + refresh_available_backends, + AVAILABLE_BACKENDS, + LLM_BACKENDS, +) class TestGetAvailableBackends(unittest.TestCase): diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py b/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py new file mode 100644 index 00000000..72e00d41 --- /dev/null +++ b/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py @@ -0,0 +1,364 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from bson import ObjectId +from fastapi import HTTPException +from pydantic import ValidationError + +from app.api.routes.chat import resolve_llm_clients +from app.api.routes.provider import switch_provider +from app.models.user import User, UserLLMConfig + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_user(llm_config=None): + return User( + _id=ObjectId(), + firstName="Test", + lastName="User", + email="test@example.com", + hashed_password="fakehash", + llm_config=llm_config, + ) + + +# =================================================================== +# 1. UserLLMConfig — Pydantic model validation +# =================================================================== + +class TestUserLLMConfig(unittest.TestCase): + """Validate the UserLLMConfig Pydantic model and its _validate_hybrid_fields + model validator.""" + + def test_defaults(self): + cfg = UserLLMConfig() + self.assertEqual(cfg.mode, "uniform") + self.assertEqual(cfg.default_backend, "gemini") + self.assertIsNone(cfg.orchestrator_backend) + self.assertIsNone(cfg.persona_backends) + + def test_uniform_strips_hybrid_fields(self): + cfg = UserLLMConfig( + mode="uniform", + default_backend="gemini", + orchestrator_backend="ollama", + persona_backends={"x": "vllm"}, + ) + self.assertIsNone(cfg.orchestrator_backend) + self.assertIsNone(cfg.persona_backends) + + def test_hybrid_requires_at_least_one_override(self): + with self.assertRaises(ValidationError) as ctx: + UserLLMConfig(mode="hybrid", default_backend="gemini") + self.assertIn("hybrid mode requires", str(ctx.exception)) + + def test_hybrid_with_orchestrator_only(self): + cfg = UserLLMConfig( + mode="hybrid", + default_backend="gemini", + orchestrator_backend="ollama", + ) + self.assertEqual(cfg.orchestrator_backend, "ollama") + self.assertIsNone(cfg.persona_backends) + + def test_hybrid_with_persona_backends_only(self): + cfg = UserLLMConfig( + mode="hybrid", + default_backend="gemini", + persona_backends={"advisor_1": "vllm"}, + ) + self.assertIsNone(cfg.orchestrator_backend) + self.assertEqual(cfg.persona_backends, {"advisor_1": "vllm"}) + + def test_hybrid_with_both(self): + cfg = UserLLMConfig( + mode="hybrid", + default_backend="gemini", + orchestrator_backend="ollama", + persona_backends={"advisor_1": "vllm"}, + ) + self.assertEqual(cfg.orchestrator_backend, "ollama") + self.assertEqual(cfg.persona_backends, {"advisor_1": "vllm"}) + + def test_rejects_unknown_backend_name(self): + with self.assertRaises(ValidationError): + UserLLMConfig(default_backend="claude") + + def test_extra_fields_forbidden(self): + with self.assertRaises(ValidationError): + UserLLMConfig(default_backend="gemini", surprise="boom") + + def test_model_dump_roundtrip(self): + original = UserLLMConfig( + mode="hybrid", + default_backend="ollama", + orchestrator_backend="gemini", + persona_backends={"a": "vllm", "b": "gemini"}, + ) + restored = UserLLMConfig(**original.model_dump()) + self.assertEqual(original.model_dump(), restored.model_dump()) + + +# =================================================================== +# 2. resolve_llm_clients — chat routing logic +# =================================================================== + +class TestResolveLlmClients(unittest.TestCase): + """Verify that resolve_llm_clients maps a user's stored config to the + correct LLM client instances.""" + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_no_config_returns_nones(self, mock_get, mock_orch): + result = resolve_llm_clients(_make_user(llm_config=None)) + self.assertIsNone(result["orchestrator"]) + self.assertIsNone(result["personas"]) + mock_get.assert_not_called() + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_uniform_same_client_for_all(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock(), "b": MagicMock(), "c": MagicMock()} + sentinel = MagicMock(name="shared_client") + mock_get.return_value = sentinel + + user = _make_user(UserLLMConfig(mode="uniform", default_backend="ollama")) + result = resolve_llm_clients(user) + + mock_get.assert_called_once_with("ollama") + self.assertIs(result["orchestrator"], sentinel) + for pid in ("a", "b", "c"): + self.assertIs(result["personas"][pid], sentinel) + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_hybrid_orchestrator_override(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock()} + clients = {"gemini": MagicMock(), "ollama": MagicMock()} + mock_get.side_effect = lambda b: clients[b] + + user = _make_user(UserLLMConfig( + mode="hybrid", default_backend="gemini", + orchestrator_backend="ollama", + persona_backends={"a": "gemini"}, + )) + result = resolve_llm_clients(user) + self.assertIs(result["orchestrator"], clients["ollama"]) + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_hybrid_orchestrator_falls_back_to_default(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock()} + sentinel = MagicMock() + mock_get.return_value = sentinel + + user = _make_user(UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"a": "gemini"}, + )) + result = resolve_llm_clients(user) + self.assertIs(result["orchestrator"], sentinel) + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_hybrid_persona_override(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + clients = {"gemini": MagicMock(), "vllm": MagicMock()} + mock_get.side_effect = lambda b: clients[b] + + user = _make_user(UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"a": "vllm", "b": "gemini"}, + )) + result = resolve_llm_clients(user) + self.assertIs(result["personas"]["a"], clients["vllm"]) + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_hybrid_unmapped_persona_uses_default(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock(), "unmapped": MagicMock()} + clients = {"gemini": MagicMock(), "vllm": MagicMock()} + mock_get.side_effect = lambda b: clients[b] + + user = _make_user(UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"a": "vllm"}, + )) + result = resolve_llm_clients(user) + self.assertIs(result["personas"]["unmapped"], clients["gemini"]) + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_hybrid_mixed(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + clients = {"gemini": MagicMock(), "ollama": MagicMock(), "vllm": MagicMock()} + mock_get.side_effect = lambda b: clients[b] + + user = _make_user(UserLLMConfig( + mode="hybrid", default_backend="gemini", + orchestrator_backend="ollama", + persona_backends={"a": "vllm"}, + )) + result = resolve_llm_clients(user) + self.assertIs(result["orchestrator"], clients["ollama"]) + self.assertIs(result["personas"]["a"], clients["vllm"]) + self.assertIs(result["personas"]["b"], clients["gemini"]) + + +# =================================================================== +# 3. switch_provider — endpoint validation +# =================================================================== + +class TestSwitchProvider(unittest.IsolatedAsyncioTestCase): + """Validate the switch_provider endpoint's guard logic.""" + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_rejects_unknown_persona_id(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {"known": MagicMock()} + mock_get.return_value = MagicMock() + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"unknown_id": "gemini"}, + ) + with self.assertRaises(HTTPException) as ctx: + await switch_provider(cfg, _make_user()) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("Unknown persona IDs", ctx.exception.detail) + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_accepts_known_persona_ids(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + mock_get.return_value = MagicMock() + mock_db.return_value.users.update_one = AsyncMock() + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"a": "gemini", "b": "gemini"}, + ) + result = await switch_provider(cfg, _make_user()) + self.assertEqual(result["message"], "LLM configuration updated") + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_rejects_unconfigured_default_backend(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {} + mock_get.side_effect = ValueError("not configured") + + cfg = UserLLMConfig(mode="uniform", default_backend="ollama") + with self.assertRaises(HTTPException) as ctx: + await switch_provider(cfg, _make_user()) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("not configured", ctx.exception.detail) + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_rejects_unconfigured_orchestrator_backend(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {} + + def side_effect(backend): + if backend == "vllm": + raise ValueError("no vLLM endpoint") + return MagicMock() + mock_get.side_effect = side_effect + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + orchestrator_backend="vllm", + ) + with self.assertRaises(HTTPException) as ctx: + await switch_provider(cfg, _make_user()) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("not configured", ctx.exception.detail) + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_rejects_unconfigured_persona_backend(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {"a": MagicMock()} + + def side_effect(backend): + if backend == "vllm": + raise ValueError("no vLLM endpoint") + return MagicMock() + mock_get.side_effect = side_effect + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"a": "vllm"}, + ) + with self.assertRaises(HTTPException) as ctx: + await switch_provider(cfg, _make_user()) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("not configured", ctx.exception.detail) + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_checks_all_distinct_backends(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + mock_get.return_value = MagicMock() + mock_db.return_value.users.update_one = AsyncMock() + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + orchestrator_backend="ollama", + persona_backends={"a": "vllm", "b": "gemini"}, + ) + await switch_provider(cfg, _make_user()) + + checked = {call.args[0] for call in mock_get.call_args_list} + self.assertEqual(checked, {"gemini", "ollama", "vllm"}) + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_persists_to_database(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {} + mock_get.return_value = MagicMock() + mock_collection = MagicMock() + mock_collection.update_one = AsyncMock() + mock_db.return_value.users = mock_collection + + user = _make_user() + cfg = UserLLMConfig(mode="uniform", default_backend="ollama") + await switch_provider(cfg, user) + + mock_collection.update_one.assert_awaited_once() + call_args = mock_collection.update_one.call_args + self.assertEqual(call_args[0][0], {"_id": user.id}) + self.assertEqual( + call_args[0][1], + {"$set": {"llm_config": cfg.model_dump()}}, + ) + + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_returns_updated_config(self, mock_orch, mock_get, mock_db): + mock_orch.personas = {"a": MagicMock()} + mock_get.return_value = MagicMock() + mock_db.return_value.users.update_one = AsyncMock() + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + orchestrator_backend="ollama", + persona_backends={"a": "vllm"}, + ) + result = await switch_provider(cfg, _make_user()) + + self.assertEqual(result["message"], "LLM configuration updated") + self.assertEqual(result["llm_config"], cfg.model_dump()) + + +if __name__ == "__main__": + unittest.main() From 3acdc87ef283690681691f83493b919c1eab3c0a Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 12 May 2026 14:47:48 -0700 Subject: [PATCH 09/18] restored needs_clarification_improved function lost during rebase. --- multi_llm_chatbot_backend/app/api/routes/chat.py | 2 +- multi_llm_chatbot_backend/app/core/improved_orchestrator.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index abc55d50..f01eb1f9 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -146,7 +146,7 @@ async def _event_generator(): type="progress", data={"phase": "received"}, ).to_ndjson() - if chat_orchestrator.needs_clarification(session, message.user_input): + if await chat_orchestrator.needs_clarification_improved(session, message.user_input): clar = await chat_orchestrator.generate_contextual_clarification( message.user_input, llm_client=orchestrator_llm, ) diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index dd391ff1..774c8687 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -327,7 +327,6 @@ async def needs_clarification_improved(self, session: ConversationContext, user_ logger.warning("Falling back to rule-based clarification check") return self.needs_clarification(session, user_input) - async def generate_contextual_clarification(self, user_input: str, llm_client: LLMClient = None) -> Dict[str, Any]: """ From 104c471ca2e9c1cb8453bf85582579ca8b7bf608 Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Thu, 21 May 2026 15:41:41 -0600 Subject: [PATCH 10/18] Enhance SettingsModal with user profile and account management features - Added functionality for updating user profile information (first name, last name). - Implemented password change and account deletion processes with confirmation. - Improved modal behavior to prevent accidental closure during text selection. - Updated ChatPage to integrate new SettingsModal features, including user update and sign-out callbacks. --- .../src/components/AvatarPickerModal 2.js | 75 +++++ .../src/components/SettingsModal.js | 310 ++++++++++++------ phd-advisor-frontend/src/pages/ChatPage.js | 22 +- 3 files changed, 282 insertions(+), 125 deletions(-) create mode 100644 phd-advisor-frontend/src/components/AvatarPickerModal 2.js diff --git a/phd-advisor-frontend/src/components/AvatarPickerModal 2.js b/phd-advisor-frontend/src/components/AvatarPickerModal 2.js new file mode 100644 index 00000000..a7ac07a4 --- /dev/null +++ b/phd-advisor-frontend/src/components/AvatarPickerModal 2.js @@ -0,0 +1,75 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { X } from 'lucide-react'; +import { useAppConfig } from '../contexts/AppConfigContext'; + +const API = process.env.REACT_APP_API_URL || ''; + +const BUNDLED = [ + 'advisor1.png','advisor2.png','advisor3.png','advisor4.png', + 'advisor5.png','advisor6.png','advisor7.png', +]; + +const overlay = { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', + display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, +}; + +const modal = { + background: 'var(--bg-primary)', borderRadius: 16, padding: 24, width: 480, + maxWidth: '95vw', maxHeight: '85vh', overflowY: 'auto', + boxShadow: 'var(--shadow-xl)', +}; + +const AvatarPickerModal = ({ advisorId, advisorName, onClose }) => { + const { setAdvisorAvatar } = useAppConfig(); + + const select = (url) => { + setAdvisorAvatar(advisorId, url || ''); + onClose(); + }; + + return ReactDOM.createPortal( +
e.target === e.currentTarget && onClose()} onMouseDown={(e) => e.stopPropagation()}> +
+
+

+ Choose Avatar — {advisorName} +

+ +
+ +

Pre-made Avatars

+
+ {BUNDLED.map((file) => ( + {file} select(`${API}/api/avatars/bundled/${file}`)} + style={{ width: '100%', aspectRatio: '1', borderRadius: '50%', objectFit: 'cover', cursor: 'pointer', border: '2px solid transparent', transition: 'border-color 0.15s' }} + onMouseEnter={e => e.target.style.borderColor = 'var(--accent-primary)'} + onMouseLeave={e => e.target.style.borderColor = 'transparent'} + /> + ))} +
+ + +
+
, + document.body + ); +}; + +export default AvatarPickerModal; diff --git a/phd-advisor-frontend/src/components/SettingsModal.js b/phd-advisor-frontend/src/components/SettingsModal.js index 9023ad0d..5324ab80 100644 --- a/phd-advisor-frontend/src/components/SettingsModal.js +++ b/phd-advisor-frontend/src/components/SettingsModal.js @@ -1,28 +1,10 @@ -// TODO(@frontend-dev): This file has unresolved merge conflicts. -// HEAD = disable-specific-personas + brainforge + message-persistence features -// INCOMING = hybrid model selection feature (AdvisorConfigPanel, LLM config draft state) -// Both sides need to be merged: keep HEAD's full multi-tab modal (Profile, Password, -// Advisors toggles, Delete Account) and add incoming's AdvisorConfigPanel + LLM config -// into the Advisors tab. - -<<<<<<< HEAD -import React, { useState, useRef, useEffect } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { X, User as UserIcon, Lock, Trash2, AlertTriangle, Users } from 'lucide-react'; +import { X, User as UserIcon, Lock, Trash2, AlertTriangle, Users, Layers } from 'lucide-react'; import Toggle from './Toggle'; import { useAppConfig } from '../contexts/AppConfigContext'; -======= -import React, { useMemo, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { X, Layers } from 'lucide-react'; import AdvisorConfigPanel from './AdvisorConfigPanel'; -// Settings modal. On feat/UI-for-User-Account-updates this file also has -// Profile / Password / Delete Account tabs. When that branch merges, fold -// those tabs into the tabRow + body sections below — the "advisors" tab -// shipped on this branch is the only one not present there. ->>>>>>> 8b7dfb4 (Pending changes to build backend the menus will be moved to the welcome screens and settigns pages once merges are completed.) - const overlay = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, @@ -55,18 +37,8 @@ const tabBtn = (active) => ({ const body = { padding: 24, overflowY: 'auto', flex: 1 }; -const SettingsModal = ({ - user, - advisors, - availableBackends, - llmConfig, - isSaving, - onSubmitConfig, - onClose, -}) => { - const [activeTab, setActiveTab] = useState('advisors'); +const label = { display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 6 }; -<<<<<<< HEAD const input = { width: '100%', padding: '10px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', @@ -96,27 +68,31 @@ const miniBtn = { fontFamily: 'inherit', }; -const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => { +const SettingsModal = ({ + user, + authToken, + onUserUpdate, + onSignOut, + onClose, + advisors, + availableBackends, + llmConfig, + isSaving, + onSubmitConfig, +}) => { const [activeTab, setActiveTab] = useState('profile'); const { - advisors, isAdvisorEnabled, setAdvisorEnabled, setAllAdvisorsEnabled, hydrateAdvisorPreferences, } = useAppConfig(); - // Reconcile with the backend whenever the user opens Settings (covers fresh - // logins and changes made on another device). useEffect(() => { hydrateAdvisorPreferences(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Track where the mouse went DOWN so we don't close the modal when a user - // drags to select text inside an input and the mouseup happens outside the modal. - // (React's onClick fires on the common ancestor of down+up, which can be the - // overlay itself — causing accidental close on text selection.) const mouseDownOnOverlay = useRef(false); const handleOverlayMouseDown = (e) => { mouseDownOnOverlay.current = e.target === e.currentTarget; @@ -139,6 +115,19 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => const [message, setMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const personaIds = useMemo(() => Object.keys(advisors || {}), [advisors]); + const [modelDraft, setModelDraft] = useState(() => { + const fallback = llmConfig?.default_backend || availableBackends?.[0] || 'gemini'; + const seed = llmConfig?.persona_backends || {}; + const personas = {}; + for (const id of personaIds) personas[id] = seed[id] || fallback; + return { + default_backend: fallback, + orchestrator_backend: llmConfig?.orchestrator_backend || fallback, + persona_backends: personas, + }; + }); + const apiUrl = process.env.REACT_APP_API_URL; const extractError = (data, fallback) => { @@ -154,31 +143,141 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => if (!firstName.trim() && !lastName.trim()) { setMessage({ type: 'error', text: 'Enter a first or last name.' }); return; -======= - const personaIds = useMemo(() => Object.keys(advisors || {}), [advisors]); - const [draft, setDraft] = useState(() => { - const fallback = llmConfig?.default_backend || availableBackends?.[0] || 'gemini'; - const seed = llmConfig?.persona_backends || {}; - const personas = {}; - for (const id of personaIds) { - personas[id] = seed[id] || fallback; ->>>>>>> 8b7dfb4 (Pending changes to build backend the menus will be moved to the welcome screens and settigns pages once merges are completed.) } - return { - default_backend: fallback, - orchestrator_backend: llmConfig?.orchestrator_backend || fallback, - persona_backends: personas, - }; + setIsSubmitting(true); + try { + const response = await fetch(`${apiUrl}/auth/me`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + first_name: firstName.trim(), + last_name: lastName.trim(), + }), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + setMessage({ type: 'error', text: extractError(data, 'Could not update profile.') }); + return; + } + onUserUpdate?.(data); + setFirstName(data.firstName || ''); + setLastName(data.lastName || ''); + setMessage({ type: 'success', text: 'Profile updated.' }); + } catch (err) { + setMessage({ type: 'error', text: 'Network error. Please try again.' }); + } finally { + setIsSubmitting(false); + } + }; + + const handlePasswordSubmit = async (e) => { + e.preventDefault(); + setMessage(null); + if (newPassword !== confirmPassword) { + setMessage({ type: 'error', text: 'New passwords do not match.' }); + return; + } + if (newPassword.length < 8) { + setMessage({ type: 'error', text: 'New password must be at least 8 characters.' }); + return; + } + setIsSubmitting(true); + try { + const response = await fetch(`${apiUrl}/auth/me/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + setMessage({ type: 'error', text: extractError(data, 'Could not change password.') }); + return; + } + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setMessage({ type: 'success', text: 'Password changed.' }); + } catch (err) { + setMessage({ type: 'error', text: 'Network error. Please try again.' }); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteAccount = async (e) => { + e.preventDefault(); + setMessage(null); + if (deleteConfirmText !== 'DELETE') { + setMessage({ type: 'error', text: 'Type DELETE to confirm.' }); + return; + } + if (!deleteConfirmPassword) { + setMessage({ type: 'error', text: 'Password required to delete account.' }); + return; + } + setIsSubmitting(true); + try { + const response = await fetch(`${apiUrl}/auth/me`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ password: deleteConfirmPassword }), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + setMessage({ type: 'error', text: extractError(data, 'Could not delete account.') }); + return; + } + onClose?.(); + onSignOut?.(); + } catch (err) { + setMessage({ type: 'error', text: 'Network error. Please try again.' }); + } finally { + setIsSubmitting(false); + } + }; + + const handleModelSave = async () => { + if (!onSubmitConfig) return; + await onSubmitConfig(modelDraft); + }; + + const messageStyle = (type) => ({ + padding: '10px 12px', borderRadius: 8, marginBottom: 16, fontSize: 13, + background: type === 'error' + ? 'rgba(220,38,38,0.1)' + : type === 'success' + ? 'rgba(22,163,74,0.1)' + : 'var(--bg-secondary)', + color: type === 'error' + ? '#dc2626' + : type === 'success' + ? '#16a34a' + : 'var(--text-secondary)', + border: `1px solid ${ + type === 'error' + ? 'rgba(220,38,38,0.3)' + : type === 'success' + ? 'rgba(22,163,74,0.3)' + : 'var(--border-primary)' + }`, }); -<<<<<<< HEAD const advisorEntries = Object.entries(advisors || {}); const enabledCount = advisorEntries.filter(([id]) => isAdvisorEnabled(id)).length; const setAll = (enabled) => setAllAdvisorsEnabled(enabled); - // pendingDisable: { type: 'all' } | { type: 'single', id } — set when the - // user is about to leave zero advisors enabled. Confirming runs the action; - // "Go back" leaves state untouched. const [pendingDisable, setPendingDisable] = useState(null); const handleDisableAllClick = () => { @@ -201,28 +300,19 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => setAdvisorEnabled(pendingDisable.id, false); } setPendingDisable(null); -======= - const handleSave = () => { - onSubmitConfig(draft); ->>>>>>> 8b7dfb4 (Pending changes to build backend the menus will be moved to the welcome screens and settigns pages once merges are completed.) }; return ReactDOM.createPortal( -
e.target === e.currentTarget && onClose()}> +

Settings

-<<<<<<< HEAD
-<<<<<<< HEAD @@ -232,49 +322,23 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => +
- {activeTab === 'advisors' && ( - <> - -
- - + {message &&
{message.text}
} + + {activeTab === 'profile' && ( +
+
+ +
-<<<<<<< HEAD
@@ -382,6 +446,43 @@ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => )} + {activeTab === 'model' && ( + <> + +
+ + +
+ + )} + {activeTab === 'danger' && (
{isSubmitting ? 'Deleting…' : 'Permanently Delete Account'} -======= - ->>>>>>> 8b7dfb4 (Pending changes to build backend the menus will be moved to the welcome screens and settigns pages once merges are completed.) )}
diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 0a13b6e0..8b734293 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -7,7 +7,6 @@ import MessageBubble from '../components/MessageBubble'; import ThinkingIndicator from '../components/ThinkingIndicator'; import SuggestionsPanel from '../components/SuggestionsPanel'; import ThemeToggle from '../components/ThemeToggle'; -import WelcomeModelPicker from '../components/WelcomeModelPicker'; import SettingsModal from '../components/SettingsModal'; import ExportButton from '../components/ExportButton'; import Sidebar from '../components/Sidebar'; @@ -133,16 +132,6 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig } }; - const handleProviderSwitch = async (newProvider) => { - if (isProviderSwitching) return; - if (llmConfig.mode === 'uniform' && newProvider === llmConfig.default_backend) return; - - await submitProviderConfig( - { mode: 'uniform', default_backend: newProvider }, - newProvider.charAt(0).toUpperCase() + newProvider.slice(1) - ); - }; - const handleHybridSubmit = async (hybridConfig) => { const ok = await submitProviderConfig( { mode: 'hybrid', ...hybridConfig }, @@ -833,14 +822,6 @@ const handleNewChat = async (sessionId = null) => {
{!hasMessages ? (
-
@@ -998,6 +979,9 @@ const handleNewChat = async (sessionId = null) => { {isSettingsOpen && ( Date: Mon, 1 Jun 2026 17:09:51 -0700 Subject: [PATCH 11/18] fix stubbing issue in conftest.py from rebase. --- .../app/tests/unit/conftest.py | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/multi_llm_chatbot_backend/app/tests/unit/conftest.py b/multi_llm_chatbot_backend/app/tests/unit/conftest.py index 8192a261..09b73bad 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/conftest.py +++ b/multi_llm_chatbot_backend/app/tests/unit/conftest.py @@ -1,20 +1,18 @@ -"""Session-wide stubs for modules that do heavy work at import time. - -``app.api.routes.__init__`` eagerly imports every sibling route, and -``app.api.routes.provider`` instantiates real LLM clients at module -load. ``app.core.bootstrap`` and ``app.core.rag_manager`` likewise -start NLTK, ChromaDB, and the LLM stack the moment they are imported. - -We install harmless ``MagicMock`` substitutes for those modules once, -before any test file in this directory is collected, so every test -gets a consistent, importable view of ``app.api.routes`` without -having to reproduce the same stubbing recipe in every test module. - -Tests that want to exercise the real version of a specific route -module (for example, ``test_version.py`` wanting the real -``app.api.routes.root``) can still pop their target out of -``sys.modules`` in their own setup -- they no longer have to -coordinate cleanup with peer test modules. +"""Session-wide stubs for heavy-import modules. + +``app.core.rag_manager`` starts NLTK / ChromaDB the moment it is +imported, so we replace it with a ``MagicMock`` before any test is +collected. + +``app.core.bootstrap`` (and the route modules that import it) can load +normally because we pre-set ``GEMINI_API_KEY`` and ``CONFIG_PATH`` +before any import occurs. This lets ``get_settings()``, the LLM-client +constructors, and the orchestrator initialise without real credentials +or config files. + +Route modules that are *not* under direct test (documents, sessions, +debug, phd_canvas) are still replaced with lightweight stubs so their +dependency trees are never pulled in. """ import os @@ -26,15 +24,15 @@ os.environ.setdefault("GEMINI_API_KEY", "fake-test-key") os.environ.setdefault("CONFIG_PATH", "") -for _name in ("app.core.bootstrap", "app.core.rag_manager"): - sys.modules.setdefault(_name, MagicMock()) +# rag_manager triggers NLTK / ChromaDB on import — always stub it. +sys.modules.setdefault("app.core.rag_manager", MagicMock()) +# Stub route modules that are NOT under direct test to avoid pulling +# in their full dependency trees when app.api.routes.__init__ runs. _stub_router_module = MagicMock(router=APIRouter()) for _name in ( - "app.api.routes.chat", "app.api.routes.documents", "app.api.routes.sessions", - "app.api.routes.provider", "app.api.routes.debug", "app.api.routes.phd_canvas", ): From 6895b06b97050756593526a62108b61714b19a3e Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Mon, 1 Jun 2026 17:25:04 -0700 Subject: [PATCH 12/18] fix backend config values to lock brainforge models on frontend and prevent their underlying models from being changed. --- multi_llm_chatbot_backend/app/main.py | 2 ++ .../src/components/AdvisorConfigPanel.js | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/multi_llm_chatbot_backend/app/main.py b/multi_llm_chatbot_backend/app/main.py index b63e72c0..d8214fcb 100644 --- a/multi_llm_chatbot_backend/app/main.py +++ b/multi_llm_chatbot_backend/app/main.py @@ -124,6 +124,8 @@ def get_public_config(): "dark_color": colors["dark_color"], "dark_bg_color": colors["dark_bg_color"], "image": "icon://Brain", + "backend_locked": True, + "default_backend": "brainforge", }) return config diff --git a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js index 296dd637..37714b7f 100644 --- a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js +++ b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js @@ -142,14 +142,19 @@ const AdvisorConfigPanel = ({ {advisor?.role || id}{locked ? ' · backend locked' : ''}
- + {locked ? ( +
+ {personaValue} +
+ ) : ( + + )}
); })} From 5b34135a3988e191cbba6d951f3ab38bd147abf2 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 2 Jun 2026 15:52:56 -0700 Subject: [PATCH 13/18] added admin-level enabled toggle to each provider and set default backend dynamically. --- .../app/api/routes/provider.py | 9 ++++- multi_llm_chatbot_backend/app/config.py | 3 ++ .../app/core/bootstrap.py | 33 +++++++++++++++---- phd_config.yaml | 3 ++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/provider.py b/multi_llm_chatbot_backend/app/api/routes/provider.py index 4aa9e9e2..7d8b45f4 100644 --- a/multi_llm_chatbot_backend/app/api/routes/provider.py +++ b/multi_llm_chatbot_backend/app/api/routes/provider.py @@ -1,6 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException, status from app.core.auth import get_current_active_user -from app.core.bootstrap import chat_orchestrator, get_llm_client, AVAILABLE_BACKENDS +from app.core.bootstrap import ( + chat_orchestrator, get_llm_client, AVAILABLE_BACKENDS, _is_backend_enabled, +) from app.core.database import get_database from app.models.user import User, UserLLMConfig import logging @@ -45,6 +47,11 @@ async def switch_provider( backends_to_check.update(llm_config.persona_backends.values()) for backend in backends_to_check: + if not _is_backend_enabled(backend): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Backend {backend!r} is disabled by the administrator.", + ) try: get_llm_client(backend) except Exception as exc: diff --git a/multi_llm_chatbot_backend/app/config.py b/multi_llm_chatbot_backend/app/config.py index 045d3320..cf643bbc 100644 --- a/multi_llm_chatbot_backend/app/config.py +++ b/multi_llm_chatbot_backend/app/config.py @@ -253,6 +253,7 @@ def _warn_connection_envvar(self): class GeminiConfig(BaseModel): + enabled: bool = True api_key: str = Field(default=os.getenv("GEMINI_API_KEY")) model: str = "gemini-2.5-flash" @@ -272,12 +273,14 @@ def _warn_gemini_envvar(self): class OllamaConfig(BaseModel): + enabled: bool = True model: str = "llama3.2:1b" # TODO: Drop support for `OLLAMA_BASE_URL` envvar handling base_url: str = Field(default=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")) class VllmConfig(BaseModel): + enabled: bool = True api_url: str = "" api_key: str = Field(default=os.getenv("VLLM_API_KEY", "")) diff --git a/multi_llm_chatbot_backend/app/core/bootstrap.py b/multi_llm_chatbot_backend/app/core/bootstrap.py index 1a615a0e..da0e43b3 100644 --- a/multi_llm_chatbot_backend/app/core/bootstrap.py +++ b/multi_llm_chatbot_backend/app/core/bootstrap.py @@ -12,13 +12,12 @@ settings = get_settings() -DEFAULT_BACKEND = "gemini" - +_PREFERRED_BACKEND = "gemini" _client_cache = {} -def create_llm_client(backend: str = DEFAULT_BACKEND): +def create_llm_client(backend: str = _PREFERRED_BACKEND): """Create an LLM client for the given backend name.""" if backend not in LLM_BACKENDS: raise ValueError( @@ -47,10 +46,18 @@ def get_llm_client(backend: str) -> LLMClient: return _client_cache[backend] +def _is_backend_enabled(backend: str) -> bool: + """Check whether *backend* is enabled in the admin config.""" + backend_config = getattr(settings.llm, backend, None) + return getattr(backend_config, "enabled", True) + + def get_available_backends() -> list: - """Return backends that are properly configured (sync, used at startup).""" + """Return backends that are enabled and properly configured (sync, used at startup).""" available = [] for backend in LLM_BACKENDS: + if not _is_backend_enabled(backend): + continue try: get_llm_client(backend) available.append(backend) @@ -60,9 +67,11 @@ def get_available_backends() -> list: async def refresh_available_backends(): - """Re-check which backends are configured and reachable.""" + """Re-check which backends are enabled, configured, and reachable.""" available = [] for backend in LLM_BACKENDS: + if not _is_backend_enabled(backend): + continue try: client = get_llm_client(backend) if await client.health_check(): @@ -80,9 +89,19 @@ async def _backend_health_loop(): await asyncio.sleep(interval) -llm = create_llm_client() -_client_cache[DEFAULT_BACKEND] = llm +# Resolve the default backend: prefer _PREFERRED_BACKEND, but fall back to the first available. AVAILABLE_BACKENDS = get_available_backends() +if _PREFERRED_BACKEND in AVAILABLE_BACKENDS: + DEFAULT_BACKEND = _PREFERRED_BACKEND +elif AVAILABLE_BACKENDS: + DEFAULT_BACKEND = AVAILABLE_BACKENDS[0] +else: + raise RuntimeError( + "No LLM backends are available. Check your config.yaml — " + "at least one backend must be enabled and properly configured." + ) +llm = create_llm_client(DEFAULT_BACKEND) +_client_cache[DEFAULT_BACKEND] = llm chat_orchestrator = ImprovedChatOrchestrator(llm_client=llm) DEFAULT_PERSONAS = get_default_personas(llm) diff --git a/phd_config.yaml b/phd_config.yaml index 121605cd..cb353647 100644 --- a/phd_config.yaml +++ b/phd_config.yaml @@ -174,10 +174,13 @@ mongodb: llm: gemini: + enabled: true model: "gemini-2.5-flash" ollama: + enabled: false model: "llama3.2:1b" vllm: + enabled: true api_url: https://rtx6000blackwell-1.neonaiservices2.com/vllm0 brainforge: api_url: https://hana.neonaialpha.com From a2a5293dad5fb6f6457181257ff303d9fafec0b9 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 2 Jun 2026 16:00:04 -0700 Subject: [PATCH 14/18] replaced frontend hardcoded gemini fallback with dynamic defaults. --- phd-advisor-frontend/src/components/AdvisorConfigPanel.js | 4 ++-- phd-advisor-frontend/src/components/HybridConfigModal.js | 2 +- phd-advisor-frontend/src/components/SettingsModal.js | 2 +- phd-advisor-frontend/src/components/WelcomeModelPicker.js | 4 ++-- phd-advisor-frontend/src/pages/ChatPage.js | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js index 37714b7f..8497012d 100644 --- a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js +++ b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js @@ -24,7 +24,7 @@ const selectStyle = { }; const buildInitial = (initialConfig, personaIds, availableBackends) => { - const fallback = initialConfig?.default_backend || availableBackends[0] || 'gemini'; + const fallback = initialConfig?.default_backend || availableBackends[0]; const seed = initialConfig?.persona_backends || {}; const personas = {}; for (const id of personaIds) { @@ -59,7 +59,7 @@ const AdvisorConfigPanel = ({ setInternal(prev => { const next = { ...prev.persona_backends }; let changed = false; - const fallback = prev.default_backend || availableBackends[0] || 'gemini'; + const fallback = prev.default_backend || availableBackends[0]; for (const id of personaIds) { if (next[id] === undefined) { next[id] = fallback; diff --git a/phd-advisor-frontend/src/components/HybridConfigModal.js b/phd-advisor-frontend/src/components/HybridConfigModal.js index 31fcef49..ccd78af6 100644 --- a/phd-advisor-frontend/src/components/HybridConfigModal.js +++ b/phd-advisor-frontend/src/components/HybridConfigModal.js @@ -15,7 +15,7 @@ const modal = { }; const seedConfig = (initialConfig, personaIds, availableBackends) => { - const fallback = initialConfig?.default_backend || availableBackends[0] || 'gemini'; + const fallback = initialConfig?.default_backend || availableBackends[0]; const seedPersonas = initialConfig?.persona_backends || {}; const personas = {}; for (const id of personaIds) { diff --git a/phd-advisor-frontend/src/components/SettingsModal.js b/phd-advisor-frontend/src/components/SettingsModal.js index 5324ab80..702625f2 100644 --- a/phd-advisor-frontend/src/components/SettingsModal.js +++ b/phd-advisor-frontend/src/components/SettingsModal.js @@ -117,7 +117,7 @@ const SettingsModal = ({ const personaIds = useMemo(() => Object.keys(advisors || {}), [advisors]); const [modelDraft, setModelDraft] = useState(() => { - const fallback = llmConfig?.default_backend || availableBackends?.[0] || 'gemini'; + const fallback = llmConfig?.default_backend || availableBackends?.[0]; const seed = llmConfig?.persona_backends || {}; const personas = {}; for (const id of personaIds) personas[id] = seed[id] || fallback; diff --git a/phd-advisor-frontend/src/components/WelcomeModelPicker.js b/phd-advisor-frontend/src/components/WelcomeModelPicker.js index cf004a70..32657968 100644 --- a/phd-advisor-frontend/src/components/WelcomeModelPicker.js +++ b/phd-advisor-frontend/src/components/WelcomeModelPicker.js @@ -54,8 +54,8 @@ const WelcomeModelPicker = ({ }) => { const [advancedOpen, setAdvancedOpen] = useState(llmConfig?.mode === 'hybrid'); const [draft, setDraft] = useState(() => ({ - default_backend: llmConfig?.default_backend || availableBackends[0] || 'gemini', - orchestrator_backend: llmConfig?.orchestrator_backend || llmConfig?.default_backend || availableBackends[0] || 'gemini', + default_backend: llmConfig?.default_backend || availableBackends[0], + orchestrator_backend: llmConfig?.orchestrator_backend || llmConfig?.default_backend || availableBackends[0], persona_backends: llmConfig?.persona_backends || {}, })); diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 8b734293..6bbb6464 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -27,11 +27,11 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig const [replyingTo, setReplyingTo] = useState(null); const [llmConfig, setLlmConfig] = useState({ mode: 'uniform', - default_backend: 'gemini', + default_backend: null, orchestrator_backend: null, persona_backends: null, }); - const [availableBackends, setAvailableBackends] = useState(['gemini', 'ollama', 'vllm']); + const [availableBackends, setAvailableBackends] = useState([]); const [isProviderSwitching, setIsProviderSwitching] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [uploadedDocuments, setUploadedDocuments] = useState([]); From 377a7384bdf8662305e501fac22f87d32640a34a Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 2 Jun 2026 16:38:46 -0700 Subject: [PATCH 15/18] fallback to default_backend when hybrid mode has no overrides. --- multi_llm_chatbot_backend/app/models/user.py | 5 +---- .../app/tests/unit/test_llm_provider_config.py | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/multi_llm_chatbot_backend/app/models/user.py b/multi_llm_chatbot_backend/app/models/user.py index 4592c7b4..32ecd31f 100644 --- a/multi_llm_chatbot_backend/app/models/user.py +++ b/multi_llm_chatbot_backend/app/models/user.py @@ -25,10 +25,7 @@ class UserLLMConfig(BaseModel): def _validate_hybrid_fields(self): if self.mode == "hybrid": if not self.orchestrator_backend and not self.persona_backends: - raise ValueError( - "hybrid mode requires at least one of " - "orchestrator_backend or persona_backends" - ) + self.orchestrator_backend = self.default_backend else: self.orchestrator_backend = None self.persona_backends = None diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py b/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py index 72e00d41..bcac69d9 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py @@ -50,10 +50,10 @@ def test_uniform_strips_hybrid_fields(self): self.assertIsNone(cfg.orchestrator_backend) self.assertIsNone(cfg.persona_backends) - def test_hybrid_requires_at_least_one_override(self): - with self.assertRaises(ValidationError) as ctx: - UserLLMConfig(mode="hybrid", default_backend="gemini") - self.assertIn("hybrid mode requires", str(ctx.exception)) + def test_hybrid_without_overrides_falls_back_to_default(self): + cfg = UserLLMConfig(mode="hybrid", default_backend="gemini") + self.assertEqual(cfg.orchestrator_backend, "gemini") + self.assertIsNone(cfg.persona_backends) def test_hybrid_with_orchestrator_only(self): cfg = UserLLMConfig( From d223e2a58a881e7d3c54dcc3854edf9b9d41e5c1 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 2 Jun 2026 16:46:02 -0700 Subject: [PATCH 16/18] add test case for gemini missing. --- .../app/tests/unit/test_available_backends.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py index fc4c2d45..c9bb362b 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_available_backends.py @@ -29,6 +29,16 @@ def side_effect(backend): result = get_available_backends() self.assertEqual(result, ["gemini", "ollama"]) + @patch("app.core.bootstrap.get_llm_client") + def test_gemini_not_configured(self, mock_get_client): + def side_effect(backend): + if backend == "gemini": + raise ValueError("No Gemini endpoint configured.") + return MagicMock() + mock_get_client.side_effect = side_effect + result = get_available_backends() + self.assertEqual(result, ["ollama", "vllm"]) + @patch("app.core.bootstrap.get_llm_client") def test_none_configured(self, mock_get_client): mock_get_client.side_effect = ValueError("not configured") @@ -86,6 +96,19 @@ def side_effect(backend): asyncio.run(refresh_available_backends()) self.assertEqual(list(AVAILABLE_BACKENDS), ["gemini", "ollama"]) + @patch("app.core.bootstrap.get_llm_client") + def test_gemini_unconfigured_excluded(self, mock_get_client): + def side_effect(backend): + if backend == "gemini": + raise ValueError("No Gemini endpoint configured.") + client = MagicMock() + client.health_check = AsyncMock(return_value=True) + return client + mock_get_client.side_effect = side_effect + + asyncio.run(refresh_available_backends()) + self.assertEqual(list(AVAILABLE_BACKENDS), ["ollama", "vllm"]) + if __name__ == "__main__": unittest.main() From 342988e33fd3984052ca13bc062a17fc2d7bdebf Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Tue, 2 Jun 2026 17:16:42 -0700 Subject: [PATCH 17/18] added configurable default_backend parameter to config.yaml. --- multi_llm_chatbot_backend/app/config.py | 3 ++- .../app/core/bootstrap.py | 21 ++++++++++++------- phd_config.yaml | 1 + 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/multi_llm_chatbot_backend/app/config.py b/multi_llm_chatbot_backend/app/config.py index cf643bbc..c8154837 100644 --- a/multi_llm_chatbot_backend/app/config.py +++ b/multi_llm_chatbot_backend/app/config.py @@ -293,11 +293,12 @@ class BrainForgeConfig(BaseModel): class LLMConfig(BaseModel): + default_backend: str = "" gemini: GeminiConfig = GeminiConfig() ollama: OllamaConfig = OllamaConfig() vllm: VllmConfig = VllmConfig() brainforge: BrainForgeConfig = BrainForgeConfig() - health_check_interval: int = 300 + health_check_interval_seconds: int = 300 class RAGConfig(BaseModel): diff --git a/multi_llm_chatbot_backend/app/core/bootstrap.py b/multi_llm_chatbot_backend/app/core/bootstrap.py index da0e43b3..99283643 100644 --- a/multi_llm_chatbot_backend/app/core/bootstrap.py +++ b/multi_llm_chatbot_backend/app/core/bootstrap.py @@ -1,5 +1,6 @@ # app/core/bootstrap.py import asyncio +import logging from app.config import get_settings from app.llm.improved_gemini_client import ImprovedGeminiClient @@ -10,15 +11,17 @@ from app.models.user import LLM_BACKENDS from app.llm.llm_client import LLMClient -settings = get_settings() +logger = logging.getLogger(__name__) -_PREFERRED_BACKEND = "gemini" +settings = get_settings() _client_cache = {} -def create_llm_client(backend: str = _PREFERRED_BACKEND): +def create_llm_client(backend: str = None): """Create an LLM client for the given backend name.""" + if backend is None: + backend = settings.llm.default_backend if backend not in LLM_BACKENDS: raise ValueError( f"Unknown backend {backend!r}. Must be one of {LLM_BACKENDS}" @@ -83,18 +86,22 @@ async def refresh_available_backends(): async def _backend_health_loop(): """Background task that periodically refreshes AVAILABLE_BACKENDS.""" - interval = settings.llm.health_check_interval + interval = settings.llm.health_check_interval_seconds while True: await refresh_available_backends() await asyncio.sleep(interval) -# Resolve the default backend: prefer _PREFERRED_BACKEND, but fall back to the first available. +# Resolve the default backend: prefer the configured default, fall back to the first available. AVAILABLE_BACKENDS = get_available_backends() -if _PREFERRED_BACKEND in AVAILABLE_BACKENDS: - DEFAULT_BACKEND = _PREFERRED_BACKEND +if settings.llm.default_backend in AVAILABLE_BACKENDS: + DEFAULT_BACKEND = settings.llm.default_backend elif AVAILABLE_BACKENDS: DEFAULT_BACKEND = AVAILABLE_BACKENDS[0] + logger.warning( + "Configured default_backend %r is not available; falling back to %r", + settings.llm.default_backend, DEFAULT_BACKEND, + ) else: raise RuntimeError( "No LLM backends are available. Check your config.yaml — " diff --git a/phd_config.yaml b/phd_config.yaml index cb353647..bef68cf0 100644 --- a/phd_config.yaml +++ b/phd_config.yaml @@ -173,6 +173,7 @@ mongodb: database_name: "phd_advisor" llm: + default_backend: gemini gemini: enabled: true model: "gemini-2.5-flash" From b5b4769a2c806a60da65b15f1a8b687c9801ace7 Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Fri, 5 Jun 2026 14:34:35 -0500 Subject: [PATCH 18/18] Fixed the black on black text and added the default option --- .../src/components/AdvisorConfigPanel.js | 52 ++++++++++++++----- .../src/components/HybridConfigModal.js | 8 +-- .../src/components/SettingsModal.js | 8 +-- .../src/components/WelcomeModelPicker.js | 6 +-- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js index 8497012d..90f0d153 100644 --- a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js +++ b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js @@ -11,6 +11,27 @@ import React, { useEffect, useMemo, useState } from 'react'; // // Shape of `value`: // { default_backend, orchestrator_backend, persona_backends: { [personaId]: backend } } +// +// The orchestrator and each advisor can be set to DEFAULT_BACKEND ("Default"), +// meaning "follow default_backend". Call stripDefaultBackends() before persisting: +// it drops those sentinels so the backend falls through to default_backend (and +// moves them automatically when the default changes). + +export const DEFAULT_BACKEND = '__default__'; + +export const stripDefaultBackends = (config) => { + if (!config) return config; + const source = config.persona_backends || {}; + const cleaned = {}; + for (const [id, backend] of Object.entries(source)) { + if (backend && backend !== DEFAULT_BACKEND) cleaned[id] = backend; + } + const orchestrator = + config.orchestrator_backend && config.orchestrator_backend !== DEFAULT_BACKEND + ? config.orchestrator_backend + : null; + return { ...config, orchestrator_backend: orchestrator, persona_backends: cleaned }; +}; const rowStyle = { display: 'grid', gridTemplateColumns: '1fr 180px', alignItems: 'center', @@ -20,19 +41,25 @@ const rowStyle = { const selectStyle = { padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13.5, - width: '100%', + width: '100%', colorScheme: 'light dark', }; +// Native dropdown option list ignores the
)} @@ -113,7 +139,7 @@ const AdvisorConfigPanel = ({ {!hideOrchestrator && (
-
Orchestrator
+
Orchestrator
Routes user input across advisors.
@@ -123,7 +149,8 @@ const AdvisorConfigPanel = ({ value={config.orchestrator_backend} onChange={(e) => setOrchestrator(e.target.value)} > - {availableBackends.map(b => )} + + {availableBackends.map(b => )}
)} @@ -133,11 +160,11 @@ const AdvisorConfigPanel = ({ const locked = advisor?.backendLocked; const personaValue = locked && advisor?.defaultBackend ? advisor.defaultBackend - : (config.persona_backends?.[id] || config.default_backend); + : (config.persona_backends?.[id] || DEFAULT_BACKEND); return (
-
{advisor?.name || id}
+
{advisor?.name || id}
{advisor?.role || id}{locked ? ' · backend locked' : ''}
@@ -152,7 +179,8 @@ const AdvisorConfigPanel = ({ value={personaValue} onChange={(e) => setPersona(id, e.target.value)} > - {availableBackends.map(b => )} + + {availableBackends.map(b => )} )}
diff --git a/phd-advisor-frontend/src/components/HybridConfigModal.js b/phd-advisor-frontend/src/components/HybridConfigModal.js index ccd78af6..9da0629d 100644 --- a/phd-advisor-frontend/src/components/HybridConfigModal.js +++ b/phd-advisor-frontend/src/components/HybridConfigModal.js @@ -1,7 +1,7 @@ import React, { useMemo, useState } from 'react'; import ReactDOM from 'react-dom'; import { X, Layers } from 'lucide-react'; -import AdvisorConfigPanel from './AdvisorConfigPanel'; +import AdvisorConfigPanel, { DEFAULT_BACKEND, stripDefaultBackends } from './AdvisorConfigPanel'; const overlay = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', @@ -19,11 +19,11 @@ const seedConfig = (initialConfig, personaIds, availableBackends) => { const seedPersonas = initialConfig?.persona_backends || {}; const personas = {}; for (const id of personaIds) { - personas[id] = seedPersonas[id] || fallback; + personas[id] = seedPersonas[id] || DEFAULT_BACKEND; } return { default_backend: fallback, - orchestrator_backend: initialConfig?.orchestrator_backend || fallback, + orchestrator_backend: initialConfig?.orchestrator_backend || DEFAULT_BACKEND, persona_backends: personas, }; }; @@ -75,7 +75,7 @@ const HybridConfigModal = ({ Cancel