Spaces:
Running
Running
| # -*- 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 | |
| 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() |