| |
| """ |
| 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 |
|
|
| |
| |
| |
| |
| MODEL_NAME = os.getenv("MODEL_NAME", "OpenResearcher/Nemotron-3-Nano-30B-A3B") |
| |
| |
| |
| |
| 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", "") |
| |
| |
| |
| |
| |
| |
| |
| 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")) |
| |
| |
| |
| MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", "100000")) |
|
|
| |
| |
| |
| 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() |
|
|
| |
| |
| |
| 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] = {} |
| self.used_citations = [] |
|
|
| @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]]: |
| |
| if cursor in self.link_map: |
| return self.link_map[cursor] |
| |
| |
| 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) |
| 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 |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| def render_citations(text: str, browser: SimpleBrowser) -> str: |
| """Convert citation markers to clickable HTML links.""" |
| def replace_citation(m): |
| cursor_str = m.group(1) |
| |
| |
|
|
| try: |
| cursor = int(cursor_str) |
| index = browser.get_citation_index(cursor) |
| |
| |
| info = browser.get_page_info(cursor) |
| if info and info.get('url'): |
| |
| |
| url = info.get('url') |
| return f'<a href="{html.escape(url)}" target="_blank" class="citation-link">[{index}]</a>' |
| |
| |
| return f'<span class="citation-link">[{index}]</span>' |
| except Exception as e: |
| |
| pass |
| return m.group(0) |
|
|
| |
| result = re.sub(r'[【\[](\d+)†.*?[】\]]', replace_citation, text) |
| |
| |
| |
| |
| while True: |
| new_result = re.sub(r'(<a [^>]+>\[\d+\]</a>)(\s*)\1', r'\1', result) |
| if new_result == result: |
| break |
| result = new_result |
|
|
| |
| 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") |
| |
| |
| 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>''' |
| |
| |
| 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 |
|
|
|
|
|
|
| |
| |
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| estimate_tokens = shared_utils.estimate_tokens |
| compress_messages = shared_utils.compress_messages |
|
|
|
|
| |
| |
| |
| _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 |
| 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) |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| 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: |
| |
| raise Exception(f"Cloud backup API error {response.status_code}: {response.text}") |
| if response.status_code != 200: |
| |
| 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, |
| "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>''' |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| 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 |
|
|
| |
| html_parts.pop() |
|
|
| except Exception as e: |
| html_parts.pop() |
| 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) |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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(): |
| |
| script_dir = os.path.dirname(os.path.abspath(__file__)) |
|
|
| |
| 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') |
| |
| 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 = """ |
| /* 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: |
| |
| |
| 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) |
|
|
| |
| 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> |
| """) |
|
|
| |
| with gr.Row(): |
| |
| with gr.Column(scale=1, min_width=280): |
| |
| 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 |
| ) |
|
|
| |
| 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> |
| """) |
|
|
| |
| 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> |
| """) |
|
|
| |
| with gr.Column(scale=3, elem_id="main-content"): |
| |
| output_area = gr.HTML( |
| value="", |
| elem_classes=["output-box"], |
| elem_id="output-area", |
| visible=True |
| ) |
|
|
| |
| 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" |
| ) |
|
|
| |
| 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_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> |
| """) |
|
|
| |
| async def start_research(question, serper_key, max_rounds, inference_mode, agent_type): |
| |
| |
|
|
| |
| |
| 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" |
| ) |
|
|
| |
| 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] |
| ) |
|
|
| |
| 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) |