import os import gradio as gr import markdown import requests import yaml from dotenv import load_dotenv try: from src.api.models.provider_models import MODEL_REGISTRY except ImportError as e: raise ImportError( "Could not import MODEL_REGISTRY from src.api.models.provider_models. " "Check the path and file existence." ) from e # Initialize environment variables load_dotenv() BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8080") API_BASE_URL = f"{BACKEND_URL}/search" # Load feeds from YAML def load_feeds(): """Load feeds from the YAML configuration file. Returns: list: List of feeds with their details. """ feeds_path = os.path.join(os.path.dirname(__file__), "../src/configs/feeds_rss.yaml") with open(feeds_path) as f: feeds_yaml = yaml.safe_load(f) return feeds_yaml.get("feeds", []) feeds = load_feeds() feed_names = [f["name"] for f in feeds] feed_authors = [f["author"] for f in feeds] # ----------------------- # Custom CSS for modern UI # ----------------------- CUSTOM_CSS = """ /* Minimal, utility-first vibe with a neutral palette */ :root { --border: 1px solid rgba(2, 6, 23, 0.08); --surface: #ffffff; --surface-muted: #f8fafc; --text: #0f172a; --muted: #475569; --accent: #0ea5e9; --accent-strong: #0284c7; --radius: 12px; --shadow: 0 8px 20px rgba(2, 6, 23, 0.06); } .gradio-container, body { background: var(--surface-muted); color: var(--text); } .dark .gradio-container, .dark body { background: #0b1220; color: #e5e7eb; } .section { background: var(--surface); border: var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; } .dark .section { background: #0f172a; border: 1px solid rgba(255,255,255,0.08); } .header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; } .header h2 { margin: 0; font-size: 22px; } .subtle { color: var(--muted); font-size: 13px; } .results-table { width: 100%; border-collapse: collapse; font-size: 14px; } .results-table th, .results-table td { border: 1px solid #e2e8f0; padding: 10px; text-align: left; vertical-align: top; } .results-table th { background: #f1f5f9; } .dark .results-table th { background: #0b1325; border-color: rgba(255,255,255,0.08); color: #e5e7eb; } .dark .results-table td { border-color: rgba(255,255,255,0.08); color: #e2e8f0; } .results-table a { color: var(--accent-strong); text-decoration: none; font-weight: 600; } .results-table a:hover { text-decoration: underline; } .dark .results-table a { color: #7dd3fc; } .answer { background: var(--surface); border: var(--border); border-radius: var(--radius); padding: 14px; } .dark .answer { background: #0f172a; border: 1px solid rgba(255,255,255,0.08); color: #e5e7eb; } .model-badge { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: 999px; border: var(--border); background: #eef2ff; color: #3730a3; font-weight: 600; } .dark .model-badge { background: rgba(59,130,246,0.15); color: #c7d2fe; border: 1px solid rgba(255,255,255,0.08); } .error { border: 1px solid #fecaca; background: #fff1f2; color: #7f1d1d; border-radius: var(--radius); padding: 10px 12px; } .dark .error { border: 1px solid rgba(248,113,113,0.35); background: rgba(127,29,29,0.25); color: #fecaca; } /* Sticky status banner with spinner */ #status-banner { position: sticky; top: 0; z-index: 1000; margin: 8px 0 12px 0; } #status-banner .banner { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: var(--radius); border: 1px solid #bae6fd; background: #e0f2fe; color: #075985; box-shadow: var(--shadow); } #status-banner .spinner { width: 16px; height: 16px; border-radius: 999px; border: 2px solid currentColor; border-right-color: transparent; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .dark #status-banner .banner { border-color: rgba(59,130,246,0.35); background: rgba(2,6,23,0.55); color: #93c5fd; } /* Actions row aligns buttons to the right, outside filter sections */ .actions { display: flex; justify-content: flex-end; margin: 8px 0 12px 0; gap: 8px; } /* Prominent CTA buttons (not full-width) */ .cta { display: inline-flex; } .cta .gr-button { background: linear-gradient(180deg, var(--accent), var(--accent-strong)); color: #ffffff; border: none; border-radius: 14px; padding: 12px 18px; font-weight: 700; font-size: 15px; box-shadow: 0 10px 22px rgba(2,6,23,0.18); width: auto !important; } .cta .gr-button:hover { transform: translateY(-1px); filter: brightness(1.05); } .cta .gr-button:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; } .dark .cta .gr-button { box-shadow: 0 12px 26px rgba(2,6,23,0.45); } """ # ----------------------- # API helpers # ----------------------- def fetch_unique_titles(payload): """ Fetch unique article titles based on the search criteria. Args: payload (dict): The search criteria including query_text, feed_author, feed_name, limit, and optional title_keywords. Returns: list: A list of articles matching the criteria. Raises: Exception: If the API request fails. """ try: resp = requests.post(f"{API_BASE_URL}/unique-titles", json=payload) resp.raise_for_status() return resp.json().get("results", []) except Exception as e: raise Exception(f"Failed to fetch titles: {str(e)}") from e def call_ai(payload, streaming=True): """ " Call the AI endpoint with the given payload. Args: payload (dict): The payload to send to the AI endpoint. streaming (bool): Whether to use streaming or non-streaming endpoint. Yields: tuple: A tuple containing the type of response and the response text. """ endpoint = f"{API_BASE_URL}/ask/stream" if streaming else f"{API_BASE_URL}/ask" answer_text = "" try: if streaming: with requests.post(endpoint, json=payload, stream=True) as r: r.raise_for_status() for chunk in r.iter_content(chunk_size=None, decode_unicode=True): if not chunk: continue if chunk.startswith("__model_used__:"): yield "model", chunk.replace("__model_used__:", "").strip() elif chunk.startswith("__error__"): yield "error", "Request failed. Please try again later." break elif chunk.startswith("__truncated__"): yield "truncated", "AI response truncated due to token limit." else: answer_text += chunk yield "text", answer_text else: resp = requests.post(endpoint, json=payload) resp.raise_for_status() data = resp.json() answer_text = data.get("answer", "") yield "text", answer_text if data.get("finish_reason") == "length": yield "truncated", "AI response truncated due to token limit." except Exception as e: yield "error", f"Request failed: {str(e)}" def get_models_for_provider(provider): """ Get available models for a provider Args: provider (str): The name of the provider (e.g., "openrouter", "openai") Returns: list: List of model names available for the provider """ provider_key = provider.lower() try: config = MODEL_REGISTRY.get_config(provider_key) return ( ["Automatic Model Selection (Model Routing)"] + ([config.primary_model] if config.primary_model else []) + list(config.candidate_models) ) except Exception: return ["Automatic Model Selection (Model Routing)"] # ----------------------- # Gradio interface functions # ----------------------- def handle_search_articles(query_text, feed_name, feed_author, title_keywords, limit): """ Handle article search Args: query_text (str): The text to search for in article titles. feed_name (str): The name of the feed to filter articles by. feed_author (str): The author of the feed to filter articles by. title_keywords (str): Keywords to search for in article titles. limit (int): The maximum number of articles to return. Returns: str: HTML formatted string of search results or error message. Raises: Exception: If the API request fails. """ if not query_text.strip(): return "Please enter a query text." payload = { "query_text": query_text.strip().lower(), "feed_author": feed_author.strip() if feed_author else "", "feed_name": feed_name.strip() if feed_name else "", "limit": limit, "title_keywords": title_keywords.strip().lower() if title_keywords else None, } try: results = fetch_unique_titles(payload) if not results: return "No results found." # Render results as a compact table html_output = ( "
| Title | Newsletter | Feed Author | Article Authors | Link |
|---|---|---|---|---|
| {title} | " f"{feed_n} | " f"{feed_a} | " f"{authors} | " f"Open | " "