From 001cca42e63bb11fc72fee1484eed8a530018f08 Mon Sep 17 00:00:00 2001 From: Stephen Nwankwo Date: Thu, 25 Dec 2025 19:17:20 +0100 Subject: [PATCH 01/69] feat: allow unsetting widget icon URL by providing an empty string. --- backend/app/api/widget.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/app/api/widget.py b/backend/app/api/widget.py index 38668ea..da99c78 100644 --- a/backend/app/api/widget.py +++ b/backend/app/api/widget.py @@ -100,8 +100,11 @@ def update_my_widget_settings( widget.theme = settings.theme if settings.primary_color: widget.primary_color = settings.primary_color - if settings.icon_url: - widget.icon_url = settings.icon_url + if settings.icon_url is not None: + if settings.icon_url == "": + widget.icon_url = None + else: + widget.icon_url = settings.icon_url if settings.welcome_message is not None: val = settings.welcome_message.strip() if val == "": From a12ec2588e3ea40e27976b061ab81bd8d06e07f0 Mon Sep 17 00:00:00 2001 From: Stephen Nwankwo Date: Thu, 25 Dec 2025 21:04:49 +0100 Subject: [PATCH 02/69] feat: Implement API key validation and improved error handling for AI services --- backend/app/api/analytics.py | 11 ++- backend/app/api/business.py | 63 +++++++++++- backend/app/api/widget.py | 68 +++++++++---- backend/app/schemas/business.py | 7 +- backend/app/services/agent_service.py | 4 +- .../services/agent_system/agent_factory.py | 3 + backend/app/services/analysis_agent.py | 57 ++++++----- frontend/src/app/dashboard/business/page.tsx | 73 ++++++++++++-- frontend/src/app/dashboard/layout.tsx | 96 +++++++++++++++++-- frontend/src/components/ui/Sidebar.tsx | 22 +++++ frontend/src/lib/api.ts | 6 ++ frontend/src/lib/types.ts | 2 + 12 files changed, 347 insertions(+), 65 deletions(-) diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py index 273c161..e972d3f 100644 --- a/backend/app/api/analytics.py +++ b/backend/app/api/analytics.py @@ -187,6 +187,10 @@ class FollowUpRequest(BaseModel): type: str # "email" or "transcript" extra_info: Optional[str] = "" +from app.core.security_utils import decrypt_string + +# ... + @router.post("/followup", response_model=None) async def generate_followup( request: FollowUpRequest, @@ -208,7 +212,12 @@ async def generate_followup( messages = db.query(GuestMessage).filter(GuestMessage.session_id == request.session_id).order_by(GuestMessage.created_at).all() - content = await generate_followup_content(messages, request.type, request.extra_info) + # Get API key + api_key = None + if current_user.business and current_user.business.gemini_api_key: + api_key = decrypt_string(current_user.business.gemini_api_key) + + content = await generate_followup_content(messages, request.type, request.extra_info, api_key=api_key) return success_response(data={"content": content}) diff --git a/backend/app/api/business.py b/backend/app/api/business.py index 120e8f1..7e74802 100644 --- a/backend/app/api/business.py +++ b/backend/app/api/business.py @@ -7,7 +7,8 @@ from app.schemas.business import BusinessCreate, BusinessUpdate, BusinessResponse from app.core.response_wrapper import success_response from app.services.analysis_agent import generate_business_intents -from app.core.security_utils import encrypt_string +from app.core.security_utils import encrypt_string, decrypt_string + router = APIRouter() @@ -37,9 +38,12 @@ async def create_business( db.commit() db.refresh(business) + response = BusinessResponse.model_validate(business) + response.is_api_key_set = bool(business.gemini_api_key) + return success_response( message="Business profile created successfully", - data=BusinessResponse.model_validate(business) + data=response ) @router.get("/business", response_model=None) @@ -52,7 +56,9 @@ async def get_business( if not business: return success_response(data=None) - return success_response(data=BusinessResponse.model_validate(business)) + response = BusinessResponse.model_validate(business) + response.is_api_key_set = bool(business.gemini_api_key) + return success_response(data=response) @router.put("/business", response_model=None) async def update_business( @@ -84,11 +90,54 @@ async def update_business( db.commit() db.refresh(business) + response = BusinessResponse.model_validate(business) + response.is_api_key_set = bool(business.gemini_api_key) + return success_response( message="Business profile updated successfully", - data=BusinessResponse.model_validate(business) + data=response ) +@router.post("/business/validate-key", response_model=None) +async def validate_api_key( + key_data: dict, # Using dict to avoid importing ValidateKeyRequest for now or just generic + current_user: User = Depends(get_current_user) +): + """Validate a Google Gemini API Key.""" + api_key = key_data.get("api_key") + if not api_key: + raise HTTPException(status_code=400, detail="API Key is required") + + try: + from google import genai + # Initialize client with the key + client = genai.Client(api_key=api_key) + # Attempt a simple lightweight call. Validating 'models.list' or similar is usually cheapest/fastest. + # Or just sending "Hello"? + # Checking list of models is a good connectivity test. + # However, listing models might not verify if the key has access to generate content? + # Let's try to list models. + # Note: genai v2 SDK usage might differ slightly. Assuming standard google-genai usage. + # If the environment is using `google.generativeai` (old sdk) or `google-genai` (new SDK). + # Based on imports in agent_service.py: `from google.genai import types` -> It's the new SDK. + + # New SDK supports `client.models.list()`? + # Or we can just try generating "test". + + response = client.models.generate_content( + model='gemini-2.0-flash', + contents='Test' + ) + # If no exception, we are good? + + except Exception as e: + print(f"Key Validation Failed: {e}") + # Return 200 with success=False to let frontend handle message? + # Or 400? 400 is better for 'Invalid Request/Input'. + raise HTTPException(status_code=400, detail=f"Invalid API Key: {str(e)}") + + return success_response(message="API Key is valid") + @router.post("/business/generate-intents", response_model=None) async def generate_intents( current_user: User = Depends(get_current_user), @@ -98,5 +147,9 @@ async def generate_intents( if not business or not business.description: raise HTTPException(status_code=400, detail="Business description is required to generate intents.") - intents = await generate_business_intents(business.description) + api_key = None + if business.gemini_api_key: + api_key = decrypt_string(business.gemini_api_key) + + intents = await generate_business_intents(business.description, api_key=api_key) return success_response(data={"intents": intents}) diff --git a/backend/app/api/widget.py b/backend/app/api/widget.py index da99c78..3cf312f 100644 --- a/backend/app/api/widget.py +++ b/backend/app/api/widget.py @@ -102,9 +102,9 @@ def update_my_widget_settings( widget.primary_color = settings.primary_color if settings.icon_url is not None: if settings.icon_url == "": - widget.icon_url = None + widget.icon_url = None else: - widget.icon_url = settings.icon_url + widget.icon_url = settings.icon_url if settings.welcome_message is not None: val = settings.welcome_message.strip() if val == "": @@ -502,20 +502,51 @@ async def process_chat_message(db: Session, widget: WidgetSettings, guest: Guest ) ) + # Decrypt API Key # Decrypt API Key decrypted_key = None if owner_user and owner_user.business and owner_user.business.gemini_api_key: decrypted_key = decrypt_string(owner_user.business.gemini_api_key) + + if not decrypted_key: + print(f"Missing API Key for business {widget.user_id}") + return WidgetChatResponse( + message=GuestMessageSchema.model_validate(guest_msg), + response=GuestMessageSchema( + id=str(uuid.uuid4()), + guest_id=guest.id, + session_id=session_id, + sender="ai", + message_text="Service unavailable: The business has not configured the AI service correctly (Missing API Key).", + created_at=datetime.now(timezone.utc) + ) + ) + + + try: + ai_response_text = await run_conversation( + message=message_text, + user_id=widget.user_id, + business_name=business_name, + custom_instruction=instruction, + session_id=session_id, # Use session_id for thread consistency + intents=intents, + api_key=decrypted_key + ) + except Exception as e: + print(f"Agent Execution Error: {e}") + return WidgetChatResponse( + message=GuestMessageSchema.model_validate(guest_msg), + response=GuestMessageSchema( + id=str(uuid.uuid4()), + guest_id=guest.id, + session_id=session_id, + sender="ai", + message_text="I'm having trouble connecting right now. Please try again later.", + created_at=datetime.now(timezone.utc) + ) + ) - ai_response_text = await run_conversation( - message=message_text, - user_id=widget.user_id, - business_name=business_name, - custom_instruction=instruction, - session_id=session_id, # Use session_id for thread consistency - intents=intents, - api_key=decrypted_key - ) # 4. Store AI response ai_msg = GuestMessage( @@ -642,8 +673,9 @@ async def analyze_chat_session(session_id: str, db: Session = Depends(get_db)): if not session: raise HTTPException(status_code=404, detail="Session not found") - # Fetch intents from business if available + # Fetch intents and API key from business if available intents = None + decrypted_key = None try: # ChatSession -> GuestUser -> WidgetSettings -> User -> Business guest = db.query(GuestUser).filter(GuestUser.id == session.guest_id).first() @@ -651,13 +683,17 @@ async def analyze_chat_session(session_id: str, db: Session = Depends(get_db)): widget = db.query(WidgetSettings).filter(WidgetSettings.id == guest.widget_id).first() if widget: owner_user = db.query(User).filter(User.id == widget.user_id).first() - if owner_user and owner_user.business and owner_user.business.intents: - intents = owner_user.business.intents + if owner_user and owner_user.business: + if owner_user.business.intents: + intents = owner_user.business.intents + if owner_user.business.gemini_api_key: + decrypted_key = decrypt_string(owner_user.business.gemini_api_key) except Exception as e: - print(f"Error fetching intents: {e}") + print(f"Error fetching intents/key: {e}") # 2. Run analysis - summary, intent = await analyze_session(db, session_id, intents=intents) + summary, intent = await analyze_session(db, session_id, intents=intents, api_key=decrypted_key) + # 3. Persist updated_session = await persist_analysis(db, session_id, summary, intent) diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index 2dc3522..87407f6 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -25,9 +25,14 @@ class BusinessUpdate(BaseModel): class BusinessResponse(BusinessBase): id: str user_id: str - # gemini_api_key is not in Base, so safe. + is_api_key_set: bool = False + created_at: datetime updated_at: datetime class Config: from_attributes = True + +class ValidateKeyRequest(BaseModel): + api_key: str + diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 53738f3..16c6aaf 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -28,8 +28,10 @@ def query(self, text, user_id): logging.basicConfig(level=logging.ERROR) + print("Libraries imported.") -print(f"Google API Key set: {'Yes' if os.environ.get('GOOGLE_API_KEY') and os.environ['GOOGLE_API_KEY'] != 'YOUR_GOOGLE_API_KEY' else 'No (REPLACE PLACEHOLDER!)'}") +# print(f"Google API Key set: {'Yes' if os.environ.get('GOOGLE_API_KEY') and os.environ['GOOGLE_API_KEY'] != 'YOUR_GOOGLE_API_KEY' else 'No (REPLACE PLACEHOLDER!)'}") + async def call_agent_async(query: str, runner: Runner, user_id: str, session_id: str): diff --git a/backend/app/services/agent_system/agent_factory.py b/backend/app/services/agent_system/agent_factory.py index 360addb..c4b0b23 100644 --- a/backend/app/services/agent_system/agent_factory.py +++ b/backend/app/services/agent_system/agent_factory.py @@ -79,6 +79,9 @@ def create_rag_agent(business_name: str, custom_instruction: Optional[str] = Non # Determine model to use model = MODEL_GEMINI_2_0_FLASH + if not api_key: + raise ValueError("API Key is required for this business configuration.") + if api_key: # Use LiteLlm with specific API key if provided # Note: LiteLlm requires provider prefix 'gemini/' usually if using litellm directly, diff --git a/backend/app/services/analysis_agent.py b/backend/app/services/analysis_agent.py index 0b9193d..07a959b 100644 --- a/backend/app/services/analysis_agent.py +++ b/backend/app/services/analysis_agent.py @@ -6,19 +6,21 @@ from app.models.widget import GuestMessage, GuestUser from app.models.chat_session import ChatSession -# Using hypothetical google.generativeai for the agent as per project patterns -import google.generativeai as genai - -# For simplicity, assuming environment key is set -genai.configure(api_key=os.environ.get("GOOGLE_API_KEY")) +# Use specific client for multi-tenant API key support +from google import genai INTENT_ENUM = ["Support", "Sales", "Feedback", "Bug Report", "General"] -async def analyze_session(db: Session, session_id: str, intents: Optional[List[str]] = None) -> Tuple[str, str]: +async def analyze_session(db: Session, session_id: str, intents: Optional[List[str]] = None, api_key: str = None) -> Tuple[str, str]: """ Analyzes a chat session to generate a summary and determine intent. Returns (summary, intent). """ + if not api_key: + # Fail fast if no key provided + print("Analysis Agent: No API Key provided") + return "Analysis unavailable (Missing Key)", "General" + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() if not session: raise ValueError("Session not found") @@ -59,10 +61,13 @@ async def analyze_session(db: Session, session_id: str, intents: Optional[List[s """ try: - model = genai.GenerativeModel("gemini-2.0-flash") # Or pro - response = model.generate_content(prompt) + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model="gemini-2.0-flash", + contents=prompt + ) - # Simple parsing logic (assuming well-behaved model or using response_schema in future) + # Simple parsing logic content = response.text # Strip code blocks if present if "```json" in content: @@ -80,7 +85,7 @@ async def analyze_session(db: Session, session_id: str, intents: Optional[List[s if "General" in intent_list: intent = "General" else: - intent = intent_list[-1] # Fallback to last item or just keep raw if model hallucinates slightly? Safe to map to first/last. + intent = intent_list[-1] return summary, intent @@ -90,13 +95,6 @@ async def analyze_session(db: Session, session_id: str, intents: Optional[List[s async def persist_analysis(db: Session, session_id: str, summary: str, intent: str): session = db.query(ChatSession).filter(ChatSession.id == session_id).first() - if session: - session.summary = summary - session.top_intent = intent - session.summary_generated_at = datetime.now(timezone.utc) - db.commit() - db.refresh(session) - return session if session: session.summary = summary session.top_intent = intent @@ -106,7 +104,10 @@ async def persist_analysis(db: Session, session_id: str, summary: str, intent: s return session return None -async def generate_business_intents(business_description: str) -> List[str]: +async def generate_business_intents(business_description: str, api_key: str = None) -> List[str]: + if not api_key: + return [] + prompt = f""" You are a Business Consultant. Based on the following business description, generate exactly 5 intent categories that customers of this business might have. @@ -117,8 +118,11 @@ async def generate_business_intents(business_description: str) -> List[str]: """ try: - model = genai.GenerativeModel("gemini-2.0-flash") - response = model.generate_content(prompt) + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model="gemini-2.0-flash", + contents=prompt + ) text = response.text if "```json" in text: text = text.replace("```json", "").replace("```", "") @@ -133,7 +137,10 @@ async def generate_business_intents(business_description: str) -> List[str]: print(f"Error generating intents: {e}") return [] -async def generate_followup_content(messages: List[GuestMessage], follow_up_type: str, extra_info: str) -> str: +async def generate_followup_content(messages: List[GuestMessage], follow_up_type: str, extra_info: str, api_key: str = None) -> str: + if not api_key: + return "Error: No API Key configured." + conversation_text = "" for msg in messages: role = "User" if msg.sender == "guest" else "Agent" @@ -157,9 +164,13 @@ async def generate_followup_content(messages: List[GuestMessage], follow_up_type """ try: - model = genai.GenerativeModel("gemini-2.0-flash") - response = model.generate_content(prompt) + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model="gemini-2.0-flash", + contents=prompt + ) return response.text except Exception as e: print(f"Error generating follow up: {e}") return "Error generating follow up." + diff --git a/frontend/src/app/dashboard/business/page.tsx b/frontend/src/app/dashboard/business/page.tsx index c8d1e03..7f26201 100644 --- a/frontend/src/app/dashboard/business/page.tsx +++ b/frontend/src/app/dashboard/business/page.tsx @@ -215,14 +215,71 @@ export default function BusinessProfilePage() { onChange={(e) => setFormData({ ...formData, logo_url: e.target.value })} disabled={!editing} /> - setFormData({ ...formData, gemini_api_key: e.target.value })} - disabled={!editing} - /> + + {/* API Key Input + Validation */} +
+
+ + {profile?.is_api_key_set && !editing && ( + + Key Set + + )} + {(!profile || !profile.is_api_key_set) && !editing && ( + + Not Set + + )} +
+ +
+
+ setFormData({ ...formData, gemini_api_key: e.target.value })} + disabled={!editing} + /> +
+ + {/* Test Button - Only active when key is present (either saved or in input) */} + {editing && ( + + )} +
+

+ Required for the AI agent to function. +

+