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 | |
| from gradio_client import Client as GradioClient | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass | |
| # ============================================================ | |
| # Configuration | |
| # ============================================================ | |
| MODEL_NAME = os.getenv("MODEL_NAME", "alias-fast") | |
| REMOTE_API_BASE = os.getenv("REMOTE_API_BASE", "https://api.helmholtz-blablador.fz-juelich.de/v1") | |
| BLABLADOR_API_KEY = os.getenv("BLABLADOR_API_KEY", "") | |
| MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "4096")) | |
| # ============================================================ | |
| # 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 victor/websearch Gradio API.""" | |
| def __init__(self): | |
| 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 | |
| try: | |
| # victor/websearch is a public space, but we can pass token if available | |
| hf_token = os.getenv("HF_TOKEN", "") | |
| # Use 'token' instead of 'hf_token' for Gradio Client | |
| self.client = GradioClient("victor/websearch", token=hf_token if hf_token else None) | |
| except Exception as e: | |
| print(f"Error initializing Gradio client: {e}") | |
| self.client = None | |
| 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 _parse_websearch_output(self, output: str) -> List[Dict]: | |
| results = [] | |
| # Split by the separator ---, handling potential variations in newlines | |
| parts = re.split(r'\n---\n|^\s*---\s*$', output, flags=re.MULTILINE) | |
| for part in parts: | |
| part = part.strip() | |
| if not part or "Successfully extracted content" in part: | |
| continue | |
| title_match = re.search(r'## (.*)', part) | |
| domain_match = re.search(r'\*\*Domain:\*\* (.*)', part) | |
| url_match = re.search(r'\*\*URL:\*\* (.*)', part) | |
| if title_match and url_match: | |
| title = title_match.group(1).strip() | |
| url = url_match.group(1).strip() | |
| domain = domain_match.group(1).strip() if domain_match else "" | |
| # Content starts after metadata | |
| metadata_end = url_match.end() | |
| content = part[metadata_end:].strip() | |
| results.append({ | |
| 'title': title, | |
| 'url': url, | |
| 'domain': domain, | |
| 'content': content | |
| }) | |
| return results | |
| async def search(self, query: str, topn: int = 4) -> str: | |
| if not self.client: | |
| return "Error: Search client not initialized" | |
| try: | |
| # Call the Gradio API | |
| loop = asyncio.get_event_loop() | |
| result_str = await loop.run_in_executor( | |
| None, | |
| lambda: self.client.predict( | |
| query=query, | |
| search_type="search", | |
| num_results=topn, | |
| api_name="/search_web" | |
| ) | |
| ) | |
| results = self._parse_websearch_output(result_str) | |
| if not results: | |
| return f"No results found for: '{query}'" | |
| # Populate pages and link_map | |
| new_link_map = {} | |
| lines = [] | |
| for i, r in enumerate(results): | |
| title = r['title'] | |
| url = r['url'] | |
| domain = r['domain'] | |
| content = r['content'] | |
| # Create a snippet for the search result view | |
| snippet = content[:200].replace('\n', ' ') + "..." | |
| self.link_map[i] = {'url': url, 'title': title} | |
| new_link_map[i] = {'url': url, 'title': title} | |
| # Cache the full content | |
| self.pages[url] = { | |
| 'url': url, | |
| 'title': title, | |
| 'text': content | |
| } | |
| link_text = f"ใ{i}โ {title}โ {domain}ใ" if domain else f"ใ{i}โ {title}ใ" | |
| lines.append(f"{link_text}") | |
| lines.append(f" {snippet}") | |
| lines.append("") | |
| formatted_content = '\n'.join(lines) | |
| pseudo_url = f"web-search://q={query}&ts={int(time.time())}" | |
| cursor = self.current_cursor + 1 | |
| self.pages[pseudo_url] = { | |
| 'url': pseudo_url, | |
| 'title': f"Search Results: {query}", | |
| 'text': formatted_content, | |
| 'urls': {str(k): v['url'] for k, v in new_link_map.items()} | |
| } | |
| self.page_stack.append(pseudo_url) | |
| header = f"Search Results: {query} ({pseudo_url})\n**viewing lines [0 - {len(formatted_content.split(chr(10)))-1}]**\n\n" | |
| body = self._format_line_numbers(formatted_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" | |
| # Check if we already have the page content cached | |
| if target_url in self.pages: | |
| page = self.pages[target_url] | |
| text = page['text'] | |
| lines = text.split('\n') | |
| new_cursor = self.current_cursor + 1 | |
| self.page_stack.append(target_url) | |
| 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']} ({target_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"[{new_cursor}] {header}{body}" | |
| return f"Error: Content for {target_url} not found in search results. The current search API only provides content for pages returned in search results." | |
| 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: | |
| # We use Nemotron as a proxy tokenizer for token counting | |
| token_model = "OpenResearcher/Nemotron-3-Nano-30B-A3B" | |
| print(f"Loading tokenizer: {token_model}") | |
| try: | |
| tokenizer = AutoTokenizer.from_pretrained( | |
| token_model, | |
| 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.""" | |
| def replace_citation(m): | |
| cursor_str = m.group(1) | |
| # l1 = m.group(2) | |
| # l2 = m.group(3) | |
| try: | |
| cursor = int(cursor_str) | |
| index = browser.get_citation_index(cursor) | |
| # Check if we have URL info | |
| info = browser.get_page_info(cursor) | |
| if info and info.get('url'): | |
| # Return clickable index link pointing to reference section | |
| # Aligned with generate_html_example.py style (green via CSS class) | |
| url = info.get('url') | |
| return f'<a href="{html.escape(url)}" target="_blank" class="citation-link">[{index}]</a>' | |
| # Fallback if no URL | |
| return f'<span class="citation-link">[{index}]</span>' | |
| except Exception as e: | |
| # print(f"Error in replace_citation: {e}, match: {m.group(0)}") | |
| pass | |
| return m.group(0) | |
| # First pass: replace citations with linked citations | |
| result = re.sub(r'[ใ\[](\d+)โ .*?[ใ\]]', replace_citation, text) | |
| # Second pass: Deduplicate adjacent identical citations | |
| # Matches: <a ...>[N]</a> followed by optional whitespace and same link | |
| # We repeat this until no more changes to handle multiple duplicates | |
| while True: | |
| new_result = re.sub(r'(<a [^>]+>\[\d+\]</a>)(\s*)\1', r'\1', result) | |
| if new_result == result: | |
| break | |
| result = new_result | |
| # Convert basic markdown to HTML | |
| result = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', result) | |
| result = re.sub(r'\*(.+?)\*', r'<em>\1</em>', result) | |
| result = re.sub(r'`(.+?)`', r'<code>\1</code>', result) | |
| result = result.replace('\n\n', '</p><p>').replace('\n', '<br>') | |
| if not result.startswith('<p>'): | |
| result = f'<p>{result}</p>' | |
| return result | |
| def render_thinking_streaming(text: str) -> str: | |
| """Render thinking content in streaming mode (visible, with animation).""" | |
| escaped = html.escape(text) | |
| return f'<div class="thinking-streaming">{escaped}</div>' | |
| 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="color: #9ca3af; font-size: 0.75rem; margin-top: 0.5rem; font-family: monospace; word-break: break-all;"> | |
| {html.escape(url)} | |
| </div> | |
| </div> | |
| </div> | |
| ''' | |
| result_html += '</div>' | |
| return f'''<div class="result-card-expanded" style="border-left: 3px solid {border_color};"> | |
| <div class="result-header-expanded">{tool_label}</div> | |
| <div class="result-content-expanded" style="font-family: inherit;">{search_title}{result_html}</div> | |
| </div>''' | |
| # ===== BROWSER.OPEN and BROWSER.FIND ===== | |
| lines = result.split('\n') | |
| title_html = "" | |
| content_start_idx = 0 | |
| pattern_to_highlight = None | |
| if lines and re.match(r'^\[\d+\]\s+.+\s+\(.+\)$', lines[0]): | |
| first_line = lines[0] | |
| match = re.match(r'^\[(\d+)\]\s+(.+?)\s+\((.+)\)$', first_line) | |
| if match: | |
| ref_num, title, url = match.groups() | |
| if fn_name == "browser.find": | |
| pattern_match = re.search(r'Find Results:\s*(.+)', title) | |
| if pattern_match: | |
| pattern_to_highlight = pattern_match.group(1).strip() | |
| is_clickable = not url.startswith('web-search://') | |
| if is_clickable: | |
| title_html = f''' | |
| <div style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); padding: 0.875rem 1rem; margin: -1.25rem -1.25rem 1rem -1.25rem; border-bottom: 1px solid #e0e7ff;"> | |
| <div style="display: flex; align-items: center; gap: 0.5rem;"> | |
| <span style="background: {border_color}; color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;">ใ{ref_num}ใ</span> | |
| <a href="{html.escape(url)}" target="_blank" | |
| style="color: #1e40af; font-weight: 600; font-size: 0.95rem; text-decoration: none; flex: 1;"> | |
| {html.escape(title)} | |
| </a> | |
| </div> | |
| <div style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; font-family: monospace;"> | |
| {html.escape(url)} | |
| </div> | |
| </div> | |
| ''' | |
| else: | |
| title_html = f''' | |
| <div style="background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%); padding: 0.875rem 1rem; margin: -1.25rem -1.25rem 1rem -1.25rem; border-bottom: 1px solid #e0e7ff;"> | |
| <div style="display: flex; align-items: center; gap: 0.5rem;"> | |
| <span style="background: {border_color}; color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;">ใ{ref_num}ใ</span> | |
| <span style="color: #1e40af; font-weight: 600; font-size: 0.95rem;">{html.escape(title)}</span> | |
| </div> | |
| </div> | |
| ''' | |
| content_start_idx = 1 | |
| if content_start_idx < len(lines) and lines[content_start_idx].startswith('**viewing lines'): | |
| content_start_idx += 1 | |
| if content_start_idx < len(lines) and lines[content_start_idx].strip() == '': | |
| content_start_idx += 1 | |
| cleaned_lines = [] | |
| for line in lines[content_start_idx:]: | |
| cleaned_line = re.sub(r'^L\d+:\s*', '', line) | |
| cleaned_lines.append(cleaned_line) | |
| cleaned_content = '\n'.join(cleaned_lines) | |
| formatted_result = html.escape(cleaned_content) | |
| if pattern_to_highlight and fn_name == "browser.find": | |
| escaped_pattern = re.escape(pattern_to_highlight) | |
| def highlight_match(match): | |
| return f'<mark style="background: #86efac; padding: 0.125rem 0.25rem; border-radius: 2px; font-weight: 600; color: #064e3b;">{match.group(0)}</mark>' | |
| formatted_result = re.sub( | |
| escaped_pattern, | |
| highlight_match, | |
| formatted_result, | |
| flags=re.IGNORECASE | |
| ) | |
| def make_citation_clickable(match): | |
| full_text = match.group(0) | |
| parts_match = re.match(r'ใ(\d+)โ ([^โ ]+)โ ([^ใ]+)ใ', full_text) | |
| if parts_match: | |
| ref_num = parts_match.group(1) | |
| title = parts_match.group(2) | |
| domain = parts_match.group(3) | |
| url = f"https://{domain}" if not domain.startswith('http') else domain | |
| return f'<a href="{html.escape(url)}" target="_blank" style="background: #e0f7fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; color: #006064; font-weight: 500; text-decoration: none; display: inline-block;" title="{html.escape(title)}">ใ{ref_num}โ {html.escape(domain)}ใ</a>' | |
| else: | |
| simple_match = re.match(r'ใ(\d+)โ ([^ใ]+)ใ', full_text) | |
| if simple_match: | |
| ref_num = simple_match.group(1) | |
| text = simple_match.group(2) | |
| return f'<span style="background: #e0f7fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; color: #006064; font-weight: 500;">ใ{ref_num}โ {html.escape(text)}ใ</span>' | |
| return full_text | |
| formatted_result = re.sub(r'ใ\d+โ [^ใ]+ใ', make_citation_clickable, formatted_result) | |
| formatted_result = formatted_result.replace('\n\n', '</p><p style="margin: 0.75rem 0;">') | |
| formatted_result = formatted_result.replace('\n', '<br>') | |
| if not formatted_result.startswith('<p'): | |
| formatted_result = f'<p style="margin: 0.75rem 0;">{formatted_result}</p>' | |
| max_length = 5000 | |
| if len(result) > max_length: | |
| formatted_result = formatted_result[:max_length] + '<br><br><em style="color: #9ca3af;">...(content truncated for display)...</em>' | |
| return f'''<div class="result-card-expanded" style="border-left: 3_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 OpenAI-compatible endpoint) | |
| # ============================================================ | |
| def count_tokens(text: str) -> int: | |
| """Count tokens in text using the loaded tokenizer.""" | |
| try: | |
| tok = load_tokenizer() | |
| return len(tok.encode(text)) | |
| except Exception: | |
| # Fallback to rough estimate if tokenizer fails | |
| return len(text) // 4 | |
| async def generate_response(prompt: str, max_new_tokens: int = MAX_NEW_TOKENS) -> str: | |
| """Generate response using OpenAI-compatible API with model switching.""" | |
| # Choose model based on prompt length | |
| prompt_tokens = count_tokens(prompt) | |
| selected_model = "alias-large" if prompt_tokens > 4000 else "alias-fast" | |
| url = f"{REMOTE_API_BASE}/completions" | |
| headers = { | |
| "Content-Type": "application/json" | |
| } | |
| if BLABLADOR_API_KEY: | |
| headers["Authorization"] = f"Bearer {BLABLADOR_API_KEY}" | |
| payload = { | |
| "model": selected_model, | |
| "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"LLM 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, | |
| 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 BLABLADOR_API_KEY: | |
| yield """<div class="error-message"> | |
| <p><strong>BLABLADOR_API_KEY Missing</strong></p> | |
| <p>The Blablador API Key is not configured in the Space secrets. Please add it to your Space settings.</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() | |
| 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 generation 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", 4)) | |
| 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; | |
| } | |
| /* User Message Bubble - ๆทก่่ฒ่ๆฏ๏ผๅณๅฏน้ฝ */ | |
| .user-message-bubble { | |
| background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); | |
| color: #0c4a6e; | |
| border-radius: 1rem 1rem 0.25rem 1rem; | |
| padding: 1rem 1.25rem; | |
| margin: 1rem 0 1rem auto; | |
| max-width: 80%; | |
| box-shadow: 0 2px 8px rgba(14, 165, 233, 0.2); | |
| border: 1px solid #7dd3fc; | |
| text-align: left; | |
| } | |
| /* Reference Section Collapsible */ | |
| .reference-section { | |
| margin-top: 40px; | |
| border-top: 1px solid #e5e7eb; | |
| padding-top: 20px; | |
| } | |
| .reference-title { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| color: #111827; | |
| cursor: pointer; | |
| outline: none; | |
| } | |
| .ref-url { | |
| text-align: left !important; | |
| } | |
| .user-message-content { | |
| line-height: 1.6; | |
| font-size: 0.95rem; | |
| } | |
| /* Output area - ๅบๅฎ้ซๅบฆๅฏๆปๅจ */ | |
| /* ๅผบๅถๅบๅฎ้ซๅบฆ๏ผๅ ๅฎนๅจ้้ขๆปๅจ */ | |
| #output-area, | |
| #output-area.output-box, | |
| div#output-area { | |
| height: 50vh !important; | |
| min-height: 250px !important; | |
| max-height: 50vh !important; | |
| overflow-y: scroll !important; | |
| overflow-x: hidden !important; | |
| padding: 1rem !important; | |
| border: 1px solid #e5e7eb !important; | |
| border-radius: 8px !important; | |
| background: #fafafa !important; | |
| scroll-behavior: smooth; | |
| flex-shrink: 0 !important; | |
| flex-grow: 0 !important; | |
| } | |
| /* ๅ ้จๆๆๅ ็ด ไธ่ฝๆ็ ดๅฎนๅจ */ | |
| #output-area * { | |
| max-height: none !important; | |
| overflow: visible !important; | |
| } | |
| #output-area > div { | |
| height: auto !important; | |
| max-height: none !important; | |
| overflow: visible !important; | |
| border: none !important; | |
| background: transparent !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| box-shadow: none !important; | |
| } | |
| /* ๅๅง็ถๆๅ็ฉบๅ ๅฎนๆถ้่ output-area */ | |
| #output-area:empty, | |
| #output-area.hidden-output, | |
| #output-area:not(.has-content), | |
| .hidden-output#output-area, | |
| div.hidden-output#output-area, | |
| #main-content #output-area.hidden-output, | |
| #main-content .output-box.hidden-output { | |
| display: none !important; | |
| visibility: hidden !important; | |
| height: 0 !important; | |
| min-height: 0 !important; | |
| max-height: 0 !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| border: none !important; | |
| opacity: 0 !important; | |
| overflow: hidden !important; | |
| } | |
| /* ้ฒๆญขๅ ้จๅ ๅฎนๆ็ ดๅฎนๅจ */ | |
| #output-area > * { | |
| max-width: 100%; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| } | |
| /* ไธปๅ ๅฎนๅบๅๅธๅฑ - ้ๅถๆดไฝ้ซๅบฆ */ | |
| #main-content { | |
| display: flex !important; | |
| flex-direction: column !important; | |
| height: auto !important; | |
| max-height: none !important; | |
| overflow: visible !important; | |
| } | |
| /* Gradio ๅ ่ฃ ๅฎนๅจ้ๅถ */ | |
| #main-content > div { | |
| flex-shrink: 0; | |
| } | |
| #output-area::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| #output-area::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 4px; | |
| } | |
| #output-area::-webkit-scrollbar-thumb { | |
| background: #c1c1c1; | |
| border-radius: 4px; | |
| } | |
| #output-area::-webkit-scrollbar-thumb:hover { | |
| background: #a1a1a1; | |
| } | |
| /* ่ชๅจๆปๅจๆงๅถๆ้ฎๆ ทๅผ */ | |
| #auto-scroll-checkbox { | |
| margin-top: 0.5rem; | |
| } | |
| #auto-scroll-checkbox label { | |
| font-size: 0.85rem; | |
| color: #4b5563; | |
| } | |
| /* Completion Message */ | |
| .completion-msg { | |
| text-align: center; | |
| color: #10a37f; | |
| font-weight: 600; | |
| padding: 1rem; | |
| margin: 1rem 0; | |
| background: #f0fdf4; | |
| border-radius: 8px; | |
| border: 1px solid #86efac; | |
| } | |
| /* Error Message */ | |
| .error-message { | |
| background: #fee2e2; | |
| border-left: 4px solid #dc2626; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin: 1rem 0; | |
| color: #991b1b; | |
| } | |
| .error-message strong { | |
| color: #7f1d1d; | |
| } | |
| .error-message a { | |
| color: #dc2626; | |
| font-weight: 500; | |
| } | |
| /* Round Badge - ๆทก่่ฒ่ๆฏ */ | |
| .round-badge { | |
| display: inline-block; | |
| background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); | |
| color: #0369a1; | |
| padding: 0.25rem 0.75rem; | |
| border-radius: 999px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| margin: 0.5rem 0; | |
| box-shadow: 0 2px 4px rgba(14, 165, 233, 0.15); | |
| border: 1px solid #7dd3fc; | |
| } | |
| /* Settings Section */ | |
| #settings-group { | |
| background: transparent !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| box-shadow: none !important; | |
| gap: 0 !important; | |
| } | |
| #max-rounds-slider, #auto-scroll-checkbox { | |
| background: #f9fafb; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 6px; | |
| padding: 0.75rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .settings-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 0.875rem; | |
| } | |
| .settings-title { | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| color: #374151; | |
| } | |
| .settings-api-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 0.375rem; | |
| } | |
| .settings-label { | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| color: #4b5563; | |
| } | |
| .settings-help-link { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.25rem; | |
| font-size: 0.7rem; | |
| color: #667eea; | |
| text-decoration: none; | |
| transition: opacity 0.2s; | |
| } | |
| .settings-help-icon { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| font-size: 0.6rem; | |
| font-weight: bold; | |
| } | |
| /* Tools Section */ | |
| .tools-section { | |
| margin-top: 0; | |
| } | |
| .tools-title { | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| color: #374151; | |
| margin-bottom: 0.5rem; | |
| margin-top: 0; | |
| } | |
| /* Tool Item */ | |
| .tool-item { | |
| background: #f9fafb; | |
| padding: 0.75rem; | |
| border-radius: 6px; | |
| margin-bottom: 0.5rem; | |
| border: 1px solid #e5e7eb; | |
| } | |
| .tool-item strong { | |
| color: #374151; | |
| font-size: 0.85rem; | |
| } | |
| .tool-item span { | |
| color: #6b7280; | |
| font-size: 0.8rem; | |
| } | |
| /* Examples Section */ | |
| .examples-section { | |
| margin-top: -0.5rem; | |
| } | |
| .examples-title { | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| color: #374151; | |
| margin-bottom: 0.5rem; | |
| } | |
| /* Example Carousel */ | |
| .example-carousel { | |
| background: white; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| border: 1px solid #e5e7eb; | |
| } | |
| .carousel-container { | |
| position: relative; | |
| min-height: 60px; | |
| margin-bottom: 0.75rem; | |
| } | |
| .carousel-item { | |
| display: none; | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .carousel-item.active { | |
| display: block; | |
| opacity: 1; | |
| } | |
| .carousel-item-text { | |
| background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%); | |
| padding: 1rem; | |
| border-radius: 6px; | |
| color: #374151; | |
| font-size: 0.875rem; | |
| line-height: 1.5; | |
| border: 1px solid #e0e7ff; | |
| } | |
| .carousel-controls { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 1rem; | |
| } | |
| .carousel-btn { | |
| cursor: pointer; | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| background: #f3f4f6; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.25rem; | |
| color: #6b7280; | |
| transition: all 0.2s ease; | |
| user-select: none; | |
| } | |
| .carousel-btn:hover { | |
| background: #667eea; | |
| color: white; | |
| } | |
| .carousel-indicators { | |
| display: flex; | |
| gap: 0.5rem; | |
| } | |
| .carousel-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #d1d5db; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .carousel-dot.active { | |
| background: #667eea; | |
| width: 24px; | |
| border-radius: 4px; | |
| } | |
| /* Welcome Message */ | |
| .welcome-container { | |
| text-align: center; | |
| padding: 3rem 2rem; | |
| } | |
| .welcome-title { | |
| font-size: 2rem; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| margin-bottom: 1rem; | |
| } | |
| .welcome-subtitle { | |
| color: #6b7280; | |
| font-size: 1.1rem; | |
| margin-bottom: 2rem; | |
| } | |
| /* Footer */ | |
| .footer-container { | |
| text-align: center; | |
| padding: 1.5rem; | |
| color: #9ca3af; | |
| font-size: 0.875rem; | |
| border-top: 1px solid #e5e7eb; | |
| margin-top: 2rem; | |
| } | |
| .footer-container a { | |
| color: #667eea; | |
| text-decoration: none; | |
| } | |
| .footer-container a:hover { | |
| text-decoration: underline; | |
| } | |
| /* Disclaimer */ | |
| .disclaimer { | |
| text-align: center; | |
| padding: 1rem; | |
| color: #6b7280; | |
| font-size: 0.875rem; | |
| border-top: 1px solid #e5e7eb; | |
| margin-top: 1rem; | |
| } | |
| /* ========== ๆทฑ่ฒๆจกๅผ้้ ========== */ | |
| @media (prefers-color-scheme: dark) { | |
| /* Settings ๅบๅ */ | |
| #settings-group { | |
| background: transparent !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| gap: 0 !important; | |
| } | |
| #max-rounds-slider, #auto-scroll-checkbox { | |
| background: #1f2937 !important; | |
| border: 1px solid #374151 !important; | |
| border-radius: 6px !important; | |
| padding: 0.75rem !important; | |
| } | |
| .settings-title, | |
| .tools-title, | |
| .examples-title { | |
| color: #e5e7eb !important; | |
| } | |
| .settings-label { | |
| color: #9ca3af !important; | |
| } | |
| .settings-help-link { | |
| color: #818cf8 !important; | |
| } | |
| /* Available Tools ๅบๅ */ | |
| .tool-item { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .tool-item strong { | |
| color: #e5e7eb !important; | |
| } | |
| .tool-item span { | |
| color: #9ca3af !important; | |
| } | |
| /* Example Carousel ๅบๅ */ | |
| .example-carousel { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .carousel-item-text { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| border-color: #3b82f6 !important; | |
| color: #e0f2fe !important; | |
| } | |
| .carousel-btn { | |
| background: #374151 !important; | |
| color: #9ca3af !important; | |
| } | |
| .carousel-btn:hover { | |
| background: #667eea !important; | |
| color: white !important; | |
| } | |
| .carousel-dot { | |
| background: #4b5563 !important; | |
| } | |
| .carousel-dot.active { | |
| background: #667eea !important; | |
| } | |
| /* Output area ๆทฑ่ฒๆจกๅผ */ | |
| #output-area, | |
| #output-area.output-box, | |
| div#output-area { | |
| background: #111827 !important; | |
| border: 1px solid #374151 !important; | |
| } | |
| #output-area::-webkit-scrollbar-track { | |
| background: #1f2937 !important; | |
| } | |
| #output-area::-webkit-scrollbar-thumb { | |
| background: #4b5563 !important; | |
| } | |
| #output-area::-webkit-scrollbar-thumb:hover { | |
| background: #6b7280 !important; | |
| } | |
| /* Tool call card ๆทฑ่ฒๆจกๅผ */ | |
| .tool-call-card { | |
| background: #1f2937 !important; | |
| } | |
| .tool-name { | |
| color: #e5e7eb !important; | |
| } | |
| .tool-detail { | |
| color: #9ca3af !important; | |
| } | |
| /* Result card ๆทฑ่ฒๆจกๅผ */ | |
| .result-card-expanded { | |
| background: #1f2937 !important; | |
| } | |
| .result-header-expanded { | |
| color: #e5e7eb !important; | |
| border-bottom-color: #374151 !important; | |
| } | |
| .result-content-expanded { | |
| color: #d1d5db !important; | |
| } | |
| /* Thinking ๆทฑ่ฒๆจกๅผ */ | |
| .thinking-collapsed { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .thinking-collapsed summary { | |
| color: #9ca3af !important; | |
| } | |
| .thinking-content { | |
| color: #d1d5db !important; | |
| } | |
| .thinking-streaming { | |
| background: #0c4a6e !important; | |
| border-color: #0369a1 !important; | |
| color: #bae6fd !important; | |
| } | |
| /* Answer section ๆทฑ่ฒๆจกๅผ */ | |
| .answer-section { | |
| background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%) !important; | |
| } | |
| .answer-section p { | |
| color: #e0f2fe !important; | |
| } | |
| .answer-section strong { | |
| color: #f0f9ff !important; | |
| } | |
| /* ็จๆท้ฎ้ขๆฐๆณกๆทฑ่ฒๆจกๅผ */ | |
| .user-message-bubble { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| color: #e0f2fe !important; | |
| border-color: #3b82f6 !important; | |
| box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important; | |
| } | |
| .user-message-content { | |
| color: #e0f2fe !important; | |
| } | |
| /* Round Badge ๆทฑ่ฒๆจกๅผ */ | |
| .round-badge { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| color: #93c5fd !important; | |
| border-color: #3b82f6 !important; | |
| box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important; | |
| } | |
| /* ๆ็ดข็ปๆๅก็ๆทฑ่ฒๆจกๅผ */ | |
| .search-result-card { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .search-result-card:hover { | |
| border-color: #667eea !important; | |
| } | |
| /* ๅทฅๅ ท็ปๆๆ ้ขๅบๅๆทฑ่ฒๆจกๅผ */ | |
| .result-card-expanded div[style*="background: linear-gradient"] { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| border-color: #3b82f6 !important; | |
| } | |
| .result-card-expanded div[style*="background: linear-gradient"] span[style*="color: #1e40af"], | |
| .result-card-expanded div[style*="background: linear-gradient"] a[style*="color: #1e40af"] { | |
| color: #93c5fd !important; | |
| } | |
| .result-card-expanded div[style*="color: #64748b"] { | |
| color: #9ca3af !important; | |
| } | |
| /* ๅฎๆๆถๆฏๆทฑ่ฒๆจกๅผ */ | |
| .completion-msg { | |
| background: #064e3b !important; | |
| border-color: #059669 !important; | |
| color: #6ee7b7 !important; | |
| } | |
| /* ้่ฏฏๆถๆฏๆทฑ่ฒๆจกๅผ */ | |
| .error-message { | |
| background: #450a0a !important; | |
| border-color: #b91c1c !important; | |
| color: #fca5a5 !important; | |
| } | |
| /* ไพง่พนๆ ๆ ้ขๆทฑ่ฒๆจกๅผ */ | |
| div[style*="font-weight: 600"][style*="color: #374151"] { | |
| color: #e5e7eb !important; | |
| } | |
| /* Disclaimer ๆทฑ่ฒๆจกๅผ */ | |
| .disclaimer { | |
| color: #9ca3af !important; | |
| border-color: #374151 !important; | |
| } | |
| } | |
| /* Gradio ๆทฑ่ฒไธป้ข็ฑปๅ้้ */ | |
| .dark #settings-group, | |
| .dark .tool-item, | |
| .dark .example-carousel { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .dark .settings-title, | |
| .dark .tools-title, | |
| .dark .examples-title, | |
| .dark .tool-item strong, | |
| .dark .result-header-expanded, | |
| .dark .tool-name { | |
| color: #e5e7eb !important; | |
| } | |
| .dark .settings-label, | |
| .dark .tool-item span, | |
| .dark .tool-detail, | |
| .dark .thinking-collapsed summary { | |
| color: #9ca3af !important; | |
| } | |
| .dark .settings-help-link { | |
| color: #818cf8 !important; | |
| } | |
| .dark .carousel-item-text { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| border-color: #3b82f6 !important; | |
| color: #e0f2fe !important; | |
| } | |
| .dark #output-area, | |
| .dark #output-area.output-box, | |
| .dark div#output-area { | |
| background: #111827 !important; | |
| border: 1px solid #374151 !important; | |
| } | |
| .dark .tool-call-card, | |
| .dark .result-card-expanded, | |
| .dark .thinking-collapsed { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .dark .result-content-expanded, | |
| .dark .thinking-content { | |
| color: #d1d5db !important; | |
| } | |
| .dark .answer-section { | |
| background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%) !important; | |
| } | |
| .dark .answer-section p { | |
| color: #e0f2fe !important; | |
| } | |
| .dark .search-result-card { | |
| background: #1f2937 !important; | |
| border-color: #374151 !important; | |
| } | |
| .dark .completion-msg { | |
| background: #064e3b !important; | |
| border-color: #059669 !important; | |
| color: #6ee7b7 !important; | |
| } | |
| /* ็จๆท้ฎ้ขๆฐๆณก Gradio dark ๆจกๅผ */ | |
| .dark .user-message-bubble { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| color: #e0f2fe !important; | |
| border-color: #3b82f6 !important; | |
| box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important; | |
| } | |
| .dark .user-message-content { | |
| color: #e0f2fe !important; | |
| } | |
| /* Round Badge Gradio dark ๆจกๅผ */ | |
| .dark .round-badge { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| color: #93c5fd !important; | |
| border-color: #3b82f6 !important; | |
| box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important; | |
| } | |
| /* ๅทฅๅ ท็ปๆๆ ้ข Gradio dark ๆจกๅผ */ | |
| .dark .result-card-expanded div[style*="background: linear-gradient"] { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important; | |
| border-color: #3b82f6 !important; | |
| } | |
| /* Disclaimer Gradio dark ๆจกๅผ */ | |
| .dark .disclaimer { | |
| color: #9ca3af !important; | |
| border-color: #374151 !important; | |
| } | |
| /* Reference Section */ | |
| .reference-section { | |
| margin-top: 40px; | |
| border-top: 1px solid #e5e7eb; | |
| padding-top: 20px; | |
| } | |
| .reference-title { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| color: #111827; | |
| } | |
| .reference-item { | |
| display: block; | |
| background-color: #fff; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 12px; | |
| padding: 12px 16px; | |
| margin-bottom: 12px; | |
| text-decoration: none; | |
| color: #374151; | |
| transition: all 0.2s; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.05); | |
| } | |
| .reference-item:hover { | |
| border-color: #3b82f6; | |
| box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); | |
| transform: translateY(-1px); | |
| text-decoration: none; | |
| } | |
| .ref-number { | |
| display: inline-block; | |
| background-color: #eff6ff; | |
| color: #2563eb; | |
| font-weight: 600; | |
| padding: 2px 6px; | |
| border-radius: 6px; | |
| margin-right: 8px; | |
| font-size: 0.85em; | |
| } | |
| .ref-text { | |
| font-weight: 500; | |
| color: #1f2937; | |
| } | |
| .ref-url { | |
| display: block; | |
| margin-top: 4px; | |
| font-size: 0.8em; | |
| color: #6b7280; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| /* ๆทฑ่ฒๆจกๅผ้้ Reference Section */ | |
| @media (prefers-color-scheme: dark) { | |
| .reference-title { | |
| color: #e5e7eb !important; | |
| } | |
| .reference-item { | |
| background-color: #1f2937 !important; | |
| border-color: #374151 !important; | |
| color: #d1d5db !important; | |
| } | |
| .reference-item:hover { | |
| border-color: #667eea !important; | |
| } | |
| .ref-number { | |
| background-color: #1e3a5f !important; | |
| color: #93c5fd !important; | |
| } | |
| .ref-text { | |
| color: #e5e7eb !important; | |
| } | |
| .ref-url { | |
| color: #9ca3af !important; | |
| } | |
| } | |
| /* Gradio dark ๆจกๅผ้้ Reference Section */ | |
| .dark .reference-title { | |
| color: #e5e7eb !important; | |
| } | |
| .dark .reference-item { | |
| background-color: #1f2937 !important; | |
| border-color: #374151 !important; | |
| color: #d1d5db !important; | |
| } | |
| .dark .reference-item:hover { | |
| border-color: #667eea !important; | |
| } | |
| .dark .ref-number { | |
| background-color: #1e3a5f !important; | |
| color: #93c5fd !important; | |
| } | |
| .dark .ref-text { | |
| color: #e5e7eb !important; | |
| } | |
| .dark .ref-url { | |
| color: #9ca3af !important; | |
| } | |
| """ | |
| with gr.Blocks(css=INLINE_CSS, theme=gr.themes.Soft(), js=CAROUSEL_JS) as demo: | |
| # Header with logo and title images | |
| 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) | |
| 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> | |
| ''') | |
| 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, 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, max_rounds): | |
| yield "", result, "" | |
| # Event handlers | |
| submit_event = submit_btn.click( | |
| fn=start_research, | |
| inputs=[question_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, 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 - Helmholtz Blablador Provider") | |
| print("="*60) | |
| demo = create_interface() | |
| demo.queue(default_concurrency_limit=20).launch() | |