| import gradio as gr |
| import base64 |
| import json |
| import os |
| from pathlib import Path |
| from langgraph_agent import AgentFactory |
| from langgraph_agent.config import AgentConfig |
| from langgraph_agent.subagent_config import SubAgentConfig |
| from langgraph_agent.prompts import BIRDSCOPE_AI_PROMPT, NUTHATCH_BIRDSCOPE_PROMPT |
| from fastmcp.client import Client |
| from fastmcp.client.transports import StreamableHttpTransport |
| from agent_cache import get_or_create_agent |
| from langgraph_agent.structured_output import parse_agent_response |
|
|
| |
| from dotenv import load_dotenv |
| load_dotenv() |
|
|
| |
| |
| |
|
|
| |
| PHOTO_EXAMPLES = [ |
| {"text": "What bird is this?", "files": ["examples/bird_example_1.jpg"]}, |
| {"text": "Can you identify this bird?", "files": ["examples/bird_example_2.jpg"]}, |
| {"text": "Identify this bird and show me similar species", "files": ["examples/bird_example_5.jpg"]}, |
| {"text": "", "files": ["examples/bird_example_6.jpg"]} |
| ] |
|
|
| |
| MULTI_AGENT_TEXT_EXAMPLES = [ |
| "Tell me about Northern Cardinals - show me images and audio", |
| "What birds are in the Cardinalidae family?", |
| "Find me audio recordings for Snow Goose", |
| "Get me bird call samples for any two species" |
| ] |
|
|
| |
| AUDIO_FINDER_TEXT_EXAMPLES = [ |
| "Find me audio for any bird", |
| "Get audio recordings for Snow Goose", |
| "Find me any two audio samples of bird calls", |
| "Show me audio recordings of Common Goldeneye" |
| ] |
|
|
| |
| |
| |
|
|
| custom_css = """ |
| /* ======================================================================== |
| GLOBAL STYLES - SKY/CLOUD AESTHETIC |
| ======================================================================== */ |
| |
| /* Unified cloud/sky background across entire page */ |
| body, html { |
| background: linear-gradient(180deg, #E0F4FF 0%, #B0E2FF 40%, #87CEEB 100%) !important; |
| min-height: 100vh !important; |
| } |
| |
| .gradio-container { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif !important; |
| background: |
| /* Cloud formations - concentrated at TOP, fading down */ |
| radial-gradient(ellipse 1200px 400px at 20% 0%, rgba(255, 255, 255, 0.6), transparent 70%), |
| radial-gradient(ellipse 1000px 350px at 80% 3%, rgba(255, 255, 255, 0.5), transparent 70%), |
| radial-gradient(ellipse 900px 300px at 50% 5%, rgba(255, 255, 255, 0.55), transparent 70%), |
| radial-gradient(ellipse 800px 250px at 10% 8%, rgba(255, 255, 255, 0.45), transparent 70%), |
| radial-gradient(ellipse 700px 200px at 90% 10%, rgba(255, 255, 255, 0.4), transparent 70%), |
| radial-gradient(ellipse 600px 180px at 40% 12%, rgba(255, 255, 255, 0.35), transparent 70%), |
| radial-gradient(ellipse 500px 150px at 60% 15%, rgba(255, 255, 255, 0.3), transparent 70%), |
| /* Base sky gradient - REVERSED: lighter at top, deeper blue at bottom */ |
| linear-gradient(180deg, #E0F4FF 0%, #B0E2FF 40%, #87CEEB 100%) !important; |
| } |
| |
| |
| /* ======================================================================== |
| SIDEBAR STYLING - DARK THEME |
| ======================================================================== */ |
| |
| .sidebar { |
| background: #1f2937 !important; |
| padding: 24px 20px !important; |
| border-radius: 12px !important; |
| border: 1px solid #374151 !important; |
| } |
| |
| /* Hide Gradio's default loading indicator in sidebar (we use badge for loading state) */ |
| .sidebar .loading, |
| .sidebar .wrap.pending, |
| .sidebar .progress-bar, |
| .sidebar [class*="loading"], |
| .sidebar [class*="progress"] { |
| display: none !important; |
| } |
| |
| /* Also hide the loading indicator that appears as a child of the sidebar */ |
| .gradio-container .sidebar ~ * .loading, |
| .gradio-container .sidebar ~ * .progress-bar { |
| display: none !important; |
| } |
| |
| /* Hide Gradio's global top progress bar (the blue horizontal line) */ |
| .app > div > div > .progress-level-inner, |
| body > gradio-app > div > div > div.progress-level-inner, |
| [class*="progress-level"], |
| .progress-level-inner { |
| display: none !important; |
| visibility: hidden !important; |
| } |
| |
| /* Make all sidebar text light for dark background */ |
| .sidebar h1, |
| .sidebar h2, |
| .sidebar h3, |
| .sidebar h4, |
| .sidebar h5, |
| .sidebar h6 { |
| color: #f9fafb !important; |
| } |
| |
| .sidebar p, |
| .sidebar span, |
| .sidebar label { |
| color: #d1d5db !important; |
| } |
| |
| /* Keep links distinguishable */ |
| .sidebar a { |
| color: #818cf8 !important; |
| text-decoration: underline !important; |
| } |
| |
| .sidebar a:hover { |
| color: #a5b4fc !important; |
| } |
| |
| /* API Key sections */ |
| .hf-section, .openai-section, .anthropic-section { |
| margin-top: 12px !important; |
| } |
| |
| /* Dark theme input styling */ |
| .sidebar input[type="password"], |
| .sidebar input[type="text"], |
| .sidebar textarea { |
| border: 1px solid #374151 !important; |
| border-radius: 8px !important; |
| padding: 10px 14px !important; |
| font-size: 14px !important; |
| font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace !important; |
| background: #111827 !important; |
| color: #f9fafb !important; |
| transition: all 0.2s ease !important; |
| } |
| |
| .sidebar input[type="password"]::placeholder, |
| .sidebar input[type="text"]::placeholder, |
| .sidebar textarea::placeholder { |
| color: #6b7280 !important; |
| } |
| |
| .sidebar input[type="password"]:focus, |
| .sidebar input[type="text"]:focus, |
| .sidebar textarea:focus { |
| border-color: #818cf8 !important; |
| box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.2) !important; |
| outline: none !important; |
| background: #1f2937 !important; |
| } |
| |
| /* ======================================================================== |
| CHATBOT & TOOL LOG PANELS |
| ======================================================================== */ |
| |
| .chatbot-container { |
| border-radius: 12px !important; |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important; |
| border: 1px solid #e5e7eb !important; |
| } |
| |
| /* Force icon SVG elements to use light colors for visibility on dark background */ |
| .chatbot-container svg, |
| .chatbot-container svg path, |
| .chatbot-container svg circle, |
| .chatbot-container svg rect { |
| fill: #d1d5db !important; |
| stroke: #d1d5db !important; |
| } |
| |
| .tool-log-panel textarea { |
| background: #1f2937 !important; |
| border-radius: 12px !important; |
| padding: 20px !important; |
| border: 1px solid #374151 !important; |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important; |
| font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace !important; |
| font-size: 13px !important; |
| line-height: 1.6 !important; |
| color: #d1d5db !important; |
| resize: none !important; |
| height: 500px !important; |
| min-height: 500px !important; |
| max-height: 500px !important; |
| overflow-y: auto !important; |
| } |
| |
| /* Ensure tool log panel container aligns perfectly */ |
| .tool-log-panel { |
| margin: 0 !important; |
| padding: 0 !important; |
| } |
| |
| .tool-log-panel textarea::-webkit-scrollbar { |
| width: 8px !important; |
| } |
| |
| .tool-log-panel textarea::-webkit-scrollbar-track { |
| background: #111827 !important; |
| border-radius: 4px !important; |
| } |
| |
| .tool-log-panel textarea::-webkit-scrollbar-thumb { |
| background: #4b5563 !important; |
| border-radius: 4px !important; |
| } |
| |
| .tool-log-panel textarea::-webkit-scrollbar-thumb:hover { |
| background: #6b7280 !important; |
| } |
| |
| hr { |
| border: none !important; |
| border-top: 1px solid #374151 !important; |
| margin: 20px 0 !important; |
| } |
| |
| .sidebar hr { |
| border-top-color: #374151 !important; |
| } |
| |
| /* ======================================================================== |
| TEXT ON LIGHT BACKGROUND - MAKE DARK FOR READABILITY |
| ======================================================================== */ |
| |
| /* All text elements outside dark panels should be dark for readability */ |
| .gradio-container label:not(.sidebar label):not(.tool-log-panel label):not(.chatbot-container label), |
| .gradio-container span:not(.sidebar span):not(.tool-log-panel span):not(.chatbot-container span):not(.birdscope-header span), |
| .gradio-container p:not(.sidebar p):not(.tool-log-panel p):not(.chatbot-container p):not(.birdscope-header p), |
| .gradio-container div:not(.sidebar div):not(.tool-log-panel div):not(.chatbot-container div):not(.birdscope-header div) { |
| color: #1a1a1a !important; |
| } |
| |
| /* Markdown text outside dark panels */ |
| .gradio-container .markdown:not(.sidebar .markdown):not(.tool-log-panel .markdown) { |
| color: #1a1a1a !important; |
| } |
| |
| /* Markdown headings - ensure all are black on light background (except sidebar) */ |
| .gradio-container .markdown:not(.sidebar .markdown) h1, |
| .gradio-container .markdown:not(.sidebar .markdown) h2, |
| .gradio-container .markdown:not(.sidebar .markdown) h3, |
| .gradio-container .markdown:not(.sidebar .markdown) h4, |
| .gradio-container .markdown:not(.sidebar .markdown) h5, |
| .gradio-container .markdown:not(.sidebar .markdown) h6 { |
| color: #1a1a1a !important; |
| } |
| |
| /* Regular buttons (not primary) should have dark text */ |
| button:not([variant="primary"]) { |
| color: #1a1a1a !important; |
| } |
| |
| /* BUT sidebar buttons should have light text (override above) */ |
| .sidebar button:not([variant="primary"]), |
| .sidebar button:not([variant="primary"]) span, |
| .sidebar button:not([variant="primary"]) * { |
| color: #f9fafb !important; |
| } |
| |
| /* Modal check button with logo */ |
| .modal-check-btn { |
| background: rgba(59, 130, 246, 0.1) !important; |
| border: 1px solid rgba(59, 130, 246, 0.3) !important; |
| border-radius: 9999px !important; |
| transition: all 0.2s ease !important; |
| cursor: pointer !important; |
| } |
| |
| .modal-check-btn:hover { |
| background: rgba(59, 130, 246, 0.2) !important; |
| border-color: rgba(59, 130, 246, 0.5) !important; |
| transform: translateY(-1px); |
| box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important; |
| } |
| |
| .modal-check-btn:active { |
| transform: translateY(0); |
| } |
| |
| .modal-check-btn::before { |
| content: ""; |
| display: inline-block; |
| width: 18px; |
| height: 18px; |
| margin-right: 8px; |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' fill='none'%3E%3C!-- Left ribbon --%3E%3Cpath d='M0 30 L25 15 L50 30 L50 70 L25 85 L0 70 Z' fill='%2335D07F'/%3E%3Cpath d='M25 15 L50 30 L25 45 L0 30 Z' fill='%2388E5A8'/%3E%3Cpath d='M25 45 L50 30 L50 70 L25 85 Z' fill='%2315B866'/%3E%3C!-- Right ribbon --%3E%3Cpath d='M50 30 L75 15 L100 30 L100 70 L75 85 L50 70 Z' fill='%2335D07F'/%3E%3Cpath d='M75 15 L100 30 L75 45 L50 30 Z' fill='%2388E5A8'/%3E%3Cpath d='M75 45 L100 30 L100 70 L75 85 Z' fill='%2315B866'/%3E%3C/svg%3E"); |
| background-size: contain; |
| background-repeat: no-repeat; |
| background-position: center; |
| vertical-align: middle; |
| } |
| |
| /* ======================================================================== |
| EXAMPLES - BLACK TEXT FOR READABILITY |
| ======================================================================== */ |
| |
| /* Examples label - force black text with very high specificity */ |
| label.svelte-1gfkn6j, |
| .label, |
| span.svelte-1gfkn6j { |
| color: #1a1a1a !important; |
| } |
| |
| /* Target example buttons specifically, excluding footer */ |
| .gradio-container button:not([variant="primary"]):not(.sidebar button):not(footer button):not([class*="footer"] button) { |
| color: #1a1a1a !important; |
| } |
| |
| /* Footer text should be black on light background */ |
| footer, |
| footer *, |
| footer a, |
| [class*="footer"], |
| [class*="footer"] *, |
| [class*="footer"] a { |
| color: #1a1a1a !important; |
| } |
| |
| /* ======================================================================== |
| ENHANCED HEADER - BIRDSCOPE BRANDING |
| ======================================================================== */ |
| |
| @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500;700&display=swap'); |
| |
| .birdscope-header { |
| position: relative; |
| overflow: hidden; |
| padding: 2rem 1.5rem; |
| } |
| |
| /* Decorative cloud elements */ |
| .cloud-decor-1 { |
| position: absolute; |
| top: -2.5rem; |
| right: 2.5rem; |
| width: 10rem; |
| height: 10rem; |
| background: rgba(255, 255, 255, 0.4); |
| border-radius: 50%; |
| filter: blur(60px); |
| pointer-events: none; |
| } |
| |
| .cloud-decor-2 { |
| position: absolute; |
| top: 0; |
| right: 33%; |
| width: 8rem; |
| height: 8rem; |
| background: rgba(224, 242, 254, 0.5); |
| border-radius: 50%; |
| filter: blur(40px); |
| pointer-events: none; |
| } |
| |
| .cloud-decor-3 { |
| position: absolute; |
| top: -1.25rem; |
| left: 5rem; |
| width: 6rem; |
| height: 6rem; |
| background: rgba(255, 255, 255, 0.3); |
| border-radius: 50%; |
| filter: blur(40px); |
| pointer-events: none; |
| } |
| |
| /* Flying birds animation */ |
| @keyframes drift { |
| 0%, 100% { transform: translateX(0) translateY(0); } |
| 50% { transform: translateX(10px) translateY(-5px); } |
| } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(5px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .bird-silhouette { |
| position: absolute; |
| animation: drift 8s ease-in-out infinite; |
| } |
| |
| .bird-1 { top: 1.5rem; right: 8rem; width: 1.25rem; height: 1.25rem; color: rgba(148, 163, 184, 0.3); } |
| .bird-2 { top: 2.5rem; right: 12rem; width: 1rem; height: 1rem; color: rgba(148, 163, 184, 0.2); animation-delay: 1s; } |
| .bird-3 { top: 1rem; right: 16rem; width: 0.75rem; height: 0.75rem; color: rgba(148, 163, 184, 0.15); animation-delay: 2s; } |
| |
| /* Logo container */ |
| .bird-logo-wrapper { |
| position: relative; |
| display: inline-block; |
| } |
| |
| .bird-logo-glow { |
| position: absolute; |
| inset: 0; |
| background: linear-gradient(135deg, #38bdf8 0%, #3b82f6 100%); |
| border-radius: 1rem; |
| filter: blur(8px); |
| opacity: 0.3; |
| transition: opacity 0.3s; |
| } |
| |
| .bird-logo-wrapper:hover .bird-logo-glow { |
| opacity: 0.5; |
| } |
| |
| .bird-logo { |
| position: relative; |
| background: linear-gradient(135deg, #38bdf8 0%, #3b82f6 100%); |
| padding: 0.75rem; |
| border-radius: 1rem; |
| box-shadow: 0 10px 25px rgba(56, 189, 248, 0.2); |
| } |
| |
| /* Header content */ |
| .header-content { |
| position: relative; |
| z-index: 10; |
| max-width: 72rem; |
| margin: 0 auto; |
| } |
| |
| .header-top { |
| display: flex; |
| align-items: center; |
| gap: 1rem; |
| } |
| |
| .header-title-group h1 { |
| font-family: 'Quicksand', 'Nunito', sans-serif !important; |
| font-size: 1.875rem !important; |
| font-weight: 700 !important; |
| color: #1e293b !important; |
| letter-spacing: -0.025em !important; |
| margin: 0 !important; |
| display: inline !important; |
| } |
| |
| .header-ai-text { |
| font-size: 1.5rem; |
| font-weight: 300; |
| color: #0ea5e9; |
| margin-left: 0.5rem; |
| } |
| |
| .header-v2-badge { |
| display: inline-block; |
| padding: 0.125rem 0.5rem; |
| font-size: 0.75rem; |
| font-weight: 600; |
| background: linear-gradient(to right, #fbbf24, #f97316); |
| color: white; |
| border-radius: 9999px; |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); |
| margin-left: 0.5rem; |
| } |
| |
| .header-subtitle { |
| color: #64748b !important; |
| font-size: 0.875rem !important; |
| margin-top: 0.125rem !important; |
| } |
| |
| .mcp-badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 0.5rem; |
| padding: 0.375rem 0.75rem; |
| background: rgba(255, 255, 255, 0.6); |
| backdrop-filter: blur(8px); |
| border: 1px solid #e2e8f0; |
| border-radius: 6px; |
| font-size: 0.75rem; |
| color: #1a1a1a !important; |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); |
| margin-left: auto; |
| user-select: none; |
| } |
| |
| .mcp-badge span { |
| color: #1a1a1a !important; |
| } |
| |
| .mcp-badge.checking { |
| animation: badgePulse 1.5s ease-in-out infinite !important; |
| background: rgba(251, 191, 36, 0.15) !important; |
| border-color: #fbbf24 !important; |
| } |
| |
| /* White text while checking */ |
| .mcp-badge.checking span { |
| color: #ffffff !important; |
| } |
| |
| /* Disable hover effects while checking */ |
| .mcp-badge.checking:hover { |
| transform: none !important; |
| animation: badgePulse 1.5s ease-in-out infinite !important; |
| } |
| |
| .mcp-badge.checking .mcp-pulse { |
| background: #fbbf24; |
| animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; |
| } |
| |
| .mcp-badge.offline .mcp-pulse { |
| background: #ef4444; |
| animation: none; |
| } |
| |
| .mcp-badge.online .mcp-pulse { |
| background: #34d399; |
| } |
| |
| .mcp-pulse { |
| width: 0.5rem; |
| height: 0.5rem; |
| background: #34d399; |
| border-radius: 50%; |
| animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; |
| } |
| |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.5; } |
| } |
| |
| @keyframes badgePulse { |
| 0%, 100% { |
| opacity: 1; |
| transform: scale(1); |
| } |
| 50% { |
| opacity: 0.8; |
| transform: scale(1.08); |
| } |
| } |
| |
| /* Feature tags */ |
| .feature-tags { |
| margin-top: 1.25rem; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 0.5rem; |
| } |
| |
| .feature-tag { |
| display: inline-flex; |
| align-items: center; |
| gap: 0.5rem; |
| padding: 0.375rem 0.75rem; |
| background: rgba(255, 255, 255, 0.7); |
| backdrop-filter: blur(8px); |
| border: 1px solid rgba(226, 232, 240, 0.8); |
| border-radius: 9999px; |
| font-size: 0.875rem; |
| color: #1a1a1a !important; |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); |
| cursor: default; |
| animation: fadeIn 0.4s ease-out forwards; |
| opacity: 0; |
| } |
| |
| .feature-tag span { |
| color: #1a1a1a !important; |
| } |
| |
| .feature-tag:nth-child(1) { animation-delay: 0ms; } |
| .feature-tag:nth-child(2) { animation-delay: 80ms; } |
| .feature-tag:nth-child(3) { animation-delay: 160ms; } |
| .feature-tag:nth-child(4) { animation-delay: 240ms; } |
| |
| /* Bottom border */ |
| .header-border { |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| height: 1px; |
| background: linear-gradient(to right, transparent, #e2e8f0, transparent); |
| } |
| |
| /* Mobile responsive */ |
| @media (max-width: 640px) { |
| .mcp-badge { |
| display: none; |
| } |
| .header-top { |
| flex-direction: column; |
| align-items: flex-start; |
| } |
| } |
| |
| /* ======================================================================== |
| ONBOARDING FLOW STYLING |
| ======================================================================== */ |
| |
| /* Center and constrain onboarding pages */ |
| .onboarding-page { |
| max-width: 500px !important; |
| margin: 2rem auto !important; |
| padding: 32px !important; |
| } |
| |
| /* Ensure welcome text is visible on dark background */ |
| .welcome-text h1, .api-key-text h1 { |
| color: #f9fafb !important; |
| } |
| |
| /* Scroll animation for step transitions */ |
| .onboarding-page { |
| animation: fadeInStep 0.3s ease-out; |
| } |
| |
| @keyframes fadeInStep { |
| from { |
| opacity: 0.5; |
| transform: translateY(10px); |
| } |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| |
| /* ======================================================================== |
| README TAB STYLING - BLACK TEXT ON WHITE BACKGROUND |
| ======================================================================== */ |
| |
| .readme-tab-container { |
| background-color: #ffffff !important; |
| padding: 2rem !important; |
| border-radius: 12px !important; |
| max-width: 1200px !important; |
| margin: 1rem auto !important; |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important; |
| } |
| |
| .readme-markdown, |
| .readme-markdown *, |
| .readme-markdown h1, |
| .readme-markdown h2, |
| .readme-markdown h3, |
| .readme-markdown h4, |
| .readme-markdown h5, |
| .readme-markdown h6, |
| .readme-markdown p, |
| .readme-markdown li, |
| .readme-markdown span, |
| .readme-markdown div, |
| .readme-markdown strong, |
| .readme-markdown em, |
| .readme-markdown code { |
| color: #000000 !important; |
| background-color: transparent !important; |
| } |
| |
| .readme-markdown a { |
| color: #2563eb !important; |
| text-decoration: underline !important; |
| } |
| |
| .readme-markdown a:hover { |
| color: #1d4ed8 !important; |
| } |
| |
| .readme-markdown code { |
| background-color: #f3f4f6 !important; |
| padding: 2px 6px !important; |
| border-radius: 4px !important; |
| font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace !important; |
| } |
| |
| .readme-markdown pre { |
| background-color: #f3f4f6 !important; |
| padding: 1rem !important; |
| border-radius: 8px !important; |
| overflow-x: auto !important; |
| } |
| |
| .readme-markdown pre code { |
| background-color: transparent !important; |
| padding: 0 !important; |
| } |
| |
| .readme-markdown blockquote { |
| border-left: 4px solid #e5e7eb !important; |
| padding-left: 1rem !important; |
| color: #4b5563 !important; |
| } |
| |
| .readme-markdown hr { |
| border-top: 1px solid #e5e7eb !important; |
| } |
| |
| .readme-markdown table { |
| border-collapse: collapse !important; |
| width: 100% !important; |
| } |
| |
| .readme-markdown table th, |
| .readme-markdown table td { |
| border: 1px solid #e5e7eb !important; |
| padding: 0.5rem !important; |
| } |
| |
| .readme-markdown table th { |
| background-color: #f9fafb !important; |
| font-weight: 600 !important; |
| } |
| """ |
|
|
| |
| |
| |
|
|
| def format_tool_output_for_chat(tool_output): |
| """ |
| Parse tool output and format images/content for display in chatbot. |
| Detects image URLs and converts them to markdown image syntax. |
| |
| Handles both JSON-formatted MCP responses and plain text. |
| """ |
| import re |
|
|
| |
| if hasattr(tool_output, 'content'): |
| output_str = tool_output.content |
| print(f"[FORMAT_TOOL_OUTPUT] Extracted content from ToolMessage") |
| elif isinstance(tool_output, dict) and 'content' in tool_output: |
| output_str = tool_output['content'] |
| print(f"[FORMAT_TOOL_OUTPUT] Extracted content from dict") |
| else: |
| output_str = str(tool_output) |
| print(f"[FORMAT_TOOL_OUTPUT] Using str() fallback") |
|
|
| image_urls = [] |
|
|
| |
| try: |
| import json |
| parsed = json.loads(output_str) |
| print(f"[FORMAT_TOOL_OUTPUT] Successfully parsed JSON") |
|
|
| |
| if isinstance(parsed, dict): |
| |
| data = parsed.get("data", []) |
| if isinstance(data, list): |
| |
| for item in data: |
| if isinstance(item, str) and item.startswith("http"): |
| image_urls.append(item) |
| elif isinstance(data, str) and data.startswith("http"): |
| image_urls.append(data) |
|
|
| |
| for key, value in parsed.items(): |
| if isinstance(value, list): |
| for item in value: |
| if isinstance(item, str) and item.startswith("http") and any(ext in item.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']): |
| image_urls.append(item) |
| except (json.JSONDecodeError, ValueError): |
| |
| pass |
|
|
| |
| if not image_urls: |
| |
| |
| image_pattern = r'https?://[^\s]+?\.(?:jpg|jpeg|png|gif|webp|svg)(?:\?[^\s"]*)?' |
| found_urls = re.findall(image_pattern, output_str, re.IGNORECASE) |
| image_urls.extend(found_urls) |
|
|
| |
| seen = set() |
| unique_urls = [] |
| for url in image_urls: |
| |
| clean_url = url.rstrip('",}]') |
| if clean_url not in seen: |
| seen.add(clean_url) |
| unique_urls.append(clean_url) |
|
|
| if unique_urls: |
| |
| formatted_output = "" |
| for url in unique_urls[:3]: |
| formatted_output += f"\n\n" |
| print(f"[FORMAT_TOOL_OUTPUT] ✅ Formatted {len(unique_urls[:3])} images as markdown") |
| return formatted_output |
|
|
| |
| if len(output_str) > 200: |
| return output_str[:200] + "...\n\n" |
|
|
| return output_str + "\n\n" if output_str else "" |
|
|
| async def chat_with_tool_visibility( |
| message, |
| history, |
| provider, |
| hf_key, |
| openai_key, |
| anthropic_key, |
| agent_mode, |
| request: gr.Request, |
| progress=gr.Progress() |
| ): |
| """ |
| Dual-output streaming: chat response + tool execution log |
| |
| Yields: tuple(chat_response_text, tool_log_markdown) |
| """ |
| |
| |
| |
| if provider == "HuggingFace": |
| api_key = (hf_key.strip() if hf_key and hf_key.strip() |
| else os.getenv("HF_API_KEY", "")) |
| if not api_key: |
| yield "**API Key Required**\n\nPlease enter your HuggingFace API key in the sidebar.", "*Waiting for API key...*" |
| return |
| provider_key = "huggingface" |
| model = AgentConfig.DEFAULT_HF_MODEL |
| elif provider == "Anthropic": |
| api_key = (anthropic_key.strip() if anthropic_key and anthropic_key.strip() |
| else os.getenv("ANTHROPIC_API_KEY", "")) |
| if not api_key: |
| yield "**API Key Required**\n\nPlease enter your Anthropic API key in the sidebar.", "*Waiting for API key...*" |
| return |
| provider_key = "anthropic" |
| model = AgentConfig.DEFAULT_ANTHROPIC_MODEL |
| else: |
| api_key = (openai_key.strip() if openai_key and openai_key.strip() |
| else os.getenv("OPENAI_API_KEY", "")) |
| if not api_key: |
| yield "**API Key Required**\n\nPlease enter your OpenAI API key in the sidebar.", "*Waiting for API key...*" |
| return |
| provider_key = "openai" |
| model = AgentConfig.DEFAULT_OPENAI_MODEL |
|
|
| |
| |
| |
| progress(0.1, desc="🔧 Initializing agent...") |
|
|
| try: |
| session_id = request.session_hash |
|
|
| |
| agent = await get_or_create_agent( |
| session_id=session_id, |
| provider=provider_key, |
| api_key=api_key, |
| model=model, |
| mode=agent_mode, |
| agent_factory_method=lambda: AgentFactory.create_subagent_orchestrator( |
| model=model, |
| api_key=api_key, |
| provider=provider_key, |
| mode=agent_mode |
| ) |
| ) |
| except Exception as e: |
| yield f"**Agent Creation Failed**\n\n{str(e)}", "*Agent creation failed*" |
| return |
|
|
| progress(0.3, desc="🤖 Agent ready...") |
|
|
| config = {"configurable": {"thread_id": session_id}} |
|
|
| |
| |
| |
| |
| chat_response = "" |
| tool_log = "" |
| tool_count = 0 |
|
|
| user_text = "" |
| if isinstance(message, dict): |
| user_text = message.get("text", "") |
| print(f"[DEBUG MESSAGE] User query: {user_text}") |
| files = message.get("files", []) |
|
|
| |
| if files and len(files) > 0: |
| image_path = files[0] |
|
|
| if image_path.startswith("http"): |
| |
| user_text += f"\n\nWhat bird is this? {image_path}" |
| else: |
| |
| tool_log += "🟢 Pre-Classification (Direct MODAL MCP Call)\n" |
| tool_log += "Tool: classify_from_base64\n" |
| tool_log += "Status: Calling Modal GPU classifier directly to avoid token limits...\n\n" |
| yield chat_response, tool_log |
|
|
| with open(image_path, "rb") as img_file: |
| image_data = base64.b64encode(img_file.read()).decode('utf-8') |
|
|
| |
| transport = StreamableHttpTransport( |
| url=AgentConfig.MODAL_MCP_URL, |
| headers={"X-API-Key": AgentConfig.BIRD_CLASSIFIER_API_KEY} |
| ) |
| async with Client(transport) as client: |
| result = await client.call_tool( |
| "classify_from_base64", |
| arguments={"image_data": image_data} |
| ) |
|
|
| if result and result.content: |
| classification = json.loads(result.content[0].text) |
| species = classification.get("species", "Unknown") |
| confidence = classification.get("confidence", 0) |
|
|
| |
| tool_log += f"✅ Result: {species} ({confidence:.1%})\n" |
| tool_log += f"{json.dumps(classification, indent=2)}\n\n" |
| tool_log += "---\n\n" |
|
|
| |
| user_text += f"\n\nI uploaded a bird image. The classifier identified it as: {species} (confidence: {confidence:.1%}). Can you tell me more about this bird?" |
| else: |
| tool_log += "❌ Failed\n\n---\n\n" |
| user_text += "\n\n⚠️ Failed to classify the uploaded image." |
|
|
| yield chat_response, tool_log |
| else: |
| user_text = message |
|
|
| |
| |
| |
| |
| progress(0.5, desc="💭 Thinking...") |
| chat_response = "💭 _Thinking..._" |
| tool_log += "🔵 Agent started processing...\n" |
| yield chat_response, tool_log |
|
|
| print(f"[DEBUG AGENT INPUT] Sending to agent: {user_text}") |
| async for event in agent.astream_events( |
| {"messages": [{"role": "user", "content": user_text}]}, |
| config, |
| version="v2" |
| ): |
| kind = event["event"] |
|
|
| |
| if kind == "on_tool_start": |
| tool_count += 1 |
| tool_name = event["name"] |
| tool_input = event.get("data", {}).get("input", {}) |
|
|
| |
| progress(0.6 + (tool_count * 0.05), desc=f"🔍 Using {tool_name}...") |
|
|
| |
| tool_log += f"\n🟢 Tool #{tool_count}: {tool_name}\n" |
| tool_log += f"Status: Running...\n" |
| tool_log += f"Input:\n{json.dumps(tool_input, indent=2)}\n\n" |
|
|
| |
| chat_response += f"\n\n<tool_call>🔧 Using {tool_name}...</tool_call>\n\n" |
|
|
| yield chat_response, tool_log |
|
|
| |
| elif kind == "on_chat_model_stream": |
| content = event["data"]["chunk"].content |
| if content: |
| |
| if chat_response == "💭 _Thinking..._": |
| print("[STREAM] Clearing 'Thinking...' placeholder") |
| chat_response = "" |
| progress(0.7, desc="📝 Generating response...") |
|
|
| |
| content_to_add = "" |
| if isinstance(content, list): |
| |
| for block in content: |
| if hasattr(block, 'text'): |
| content_to_add += block.text |
| elif isinstance(block, dict) and 'text' in block: |
| content_to_add += block['text'] |
| else: |
| |
| content_to_add = content |
|
|
| if content_to_add: |
| print(f"[STREAM] Adding LLM content: {content_to_add[:100]}...") |
| chat_response += content_to_add |
| yield chat_response, tool_log |
|
|
| |
| elif kind == "on_tool_end": |
| tool_output = event.get("data", {}).get("output", "") |
|
|
| |
| progress(0.8, desc="📊 Processing results...") |
|
|
| |
| output_str = str(tool_output) |
| if len(output_str) > 1000: |
| output_str = output_str[:1000] + "\n...(truncated)" |
|
|
| |
| tool_log += f"✅ Status: Completed\n" |
| tool_log += f"Output:\n{output_str}\n\n" |
| tool_log += "---\n\n" |
|
|
| |
| formatted_output = format_tool_output_for_chat(tool_output) |
| if formatted_output.strip(): |
| print(f"[STREAM] Adding formatted tool output ({len(formatted_output)} chars): {formatted_output[:200]}...") |
| print(f"[STREAM] chat_response length before: {len(chat_response)}") |
| chat_response += formatted_output |
| print(f"[STREAM] chat_response length after: {len(chat_response)}") |
|
|
| yield chat_response, tool_log |
|
|
| |
| |
| |
| progress(0.9, desc="✨ Finalizing response...") |
|
|
| print(f"\n[FINAL] chat_response length before parsing: {len(chat_response)}") |
| print(f"[FINAL] chat_response preview (first 300): {chat_response[:300]}") |
| print(f"[FINAL] chat_response preview (last 300): {chat_response[-300:]}\n") |
|
|
| try: |
| from langgraph_agent.structured_output import parse_agent_response |
| formatted_response = await parse_agent_response( |
| raw_response=chat_response, |
| provider=provider_key, |
| api_key=api_key, |
| model=model |
| ) |
| print(f"\n[FINAL] Formatted response length: {len(formatted_response)}") |
| print(f"[FINAL] Formatted response (last 800 chars): {formatted_response[-800:]}") |
| print(f"[FINAL] Image markdown count: {formatted_response.count('![')}") |
| progress(1.0, desc="✅ Complete") |
| yield formatted_response, tool_log |
| except ImportError: |
| |
| progress(1.0, desc="✅ Complete") |
| yield chat_response, tool_log |
| except Exception as e: |
| |
| print(f"[STRUCTURED OUTPUT ERROR]: {e}") |
| progress(1.0, desc="✅ Complete") |
| yield chat_response, tool_log |
|
|
|
|
| |
| |
| |
|
|
| async def check_modal_server_health(): |
| """ |
| Check if Modal MCP server is alive and warm. |
| Returns status message for UI display. |
| """ |
| import asyncio |
|
|
| print("[DEBUG] Health check started...") |
|
|
| async def do_health_check(): |
| transport = StreamableHttpTransport( |
| url=AgentConfig.MODAL_MCP_URL, |
| headers={"X-API-Key": AgentConfig.BIRD_CLASSIFIER_API_KEY} |
| ) |
|
|
| async with Client(transport) as client: |
| |
| tools = await client.list_tools() |
| if tools and len(tools) > 0: |
| return f"✅ Online ({len(tools)} tools ready)" |
| else: |
| return "⚠️ Server responded but no tools found" |
|
|
| try: |
| |
| result = await asyncio.wait_for(do_health_check(), timeout=60.0) |
| print(f"[DEBUG] Health check result: {result}") |
| return result |
|
|
| except asyncio.TimeoutError: |
| print("[DEBUG] Health check timeout") |
| return "⏱️ Timeout (still warming up...)" |
| except Exception as e: |
| print(f"[DEBUG] Health check error: {e}") |
| error_msg = str(e) |
| if "401" in error_msg or "Unauthorized" in error_msg: |
| return "🔐 Auth failed" |
| elif "timeout" in error_msg.lower(): |
| return "⏱️ Timeout (waking up...)" |
| else: |
| return f"❌ Offline" |
|
|
| def show_immediate_loading(message, history, tool_log_state): |
| """ |
| Show immediate loading indicator when user submits a message. |
| This provides instant feedback before async processing begins. |
| |
| Returns: (updated_history, updated_tool_log) |
| """ |
| |
| |
| updated_history = history + [ |
| {"role": "assistant", "content": "⏳ _Starting..._"} |
| ] |
|
|
| |
| updated_tool_log = "🔵 Initializing agent...\n" |
|
|
| return updated_history, updated_tool_log |
|
|
| |
| async def chat_wrapper(message, history, provider, hf_key, openai_key, anthropic_key, agent_mode, tool_log_state, request: gr.Request, progress=gr.Progress()): |
| """ |
| Wrapper to convert chat outputs to Gradio 6 message format. |
| |
| Returns: (updated_history, updated_tool_log) |
| """ |
| |
| print(f"[DEBUG] chat_wrapper received - provider: {provider}, hf_key: {'***' if hf_key else 'None'}, openai_key: {'***' if openai_key else 'None'}, anthropic_key: {'***' if anthropic_key else 'None'}") |
|
|
| |
| if isinstance(message, dict): |
| user_message_text = message.get("text", "") |
| else: |
| user_message_text = message |
|
|
| |
| if (len(history) >= 1 and |
| history[-1].get("role") == "assistant" and |
| history[-1].get("content") == "⏳ _Starting..._"): |
| |
| history = history[:-1] |
|
|
| |
| history = history + [{"role": "user", "content": user_message_text}] |
|
|
| |
| async for chat_text, tool_log_text in chat_with_tool_visibility(message, history, provider, hf_key, openai_key, anthropic_key, agent_mode, request, progress): |
| |
| updated_history = history + [{"role": "assistant", "content": chat_text}] |
| yield updated_history, tool_log_text |
|
|
| |
| |
| |
|
|
| |
| def update_text_examples_for_mode(mode): |
| """Return appropriate text example dataset based on agent mode.""" |
| print(f"[DEBUG] Updating text examples for mode: {mode}") |
|
|
| |
| |
| |
| |
| |
|
|
| |
| samples = [[text] for text in MULTI_AGENT_TEXT_EXAMPLES] |
| print(f"[DEBUG] Multi-agent text samples: {len(samples)} examples") |
|
|
| return gr.Dataset(samples=samples) |
|
|
| |
| def create_config_html(provider_choice, agent_mode_choice, hf_key_input, openai_key_input, anthropic_key_input=""): |
| """Generate sky-themed config card HTML.""" |
| |
| if provider_choice == "HuggingFace": |
| model = AgentConfig.DEFAULT_HF_MODEL |
| has_key = bool((hf_key_input and hf_key_input.strip()) or os.getenv("HF_API_KEY")) |
| elif provider_choice == "Anthropic": |
| model = AgentConfig.DEFAULT_ANTHROPIC_MODEL |
| has_key = bool((anthropic_key_input and anthropic_key_input.strip()) or os.getenv("ANTHROPIC_API_KEY")) |
| else: |
| model = AgentConfig.DEFAULT_OPENAI_MODEL |
| has_key = bool((openai_key_input and openai_key_input.strip()) or os.getenv("OPENAI_API_KEY")) |
|
|
| |
| mode_display = "3 Specialists" if "Specialized Subagents" in agent_mode_choice else "Audio Finder" |
|
|
| |
| if has_key: |
| status_bg = "rgba(16, 185, 129, 0.2)" |
| status_color = "#10b981" |
| status_icon = "✓" |
| else: |
| status_bg = "rgba(239, 68, 68, 0.2)" |
| status_color = "#ef4444" |
| status_icon = "✗" |
|
|
| return f""" |
| <div style=" |
| background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%); |
| border-radius: 12px; |
| padding: 16px 20px; |
| font-family: 'Segoe UI', system-ui, sans-serif; |
| border: 1px solid #374151; |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); |
| backdrop-filter: blur(10px); |
| "> |
| <!-- Provider Row --> |
| <div style=" |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 6px 0; |
| "> |
| <span style="font-size: 12px; color: #9ca3af;">Provider</span> |
| <div style="display: flex; align-items: center; gap: 6px;"> |
| <span style=" |
| font-size: 13px; |
| font-weight: 500; |
| color: #f9fafb; |
| ">{provider_choice}</span> |
| <span style=" |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| width: 18px; |
| height: 18px; |
| border-radius: 50%; |
| background: {status_bg}; |
| color: {status_color}; |
| font-size: 11px; |
| font-weight: bold; |
| ">{status_icon}</span> |
| </div> |
| </div> |
| |
| <!-- Model Row --> |
| <div style=" |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 6px 0; |
| "> |
| <span style="font-size: 12px; color: #9ca3af;">Model</span> |
| <span style=" |
| font-size: 12px; |
| font-weight: 500; |
| color: #60a5fa; |
| font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; |
| background: rgba(59, 130, 246, 0.15); |
| padding: 2px 8px; |
| border-radius: 4px; |
| ">{model}</span> |
| </div> |
| |
| <!-- Mode Row --> |
| <div style=" |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 6px 0; |
| "> |
| <span style="font-size: 12px; color: #9ca3af;">Mode</span> |
| <span style=" |
| font-size: 12px; |
| font-weight: 500; |
| color: #38bdf8; |
| background: rgba(56, 189, 248, 0.15); |
| padding: 3px 10px; |
| border-radius: 20px; |
| border: 1px solid rgba(56, 189, 248, 0.3); |
| ">{mode_display}</span> |
| </div> |
| </div> |
| """ |
|
|
| with gr.Blocks() as demo: |
|
|
| |
| |
| |
| stored_hf_key = gr.State("") |
| stored_openai_key = gr.State("") |
| stored_anthropic_key = gr.State("") |
|
|
| |
| gr.HTML(""" |
| <header class="birdscope-header"> |
| <!-- Decorative cloud elements --> |
| <div style="position: absolute; inset: 0; overflow: hidden; pointer-events: none;"> |
| <div class="cloud-decor-1"></div> |
| <div class="cloud-decor-2"></div> |
| <div class="cloud-decor-3"></div> |
| |
| <!-- Flying bird silhouettes --> |
| <svg class="bird-silhouette bird-1" viewBox="0 0 24 24" fill="currentColor"> |
| <path d="M3.5 12C3.5 12 6 9 12 9C18 9 20.5 12 20.5 12C20.5 12 18 10 12 10C6 10 3.5 12 3.5 12Z"/> |
| </svg> |
| <svg class="bird-silhouette bird-2" viewBox="0 0 24 24" fill="currentColor"> |
| <path d="M3.5 12C3.5 12 6 9 12 9C18 9 20.5 12 20.5 12C20.5 12 18 10 12 10C6 10 3.5 12 3.5 12Z"/> |
| </svg> |
| <svg class="bird-silhouette bird-3" viewBox="0 0 24 24" fill="currentColor"> |
| <path d="M3.5 12C3.5 12 6 9 12 9C18 9 20.5 12 20.5 12C20.5 12 18 10 12 10C6 10 3.5 12 3.5 12Z"/> |
| </svg> |
| </div> |
| |
| <!-- Main content --> |
| <div class="header-content"> |
| <!-- Logo and title row --> |
| <div class="header-top"> |
| <!-- Bird logo --> |
| <div class="bird-logo-wrapper"> |
| <div class="bird-logo-glow"></div> |
| <div class="bird-logo"> |
| <svg style="width: 2rem; height: 2rem; color: white;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> |
| <!-- Stylized bird --> |
| <path d="M21 8c-2 0-4 1-6 3-1.5-2-4-3-7-3-2 0-4 .5-5 1l8 4-2 6 4-3 4 3-2-6 8-4c-1-.5-1.5-1-2-1z" fill="currentColor" stroke-width="0"/> |
| <circle cx="7" cy="9" r="1" fill="white"/> |
| </svg> |
| </div> |
| </div> |
| |
| <!-- Title --> |
| <div class="header-title-group"> |
| <div style="display: flex; align-items: baseline; gap: 0.5rem;"> |
| <h1>BirdScope</h1> |
| <span class="header-ai-text">AI</span> |
| </div> |
| <p class="header-subtitle">AI-powered bird identification & species reference</p> |
| </div> |
| |
| </div> |
| |
| <!-- Feature tags with MCP status check button --> |
| <div class="feature-tags"> |
| <div class="feature-tag"> |
| <span>🔍</span> |
| <span>Image Classification</span> |
| </div> |
| <div class="feature-tag"> |
| <span>📸</span> |
| <span>Unsplash Reference</span> |
| </div> |
| <div class="feature-tag"> |
| <span>🎵</span> |
| <span>Audio Recordings</span> |
| </div> |
| <div class="feature-tag"> |
| <span>🌍</span> |
| <span>Conservation Status</span> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Bottom border --> |
| <div class="header-border"></div> |
| </header> |
| |
| <script> |
| // Auto-scroll tool log panel continuously (for Textbox component) |
| const observer = new MutationObserver(() => { |
| const toolLog = document.querySelector('#tool-log-output textarea'); |
| if (toolLog) { |
| toolLog.scrollTop = toolLog.scrollHeight; |
| } |
| }); |
| |
| // Start observing once the page loads |
| setTimeout(() => { |
| const toolLogContainer = document.querySelector('#tool-log-output'); |
| if (toolLogContainer) { |
| observer.observe(toolLogContainer, { |
| childList: true, |
| subtree: true, |
| characterData: true, |
| attributes: true |
| }); |
| } |
| }, 1000); |
| </script> |
| """) |
|
|
| |
| |
| |
| with gr.Walkthrough(selected=1) as walkthrough: |
|
|
| |
| with gr.Step("Welcome", id=1): |
| with gr.Column(elem_classes=["sidebar", "onboarding-page"]): |
| |
| with gr.Row(): |
| gr.HTML("<div></div>") |
| skip_btn = gr.Button( |
| "Skip onboarding →", |
| size="sm", |
| variant="secondary", |
| scale=0, |
| min_width=140 |
| ) |
|
|
| gr.Markdown( |
| """ |
| # Welcome to BirdScope AI! |
| |
| Let's get you started with your AI-powered bird identification assistant. |
| """, |
| elem_classes=["welcome-text"] |
| ) |
|
|
| gr.Markdown("---") |
|
|
| gr.Markdown("### SELECT LLM PROVIDER") |
| welcome_provider = gr.Dropdown( |
| choices=["HuggingFace", "OpenAI", "Anthropic"], |
| value="OpenAI", |
| show_label=False, |
| container=False |
| ) |
|
|
| gr.Markdown("**Choose your AI provider**") |
| gr.Markdown("Select between HuggingFace (open models) or OpenAI (GPT models)") |
|
|
| gr.Markdown("---") |
|
|
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.brandfetch.io/idGqKHD5xE/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1668516030712" |
| alt="HuggingFace" |
| style="width: 20px; height: 20px;"> |
| <strong style="color: #d1d5db;">HuggingFace</strong> |
| </div> |
| """) |
| gr.Markdown("Uses open-source models like Qwen 2.5-72B") |
|
|
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-top: 16px;"> |
| <img src="https://cdn.oaistatic.com/_next/static/media/apple-touch-icon.59f2e898.png" |
| alt="OpenAI" |
| style="width: 20px; height: 20px; border-radius: 4px;"> |
| <strong style="color: #d1d5db;">OpenAI</strong> |
| </div> |
| """) |
| gr.Markdown("Uses GPT-4 models for high-quality responses") |
|
|
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-top: 16px;"> |
| <img src="https://cdn.brandfetch.io/idmJWF3N06/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1721803183716" |
| alt="Anthropic" |
| style="width: 20px; height: 20px; filter: invert(52%) sepia(48%) saturate(779%) hue-rotate(327deg) brightness(91%) contrast(88%);"> |
| <strong style="color: #d1d5db;">Anthropic</strong> |
| </div> |
| """) |
| gr.Markdown("Uses Claude models (Sonnet, Opus, Haiku)") |
|
|
| gr.Markdown("---") |
|
|
| welcome_next_btn = gr.Button("Next: Enter API Key →", variant="primary", size="lg") |
|
|
| |
| with gr.Step("API Key", id=2): |
| with gr.Column(elem_classes=["sidebar", "onboarding-page"]): |
| gr.Markdown("# Step 2: Enter Your API Key 🔑") |
| gr.Markdown("To use BirdScope AI, you'll need an API key from your selected provider.") |
|
|
| gr.Markdown("---") |
|
|
| |
| with gr.Column(visible=False) as hf_key_section: |
| gr.Markdown("### AUTHENTICATION") |
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.brandfetch.io/idGqKHD5xE/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1668516030712" |
| alt="HuggingFace" |
| style="width: 20px; height: 20px;"> |
| <strong style="color: #d1d5db;">HuggingFace API Key</strong> |
| </div> |
| """) |
| onboarding_hf_key = gr.Textbox( |
| placeholder="hf_...", |
| type="password", |
| show_label=False, |
| container=False, |
| elem_classes=["hf-section"] |
| ) |
| gr.Markdown("Get your key from [HF Settings](https://huggingface.co/settings/tokens)") |
|
|
| |
| with gr.Column(visible=False) as openai_key_section: |
| gr.Markdown("### AUTHENTICATION") |
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.oaistatic.com/_next/static/media/apple-touch-icon.59f2e898.png" |
| alt="OpenAI" |
| style="width: 20px; height: 20px; border-radius: 4px;"> |
| <strong style="color: #d1d5db;">OpenAI API Key</strong> |
| </div> |
| """) |
| onboarding_openai_key = gr.Textbox( |
| placeholder="sk-...", |
| type="password", |
| show_label=False, |
| container=False, |
| elem_classes=["openai-section"] |
| ) |
| gr.Markdown("Get your key from [OpenAI Platform](https://platform.openai.com/api-keys)") |
|
|
| |
| with gr.Column(visible=False) as anthropic_key_section: |
| gr.Markdown("### AUTHENTICATION") |
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.brandfetch.io/idmJWF3N06/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1721803183716" |
| alt="Anthropic" |
| style="width: 20px; height: 20px; filter: invert(52%) sepia(48%) saturate(779%) hue-rotate(327deg) brightness(91%) contrast(88%);"> |
| <strong style="color: #d1d5db;">Anthropic API Key</strong> |
| </div> |
| """) |
| onboarding_anthropic_key = gr.Textbox( |
| placeholder="sk-ant-...", |
| type="password", |
| show_label=False, |
| container=False, |
| elem_classes=["anthropic-section"] |
| ) |
| gr.Markdown("Get your key from [Anthropic Console](https://console.anthropic.com/settings/keys)") |
|
|
| gr.Markdown("---") |
|
|
| with gr.Row(): |
| api_back_btn = gr.Button("← Back", variant="secondary", scale=1) |
| api_start_btn = gr.Button("Start Using BirdScope →", variant="primary", scale=3) |
|
|
| |
| with gr.Step("BirdScope AI", id=3): |
| with gr.Tabs(): |
| with gr.Tab("💬 Chat"): |
| with gr.Row(): |
| |
| with gr.Column(scale=2): |
| chatbot = gr.Chatbot( |
| show_label=False, |
| height=500, |
| elem_classes=["chatbot-container"] |
| ) |
|
|
| msg = gr.MultimodalTextbox( |
| placeholder="Ask about birds or upload an image...", |
| file_count="single", |
| file_types=["image"], |
| interactive=True, |
| show_label=False |
| ) |
|
|
| with gr.Row(): |
| submit = gr.Button("Send", scale=3) |
| clear = gr.Button("Clear", scale=1) |
|
|
| |
| gr.Markdown("**Try uploading a bird photo:**") |
| gr.Examples( |
| examples=PHOTO_EXAMPLES, |
| inputs=msg, |
| cache_examples=False |
| ) |
|
|
| |
| gr.Markdown("**Or try a text query:**") |
| text_examples = gr.Examples( |
| examples=MULTI_AGENT_TEXT_EXAMPLES, |
| inputs=msg, |
| cache_examples=False |
| ) |
|
|
| |
| with gr.Column(scale=1): |
| tool_output = gr.Textbox( |
| value="*Waiting for tool calls...*", |
| elem_classes=["tool-log-panel"], |
| elem_id="tool-log-output", |
| autoscroll=True, |
| show_label=False, |
| interactive=False, |
| container=False |
| ) |
|
|
| |
| with gr.Column(scale=1, elem_classes=["sidebar"]): |
|
|
| |
| mcp_status_html = gr.HTML(""" |
| <div class="mcp-badge online" style="margin-bottom: 16px; justify-content: center;"> |
| <span class="mcp-pulse"></span> |
| <span>Powered by Modal MCP</span> |
| </div> |
| """) |
| check_mcp_btn = gr.Button("Check Modal MCP Server Status", size="sm", variant="secondary", elem_classes=["modal-check-btn"]) |
|
|
| gr.HTML(""" |
| <p style="font-size: 0.75rem; color: #9ca3af; margin-top: 8px; margin-bottom: 16px; line-height: 1.4;"> |
| Please be patient if the Modal MCP server needs to cold start |
| </p> |
| """) |
|
|
| gr.Markdown("---") |
|
|
| |
| gr.Markdown("### SELECT LLM PROVIDER") |
| provider = gr.Dropdown( |
| choices=["HuggingFace", "OpenAI", "Anthropic"], |
| value="OpenAI", |
| show_label=False, |
| container=False |
| ) |
|
|
| |
| gr.Markdown("**Agent Configuration**") |
| gr.Markdown("Choose between unified agent or specialized routing") |
| agent_mode = gr.Dropdown( |
| choices=[ |
| "Supervisor (Multi-Agent)" |
| ], |
| value="Supervisor (Multi-Agent)", |
| show_label=False, |
| container=False |
| ) |
|
|
| gr.Markdown("---") |
|
|
| |
| gr.Markdown("### AUTHENTICATION") |
|
|
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.brandfetch.io/idGqKHD5xE/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1668516030712" |
| alt="HuggingFace" |
| style="width: 20px; height: 20px;"> |
| <strong style="color: #d1d5db;">HuggingFace API Key</strong> |
| </div> |
| """) |
| hf_key = gr.Textbox( |
| placeholder="hf_...", |
| type="password", |
| show_label=False, |
| container=False, |
| elem_classes=["hf-section"] |
| ) |
| gr.Markdown("Get your key from [HF Settings](https://huggingface.co/settings/tokens)") |
|
|
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.oaistatic.com/_next/static/media/apple-touch-icon.59f2e898.png" |
| alt="OpenAI" |
| style="width: 20px; height: 20px; border-radius: 4px;"> |
| <strong style="color: #d1d5db;">OpenAI API Key</strong> |
| </div> |
| """) |
| openai_key = gr.Textbox( |
| placeholder="sk-...", |
| type="password", |
| show_label=False, |
| container=False, |
| elem_classes=["openai-section"] |
| ) |
| gr.Markdown("Get your key from [OpenAI Platform](https://platform.openai.com/api-keys)") |
|
|
| gr.HTML(""" |
| <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> |
| <img src="https://cdn.brandfetch.io/idmJWF3N06/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1721803183716" |
| alt="Anthropic" |
| style="width: 20px; height: 20px; filter: invert(52%) sepia(48%) saturate(779%) hue-rotate(327deg) brightness(91%) contrast(88%);"> |
| <strong style="color: #d1d5db;">Anthropic API Key</strong> |
| </div> |
| """) |
| anthropic_key = gr.Textbox( |
| placeholder="sk-ant-...", |
| type="password", |
| show_label=False, |
| container=False, |
| elem_classes=["anthropic-section"] |
| ) |
| gr.Markdown("Get your key from [Anthropic Console](https://console.anthropic.com/settings/keys)") |
|
|
| |
| gr.Markdown("---") |
| gr.Markdown("### CURRENT CONFIG") |
|
|
| |
| session_status = gr.HTML( |
| value=create_config_html( |
| provider_choice="OpenAI", |
| agent_mode_choice="Supervisor (Multi-Agent)", |
| hf_key_input="", |
| openai_key_input="", |
| anthropic_key_input="" |
| ) |
| ) |
|
|
| |
| gr.Markdown("---") |
| gr.Markdown(""" |
| ### ABOUT |
| |
| Built for the [Hugging Face MCP-1st-Birthday Hackathon](https://huggingface.co/MCP-1st-Birthday) |
| """) |
|
|
| gr.HTML(""" |
| <div style="text-align: center; margin: 16px 0;"> |
| <img src="https://cdn-uploads.huggingface.co/production/uploads/60d2dc1007da9c17c72708f8/s4q7RzD3S-8xQ8ecXrSwb.png" |
| alt="Hugging Face MCP 1st Birthday" |
| style="max-width: 100%; height: auto; border-radius: 8px;"> |
| </div> |
| """) |
|
|
| gr.Markdown(""" |
| **MCP Servers:** |
| - Modal GPU classifier (2 tools) |
| - Nuthatch species database (7 tools) |
| |
| **Capabilities:** |
| - Visual bird identification |
| - Species reference images (Unsplash) |
| - Audio recordings (xeno-canto) |
| - Conservation status data |
| - Taxonomic exploration |
| - Separate tool log panel |
| - Detailed execution tracking |
| - Tool input/output inspection |
| """) |
|
|
| with gr.Tab("📖 README"): |
| with gr.Column(elem_classes=["readme-tab-container"]): |
| try: |
| with open("README.md", "r", encoding="utf-8") as f: |
| readme_content = f.read() |
| gr.Markdown(readme_content, elem_classes=["readme-markdown"]) |
| except FileNotFoundError: |
| gr.Markdown("README.md not found", elem_classes=["readme-markdown"]) |
|
|
| |
| tool_log_state = gr.State("*Waiting for tool calls...*") |
|
|
| |
| |
| |
|
|
| def handle_welcome_next(provider_choice): |
| """Navigate to API key page and show appropriate input section.""" |
| show_hf = provider_choice == "HuggingFace" |
| show_openai = provider_choice == "OpenAI" |
| show_anthropic = provider_choice == "Anthropic" |
|
|
| return ( |
| gr.Walkthrough(selected=2), |
| gr.update(visible=show_hf), |
| gr.update(visible=show_openai), |
| gr.update(visible=show_anthropic) |
| ) |
|
|
| def handle_api_back(): |
| """Navigate back to welcome page.""" |
| return gr.Walkthrough(selected=1) |
|
|
| def handle_skip_onboarding(): |
| """Skip onboarding and go directly to main app.""" |
| return gr.Walkthrough(selected=3) |
|
|
| def handle_api_start(provider_choice, hf_key_input, openai_key_input, anthropic_key_input): |
| """Save credentials and navigate to main app with pre-populated values.""" |
| provider_str = str(provider_choice) if provider_choice else "OpenAI" |
|
|
| |
| print(f"[DEBUG] handle_api_start - provider: {provider_str}") |
| print(f"[DEBUG] handle_api_start - hf_key: {'***' if hf_key_input else 'empty'}") |
| print(f"[DEBUG] handle_api_start - openai_key: {'***' if openai_key_input else 'empty'}") |
| print(f"[DEBUG] handle_api_start - anthropic_key: {'***' if anthropic_key_input else 'empty'}") |
|
|
| |
| if provider_str == "HuggingFace": |
| hf_key_value = hf_key_input if hf_key_input else "" |
| openai_key_value = "" |
| anthropic_key_value = "" |
| elif provider_str == "Anthropic": |
| hf_key_value = "" |
| openai_key_value = "" |
| anthropic_key_value = anthropic_key_input if anthropic_key_input else "" |
| else: |
| hf_key_value = "" |
| openai_key_value = openai_key_input if openai_key_input else "" |
| anthropic_key_value = "" |
|
|
| |
| config_html = create_config_html( |
| provider_choice=provider_str, |
| agent_mode_choice="Supervisor (Multi-Agent)", |
| hf_key_input=hf_key_value, |
| openai_key_input=openai_key_value, |
| anthropic_key_input=anthropic_key_value |
| ) |
|
|
| return ( |
| gr.Walkthrough(selected=3), |
| provider_str, |
| hf_key_value, |
| openai_key_value, |
| anthropic_key_value, |
| config_html, |
| hf_key_value, |
| openai_key_value, |
| anthropic_key_value |
| ) |
|
|
| |
| skip_btn.click( |
| fn=handle_skip_onboarding, |
| outputs=[walkthrough] |
| ) |
|
|
| welcome_next_btn.click( |
| fn=handle_welcome_next, |
| inputs=[welcome_provider], |
| outputs=[walkthrough, hf_key_section, openai_key_section, anthropic_key_section] |
| ) |
|
|
| api_back_btn.click( |
| fn=handle_api_back, |
| outputs=[walkthrough] |
| ) |
|
|
| api_start_btn.click( |
| fn=handle_api_start, |
| inputs=[welcome_provider, onboarding_hf_key, onboarding_openai_key, onboarding_anthropic_key], |
| outputs=[ |
| walkthrough, |
| provider, |
| hf_key, |
| openai_key, |
| anthropic_key, |
| session_status, |
| stored_hf_key, |
| stored_openai_key, |
| stored_anthropic_key |
| ] |
| ) |
|
|
| |
| def update_mcp_badge_html(status_text: str) -> str: |
| """Generate HTML for MCP badge based on status.""" |
| |
| if "✅" in status_text or "Online" in status_text: |
| badge_class = "online" |
| elif "❌" in status_text or "Offline" in status_text: |
| badge_class = "offline" |
| elif "⏱️" in status_text or "Timeout" in status_text or "Checking" in status_text: |
| badge_class = "checking" |
| else: |
| badge_class = "online" |
|
|
| return f""" |
| <div class="mcp-badge {badge_class}" style="margin-bottom: 16px; justify-content: center;"> |
| <span class="mcp-pulse"></span> |
| <span>{status_text}</span> |
| </div> |
| """ |
|
|
| |
| scroll_js = """ |
| () => { |
| const toolLog = document.querySelector('#tool-log-output textarea'); |
| if (toolLog) { |
| toolLog.scrollTop = toolLog.scrollHeight; |
| } |
| } |
| """ |
|
|
| |
| |
| provider.change( |
| fn=create_config_html, |
| inputs=[provider, agent_mode, hf_key, openai_key, anthropic_key], |
| outputs=[session_status] |
| ) |
| agent_mode.change( |
| fn=create_config_html, |
| inputs=[provider, agent_mode, hf_key, openai_key, anthropic_key], |
| outputs=[session_status] |
| ) |
|
|
| |
| agent_mode.change( |
| fn=update_text_examples_for_mode, |
| inputs=[agent_mode], |
| outputs=[text_examples.dataset] |
| ) |
|
|
| hf_key.change( |
| fn=create_config_html, |
| inputs=[provider, agent_mode, hf_key, openai_key, anthropic_key], |
| outputs=[session_status] |
| ) |
| openai_key.change( |
| fn=create_config_html, |
| inputs=[provider, agent_mode, hf_key, openai_key, anthropic_key], |
| outputs=[session_status] |
| ) |
| anthropic_key.change( |
| fn=create_config_html, |
| inputs=[provider, agent_mode, hf_key, openai_key, anthropic_key], |
| outputs=[session_status] |
| ) |
| |
| submit_event = msg.submit( |
| fn=show_immediate_loading, |
| inputs=[msg, chatbot, tool_log_state], |
| outputs=[chatbot, tool_output] |
| ).then( |
| fn=chat_wrapper, |
| inputs=[msg, chatbot, provider, hf_key, openai_key, anthropic_key, agent_mode, tool_log_state], |
| outputs=[chatbot, tool_output] |
| ).then( |
| lambda: None, |
| None, |
| msg, |
| js=scroll_js |
| ) |
|
|
| submit_click = submit.click( |
| fn=show_immediate_loading, |
| inputs=[msg, chatbot, tool_log_state], |
| outputs=[chatbot, tool_output] |
| ).then( |
| fn=chat_wrapper, |
| inputs=[msg, chatbot, provider, hf_key, openai_key, anthropic_key, agent_mode, tool_log_state], |
| outputs=[chatbot, tool_output] |
| ).then( |
| lambda: None, |
| None, |
| msg, |
| js=scroll_js |
| ) |
|
|
| def clear_conversation(request: gr.Request): |
| """Clear UI and agent memory by removing agent from cache.""" |
| from agent_cache import agent_cache, agent_last_used |
|
|
| |
| session_id = request.session_hash |
| keys_to_remove = [key for key in agent_cache.keys() if key[0] == session_id] |
|
|
| for key in keys_to_remove: |
| del agent_cache[key] |
| if key in agent_last_used: |
| del agent_last_used[key] |
|
|
| print(f"[DEBUG] Clear clicked - removed {len(keys_to_remove)} cached agents for session {session_id[:8]}") |
| return [], "*Waiting for tool calls...*", None |
|
|
| clear.click( |
| fn=clear_conversation, |
| inputs=[], |
| outputs=[chatbot, tool_output, msg] |
| ) |
|
|
| |
| async def handle_mcp_check(): |
| """Check MCP status and return updated HTML.""" |
| |
| yield update_mcp_badge_html("Checking...") |
| |
| status = await check_modal_server_health() |
| yield update_mcp_badge_html(status) |
|
|
| check_mcp_btn.click( |
| fn=handle_mcp_check, |
| outputs=mcp_status_html, |
| show_progress="hidden" |
| ) |
|
|
| if __name__ == "__main__": |
| |
| force_dark_mode = """ |
| function() { |
| const params = new URLSearchParams(window.location.search); |
| if (!params.has('__theme')) { |
| params.set('__theme', 'dark'); |
| window.location.search = params.toString(); |
| } |
| } |
| """ |
|
|
| demo.launch(theme=gr.themes.Soft(), css=custom_css, js=force_dark_mode) |
|
|