"""Gradio interface for CheddahBot.""" from __future__ import annotations import logging from pathlib import Path from typing import TYPE_CHECKING import gradio as gr if TYPE_CHECKING: from .agent import Agent from .config import Config from .llm import LLMAdapter from .notifications import NotificationBus log = logging.getLogger(__name__) _HEAD = '' _CSS = """ footer { display: none !important; } .notification-banner { background: #1a1a2e; border: 1px solid #16213e; border-radius: 8px; padding: 10px 16px; margin-bottom: 8px; font-size: 0.9em; } /* Mobile optimizations */ @media (max-width: 768px) { .gradio-container { padding: 4px !important; } /* 16px base font on chat messages to prevent iOS zoom on focus */ .chatbot .message-row .message { font-size: 16px !important; } /* Chat container: scrollable, no zoom-stuck overflow */ .chatbot { overflow-y: auto !important; -webkit-overflow-scrolling: touch; height: calc(100dvh - 220px) !important; max-height: none !important; } /* Tighten up header/status bar spacing */ .gradio-container > .main > .wrap { gap: 8px !important; } /* Keep input area pinned at the bottom, never overlapping chat */ .gradio-container > .main { display: flex; flex-direction: column; height: 100dvh; } .gradio-container > .main > .wrap:last-child { position: sticky; bottom: 0; background: var(--background-fill-primary); padding-bottom: env(safe-area-inset-bottom, 8px); z-index: 10; } /* Input box: prevent tiny text that triggers zoom */ .multimodal-textbox textarea, .multimodal-textbox input { font-size: 16px !important; } /* Reduce model dropdown row padding */ .contain .gr-row { gap: 4px !important; } } """ def create_ui( agent: Agent, config: Config, llm: LLMAdapter, notification_bus: NotificationBus | None = None ) -> gr.Blocks: """Build and return the Gradio app.""" available_models = llm.list_chat_models() model_choices = [(m.name, m.id) for m in available_models] current_model = llm.current_model exec_status = "available" if llm.is_execution_brain_available() else "unavailable" clickup_status = "enabled" if config.clickup.enabled else "disabled" with gr.Blocks(title="CheddahBot", fill_width=True, css=_CSS, head=_HEAD) as app: gr.Markdown("# CheddahBot", elem_classes=["contain"]) gr.Markdown( f"*Chat Brain:* `{current_model}`  |  " f"*Execution Brain (Claude Code CLI):* `{exec_status}`  |  " f"*ClickUp:* `{clickup_status}`", elem_classes=["contain"], ) # -- Notification banner -- notification_display = gr.Markdown( value="", visible=False, elem_classes=["contain", "notification-banner"], ) with gr.Row(elem_classes=["contain"]): model_dropdown = gr.Dropdown( choices=model_choices, value=current_model, label="Model", interactive=True, allow_custom_value=True, scale=3, ) refresh_btn = gr.Button("Refresh", scale=0, min_width=70) new_chat_btn = gr.Button("New Chat", scale=1, variant="secondary") chatbot = gr.Chatbot( label="Chat", height=500, buttons=["copy"], elem_classes=["contain"], ) pipeline_status = gr.Markdown( value="", visible=False, elem_classes=["contain"], ) with gr.Row(elem_classes=["contain"]): msg_input = gr.MultimodalTextbox( placeholder="Type a message... (attach files, use mic, or camera)", show_label=False, scale=4, sources=["upload", "microphone"], ) # -- Event handlers -- def on_model_change(model_id): llm.switch_model(model_id) return f"Switched to {model_id}" def on_refresh_models(): models = llm.list_chat_models() choices = [(m.name, m.id) for m in models] return gr.update(choices=choices, value=llm.current_model) def on_new_chat(): agent.new_conversation() return [] def on_user_message(message, chat_history): chat_history = chat_history or [] # Extract text and files from MultimodalTextbox if isinstance(message, dict): text = message.get("text", "") files = message.get("files", []) else: text = str(message) files = [] if not text and not files: yield chat_history, gr.update(value=None) return # Handle audio files - transcribe them processed_files = [] for f in files: fpath = f if isinstance(f, str) else f.get("path", f.get("name", "")) if fpath and Path(fpath).suffix.lower() in ( ".wav", ".mp3", ".ogg", ".webm", ".m4a", ): try: from .media import transcribe_audio transcript = transcribe_audio(fpath) if transcript: text = ( f"{text}\n[Voice message]: {transcript}" if text else f"[Voice message]: {transcript}" ) continue except Exception as e: log.warning("Audio transcription failed: %s", e) processed_files.append(fpath) # Add user message user_display = text if processed_files: file_names = [Path(f).name for f in processed_files] user_display += f"\n[Attached: {', '.join(file_names)}]" chat_history = [*chat_history, {"role": "user", "content": user_display}] yield chat_history, gr.update(value=None) # Stream assistant response try: response_text = "" chat_history = [*chat_history, {"role": "assistant", "content": ""}] for chunk in agent.respond(text, files=processed_files): response_text += chunk chat_history[-1] = {"role": "assistant", "content": response_text} yield chat_history, gr.update(value=None) # If no response came through, show a fallback if not response_text: chat_history[-1] = { "role": "assistant", "content": "(No response received from model)", } yield chat_history, gr.update(value=None) except Exception as e: log.error("Error in agent.respond: %s", e, exc_info=True) chat_history = [*chat_history, {"role": "assistant", "content": f"Error: {e}"}] yield chat_history, gr.update(value=None) def poll_pipeline_status(): """Poll the DB for pipeline progress updates.""" status = agent.db.kv_get("pipeline:status") if status: return gr.update(value=f"⏳ {status}", visible=True) return gr.update(value="", visible=False) def poll_notifications(): """Poll the notification bus for pending messages.""" if not notification_bus: return gr.update(value="", visible=False) messages = notification_bus.get_pending("gradio") if not messages: return gr.update() # No change # Format notifications as markdown lines = [] for msg in messages[-5:]: # Show last 5 notifications max lines.append(f"**Notification:** {msg}") banner = "\n\n".join(lines) return gr.update(value=banner, visible=True) # -- Wire events -- model_dropdown.change(on_model_change, [model_dropdown], None) refresh_btn.click(on_refresh_models, None, [model_dropdown]) new_chat_btn.click(on_new_chat, None, [chatbot]) msg_input.submit( on_user_message, [msg_input, chatbot], [chatbot, msg_input], ) # Pipeline status polling timer (every 3 seconds) status_timer = gr.Timer(3) status_timer.tick(poll_pipeline_status, None, [pipeline_status]) # Notification polling timer (every 10 seconds) if notification_bus: notification_bus.subscribe("gradio", lambda msg, cat: None) # Register listener timer = gr.Timer(10) timer.tick(poll_notifications, None, [notification_display]) return app