openweb / app.py
Leon4gr45's picture
Add HEAD method to /health
2aba7ef verified
Raw
History Blame Contribute Delete
111 kB
# -*- coding: utf-8 -*-
"""
OpenResearcher DeepSearch Agent - Hugging Face Space
Uses ZeroGPU for efficient inference with the Nemotron model
Aligned with app_local.py frontend and logic
"""
import os
import gradio as gr
import httpx
import json
import json5
import re
import time
import html
import asyncio
import shared_utils
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple, Generator
import importlib
import traceback
import base64
import torch
import spaces
import yaml
from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse
import uvicorn
from transformers import AutoTokenizer, AutoModelForCausalLM
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# ============================================================
# Configuration
# ============================================================
# Primary: local ZeroGPU inference of this model.
MODEL_NAME = os.getenv("MODEL_NAME", "OpenResearcher/Nemotron-3-Nano-30B-A3B")
# Backup: only used if local inference raises an error (no GPU, OOM, load
# failure, etc). Configure as Space secrets to point at any OpenAI-compatible
# chat completions endpoint (OpenAI, Azure OpenAI, OpenRouter, Together,
# Groq, a self-hosted vLLM/TGI server, etc.)
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "").rstrip("/")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "") or MODEL_NAME
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
SERPER_API_KEY = os.getenv("SERPER_API_KEY", "")
# Self-hosted Jina MCP server. Its own JINA_API_KEY is configured on that
# server, so no key is needed here. Used exclusively for web search
# (search_web tool) - self-hosted SearXNG was tried but Hugging Face's
# abuse-detector auto-paused it (cmdline/process-name match on "searxng"),
# so it's not viable here. Also used as the primary scraping backend
# (read_url tool, with direct HTTP fetch then Serper as fallbacks for
# scraping only).
JINA_MCP_BASE_URL = os.getenv("JINA_MCP_BASE_URL", "https://leon4gr45-jina.hf.space").rstrip("/")
MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "4096")) # Safe limit for ZeroGPU
# Input budget for the conversation history sent each round. Kept
# conservative by default since it must work across whatever model is
# actually configured (local or cloud) - override per-model via env if needed.
MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", "100000"))
# ============================================================
# System Prompt & Tools
# ============================================================
DEVELOPER_CONTENT = """
You are a helpful assistant and harmless assistant.
You will be able to use a set of browsering tools to answer user queries.
Tool for browsing.
The `cursor` appears in brackets before each browsing display: `[{cursor}]`.
Cite information from the tool using the following format:
`【{cursor}†L{line_start}(-L{line_end})?】`, for example: `【6†L9-L11】` or `【8†L3】`.
Do not quote more than 10 words directly from the tool output.
sources=web
""".strip()
TOOL_CONTENT = """
[
{
"type": "function",
"function": {
"name": "browser.search",
"description": "Searches for information related to a query and displays top N results. Returns a list of search results with titles, URLs, and summaries.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string"
},
"topn": {
"type": "integer",
"description": "Number of results to display",
"default": 10
}
},
"required": [
"query"
]
}
}
},
{
"type": "function",
"function": {
"name": "browser.open",
"description": "Opens a link from the current page or a fully qualified URL. Can scroll to a specific location and display a specific number of lines. Valid link ids are displayed with the formatting: 【{id}†.*】.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": [
"integer",
"string"
],
"description": "Link id from current page (integer) or fully qualified URL (string). Default is -1 (most recent page)",
"default": -1
},
"cursor": {
"type": "integer",
"description": "Page cursor to operate on. If not provided, the most recent page is implied",
"default": -1
},
"loc": {
"type": "integer",
"description": "Starting line number. If not provided, viewport will be positioned at the beginning or centered on relevant passage",
"default": -1
},
"num_lines": {
"type": "integer",
"description": "Number of lines to display",
"default": -1
},
"view_source": {
"type": "boolean",
"description": "Whether to view page source",
"default": false
},
"source": {
"type": "string",
"description": "The source identifier (e.g., 'web')"
}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "browser.find",
"description": "Finds exact matches of a pattern in the current page or a specified page by cursor.",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The exact text pattern to search for"
},
"cursor": {
"type": "integer",
"description": "Page cursor to search in. If not provided, searches in the current page",
"default": -1
}
},
"required": [
"pattern"
]
}
}
}
]
""".strip()
# ============================================================
# Browser Tool Implementation
# ============================================================
class SimpleBrowser:
"""Browser tool using Serper API."""
def __init__(self, serper_key: str):
self.serper_key = serper_key
self.pages: Dict[str, Dict] = {}
self.page_stack: List[str] = []
self.link_map: Dict[int, Dict] = {} # Map from cursor ID (int) to {url, title}
self.used_citations = [] # List of cursor IDs (int) in order of first appearance
@property
def current_cursor(self) -> int:
return len(self.page_stack) - 1
def add_link(self, cursor: int, url: str, title: str = ""):
self.link_map[cursor] = {'url': url, 'title': title}
def get_link_info(self, cursor: int) -> Optional[dict]:
return self.link_map.get(cursor)
def get_citation_index(self, cursor: int) -> int:
if cursor not in self.used_citations:
self.used_citations.append(cursor)
return self.used_citations.index(cursor)
def get_page_info(self, cursor: int) -> Optional[Dict[str, str]]:
# Prioritize link_map as it stores search result metadata
if cursor in self.link_map:
return self.link_map[cursor]
# Fallback to page_stack for opened pages
if 0 <= cursor < len(self.page_stack):
url = self.page_stack[cursor]
page = self.pages.get(url)
if page:
return {'url': url, 'title': page.get('title', '')}
return None
def _format_line_numbers(self, text: str, offset: int = 0) -> str:
lines = text.split('\n')
return '\n'.join(f"L{i + offset}: {line}" for i, line in enumerate(lines))
def _clean_links(self, results: List[Dict], query: str) -> Tuple[str, Dict[int, str]]:
link_map = {}
lines = []
for i, r in enumerate(results):
title = html.escape(r.get('title', 'No Title'))
url = r.get('link', r.get('url', ''))
snippet = html.escape(r.get('snippet', r.get('summary', '')))
try:
domain = url.split('/')[2] if url else ''
except:
domain = ''
try:
domain = url.split('/')[2] if url else ''
except:
domain = ''
self.link_map[i] = {'url': url, 'title': title}
link_map[i] = {'url': url, 'title': title}
link_text = f"【{i}{title}{domain}】" if domain else f"【{i}{title}】"
lines.append(f"{link_text}")
lines.append(f" {snippet}")
lines.append("")
return '\n'.join(lines), link_map
async def _jina_search(self, query: str, topn: int) -> Optional[List[Dict]]:
if not JINA_MCP_BASE_URL:
return None
async def _do_call():
async with streamablehttp_client(f"{JINA_MCP_BASE_URL}/v1") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("search_web", {"query": query, "num": topn})
if result.isError or not result.content:
raise Exception("Jina search_web returned an error or empty content")
results = []
for block in result.content:
raw = getattr(block, "text", "")
if not raw:
continue
try:
data = yaml.safe_load(raw)
except Exception:
continue
if isinstance(data, dict):
results.append({
"title": data.get("title", "") or "",
"link": data.get("url", "") or "",
"snippet": data.get("snippet", "") or "",
})
return results
try:
results = await asyncio.wait_for(
_retry_with_backoff(_do_call, retries=2, initial_delay=5.0, backoff=2.0),
timeout=60.0,
)
except Exception as e:
print(f"Jina search_web failed after retries: {e}")
return None
return results[:topn] or None
async def search(self, query: str, topn: int = 10) -> str:
try:
results = await self._jina_search(query, topn)
if not results:
return f"No results found for: '{query}'"
content, new_link_map = self._clean_links(results, query)
self.link_map.update(new_link_map) # Merge new links
pseudo_url = f"web-search://q={query}&ts={int(time.time())}"
cursor = self.current_cursor + 1
page_data = {
'url': pseudo_url,
'title': f"Search Results: {query}",
'text': content,
'urls': {str(k): v['url'] for k, v in new_link_map.items()}
}
self.pages[pseudo_url] = page_data
self.page_stack.append(pseudo_url)
header = f"{page_data['title']} ({pseudo_url})\n**viewing lines [0 - {len(content.split(chr(10)))-1}]**\n\n"
body = self._format_line_numbers(content)
return f"[{cursor}] {header}{body}"
except Exception as e:
return f"Error during search: {str(e)}"
async def open(self, id: int | str = -1, cursor: int = -1, loc: int = -1, num_lines: int = -1, **kwargs) -> str:
target_url = None
if isinstance(id, str) and id.startswith("http"):
target_url = id
elif isinstance(id, int) and id >= 0:
info = self.link_map.get(id)
target_url = info['url'] if info else None
if not target_url:
return f"Error: Invalid link id '{id}'. Available: {list(self.link_map.keys())}"
elif cursor >= 0 and cursor < len(self.page_stack):
page_url = self.page_stack[cursor]
page = self.pages.get(page_url)
if page:
text = page['text']
lines = text.split('\n')
start = max(0, loc) if loc >= 0 else 0
end = min(len(lines), start + num_lines) if num_lines > 0 else len(lines)
header = f"{page['title']} ({page['url']})\n**viewing lines [{start} - {end-1}] of {len(lines)-1}**\n\n"
body = self._format_line_numbers('\n'.join(lines[start:end]), offset=start)
return f"[{cursor}] {header}{body}"
else:
return "Error: No valid target specified"
if not target_url:
return "Error: Could not determine target URL"
try:
text, title = await self._jina_scrape(target_url)
if not text:
text, title = await self._direct_fetch(target_url)
if not text and self.serper_key:
text, title = await self._serper_scrape(target_url)
if not text:
return f"No content found at URL"
lines = text.split('\n')
content = '\n'.join(lines)
max_lines = 150
if len(lines) > max_lines:
content = '\n'.join(lines[:max_lines]) + "\n\n...(content truncated)..."
new_cursor = self.current_cursor + 1
page_data = {
'url': target_url,
'title': title or target_url,
'text': content,
'urls': {}
}
self.pages[target_url] = page_data
self.page_stack.append(target_url)
start = max(0, loc) if loc >= 0 else 0
display_lines = content.split('\n')
end = min(len(display_lines), start + num_lines) if num_lines > 0 else len(display_lines)
header = f"{title or target_url} ({target_url})\n**viewing lines [{start} - {end-1}] of {len(display_lines)-1}**\n\n"
body = self._format_line_numbers('\n'.join(display_lines[start:end]), offset=start)
return f"[{new_cursor}] {header}{body}"
except Exception as e:
return f"Error fetching URL: {str(e)}"
async def _jina_scrape(self, target_url: str) -> Tuple[str, str]:
"""Primary page fetch: Jina MCP server's read_url tool (clean extraction)."""
if not JINA_MCP_BASE_URL:
return "", ""
try:
return await asyncio.wait_for(self._jina_scrape_call(target_url), timeout=45.0)
except Exception:
return "", ""
async def _jina_scrape_call(self, target_url: str) -> Tuple[str, str]:
async with streamablehttp_client(f"{JINA_MCP_BASE_URL}/v1") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("read_url", {"url": target_url})
if result.isError or not result.content:
return "", ""
raw = getattr(result.content[0], "text", "")
if not raw:
return "", ""
try:
data = yaml.safe_load(raw)
except Exception:
return raw, ""
if isinstance(data, dict):
return data.get("content", "") or "", data.get("title", "") or ""
return raw, ""
async def _serper_scrape(self, target_url: str) -> Tuple[str, str]:
headers = {'X-API-KEY': self.serper_key, 'Content-Type': 'application/json'}
payload = json.dumps({"url": target_url})
async with httpx.AsyncClient() as client:
try:
response = await client.post("https://scrape.serper.dev/", headers=headers, data=payload, timeout=30.0)
if response.status_code != 200:
return "", ""
data = response.json()
title = data.get("metadata", {}).get("title", "") if isinstance(data.get("metadata"), dict) else ""
return data.get("text", ""), title
except Exception:
return "", ""
async def _direct_fetch(self, target_url: str) -> Tuple[str, str]:
"""Primary page fetch: plain HTTP GET + HTML-to-text, no API key required."""
async with httpx.AsyncClient(follow_redirects=True) as client:
try:
response = await client.get(
target_url,
headers={"User-Agent": "Mozilla/5.0 (compatible; OpenResearcherBot/1.0)"},
timeout=20.0,
)
if response.status_code != 200:
return "", ""
raw_html = response.text
except Exception:
return "", ""
title_match = re.search(r'<title[^>]*>(.*?)</title>', raw_html, re.IGNORECASE | re.DOTALL)
title = html.unescape(title_match.group(1).strip()) if title_match else ""
body = re.sub(r'(?is)<(script|style|noscript|svg|head).*?</\1>', ' ', raw_html)
body = re.sub(r'(?s)<[^>]+>', '\n', body)
text = html.unescape(body)
text = re.sub(r'[ \t]+', ' ', text)
text = re.sub(r'\n\s*\n+', '\n\n', text).strip()
return text, title
def find(self, pattern: str, cursor: int = -1) -> str:
if not self.page_stack:
return "Error: No page open"
page_url = self.page_stack[cursor] if cursor >= 0 and cursor < len(self.page_stack) else self.page_stack[-1]
page = self.pages.get(page_url)
if not page:
return "Error: Page not found"
text = page['text']
lines = text.split('\n')
matches = []
for i, line in enumerate(lines):
if str(pattern).lower() in line.lower():
start = max(0, i - 1)
end = min(len(lines), i + 3)
context = '\n'.join(f"L{j}: {lines[j]}" for j in range(start, end))
matches.append(f"# 【{len(matches)}†match at L{i}】\n{context}")
if len(matches) >= 10:
break
if not matches:
return f"No matches found for: '{pattern}'"
result_url = f"{page_url}/find?pattern={pattern}"
new_cursor = self.current_cursor + 1
result_content = '\n\n'.join(matches)
page_data = {
'url': result_url,
'title': f"Find results for: '{pattern}'",
'text': result_content,
'urls': {}
}
self.pages[result_url] = page_data
self.page_stack.append(result_url)
header = f"Find results for text: `{pattern}` in `{page['title']}`\n\n"
return f"[{new_cursor}] {header}{result_content}"
def get_cursor_url(self, cursor: int) -> Optional[str]:
if cursor >= 0 and cursor < len(self.page_stack):
return self.page_stack[cursor]
return None
# ============================================================
# Local Model Loading (primary inference path, via ZeroGPU)
# ============================================================
tokenizer = None
local_model = None
def load_local_model():
global tokenizer, local_model
if tokenizer is None:
print(f"Loading tokenizer: {MODEL_NAME}")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
print("Tokenizer loaded successfully!")
if local_model is None:
print(f"Loading local model: {MODEL_NAME}")
local_model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
torch_dtype=torch.bfloat16,
device_map="auto",
)
print("Local model loaded successfully!")
return tokenizer, local_model
@spaces.GPU(duration=120)
def _local_generate_sync(prompt: str, max_new_tokens: int) -> str:
tok, model = load_local_model()
inputs = tok(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
temperature=0.7,
top_p=0.9,
do_sample=True,
pad_token_id=tok.eos_token_id or tok.pad_token_id,
)
new_tokens = output_ids[0][inputs["input_ids"].shape[1]:]
return tok.decode(new_tokens, skip_special_tokens=True)
# ============================================================
# Text Processing
# ============================================================
extract_thinking = shared_utils.extract_thinking
parse_tool_call = shared_utils.parse_tool_call
is_final_answer = shared_utils.is_final_answer
extract_goal_met = shared_utils.extract_goal_met
strip_goal_met_tag = shared_utils.strip_goal_met_tag
# ============================================================
# HTML Rendering Helpers (From app_local.py)
# ============================================================
def render_citations(text: str, browser: SimpleBrowser) -> str:
"""Convert citation markers to clickable HTML links."""
def replace_citation(m):
cursor_str = m.group(1)
# l1 = m.group(2)
# l2 = m.group(3)
try:
cursor = int(cursor_str)
index = browser.get_citation_index(cursor)
# Check if we have URL info
info = browser.get_page_info(cursor)
if info and info.get('url'):
# Return clickable index link pointing to reference section
# Aligned with generate_html_example.py style (green via CSS class)
url = info.get('url')
return f'<a href="{html.escape(url)}" target="_blank" class="citation-link">[{index}]</a>'
# Fallback if no URL
return f'<span class="citation-link">[{index}]</span>'
except Exception as e:
# print(f"Error in replace_citation: {e}, match: {m.group(0)}")
pass
return m.group(0)
# First pass: replace citations with linked citations
result = re.sub(r'[【\[](\d+)†.*?[】\]]', replace_citation, text)
# Second pass: Deduplicate adjacent identical citations
# Matches: <a ...>[N]</a> followed by optional whitespace and same link
# We repeat this until no more changes to handle multiple duplicates
while True:
new_result = re.sub(r'(<a [^>]+>\[\d+\]</a>)(\s*)\1', r'\1', result)
if new_result == result:
break
result = new_result
# Convert basic markdown to HTML
result = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', result)
result = re.sub(r'\*(.+?)\*', r'<em>\1</em>', result)
result = re.sub(r'`(.+?)`', r'<code>\1</code>', result)
result = result.replace('\n\n', '</p><p>').replace('\n', '<br>')
if not result.startswith('<p>'):
result = f'<p>{result}</p>'
return result
def render_thinking_streaming(text: str) -> str:
"""Render thinking content in streaming mode (visible, with animation)."""
escaped = html.escape(text)
return f'<div class="thinking-streaming">{escaped}</div>'
render_thinking_collapsed = shared_utils.render_thinking_collapsed
def render_tool_call(fn_name: str, args: dict, browser: SimpleBrowser = None) -> str:
"""Render a tool call card with unified format and subtle distinction."""
border_colors = {
"browser.search": "#667eea",
"browser.open": "#4facfe",
"browser.find": "#fa709a"
}
border_color = border_colors.get(fn_name, "#9ca3af")
if fn_name == "browser.search":
query = str(args.get('query', ''))
return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
<div class="tool-info">
<div class="tool-name">Searching the web</div>
<div class="tool-detail">Query: "{html.escape(query)}"</div>
</div>
</div>'''
elif fn_name == "browser.open":
link_id = args.get('id', '')
url_info = ""
if browser and isinstance(link_id, int) and link_id >= 0:
info = browser.link_map.get(link_id)
url = info.get('url', "") if info else ""
if url:
try:
domain = url.split('/')[2]
url_info = f" → {domain}"
except:
url_info = ""
return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
<div class="tool-info">
<div class="tool-name">Opening page</div>
<div class="tool-detail">Link #{link_id}{url_info}</div>
</div>
</div>'''
elif fn_name == "browser.find":
pattern = str(args.get('pattern', ''))
return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
<div class="tool-info">
<div class="tool-name">Finding in page</div>
<div class="tool-detail">Pattern: "{html.escape(pattern)}"</div>
</div>
</div>'''
else:
return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
<div class="tool-info">
<div class="tool-name">{html.escape(str(fn_name))}</div>
<div class="tool-detail">{html.escape(json.dumps(args))}</div>
</div>
</div>'''
def render_tool_result(result: str, fn_name: str) -> str:
"""Render tool result in an expanded card with direct HTML rendering."""
import uuid
tool_label = {
"browser.search": "🔍 Search Results",
"browser.open": "📄 Page Content",
"browser.find": "🔎 Find Results"
}.get(fn_name, "📋 Result")
border_colors = {
"browser.search": "#667eea",
"browser.open": "#4facfe",
"browser.find": "#86efac"
}
border_color = border_colors.get(fn_name, "#9ca3af")
# ===== SEARCH RESULTS =====
if fn_name == "browser.search" and "<html>" in result and "<ul>" in result:
ul_match = re.search(r'<ul>(.*?)</ul>', result, re.DOTALL)
if ul_match:
ul_content = ul_match.group(1)
items = re.findall(r"<li><a href='([^']+)'>([^<]+)</a>\s*([^<]*)</li>", ul_content)
if items:
lines = result.split('\n')
search_title = ""
if lines and re.match(r'^\[\d+\]\s+Search Results:', lines[0]):
match = re.match(r'^\[(\d+)\]\s+(.+?)\s+\(web-search://', lines[0])
if match:
ref_num, title = match.groups()
title = re.sub(r'\s+\(web-search://.*$', '', lines[0])
title = re.sub(r'^\[\d+\]\s+', '', title)
search_title = f'''
<div style="background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%); padding: 0.875rem 1rem; margin: -1.25rem -1.25rem 1rem -1.25rem; border-bottom: 1px solid #e0e7ff;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="background: #667eea; color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;">【{ref_num}】</span>
<span style="color: #1e40af; font-weight: 600; font-size: 0.95rem;">{html.escape(title)}</span>
</div>
</div>
'''
result_html = '<div style="display: flex; flex-direction: column; gap: 0.75rem;">'
for idx, (url, title, summary) in enumerate(items, 1):
card_id = f"search-card-{uuid.uuid4().hex[:8]}"
result_html += f'''
<div class="search-result-card" style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; transition: all 0.2s ease;">
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.875rem;">
<a href="{html.escape(url)}" target="_blank"
style="color: #667eea; font-weight: 600; font-size: 0.75rem; min-width: 30px; text-decoration: none;">【{idx}】</a>
<a href="{html.escape(url)}" target="_blank"
style="color: #1f2937; font-weight: 600; font-size: 0.9rem; text-decoration: none; flex: 1;">
{html.escape(title)}
</a>
</div>
<div style="padding: 0 0.875rem 0.875rem 0.875rem; border-top: 1px solid #f3f4f6;">
<div style="color: #6b7280; font-size: 0.85rem; line-height: 1.5; margin-top: 0.75rem;">
{html.escape(summary)}
</div>
<div style="color: #9ca3af; font-size: 0.75rem; margin-top: 0.5rem; font-family: monospace; word-break: break-all;">
{html.escape(url)}
</div>
</div>
</div>
'''
result_html += '</div>'
return f'''<div class="result-card-expanded" style="border-left: 3px solid {border_color};">
<div class="result-header-expanded">{tool_label}</div>
<div class="result-content-expanded" style="font-family: inherit;">{search_title}{result_html}</div>
</div>'''
# ===== BROWSER.OPEN and BROWSER.FIND =====
lines = result.split('\n')
title_html = ""
content_start_idx = 0
pattern_to_highlight = None
if lines and re.match(r'^\[\d+\]\s+.+\s+\(.+\)$', lines[0]):
first_line = lines[0]
match = re.match(r'^\[(\d+)\]\s+(.+?)\s+\((.+)\)$', first_line)
if match:
ref_num, title, url = match.groups()
if fn_name == "browser.find":
pattern_match = re.search(r'Find Results:\s*(.+)', title)
if pattern_match:
pattern_to_highlight = pattern_match.group(1).strip()
is_clickable = not url.startswith('web-search://')
if is_clickable:
title_html = f'''
<div style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); padding: 0.875rem 1rem; margin: -1.25rem -1.25rem 1rem -1.25rem; border-bottom: 1px solid #e0e7ff;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="background: {border_color}; color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;">【{ref_num}】</span>
<a href="{html.escape(url)}" target="_blank"
style="color: #1e40af; font-weight: 600; font-size: 0.95rem; text-decoration: none; flex: 1;">
{html.escape(title)}
</a>
</div>
<div style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; font-family: monospace;">
{html.escape(url)}
</div>
</div>
'''
else:
title_html = f'''
<div style="background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%); padding: 0.875rem 1rem; margin: -1.25rem -1.25rem 1rem -1.25rem; border-bottom: 1px solid #e0e7ff;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="background: {border_color}; color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;">【{ref_num}】</span>
<span style="color: #1e40af; font-weight: 600; font-size: 0.95rem;">{html.escape(title)}</span>
</div>
</div>
'''
content_start_idx = 1
if content_start_idx < len(lines) and lines[content_start_idx].startswith('**viewing lines'):
content_start_idx += 1
if content_start_idx < len(lines) and lines[content_start_idx].strip() == '':
content_start_idx += 1
cleaned_lines = []
for line in lines[content_start_idx:]:
cleaned_line = re.sub(r'^L\d+:\s*', '', line)
cleaned_lines.append(cleaned_line)
cleaned_content = '\n'.join(cleaned_lines)
formatted_result = html.escape(cleaned_content)
if pattern_to_highlight and fn_name == "browser.find":
escaped_pattern = re.escape(pattern_to_highlight)
def highlight_match(match):
return f'<mark style="background: #86efac; padding: 0.125rem 0.25rem; border-radius: 2px; font-weight: 600; color: #064e3b;">{match.group(0)}</mark>'
formatted_result = re.sub(
escaped_pattern,
highlight_match,
formatted_result,
flags=re.IGNORECASE
)
def make_citation_clickable(match):
full_text = match.group(0)
parts_match = re.match(r'【(\d+)†([^†]+)†([^】]+)】', full_text)
if parts_match:
ref_num = parts_match.group(1)
title = parts_match.group(2)
domain = parts_match.group(3)
url = f"https://{domain}" if not domain.startswith('http') else domain
return f'<a href="{html.escape(url)}" target="_blank" style="background: #e0f7fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; color: #006064; font-weight: 500; text-decoration: none; display: inline-block;" title="{html.escape(title)}">【{ref_num}{html.escape(domain)}】</a>'
else:
simple_match = re.match(r'【(\d+)†([^】]+)】', full_text)
if simple_match:
ref_num = simple_match.group(1)
text = simple_match.group(2)
return f'<span style="background: #e0f7fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; color: #006064; font-weight: 500;">【{ref_num}{html.escape(text)}】</span>'
return full_text
formatted_result = re.sub(r'【\d+†[^】]+】', make_citation_clickable, formatted_result)
formatted_result = formatted_result.replace('\n\n', '</p><p style="margin: 0.75rem 0;">')
formatted_result = formatted_result.replace('\n', '<br>')
if not formatted_result.startswith('<p'):
formatted_result = f'<p style="margin: 0.75rem 0;">{formatted_result}</p>'
max_length = 5000
if len(result) > max_length:
formatted_result = formatted_result[:max_length] + '<br><br><em style="color: #9ca3af;">...(content truncated for display)...</em>'
return f'''<div class="result-card-expanded" style="border-left: 3px solid {border_color};">
<div class="result-header-expanded">{tool_label}</div>
<div class="result-content-expanded" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; line-height: 1.7; color: #374151;">{title_html}{formatted_result}</div>
</div>'''
render_round_badge = shared_utils.render_round_badge
def render_answer(text: str, browser: SimpleBrowser) -> str:
rendered = render_citations(text, browser)
return f'<div class="answer-section">{rendered}</div>'
def render_completion() -> str:
return '<div class="completion-msg">Research Complete</div>'
render_user_message = shared_utils.render_user_message
# ============================================================
# Retry helper: transient/proxy errors (5xx, timeouts, connection drops)
# are retried with backoff starting at 30s; a non-retryable error (4xx)
# raises immediately.
# ============================================================
class _NonRetryableError(Exception):
pass
async def _retry_with_backoff(coro_factory, retries: int = 3, initial_delay: float = 30.0, backoff: float = 2.0):
delay = initial_delay
for attempt in range(retries + 1):
try:
return await coro_factory()
except _NonRetryableError as e:
raise Exception(str(e))
except Exception as e:
if attempt == retries:
raise
print(f"Request failed (attempt {attempt + 1}/{retries + 1}): {e}. Retrying in {delay:.0f}s...")
await asyncio.sleep(delay)
delay *= backoff
# ============================================================
# Context-length management: the ReAct loop appends a search/open/find
# result to `messages` every round, so long exploratory runs can blow past
# the model's context window. Compression happens in three places:
# 1. append-time truncation of individual tool results (already capped,
# e.g. open()'s 150-line limit)
# 2. proactively, before each generation call, if the running total is
# over MAX_CONTEXT_TOKENS (see compress_messages() usage below)
# 3. reactively, if a "context length exceeded" error still slips through
# (see the except-block in run_agent_streaming)
# ============================================================
estimate_tokens = shared_utils.estimate_tokens
compress_messages = shared_utils.compress_messages
# Field names various OpenAI-compatible servers (vLLM, OpenRouter, LocalAI,
# etc.) use to report a model's context length in GET /models. The official
# OpenAI API itself doesn't include this, so absence just means "unknown".
_CONTEXT_LENGTH_FIELDS = (
"context_length", "max_model_len", "context_window",
"max_context_length", "max_input_tokens", "n_ctx",
)
_cloud_context_limit_cache: Dict[str, Optional[int]] = {}
async def _detect_cloud_context_limit() -> Optional[int]:
"""Best-effort: query {OPENAI_BASE_URL}/models and read off the
configured OPENAI_MODEL's context length, if the server reports one."""
if not OPENAI_BASE_URL:
return None
cache_key = f"{OPENAI_BASE_URL}::{OPENAI_MODEL}"
if cache_key in _cloud_context_limit_cache:
return _cloud_context_limit_cache[cache_key]
headers = {}
if OPENAI_API_KEY:
headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
limit = None
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{OPENAI_BASE_URL}/models", headers=headers, timeout=15.0)
if response.status_code == 200:
entries = response.json().get("data", [])
entry = next((m for m in entries if m.get("id") == OPENAI_MODEL), None)
if entry:
for field in _CONTEXT_LENGTH_FIELDS:
value = entry.get(field)
if isinstance(value, (int, float)) and value > 0:
limit = int(value)
print(f"Detected context limit for {OPENAI_MODEL} via /models: {limit} ({field})")
break
except Exception as e:
print(f"Could not auto-detect context limit from {OPENAI_BASE_URL}/models: {e}")
_cloud_context_limit_cache[cache_key] = limit # cache the miss too, avoid re-querying every round
return limit
def _detect_local_context_limit() -> Optional[int]:
"""Best-effort: read the context length straight off the already-loaded
local model's config (no extra call needed)."""
if local_model is None:
return None
config = getattr(local_model, "config", None)
if config is None:
return None
for attr in ("max_position_embeddings", "n_positions", "n_ctx", "seq_length", "max_sequence_length"):
value = getattr(config, attr, None)
if isinstance(value, int) and value > 0:
return value
return None
async def get_context_budget(mode: str) -> int:
"""Auto-detected input token budget for whichever backend(s) this mode
can use, falling back to MAX_CONTEXT_TOKENS if nothing is detectable.
In "auto" mode, uses the tighter of the two limits since either backend
might end up serving the request."""
candidates = []
if mode in ("auto", "local"):
local_limit = _detect_local_context_limit()
if local_limit:
candidates.append(local_limit)
if mode in ("auto", "cloud"):
cloud_limit = await _detect_cloud_context_limit()
if cloud_limit:
candidates.append(cloud_limit)
if not candidates:
return MAX_CONTEXT_TOKENS
detected = min(candidates)
return max(detected - MAX_NEW_TOKENS - 2048, 2048)
# ============================================================
# Generation: local ZeroGPU inference (primary), cloud OpenAI-compatible
# endpoint (backup, only used if local inference raises an error)
# ============================================================
def _api_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Strip internal-only bookkeeping fields before sending a message upstream."""
out = {"role": m["role"], "content": m.get("content") or ""}
if m.get("tool_calls"):
out["tool_calls"] = [{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["function"]["name"],
"arguments": json.dumps(tc["function"]["arguments"])
if isinstance(tc["function"]["arguments"], dict)
else tc["function"]["arguments"],
},
} for tc in m["tool_calls"]]
if m.get("tool_call_id"):
out["tool_call_id"] = m["tool_call_id"]
return out
async def _generate_cloud(
messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]],
max_new_tokens: int,
) -> Dict[str, Any]:
"""Backup path: call an OpenAI-compatible /chat/completions API."""
if not OPENAI_BASE_URL:
raise Exception(
"Local inference failed and no cloud backup is configured. Set "
"OPENAI_BASE_URL, OPENAI_MODEL and OPENAI_API_KEY as Space secrets "
"to enable a cloud fallback."
)
url = f"{OPENAI_BASE_URL}/chat/completions"
headers = {"Content-Type": "application/json"}
if OPENAI_API_KEY:
headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
payload = {
"model": OPENAI_MODEL,
"messages": [_api_message(m) for m in messages],
"tools": tools,
"tool_choice": "auto",
"max_tokens": max_new_tokens,
"temperature": 0.7,
"top_p": 0.9,
}
async def _do_post():
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers, timeout=300.0)
if response.status_code >= 500 or response.status_code == 429:
# Transient error (5xx proxy error, or 429 rate limit) - worth retrying
raise Exception(f"Cloud backup API error {response.status_code}: {response.text}")
if response.status_code != 200:
# Client error (auth, bad request, etc.) - retrying won't help
raise _NonRetryableError(f"Cloud backup API error {response.status_code}: {response.text}")
return response.json()
data = await _retry_with_backoff(_do_post, retries=3, initial_delay=30.0, backoff=2.0)
message = data["choices"][0]["message"]
tool_call = None
raw_tool_calls = message.get("tool_calls")
if raw_tool_calls:
fn = raw_tool_calls[0]["function"]
try:
args = json.loads(fn.get("arguments") or "{}")
except (json.JSONDecodeError, TypeError):
args = {}
tool_call = {"name": fn["name"], "arguments": args}
return {
"content": message.get("content") or "",
"reasoning_content": message.get("reasoning_content"),
"tool_call": tool_call,
}
async def generate_response(
messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]],
max_new_tokens: int = MAX_NEW_TOKENS,
mode: str = "auto",
) -> Dict[str, Any]:
"""Generate a response. `mode` is the Inference Mode switch value:
- "auto": local ZeroGPU inference, falling back to the cloud
OpenAI-compatible endpoint only if local inference raises.
- "local": local ZeroGPU inference only, errors surface directly.
- "cloud": cloud OpenAI-compatible endpoint only, local is skipped.
"""
if mode == "cloud":
return await _generate_cloud(messages, tools, max_new_tokens)
try:
tok, _ = load_local_model()
prompt = tok.apply_chat_template(
messages, tools=tools, tokenize=False, add_generation_prompt=True
)
text = await asyncio.to_thread(_local_generate_sync, prompt, max_new_tokens)
return {"content": text, "reasoning_content": None, "tool_call": None}
except Exception as local_err:
if mode == "local":
raise
print(f"Local inference failed ({local_err}); falling back to cloud endpoint.")
return await _generate_cloud(messages, tools, max_new_tokens)
GOAL_VAGUENESS_LABELS = {1: "Precise", 2: "Fairly precise", 3: "Moderate", 4: "Broad", 5: "Exploratory"}
_DEFAULT_GOAL_INFO = {
"goal": None, # filled in with the raw question if planning fails
"vagueness": 3,
"completion_criteria": "A clear, well-supported answer to the question, with sources.",
}
async def determine_research_goal(question: str, mode: str) -> Dict[str, Any]:
"""Ask the model to turn the user's question into an explicit research
goal, rate how vague/open-ended it is (1=precise fact lookup, 5=broad
exploratory synthesis), and define what 'done' looks like at that level.
This lets the agent loop stop as soon as the goal is actually met instead
of always running to max_rounds."""
planning_prompt = (
"You are a research planning assistant. Given the user's question, define:\n"
"1. \"goal\": a one-sentence explicit statement of what needs to be researched.\n"
"2. \"vagueness\": an integer 1-5. 1 = a precise, fast, single-fact question "
"(e.g. 'what is the boiling point of ethanol'). 5 = a broad, open-ended, "
"exploratory question requiring synthesis across many sources/angles "
"(e.g. 'explore the state of AI safety research'). Use the full range based on "
"how narrow or broad the question actually is.\n"
"3. \"completion_criteria\": a short, concrete description of what 'done' looks "
"like for THIS goal at THIS vagueness level (e.g. vagueness 1: 'a single verified "
"fact with a source'; vagueness 5: 'a synthesized overview covering the major "
"distinct angles/sub-topics, each with supporting evidence').\n\n"
"Respond with ONLY a JSON object, no other text: "
'{"goal": "...", "vagueness": <int 1-5>, "completion_criteria": "..."}'
)
messages = [
{"role": "system", "content": planning_prompt},
{"role": "user", "content": question},
]
try:
result = await generate_response(messages, tools=[], max_new_tokens=512, mode=mode)
raw = result["content"].strip()
if "```" in raw:
raw = raw.split("```")[1]
if raw.lower().startswith("json"):
raw = raw[4:]
data = json5.loads(raw)
vagueness = max(1, min(5, int(data.get("vagueness", 3))))
return {
"goal": str(data.get("goal") or question).strip(),
"vagueness": vagueness,
"completion_criteria": str(data.get("completion_criteria") or _DEFAULT_GOAL_INFO["completion_criteria"]).strip(),
}
except Exception as e:
print(f"Failed to determine research goal, using defaults: {e}")
return {**_DEFAULT_GOAL_INFO, "goal": question}
def render_research_goal(goal_info: Dict[str, Any]) -> str:
vagueness = goal_info["vagueness"]
label = GOAL_VAGUENESS_LABELS.get(vagueness, "Moderate")
return f'''<div class="goal-card" style="background:#eef2ff;border:1px solid #c7d2fe;border-radius:8px;padding:0.75rem 1rem;margin:0.5rem 0;">
<div style="font-weight:600;color:#3730a3;">🎯 Research Goal</div>
<div style="margin-top:0.25rem;">{html.escape(goal_info["goal"])}</div>
<div style="margin-top:0.25rem;font-size:0.85em;color:#4338ca;">Vagueness: {vagueness}/5 ({label}) — stops once: {html.escape(goal_info["completion_criteria"])}</div>
</div>'''
# ============================================================
# Streaming Agent Runner
# ============================================================
def get_web_agent_runner():
try:
web_agent_runner = importlib.import_module("web_agent_runner")
return web_agent_runner.run_web_agent_streaming
except ImportError:
return None
_INFERENCE_MODE_MAP = {
"Auto (local + cloud backup)": "auto",
"Local only": "local",
"Cloud only": "cloud",
}
async def run_agent_streaming(
question: str,
serper_key: str,
max_rounds: int,
inference_mode: str = "Auto (local + cloud backup)"
) -> Generator[str, None, None]:
mode = _INFERENCE_MODE_MAP.get(inference_mode, "auto")
if not question.strip():
yield "<p style='color: var(--body-text-color-subdued); text-align: center; padding: 2rem;'>Please enter a question to begin.</p>"
return
if not JINA_MCP_BASE_URL:
yield """<div class="error-message">
<p><strong>Search Backend Required</strong></p>
<p>Set JINA_MCP_BASE_URL as a Space secret to enable web search.</p>
</div>"""
return
browser = SimpleBrowser(serper_key)
tools = json.loads(TOOL_CONTENT)
html_parts = [render_user_message(question)]
yield ''.join(html_parts)
goal_info = await determine_research_goal(question, mode)
html_parts.append(render_research_goal(goal_info))
yield ''.join(html_parts)
system_prompt = DEVELOPER_CONTENT + f"\n\nToday's date: {datetime.now().strftime('%Y-%m-%d')}"
system_prompt += (
f"\n\nRESEARCH GOAL: {goal_info['goal']}\n"
f"VAGUENESS LEVEL: {goal_info['vagueness']}/5 (1=precise/fast, 5=broad/exploratory)\n"
f"COMPLETION CRITERIA: {goal_info['completion_criteria']}\n\n"
"After each step, explicitly assess whether the completion criteria above are "
"met by what you've gathered so far. If they ARE met, give your final answer and "
"end your response with the exact tag <goal_met>true</goal_met> — do not call "
"any more tools. If they are NOT yet met, keep researching (search/open more "
"sources as needed) and end your response with <goal_met>false</goal_met>."
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": question}
]
stop_strings = ["\n<tool_response>", "<tool_response>"]
context_budget = await get_context_budget(mode)
print(f"Context budget for this run ({mode}): {context_budget} tokens")
round_num = 0
try:
while round_num < max_rounds:
round_num += 1
html_parts.append(render_round_badge(round_num, max_rounds))
yield ''.join(html_parts)
# Proactive compression: keep the running history under budget
# instead of waiting to hit the model's hard context-length error.
if estimate_tokens(messages) > context_budget:
print(f"Round {round_num}: context over budget, compressing (keep_last_rounds=3)")
messages = compress_messages(messages, keep_last_rounds=3)
if estimate_tokens(messages) > context_budget:
print(f"Round {round_num}: still over budget, compressing harder (keep_last_rounds=1)")
messages = compress_messages(messages, keep_last_rounds=1)
try:
print(f"\n{'='*60}")
print(f"Round {round_num}")
print(f"{'='*60}")
html_parts.append('<div class="thinking-streaming">Processing...</div>')
yield ''.join(html_parts)
try:
api_result = await generate_response(messages, tools, max_new_tokens=MAX_NEW_TOKENS, mode=mode)
except Exception as gen_err:
if "context length" in str(gen_err).lower() or "context_length_exceeded" in str(gen_err).lower():
print(f"Round {round_num}: hit context-length error, compressing hard and retrying once: {gen_err}")
messages = compress_messages(messages, keep_last_rounds=1)
api_result = await generate_response(messages, tools, max_new_tokens=MAX_NEW_TOKENS, mode=mode)
else:
raise
# Remove placeholder
html_parts.pop()
except Exception as e:
html_parts.pop() # Remove placeholder
html_parts.append(f"<p style='color:#dc2626;'>Generation Error: {html.escape(str(e))}</p>")
yield ''.join(html_parts)
return
generated = api_result["content"]
for stop_str in stop_strings:
if stop_str in generated:
generated = generated[:generated.find(stop_str)]
if api_result.get("reasoning_content"):
reasoning, content = api_result["reasoning_content"], generated
else:
reasoning, content = extract_thinking(generated)
if api_result.get("tool_call"):
tool_call, clean_content = api_result["tool_call"], content
else:
tool_call, clean_content = parse_tool_call(content)
if reasoning:
html_parts.append(render_thinking_collapsed(reasoning))
yield ''.join(html_parts)
if tool_call:
fn_name = tool_call.get("name", "unknown")
args = tool_call.get("arguments", {})
html_parts.append(render_tool_call(fn_name, args, browser))
yield ''.join(html_parts)
clean_content = strip_goal_met_tag(clean_content)
if clean_content.strip() and not tool_call:
rendered = render_citations(clean_content, browser)
html_parts.append(f'<div class="answer-section">{rendered}</div>')
yield ''.join(html_parts)
non_thinking = generated.split('</think>', 1)[1].strip() if '</think>' in generated else generated.strip()
messages.append({
"role": "assistant",
"content": non_thinking if tool_call is None else "",
"reasoning_content": reasoning,
"tool_calls": [{
"id": str(round_num),
"type": "function",
"function": {
"name": tool_call.get("name", ""),
"arguments": tool_call.get("arguments", {})
}
}] if tool_call else None
})
if tool_call:
fn_name = tool_call.get("name", "")
args = tool_call.get("arguments", {})
if fn_name.startswith("browser."):
actual_fn = fn_name.split(".", 1)[1]
else:
actual_fn = fn_name
result = ""
try:
if actual_fn == "search":
result = await browser.search(args.get("query", ""), args.get("topn", 10))
elif actual_fn == "open":
result = await browser.open(**args)
elif actual_fn == "find":
result = browser.find(args.get("pattern", ""), args.get("cursor", -1))
else:
result = f"Unknown tool: {fn_name}"
except Exception as e:
result = f"Tool error: {str(e)}\n{traceback.format_exc()}"
html_parts.append(render_tool_result(result, fn_name))
yield ''.join(html_parts)
messages.append({
"role": "tool",
"tool_call_id": str(round_num),
"content": result
})
continue
goal_met = extract_goal_met(generated)
if goal_met is True or (goal_met is None and is_final_answer(generated)):
html_parts.append(render_completion())
yield ''.join(html_parts)
break
if round_num >= max_rounds:
html_parts.append('<div class="completion-msg" style="background:#fef3c7;border-color:#f59e0b;color:#92400e;">Maximum rounds reached</div>')
yield ''.join(html_parts)
# Generate Reference Section
if browser.used_citations:
html_parts.append('<details class="reference-section">')
html_parts.append('<summary class="reference-title">References</summary>')
for i, cursor in enumerate(browser.used_citations):
info = browser.get_page_info(cursor)
if info:
url = info.get('url', '#')
title = info.get('title', 'Unknown Source')
else:
url = "#"
title = "Unknown Source"
ref_item = f'''
<div class="reference-item">
<div style="display: flex; align-items: baseline;">
<span class="ref-number">[{i}]</span>
<a href="{html.escape(url)}" target="_blank" class="ref-text">{html.escape(title)}</a>
</div>
<div class="ref-url" style="text-align: left;">{html.escape(url)}</div>
</div>
'''
html_parts.append(ref_item)
html_parts.append('</details>')
yield ''.join(html_parts)
except Exception as e:
tb = traceback.format_exc()
html_parts.append(f'<div style="color:#dc2626;"><p>Error: {html.escape(str(e))}</p><pre>{html.escape(tb)}</pre></div>')
yield ''.join(html_parts)
# ============================================================
# Gradio Interface
# ============================================================
CAROUSEL_JS = r"""
(function() {
let currentExample = 0;
const totalExamples = 3;
let carouselInitialized = false;
let layoutInitialized = false;
function updateCarousel() {
const items = document.querySelectorAll('.carousel-item');
const dots = document.querySelectorAll('.carousel-dot');
items.forEach((item, index) => {
if (index === currentExample) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
dots.forEach((dot, index) => {
if (index === currentExample) {
dot.classList.add('active');
} else {
dot.classList.remove('active');
}
});
}
function setExample(text) {
const container = document.querySelector('#question-input');
if (container) {
const textbox = container.querySelector('textarea');
if (textbox) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
nativeInputValueSetter.call(textbox, text);
textbox.dispatchEvent(new Event('input', { bubbles: true }));
textbox.focus();
}
}
}
function initCarousel() {
if (carouselInitialized) return;
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
const items = document.querySelectorAll('.carousel-item');
const dots = document.querySelectorAll('.carousel-dot');
if (!prevBtn || !nextBtn || items.length === 0) return;
carouselInitialized = true;
prevBtn.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
currentExample = (currentExample - 1 + totalExamples) % totalExamples;
updateCarousel();
};
nextBtn.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
currentExample = (currentExample + 1) % totalExamples;
updateCarousel();
};
dots.forEach((dot, index) => {
dot.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
currentExample = index;
updateCarousel();
};
});
items.forEach((item, index) => {
item.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
const text = this.getAttribute('data-text');
if (text) {
setExample(text);
}
};
});
}
function isAutoScrollEnabled() {
const checkbox = document.querySelector('#auto-scroll-checkbox input[type="checkbox"]');
return checkbox ? checkbox.checked : true;
}
function scrollToBottom() {
if (!isAutoScrollEnabled()) return;
const outputArea = document.querySelector('#output-area');
if (outputArea) {
// 直接滚动 #output-area
outputArea.scrollTop = outputArea.scrollHeight;
}
}
// 监听输出区域的内容变化,自动滚动
function setupAutoScroll() {
const outputArea = document.querySelector('#output-area');
if (outputArea) {
const observer = new MutationObserver(function() {
// 延迟滚动以确保 DOM 已更新
requestAnimationFrame(function() {
setTimeout(scrollToBottom, 50);
});
});
observer.observe(outputArea, { childList: true, subtree: true, characterData: true });
}
}
function updateOutputVisibility() {
const outputArea = document.getElementById('output-area');
if (outputArea) {
const content = outputArea.innerHTML.trim();
// 检查是否有实际内容(不只是空的 div 或空白)
const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
if (hasContent) {
outputArea.classList.remove('hidden-output');
outputArea.classList.add('has-content');
outputArea.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important; height: 50vh !important; min-height: 250px !important; max-height: 50vh !important; overflow-y: scroll !important; padding: 1rem !important; border: 1px solid #e5e7eb !important; border-radius: 8px !important; background: #fafafa !important;';
} else {
outputArea.classList.add('hidden-output');
outputArea.classList.remove('has-content');
outputArea.style.cssText = 'display: none !important; visibility: hidden !important; opacity: 0 !important; height: 0 !important; min-height: 0 !important; max-height: 0 !important;';
}
}
}
function initLayout() {
if (layoutInitialized) return;
const mainContent = document.getElementById('main-content');
const outputArea = document.getElementById('output-area');
if (!mainContent) return;
layoutInitialized = true;
mainContent.classList.add('initial-state');
// 初始化时立即隐藏空的输出区域 - 使用内联样式确保生效
if (outputArea) {
const content = outputArea.innerHTML.trim();
const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
if (!hasContent) {
outputArea.style.cssText = 'display: none !important; visibility: hidden !important; height: 0 !important; min-height: 0 !important; max-height: 0 !important; padding: 0 !important; margin: 0 !important; border: none !important; opacity: 0 !important;';
outputArea.classList.add('hidden-output');
outputArea.classList.remove('has-content');
}
}
// 设置自动滚动监听
setupAutoScroll();
const outputObserver = new MutationObserver(function() {
const content = outputArea ? outputArea.innerHTML.trim() : '';
const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
if (hasContent) {
mainContent.classList.remove('initial-state');
outputArea.classList.remove('hidden-output');
outputArea.classList.add('has-content');
outputArea.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important; height: 50vh !important; min-height: 250px !important; max-height: 50vh !important; overflow-y: scroll !important; padding: 1rem !important; border: 1px solid #e5e7eb !important; border-radius: 8px !important; background: #fafafa !important;';
setTimeout(scrollToBottom, 100);
} else {
mainContent.classList.add('initial-state');
if (outputArea) {
outputArea.classList.add('hidden-output');
outputArea.classList.remove('has-content');
outputArea.style.cssText = 'display: none !important; visibility: hidden !important; opacity: 0 !important; height: 0 !important; min-height: 0 !important; max-height: 0 !important;';
}
}
});
if (outputArea) {
outputObserver.observe(outputArea, { childList: true, subtree: true, characterData: true });
}
const questionInput = document.querySelector('#question-input textarea');
if (questionInput) {
questionInput.focus();
}
}
const observer = new MutationObserver(function(mutations, obs) {
initCarousel();
initLayout();
if (carouselInitialized && layoutInitialized) {
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// 立即尝试隐藏输出区域(在 DOM 完全加载之前)
function hideOutputAreaEarly() {
const outputArea = document.getElementById('output-area');
if (outputArea) {
const content = outputArea.innerHTML.trim();
const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
if (!hasContent) {
outputArea.style.cssText = 'display: none !important; visibility: hidden !important; height: 0 !important; min-height: 0 !important; max-height: 0 !important; padding: 0 !important; margin: 0 !important; border: none !important; opacity: 0 !important;';
outputArea.classList.add('hidden-output');
outputArea.classList.remove('has-content');
}
}
}
// 多次尝试隐藏,确保在各种时机都能生效
hideOutputAreaEarly();
document.addEventListener('DOMContentLoaded', hideOutputAreaEarly);
setTimeout(hideOutputAreaEarly, 0);
setTimeout(hideOutputAreaEarly, 100);
setTimeout(hideOutputAreaEarly, 300);
setTimeout(hideOutputAreaEarly, 500);
setTimeout(function() { initCarousel(); initLayout(); }, 1000);
setTimeout(function() { initCarousel(); initLayout(); }, 2000);
// 深色模式适配 - 动态更新 output-area 背景色(防闪烁优化版)
let lastUpdateTime = 0;
const UPDATE_THROTTLE = 50; // 最小更新间隔 50ms
function updateOutputAreaDarkMode() {
const now = Date.now();
if (now - lastUpdateTime < UPDATE_THROTTLE) {
return; // 跳过过于频繁的更新
}
lastUpdateTime = now;
const outputArea = document.getElementById('output-area');
if (!outputArea) return;
// 检查是否是深色模式
const isDark = document.documentElement.classList.contains('dark') ||
document.body.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
if (isDark) {
// 深色模式:深色背景
outputArea.style.setProperty('background', '#111827', 'important');
outputArea.style.setProperty('border-color', '#374151', 'important');
} else {
// 浅色模式:浅色背景
outputArea.style.setProperty('background', '#fafafa', 'important');
outputArea.style.setProperty('border-color', '#e5e7eb', 'important');
}
}
// 延迟初始化,避免页面加载时闪烁
setTimeout(function() {
updateOutputAreaDarkMode();
// 监听深色模式变化
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkModeMediaQuery.addEventListener('change', updateOutputAreaDarkMode);
// 监听 DOM class 变化
const darkModeObserver = new MutationObserver(updateOutputAreaDarkMode);
darkModeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
darkModeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
// 监听 output-area 的内容变化
const outputArea = document.getElementById('output-area');
if (outputArea) {
const outputContentObserver = new MutationObserver(function() {
// 内容变化时立即应用深色模式样式
requestAnimationFrame(updateOutputAreaDarkMode);
});
outputContentObserver.observe(outputArea, {
childList: true,
subtree: true
});
}
}, 500); // 延迟 500ms 后再启动监听
})();
"""
def create_interface():
# Get the directory where this script is located for static files
script_dir = os.path.dirname(os.path.abspath(__file__))
# Helper function to convert image to base64 for embedding in HTML
def image_to_base64(image_path):
"""Convert image file to base64 string for HTML embedding."""
try:
with open(image_path, 'rb') as img_file:
img_data = img_file.read()
b64_string = base64.b64encode(img_data).decode('utf-8')
# Determine MIME type based on file extension
ext = image_path.lower().split('.')[-1]
mime_types = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'svg': 'image/svg+xml',
'gif': 'image/gif'
}
mime_type = mime_types.get(ext, 'image/png')
return f"data:{mime_type};base64,{b64_string}"
except Exception as e:
print(f"Error loading image {image_path}: {e}")
return ""
# Inline CSS - all styles embedded directly
INLINE_CSS = """
/* Global Styles */
.gradio-container {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
}
/* Thinking Styles */
.thinking-collapsed {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem;
margin: 0.5rem 0;
}
.thinking-collapsed summary {
cursor: pointer;
font-weight: 500;
color: #6b7280;
font-size: 0.875rem;
}
.thinking-collapsed summary:hover {
color: #374151;
}
.thinking-content {
margin-top: 0.5rem;
color: #374151;
white-space: pre-wrap;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.thinking-streaming {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 0.875rem;
margin: 0.5rem 0;
color: #0c4a6e;
white-space: pre-wrap;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.875rem;
line-height: 1.5;
}
/* Tool Call Card */
.tool-call-card {
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
margin: 0.75rem 0;
}
.tool-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tool-name {
font-weight: 600;
color: #374151;
font-size: 0.875rem;
}
.tool-detail {
color: #6b7280;
font-size: 0.8rem;
}
/* Result Card */
.result-card-expanded {
background: white;
border-radius: 8px;
padding: 1.25rem;
margin: 1rem 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.result-header-expanded {
font-weight: 600;
color: #374151;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
font-size: 1rem;
}
.result-content-expanded {
color: #4b5563;
line-height: 1.6;
}
.result-content-expanded p {
margin: 0.5rem 0;
}
.result-content-expanded code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.875em;
}
/* Search Result Card Hover */
.search-result-card {
transition: all 0.2s ease;
}
.search-result-card:hover {
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15) !important;
border-color: #667eea !important;
}
/* Answer Section */
.answer-section {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-left: 4px solid #10a37f;
border-radius: 8px;
padding: 1.5rem;
margin: 1rem 0;
}
.answer-section p {
color: #374151;
line-height: 1.7;
margin: 0.5rem 0;
}
.answer-section strong {
color: #1e293b;
font-weight: 600;
}
.answer-section a {
color: #10a37f;
text-decoration: none;
font-weight: 500;
}
.answer-section a:hover {
text-decoration: underline;
}
/* User Message Bubble - 淡蓝色背景,右对齐 */
.user-message-bubble {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
color: #0c4a6e;
border-radius: 1rem 1rem 0.25rem 1rem;
padding: 1rem 1.25rem;
margin: 1rem 0 1rem auto;
max-width: 80%;
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.2);
border: 1px solid #7dd3fc;
text-align: left;
}
/* Reference Section Collapsible */
.reference-section {
margin-top: 40px;
border-top: 1px solid #e5e7eb;
padding-top: 20px;
}
.reference-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 16px;
color: #111827;
cursor: pointer;
outline: none;
}
.ref-url {
text-align: left !important;
}
.user-message-content {
line-height: 1.6;
font-size: 0.95rem;
}
/* Output area - 固定高度可滚动 */
/* 强制固定高度,内容在里面滚动 */
#output-area,
#output-area.output-box,
div#output-area {
height: 50vh !important;
min-height: 250px !important;
max-height: 50vh !important;
overflow-y: scroll !important;
overflow-x: hidden !important;
padding: 1rem !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #fafafa !important;
scroll-behavior: smooth;
flex-shrink: 0 !important;
flex-grow: 0 !important;
}
/* 内部所有元素不能撑破容器 */
#output-area * {
max-height: none !important;
overflow: visible !important;
}
#output-area > div {
height: auto !important;
max-height: none !important;
overflow: visible !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
}
/* 初始状态和空内容时隐藏 output-area */
#output-area:empty,
#output-area.hidden-output,
#output-area:not(.has-content),
.hidden-output#output-area,
div.hidden-output#output-area,
#main-content #output-area.hidden-output,
#main-content .output-box.hidden-output {
display: none !important;
visibility: hidden !important;
height: 0 !important;
min-height: 0 !important;
max-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
opacity: 0 !important;
overflow: hidden !important;
}
/* 防止内部内容撑破容器 */
#output-area > * {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 主内容区域布局 - 限制整体高度 */
#main-content {
display: flex !important;
flex-direction: column !important;
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
/* Gradio 包装容器限制 */
#main-content > div {
flex-shrink: 0;
}
#output-area::-webkit-scrollbar {
width: 8px;
}
#output-area::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#output-area::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
#output-area::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
/* 自动滚动控制按钮样式 */
#auto-scroll-checkbox {
margin-top: 0.5rem;
}
#auto-scroll-checkbox label {
font-size: 0.85rem;
color: #4b5563;
}
/* Completion Message */
.completion-msg {
text-align: center;
color: #10a37f;
font-weight: 600;
padding: 1rem;
margin: 1rem 0;
background: #f0fdf4;
border-radius: 8px;
border: 1px solid #86efac;
}
/* Error Message */
.error-message {
background: #fee2e2;
border-left: 4px solid #dc2626;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
color: #991b1b;
}
.error-message strong {
color: #7f1d1d;
}
.error-message a {
color: #dc2626;
font-weight: 500;
}
/* Round Badge - 淡蓝色背景 */
.round-badge {
display: inline-block;
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
color: #0369a1;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
margin: 0.5rem 0;
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.15);
border: 1px solid #7dd3fc;
}
/* Settings Section */
#settings-group {
background: transparent !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
gap: 0 !important;
}
#max-rounds-slider, #auto-scroll-checkbox {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.settings-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.875rem;
}
.settings-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
.settings-api-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
}
.settings-label {
font-size: 0.8rem;
font-weight: 500;
color: #4b5563;
}
.settings-help-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.7rem;
color: #667eea;
text-decoration: none;
transition: opacity 0.2s;
}
.settings-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 0.6rem;
font-weight: bold;
}
/* Tools Section */
.tools-section {
margin-top: 0;
}
.tools-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
margin-top: 0;
}
/* Tool Item */
.tool-item {
background: #f9fafb;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 0.5rem;
border: 1px solid #e5e7eb;
}
.tool-item strong {
color: #374151;
font-size: 0.85rem;
}
.tool-item span {
color: #6b7280;
font-size: 0.8rem;
}
/* Examples Section */
.examples-section {
margin-top: -0.5rem;
}
.examples-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
/* Example Carousel */
.example-carousel {
background: white;
border-radius: 8px;
padding: 1rem;
border: 1px solid #e5e7eb;
}
.carousel-container {
position: relative;
min-height: 60px;
margin-bottom: 0.75rem;
}
.carousel-item {
display: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.carousel-item.active {
display: block;
opacity: 1;
}
.carousel-item-text {
background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%);
padding: 1rem;
border-radius: 6px;
color: #374151;
font-size: 0.875rem;
line-height: 1.5;
border: 1px solid #e0e7ff;
}
.carousel-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.carousel-btn {
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: #6b7280;
transition: all 0.2s ease;
user-select: none;
}
.carousel-btn:hover {
background: #667eea;
color: white;
}
.carousel-indicators {
display: flex;
gap: 0.5rem;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d1d5db;
cursor: pointer;
transition: all 0.2s ease;
}
.carousel-dot.active {
background: #667eea;
width: 24px;
border-radius: 4px;
}
/* Welcome Message */
.welcome-container {
text-align: center;
padding: 3rem 2rem;
}
.welcome-title {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
}
.welcome-subtitle {
color: #6b7280;
font-size: 1.1rem;
margin-bottom: 2rem;
}
/* Footer */
.footer-container {
text-align: center;
padding: 1.5rem;
color: #9ca3af;
font-size: 0.875rem;
border-top: 1px solid #e5e7eb;
margin-top: 2rem;
}
.footer-container a {
color: #667eea;
text-decoration: none;
}
.footer-container a:hover {
text-decoration: underline;
}
/* Disclaimer */
.disclaimer {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
border-top: 1px solid #e5e7eb;
margin-top: 1rem;
}
/* ========== 深色模式适配 ========== */
@media (prefers-color-scheme: dark) {
/* Settings 区域 */
#settings-group {
background: transparent !important;
border: none !important;
padding: 0 !important;
gap: 0 !important;
}
#max-rounds-slider, #auto-scroll-checkbox {
background: #1f2937 !important;
border: 1px solid #374151 !important;
border-radius: 6px !important;
padding: 0.75rem !important;
}
.settings-title,
.tools-title,
.examples-title {
color: #e5e7eb !important;
}
.settings-label {
color: #9ca3af !important;
}
.settings-help-link {
color: #818cf8 !important;
}
/* Available Tools 区域 */
.tool-item {
background: #1f2937 !important;
border-color: #374151 !important;
}
.tool-item strong {
color: #e5e7eb !important;
}
.tool-item span {
color: #9ca3af !important;
}
/* Example Carousel 区域 */
.example-carousel {
background: #1f2937 !important;
border-color: #374151 !important;
}
.carousel-item-text {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
border-color: #3b82f6 !important;
color: #e0f2fe !important;
}
.carousel-btn {
background: #374151 !important;
color: #9ca3af !important;
}
.carousel-btn:hover {
background: #667eea !important;
color: white !important;
}
.carousel-dot {
background: #4b5563 !important;
}
.carousel-dot.active {
background: #667eea !important;
}
/* Output area 深色模式 */
#output-area,
#output-area.output-box,
div#output-area {
background: #111827 !important;
border: 1px solid #374151 !important;
}
#output-area::-webkit-scrollbar-track {
background: #1f2937 !important;
}
#output-area::-webkit-scrollbar-thumb {
background: #4b5563 !important;
}
#output-area::-webkit-scrollbar-thumb:hover {
background: #6b7280 !important;
}
/* Tool call card 深色模式 */
.tool-call-card {
background: #1f2937 !important;
}
.tool-name {
color: #e5e7eb !important;
}
.tool-detail {
color: #9ca3af !important;
}
/* Result card 深色模式 */
.result-card-expanded {
background: #1f2937 !important;
}
.result-header-expanded {
color: #e5e7eb !important;
border-bottom-color: #374151 !important;
}
.result-content-expanded {
color: #d1d5db !important;
}
/* Thinking 深色模式 */
.thinking-collapsed {
background: #1f2937 !important;
border-color: #374151 !important;
}
.thinking-collapsed summary {
color: #9ca3af !important;
}
.thinking-content {
color: #d1d5db !important;
}
.thinking-streaming {
background: #0c4a6e !important;
border-color: #0369a1 !important;
color: #bae6fd !important;
}
/* Answer section 深色模式 */
.answer-section {
background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%) !important;
}
.answer-section p {
color: #e0f2fe !important;
}
.answer-section strong {
color: #f0f9ff !important;
}
/* 用户问题气泡深色模式 */
.user-message-bubble {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
color: #e0f2fe !important;
border-color: #3b82f6 !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
}
.user-message-content {
color: #e0f2fe !important;
}
/* Round Badge 深色模式 */
.round-badge {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
color: #93c5fd !important;
border-color: #3b82f6 !important;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
}
/* 搜索结果卡片深色模式 */
.search-result-card {
background: #1f2937 !important;
border-color: #374151 !important;
}
.search-result-card:hover {
border-color: #667eea !important;
}
/* 工具结果标题区域深色模式 */
.result-card-expanded div[style*="background: linear-gradient"] {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
border-color: #3b82f6 !important;
}
.result-card-expanded div[style*="background: linear-gradient"] span[style*="color: #1e40af"],
.result-card-expanded div[style*="background: linear-gradient"] a[style*="color: #1e40af"] {
color: #93c5fd !important;
}
.result-card-expanded div[style*="color: #64748b"] {
color: #9ca3af !important;
}
/* 完成消息深色模式 */
.completion-msg {
background: #064e3b !important;
border-color: #059669 !important;
color: #6ee7b7 !important;
}
/* 错误消息深色模式 */
.error-message {
background: #450a0a !important;
border-color: #b91c1c !important;
color: #fca5a5 !important;
}
/* 侧边栏标题深色模式 */
div[style*="font-weight: 600"][style*="color: #374151"] {
color: #e5e7eb !important;
}
/* Disclaimer 深色模式 */
.disclaimer {
color: #9ca3af !important;
border-color: #374151 !important;
}
}
/* Gradio 深色主题类名适配 */
.dark #settings-group,
.dark .tool-item,
.dark .example-carousel {
background: #1f2937 !important;
border-color: #374151 !important;
}
.dark .settings-title,
.dark .tools-title,
.dark .examples-title,
.dark .tool-item strong,
.dark .result-header-expanded,
.dark .tool-name {
color: #e5e7eb !important;
}
.dark .settings-label,
.dark .tool-item span,
.dark .tool-detail,
.dark .thinking-collapsed summary {
color: #9ca3af !important;
}
.dark .settings-help-link {
color: #818cf8 !important;
}
.dark .carousel-item-text {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
border-color: #3b82f6 !important;
color: #e0f2fe !important;
}
.dark #output-area,
.dark #output-area.output-box,
.dark div#output-area {
background: #111827 !important;
border: 1px solid #374151 !important;
}
.dark .tool-call-card,
.dark .result-card-expanded,
.dark .thinking-collapsed {
background: #1f2937 !important;
border-color: #374151 !important;
}
.dark .result-content-expanded,
.dark .thinking-content {
color: #d1d5db !important;
}
.dark .answer-section {
background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%) !important;
}
.dark .answer-section p {
color: #e0f2fe !important;
}
.dark .search-result-card {
background: #1f2937 !important;
border-color: #374151 !important;
}
.dark .completion-msg {
background: #064e3b !important;
border-color: #059669 !important;
color: #6ee7b7 !important;
}
/* 用户问题气泡 Gradio dark 模式 */
.dark .user-message-bubble {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
color: #e0f2fe !important;
border-color: #3b82f6 !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
}
.dark .user-message-content {
color: #e0f2fe !important;
}
/* Round Badge Gradio dark 模式 */
.dark .round-badge {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
color: #93c5fd !important;
border-color: #3b82f6 !important;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
}
/* 工具结果标题 Gradio dark 模式 */
.dark .result-card-expanded div[style*="background: linear-gradient"] {
background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
border-color: #3b82f6 !important;
}
/* Disclaimer Gradio dark 模式 */
.dark .disclaimer {
color: #9ca3af !important;
border-color: #374151 !important;
}
/* Reference Section */
.reference-section {
margin-top: 40px;
border-top: 1px solid #e5e7eb;
padding-top: 20px;
}
.reference-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 16px;
color: #111827;
}
.reference-item {
display: block;
background-color: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 12px;
text-decoration: none;
color: #374151;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.reference-item:hover {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
transform: translateY(-1px);
text-decoration: none;
}
.ref-number {
display: inline-block;
background-color: #eff6ff;
color: #2563eb;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
margin-right: 8px;
font-size: 0.85em;
}
.ref-text {
font-weight: 500;
color: #1f2937;
}
.ref-url {
display: block;
margin-top: 4px;
font-size: 0.8em;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 深色模式适配 Reference Section */
@media (prefers-color-scheme: dark) {
.reference-title {
color: #e5e7eb !important;
}
.reference-item {
background-color: #1f2937 !important;
border-color: #374151 !important;
color: #d1d5db !important;
}
.reference-item:hover {
border-color: #667eea !important;
}
.ref-number {
background-color: #1e3a5f !important;
color: #93c5fd !important;
}
.ref-text {
color: #e5e7eb !important;
}
.ref-url {
color: #9ca3af !important;
}
}
/* Gradio dark 模式适配 Reference Section */
.dark .reference-title {
color: #e5e7eb !important;
}
.dark .reference-item {
background-color: #1f2937 !important;
border-color: #374151 !important;
color: #d1d5db !important;
}
.dark .reference-item:hover {
border-color: #667eea !important;
}
.dark .ref-number {
background-color: #1e3a5f !important;
color: #93c5fd !important;
}
.dark .ref-text {
color: #e5e7eb !important;
}
.dark .ref-url {
color: #9ca3af !important;
}
"""
with gr.Blocks(css=INLINE_CSS, theme=gr.themes.Soft(), js=CAROUSEL_JS) as demo:
# Header with logo and title images - convert to base64 for proper rendering
# Files are in the same directory as app.py (test1/)
logo_path = os.path.join(script_dir, "or-logo1.png")
title_path = os.path.join(script_dir, "openresearcher-title.svg")
logo_base64 = image_to_base64(logo_path)
title_base64 = image_to_base64(title_path)
lambda_path = os.path.join(script_dir, "lambda.png")
lambda_base64 = image_to_base64(lambda_path)
# Build header HTML with base64 images
header_html = f"""
<div style="
text-align: center;
padding: 0.5rem 1rem 0.5rem 1rem;
background: transparent;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1.5rem;
">
"""
if logo_base64:
header_html += f'<img src="{logo_base64}" alt="OpenResearcher Logo" style="height: 84px;">'
if title_base64:
header_html += f'<img src="{title_base64}" alt="OpenResearcher" style="height: 84px;">'
header_html += "</div>"
gr.HTML(header_html)
gr.HTML("""
<div style="display: flex; gap: 0px; justify-content: center; flex-wrap: wrap; margin-top: 0px; margin-bottom: 24px;">
<a href="https://arxiv.org/abs/2603.20278" target="_blank">
<img src="https://img.shields.io/badge/arXiv-B31B1B?style=for-the-badge&logo=arXiv&logoColor=white" alt="arXiv" style="height: 28px;">
</a>
<a href="https://huggingface.co/papers/2603.20278" target="_blank">
<img src="https://img.shields.io/badge/Paper-FFD966?style=for-the-badge&logo=huggingface&logoColor=ffffff" alt="Paper" style="height: 28px;">
</a>
<a href="https://github.com/TIGER-AI-Lab/OpenResearcher" target="_blank">
<img src="https://img.shields.io/badge/Github-181717?style=for-the-badge&logo=github&logoColor=white" alt="Github" style="height: 28px;">
</a>
<a href="https://huggingface.co/datasets/OpenResearcher/OpenResearcher-Dataset" target="_blank">
<img src="https://img.shields.io/badge/Dataset-FFB7B2?style=for-the-badge&logo=huggingface&logoColor=ffffff" alt="Dataset" style="height: 28px;">
</a>
<a href="https://huggingface.co/OpenResearcher/Nemotron-3-Nano-30B-A3B" target="_blank">
<img src="https://img.shields.io/badge/Model-FFD966?style=for-the-badge&logo=huggingface&logoColor=ffffff" alt="Model" style="height: 28px;">
</a>
<a href="https://huggingface.co/spaces/OpenResearcher/OpenResearcher" target="_blank">
<img src="https://img.shields.io/badge/Demo-F97316.svg?style=for-the-badge&logo=gradio&logoColor=white" alt="Demo" style="height: 28px;">
</a>
<a href="https://huggingface.co/datasets/OpenResearcher/OpenResearcher-Eval-Logs/tree/main" target="_blank">
<img src="https://img.shields.io/badge/Eval%20Logs-755BB4?style=for-the-badge&logo=google-sheets&logoColor=white" alt="Eval Logs" style="height: 28px;">
</a>
</div>
""")
# Main layout: Left sidebar + Right content
with gr.Row():
# Left Sidebar (settings & tools)
with gr.Column(scale=1, min_width=280):
# API Settings in a unified box
with gr.Group(elem_id="settings-group"):
gr.HTML('''
<div class="settings-header">
<span class="settings-title">⚙️ Settings</span>
</div>
''')
serper_input = gr.Textbox(
label="",
value=SERPER_API_KEY,
type="password",
placeholder="Enter your Serper API key...",
show_label=False,
elem_id="serper-api-input",
container=False,
visible=False
)
inference_mode_input = gr.Radio(
choices=["Auto (local + cloud backup)", "Local only", "Cloud only"],
value="Auto (local + cloud backup)",
label="Inference Mode",
elem_id="inference-mode-radio"
)
max_rounds_input = gr.Slider(
minimum=1,
maximum=200,
value=50,
step=1,
label="Max Rounds",
elem_id="max-rounds-slider"
)
auto_scroll_checkbox = gr.Checkbox(
label="Auto Scroll",
value=True,
elem_id="auto-scroll-checkbox",
interactive=True
)
# Tools info
gr.HTML("""
<div class="tools-section">
<div class="tools-title">🛠️ Available Tools</div>
<div class="tool-item"><strong>browser.search</strong><br><span>Search the web</span></div>
<div class="tool-item"><strong>browser.open</strong><br><span>Open & read pages</span></div>
<div class="tool-item"><strong>browser.find</strong><br><span>Find text in page</span></div>
</div>
""")
# Example carousel with navigation
gr.HTML("""
<div class="examples-section">
<div class="examples-title">💡 Try Examples</div>
<div class="example-carousel" id="example-carousel">
<div class="carousel-container">
<div class="carousel-item active" data-index="0" data-text="Who won the Nobel Prize in Physics 2024?">
<div class="carousel-item-text">🏆 Who won the Nobel Prize in Physics 2024?</div>
</div>
<div class="carousel-item" data-index="1" data-text="What are the latest breakthroughs in quantum computing in 2024?">
<div class="carousel-item-text">🔬 What are the latest breakthroughs in quantum computing in 2024?</div>
</div>
<div class="carousel-item" data-index="2" data-text="What are the new features in Python 3.12?">
<div class="carousel-item-text">🐍 What are the new features in Python 3.12?</div>
</div>
</div>
<div class="carousel-controls">
<div class="carousel-btn" id="prev-btn">‹</div>
<div class="carousel-indicators">
<div class="carousel-dot active" data-index="0"></div>
<div class="carousel-dot" data-index="1"></div>
<div class="carousel-dot" data-index="2"></div>
</div>
<div class="carousel-btn" id="next-btn">›</div>
</div>
</div>
</div>
""")
# Main content area (30-70)
with gr.Column(scale=3, elem_id="main-content"):
# Output area (on top, hidden initially)
output_area = gr.HTML(
value="",
elem_classes=["output-box"],
elem_id="output-area",
visible=True
)
# Welcome message (will be hidden after first search)
welcome_html = gr.HTML(
value="""
<div id="welcome-section" class="welcome-section">
<h2>What Would You Like to Research?</h2>
<p>I am OpenResearcher, a leading open-source Deep Research Agent, welcome to try!</p>
<p style="color: red;">Due to high traffic, if your submission has no response, please refresh the page and resubmit. Thank you!</p>
</div>
""",
elem_id="welcome-container"
)
# Input area at bottom
question_input = gr.Textbox(
label="",
placeholder="Ask me anything and I'll handle the rest...",
lines=2,
show_label=False,
elem_id="question-input",
autofocus=True
)
with gr.Row(elem_id="button-row"):
submit_btn = gr.Button(
"🔍 Start DeepResearch",
variant="primary",
elem_classes=["primary-btn"],
scale=3
)
stop_btn = gr.Button("⏹ Stop", variant="stop", scale=1)
clear_btn = gr.Button("🗑 Clear", scale=1)
# Lambda sponsor banner
_lambda_img_tag = f'<img src="{lambda_base64}" alt="Lambda" style="height:22px; vertical-align:middle; margin-left:8px;">' if lambda_base64 else '<span style="font-weight:bold;">λ</span>'
gr.HTML(f"""
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-top: 8px;
font-size: 13px;
color: #6b7280;
">
<span>Service Deployed on </span>
<a href="https://lambda.ai/" target="_blank" rel="noopener noreferrer"
style="display:inline-flex; align-items:center; color:inherit; text-decoration:none;">
{_lambda_img_tag}
</a>
</div>
""")
# Function to hide welcome and show output
async def start_research(question, serper_key, max_rounds, inference_mode, agent_type):
# Generator that first hides welcome, then streams results
# Also clears the input box for the next question
# Initial yield to immediately clear welcome, show loading in output, and clear input
# IMPORTANT: Don't use empty string for output, or JS will hide the output area!
yield "", '<div style="text-align: center; padding: 2rem; color: #6b7280;">Delving into it...</div>', ""
if agent_type == "Deep Research":
async for result in run_agent_streaming(question, serper_key, max_rounds, inference_mode):
yield "", result, ""
else:
web_runner = get_web_agent_runner()
if web_runner:
async for result in web_runner(question, max_rounds, inference_mode):
yield "", result, ""
else:
yield "", '<div class="error-message">Web Action Mode not available.</div>', ""
agent_type_input = gr.Dropdown(
choices=["Deep Research", "Web Action"],
value="Deep Research",
label="Agent Mode",
elem_id="agent-type-dropdown"
)
# Event handlers
submit_event = submit_btn.click(
fn=start_research,
inputs=[question_input, serper_input, max_rounds_input, inference_mode_input, agent_type_input],
outputs=[welcome_html, output_area, question_input],
show_progress="hidden",
concurrency_limit=20
)
question_input.submit(
fn=start_research,
inputs=[question_input, serper_input, max_rounds_input, inference_mode_input, agent_type_input],
outputs=[welcome_html, output_area, question_input],
show_progress="hidden",
concurrency_limit=20
)
stop_btn.click(fn=None, inputs=None, outputs=None, cancels=[submit_event])
clear_btn.click(
fn=lambda: ("""
<div id="welcome-section" class="welcome-section">
<h2>What would you like to research?</h2>
<p>Ask any question and I'll search the web to find answers</p>
</div>
""", "", ""),
outputs=[welcome_html, output_area, question_input]
)
# Disclaimer
gr.HTML('''
<div class="disclaimer">
⚠️ AI may generate incorrect information or citations. Please double-check important facts.
</div>
''')
return demo
app = FastAPI()
@app.get("/health")
@app.head("/health")
def health():
return {"status": "ok"}
@app.get("/api-docs", response_class=HTMLResponse)
def api_docs():
return """
<html>
<head><title>OpenResearcher API Docs</title></head>
<body>
<h1>OpenResearcher API Documentation</h1>
<ul>
<li><strong>GET /health</strong>: Returns HTTP 200 if the app is healthy.</li>
<li><strong>GET /api-docs</strong>: This documentation page.</li>
<li><strong>Gradio Interface</strong>: Accessible at the root path /.</li>
</ul>
</body>
</html>
"""
demo = create_interface()
app = gr.mount_gradio_app(app, demo, path="/")
if __name__ == "__main__":
print("="*60)
print("OpenResearcher DeepSearch Agent - ZeroGPU Space")
print("="*60)
uvicorn.run(app, host="0.0.0.0", port=7860)