OpenResearcher / app.py
IPF's picture
Upload app.py
7bad154 verified
# -*- 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
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple, Generator
import traceback
import base64
from transformers import AutoTokenizer
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# ============================================================
# Configuration
# ============================================================
MODEL_NAME = os.getenv("MODEL_NAME", "OpenResearcher/Nemotron-3-Nano-30B-A3B")
REMOTE_API_BASE = os.getenv("REMOTE_API_BASE", "")
SERPER_API_KEY = os.getenv("SERPER_API_KEY", "")
MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "4096")) # Safe limit for ZeroGPU
# ============================================================
# 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) + 1 # Start from 1 instead of 0
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', ''), 'snippet': ''}
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 = ''
# Store snippet information as well
self.link_map[i] = {'url': url, 'title': title, 'snippet': snippet}
link_map[i] = {'url': url, 'title': title, 'snippet': snippet}
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 search(self, query: str, topn: int = 10) -> str:
url = "https://google.serper.dev/search"
headers = {'X-API-KEY': self.serper_key, 'Content-Type': 'application/json'}
payload = json.dumps({"q": query, "num": topn})
async with httpx.AsyncClient() as client:
try:
response = await client.post(url, headers=headers, data=payload, timeout=20.0)
if response.status_code != 200:
return f"Error: Search failed with status {response.status_code}"
data = response.json()
results = data.get("organic", [])
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"
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 f"Error fetching URL: {response.status_code}"
data = response.json()
text = data.get("text", "")
title = data.get("metadata", {}).get("title", "") if isinstance(data.get("metadata"), dict) else ""
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)}"
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
# ============================================================
# Tokenizer Loading
# ============================================================
tokenizer = None
def load_tokenizer():
global tokenizer
if tokenizer is None:
print(f"Loading tokenizer: {MODEL_NAME}")
try:
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME,
trust_remote_code=True
)
print("Tokenizer loaded successfully!")
except Exception as e:
print(f"Error loading tokenizer: {e}")
import traceback
traceback.print_exc()
raise
return tokenizer
# ============================================================
# Text Processing
# ============================================================
def extract_thinking(text: str) -> Tuple[Optional[str], str]:
reasoning_content = None
content = text
if '<think>' in content and '</think>' in content:
match = re.search(r'<think>(.*?)</think>', content, re.DOTALL)
if match:
reasoning_content = match.group(1).strip()
content = content.replace(match.group(0), "").strip()
elif '</think>' in content:
match = re.search(r'^(.*?)</think>', content, re.DOTALL)
if match:
reasoning_content = match.group(1).strip()
content = content.replace(match.group(0), "").strip()
return reasoning_content, content
def parse_tool_call(text: str) -> Tuple[Optional[Dict], str]:
tool_call_text = None
content = text
if '<tool_call>' in content and '</tool_call>' in content:
match = re.search(r'<tool_call>(.*?)</tool_call>', content, re.DOTALL)
if match:
tool_call_text = match.group(1).strip()
content = content.replace(match.group(0), "").strip()
elif '</tool_call>' in content:
match = re.search(r'^(.*?)</tool_call>', content, re.DOTALL)
if match:
tool_call_text = match.group(1).strip()
content = content.replace(match.group(0), "").strip()
if tool_call_text:
try:
if "```json" in tool_call_text:
tool_call_text = tool_call_text.split("```json")[1].split("```")[0].strip()
elif "```" in tool_call_text:
tool_call_text = tool_call_text.split("```")[1].split("```")[0].strip()
parsed = json5.loads(tool_call_text)
return parsed, content
except:
pass
func_match = re.search(r'<function=([\w.]+)>', tool_call_text)
if func_match:
tool_name = func_match.group(1)
tool_args = {}
params = re.finditer(r'<parameter=([\w]+)>\s*(.*?)\s*</parameter>', tool_call_text, re.DOTALL)
for p in params:
param_name = p.group(1)
param_value = p.group(2).strip()
if param_value.startswith('"') and param_value.endswith('"'):
param_value = param_value[1:-1]
try:
if param_value.isdigit():
param_value = int(param_value)
except:
pass
tool_args[param_name] = param_value
return {"name": tool_name, "arguments": tool_args}, content
return None, content
def is_final_answer(text: str) -> bool:
t = text.lower()
return (
('<answer>' in t and '</answer>' in t) or
'final answer:' in t or
('exact answer:' in t and 'confidence:' in t)
)
# ============================================================
# HTML Rendering Helpers (From app_local.py)
# ============================================================
def render_citations(text: str, browser: SimpleBrowser) -> str:
"""Convert citation markers to clickable HTML links with tooltips."""
# Store citation HTML to protect from markdown conversion
citation_store = {}
citation_counter = [0]
def replace_citation(m):
cursor_str = m.group(1)
full_match = m.group(0) # Get the full match to extract line info
# Extract line information from the citation marker
# Format: 【{cursor}†L{line_start}(-L{line_end})?】
line_info = ""
line_match = re.search(r'†(L\d+(?:-L\d+)?)', full_match)
if line_match:
line_info = line_match.group(1)
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'):
url = info.get('url', '')
title = info.get('title', 'No Title')
snippet = info.get('snippet', '')
# Unescape HTML entities and remove newlines to prevent rendering issues
title_display = html.unescape(title).replace('\n', ' ').replace('\r', '').strip()
snippet_display = html.unescape(snippet).replace('\n', ' ').replace('\r', '').strip() if snippet else 'No description available'
# Extract domain from URL
try:
domain = url.split('/')[2] if len(url.split('/')) > 2 else url
except:
domain = url
# Add line info if available
line_html = ""
if line_info:
line_html = f'<div class="citation-tooltip-line">📍 {line_info}</div>'
# Create citation with tooltip (single line to avoid markdown conversion issues)
tooltip_html = f'<span class="citation-tooltip"><div class="citation-tooltip-title">{title_display}</div>{line_html}<div class="citation-tooltip-snippet">{snippet_display}</div><div class="citation-tooltip-url">🔗 {html.escape(domain)}</div></span>'
citation_html = f'<span class="citation-wrapper"><a href="{html.escape(url)}" target="_blank" class="citation-link">[{index}]</a>{tooltip_html}</span>'
# Store citation HTML and return placeholder
placeholder = f'___CITATION_{citation_counter[0]}___'
citation_store[placeholder] = citation_html
citation_counter[0] += 1
return placeholder
# 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 placeholders
result = re.sub(r'[【\[](\d+)†.*?[】\]]', replace_citation, text)
# Second pass: Remove standalone URLs that appear after text (common pattern)
# This removes URLs that directly follow sentences without proper citation
result = re.sub(r'(?<=[.!?])\s+(https?://[^\s]+)', '', result)
# Third pass: 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>'
# Fourth pass: Restore citation HTML from placeholders
for placeholder, citation_html in citation_store.items():
result = result.replace(placeholder, citation_html)
# Fifth pass: Deduplicate adjacent identical citations
while True:
new_result = re.sub(r'(<span class="citation-wrapper">.*?</span>)(\s*)\1', r'\1', result)
if new_result == result:
break
result = new_result
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>'
def render_thinking_collapsed(text: str) -> str:
"""Render thinking content in collapsed mode after completion."""
escaped = html.escape(text)
preview = text[:100] + "..." if len(text) > 100 else text
preview_escaped = html.escape(preview)
return f'''<details class="thinking-collapsed">
<summary>Thought process: "{preview_escaped}"</summary>
<div class="thinking-content">{escaped}</div>
</details>'''
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="display: flex; align-items: center; gap: 0.25rem; color: #9ca3af; font-size: 0.7rem; margin-top: 0.5rem;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<span style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">{html.escape(url.split('/')[2] if len(url.split('/')) > 2 else url)}</span>
</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>'''
def render_round_badge(round_num: int, max_rounds: int) -> str:
return f'<div class="round-badge">Round {round_num}/{max_rounds}</div>'
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>'
def render_user_message(question: str) -> str:
escaped = html.escape(question)
return f'''<div class="user-message-bubble">
<div class="user-message-content">{escaped}</div>
</div>'''
# ============================================================
# Remote API Generation (via vLLM-compatible endpoint)
# ============================================================
async def generate_response(prompt: str, max_new_tokens: int = MAX_NEW_TOKENS) -> str:
"""Generate response using vLLM OpenAI-compatible API."""
# Use /completions endpoint for raw prompt
url = f"{REMOTE_API_BASE}/completions"
headers = {
"Content-Type": "application/json",
"ngrok-skip-browser-warning": "true", # 绕过 ngrok 免费版的浏览器警告页面
}
payload = {
"model": MODEL_NAME,
"prompt": prompt,
"max_tokens": max_new_tokens,
"temperature": 0.7,
"top_p": 0.9,
"stop": ["\n<tool_response>", "<tool_response>"],
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers, timeout=300.0)
if response.status_code != 200:
raise Exception(f"vLLM API error {response.status_code}: {response.text}")
data = response.json()
return data["choices"][0]["text"]
# ============================================================
# Streaming Agent Runner
# ============================================================
async def run_agent_streaming(
question: str,
serper_key: str,
max_rounds: int
) -> Generator[str, None, None]:
global tokenizer
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 serper_key:
yield """<div class="error-message">
<p><strong>Serper API Key Required</strong></p>
<p>Please configure your Serper API Key in the left sidebar under <strong>Settings</strong>.</p>
<p>Don't have an API key? <a href="https://serper.dev/" target="_blank" style="color: #667eea; text-decoration: underline;">Get one here →</a></p>
</div>"""
return
# Load tokenizer for prompt formatting
try:
load_tokenizer()
except Exception as e:
yield f"<p style='color:#dc2626;'>Error loading tokenizer: {html.escape(str(e))}</p>"
return
browser = SimpleBrowser(serper_key)
tools = json.loads(TOOL_CONTENT)
system_prompt = DEVELOPER_CONTENT + f"\n\nToday's date: {datetime.now().strftime('%Y-%m-%d')}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": question}
]
stop_strings = ["\n<tool_response>", "<tool_response>"]
html_parts = [render_user_message(question)]
yield ''.join(html_parts)
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)
prompt = tokenizer.apply_chat_template(
messages,
tools=tools,
tokenize=False,
add_generation_prompt=True
)
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)
# Call ZeroGPU function
generated = await generate_response(prompt, max_new_tokens=MAX_NEW_TOKENS)
# 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
for stop_str in stop_strings:
if stop_str in generated:
generated = generated[:generated.find(stop_str)]
reasoning, content = extract_thinking(generated)
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)
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
if 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;
}
/* Citation Tooltip - 小便签弹窗 */
.citation-wrapper {
position: relative;
display: inline-block;
}
.citation-link {
color: #10a37f;
text-decoration: none;
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
background: #e6f7f1;
transition: all 0.2s ease;
cursor: pointer;
border: 1px solid #10a37f30;
}
.citation-link:hover {
background: #10a37f;
color: white;
text-decoration: none;
}
.citation-tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
z-index: 999999;
width: 280px;
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
border: 2px solid #10a37f;
border-radius: 12px;
padding: 12px 14px;
box-shadow: 0 8px 24px rgba(16, 163, 127, 0.25), 0 4px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: auto;
}
.citation-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: #10a37f;
}
/* Create an invisible bridge between link and tooltip */
.citation-tooltip::before {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 20px;
background: transparent;
}
.citation-wrapper:hover .citation-tooltip,
.citation-tooltip:hover {
visibility: visible;
opacity: 1;
bottom: 130%;
}
.citation-tooltip-title {
font-weight: 600;
color: #1e293b;
font-size: 0.9rem;
margin-bottom: 8px;
line-height: 1.4;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 6px;
}
.citation-tooltip-line {
color: #059669;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 6px;
padding: 3px 8px;
background: #ecfdf5;
border-radius: 4px;
display: inline-block;
border: 1px solid #10a37f30;
}
.citation-tooltip-snippet {
color: #475569;
font-size: 0.8rem;
line-height: 1.5;
margin-bottom: 8px;
max-height: 80px;
overflow-y: auto;
}
.citation-tooltip-url {
color: #6b7280;
font-size: 0.7rem;
font-family: 'SF Mono', Monaco, monospace;
word-break: break-all;
padding: 2px 0;
border-top: 1px solid #e5e7eb;
padding-top: 6px;
text-decoration: none;
opacity: 0.8;
}
.citation-tooltip-url:hover {
color: #10a37f;
opacity: 1;
}
/* 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;
}
/* Citation Tooltip 深色模式 */
.citation-link {
background: #064e3b !important;
border-color: #10a37f80 !important;
color: #6ee7b7 !important;
}
.citation-link:hover {
background: #10a37f !important;
color: #ffffff !important;
}
.citation-tooltip {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%) !important;
border-color: #10a37f !important;
box-shadow: 0 8px 24px rgba(16, 163, 127, 0.4), 0 4px 8px rgba(0, 0, 0, 0.3) !important;
}
.citation-tooltip-title {
color: #e5e7eb !important;
border-bottom-color: #374151 !important;
}
.citation-tooltip-line {
color: #6ee7b7 !important;
background: #064e3b !important;
border-color: #10a37f50 !important;
}
.citation-tooltip-snippet {
color: #d1d5db !important;
}
.citation-tooltip-url {
color: #9ca3af !important;
border-top-color: #374151 !important;
opacity: 0.8 !important;
}
.citation-tooltip-url:hover {
color: #10a37f !important;
opacity: 1 !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;
}
/* Citation Tooltip Gradio dark 模式 */
.dark .citation-link {
background: #064e3b !important;
border-color: #10a37f80 !important;
color: #6ee7b7 !important;
}
.dark .citation-link:hover {
background: #10a37f !important;
color: #ffffff !important;
}
.dark .citation-tooltip {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%) !important;
border-color: #10a37f !important;
box-shadow: 0 8px 24px rgba(16, 163, 127, 0.4), 0 4px 8px rgba(0, 0, 0, 0.3) !important;
}
.dark .citation-tooltip-title {
color: #e5e7eb !important;
border-bottom-color: #374151 !important;
}
.dark .citation-tooltip-line {
color: #6ee7b7 !important;
background: #064e3b !important;
border-color: #10a37f50 !important;
}
.dark .citation-tooltip-snippet {
color: #d1d5db !important;
}
.dark .citation-tooltip-url {
color: #9ca3af !important;
border-top-color: #374151 !important;
opacity: 0.8 !important;
}
.dark .citation-tooltip-url:hover {
color: #10a37f !important;
opacity: 1 !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)
# 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://boiled-honeycup-4c7.notion.site/OpenResearcher-A-Fully-Open-Pipeline-for-Long-Horizon-Deep-Research-Trajectory-Synthesis-2f7e290627b5800cb3a0cd7e8d6ec0ea?source=copy_link" target="_blank">
<img src="https://img.shields.io/badge/Blog-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white" alt="Blog" 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/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
)
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)
# Function to hide welcome and show output
async def start_research(question, serper_key, max_rounds):
# 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>', ""
async for result in run_agent_streaming(question, serper_key, max_rounds):
yield "", result, ""
# Event handlers
submit_event = submit_btn.click(
fn=start_research,
inputs=[question_input, serper_input, max_rounds_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],
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
if __name__ == "__main__":
print("="*60)
print("OpenResearcher DeepSearch Agent - ZeroGPU Space")
print("="*60)
demo = create_interface()
demo.queue(default_concurrency_limit=20).launch()